Reticulum-Go/pkg/controlapi/server.go

473 lines
14 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-2026 Quad4.io
package controlapi
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"quad4/reticulum-go/pkg/common"
"quad4/reticulum-go/pkg/debug"
"quad4/reticulum-go/pkg/identity"
"quad4/reticulum-go/pkg/transport"
)
// Lifecycle coordinates network sleep/wake recovery for the control API.
type Lifecycle interface {
OnNetworkAvailable() error
OnNetworkLost() error
RefreshPaths(dests ...[]byte) error
}
// Server is a localhost JSON control API bound to one Reticulum-Go
// transport. See the package doc comment for the wire protocol.
type Server struct {
transport *transport.Transport
lifecycle Lifecycle
host string
port int
unixPath string
authKey []byte
startedAt time.Time
httpServer *http.Server
listener net.Listener
unixListener net.Listener
mu sync.RWMutex
sessions map[string]*session
announceMu sync.RWMutex
announceSubs map[*wsClient]struct{}
}
// New builds a control API server bound to t, using cfg.RPCKey as the
// bearer auth token and cfg.ControlAPIHost/Port for the listen address.
// Callers are expected to only call New when cfg.EnableControlAPI is true.
// common.ReticulumConfig.Validate rejects that combination when RPCKey is
// empty.
func New(t *transport.Transport, lifecycle Lifecycle, cfg *common.ReticulumConfig) (*Server, error) {
if t == nil {
return nil, errors.New("controlapi: transport is required")
}
if cfg == nil || len(cfg.RPCKey) == 0 {
return nil, errors.New("controlapi: rpc_key must be set")
}
host := cfg.ControlAPIHost
if host == "" {
host = common.DefaultControlAPIHost
}
port := cfg.ControlAPIPort
if port == 0 {
port = common.DefaultControlAPIPort
}
s := &Server{
transport: t,
lifecycle: lifecycle,
host: host,
port: port,
unixPath: strings.TrimSpace(cfg.ControlAPISocket),
authKey: cfg.RPCKey,
startedAt: time.Now(),
sessions: make(map[string]*session),
announceSubs: make(map[*wsClient]struct{}),
}
mux := http.NewServeMux()
s.registerRoutes(mux)
s.httpServer = &http.Server{
Handler: s.authMiddleware(mux),
ReadHeaderTimeout: 10 * time.Second,
}
t.RegisterAnnounceHandler(&announceBridge{server: s})
return s, nil
}
// Listen binds the configured TCP address. Call before sandbox.Apply on
// platforms where CapEnter or pledge would block a later listen.
func (s *Server) Listen() error {
if s == nil {
return errors.New("controlapi: nil server")
}
if s.listener != nil {
return nil
}
addr := net.JoinHostPort(s.host, strconv.Itoa(s.port))
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("controlapi: listen on %s: %w", addr, common.WrapListenError(err))
}
s.listener = ln
debug.Log(debug.DebugInfo, "Control API listening", "addr", addr)
if err := s.listenUnix(); err != nil {
_ = ln.Close()
s.listener = nil
return err
}
return nil
}
func (s *Server) listenUnix() error {
if s.unixPath == "" {
return nil
}
if err := os.Remove(s.unixPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("controlapi: remove unix socket: %w", err)
}
if dir := filepath.Dir(s.unixPath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("controlapi: unix socket dir: %w", err)
}
}
ln, err := net.Listen("unix", s.unixPath)
if err != nil {
return fmt.Errorf("controlapi: listen on unix %s: %w", s.unixPath, common.WrapListenError(err))
}
if err := os.Chmod(s.unixPath, 0o600); err != nil {
_ = ln.Close()
return fmt.Errorf("controlapi: chmod unix socket: %w", err)
}
s.unixListener = ln
debug.Log(debug.DebugInfo, "Control API unix listening", "path", s.unixPath)
return nil
}
// Serve serves HTTP on the bound listener. If Listen was not called yet,
// it binds first. Blocks until Close is called. Run it in its own goroutine.
func (s *Server) Serve() error {
if s.listener == nil {
if err := s.Listen(); err != nil {
return err
}
}
if s.unixListener != nil {
go func() {
err := s.httpServer.Serve(s.unixListener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
debug.Log(debug.DebugError, "Control API unix serve", "error", err)
}
}()
}
err := s.httpServer.Serve(s.listener)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
// Close disconnects every session's WebSocket clients and shuts down the
// HTTP listener.
func (s *Server) Close() error {
s.mu.Lock()
sessions := make([]*session, 0, len(s.sessions))
for _, sess := range s.sessions {
sessions = append(sessions, sess)
}
s.sessions = make(map[string]*session)
s.mu.Unlock()
for _, sess := range sessions {
sess.close()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return s.httpServer.Shutdown(ctx)
}
func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/health", s.handleHealth)
mux.HandleFunc("GET /v1/status", s.handleStatus)
mux.HandleFunc("GET /v1/paths", s.handlePaths)
mux.HandleFunc("POST /v1/sessions", s.handleCreateSession)
mux.HandleFunc("DELETE /v1/sessions/{id}", s.handleDeleteSession)
mux.HandleFunc("POST /v1/sessions/{id}/destinations", s.handleRegisterDestination)
mux.HandleFunc("POST /v1/sessions/{id}/destinations/{hash}/announce", s.handleAnnounce)
mux.HandleFunc("POST /v1/sessions/{id}/destinations/{hash}/requests", s.handleRegisterRequestHandler)
mux.HandleFunc("DELETE /v1/sessions/{id}/destinations/{hash}/requests", s.handleDeregisterRequestHandler)
mux.HandleFunc("POST /v1/sessions/{id}/path/request", s.handlePathRequest)
mux.HandleFunc("GET /v1/sessions/{id}/events", s.handleEvents)
mux.HandleFunc("POST /v1/lifecycle/resume", s.handleLifecycleResume)
mux.HandleFunc("POST /v1/lifecycle/pause", s.handleLifecyclePause)
mux.HandleFunc("POST /v1/lifecycle/refresh-paths", s.handleLifecycleRefreshPaths)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, healthResponse{
Status: "ok",
TransportID: hex.EncodeToString(s.transport.TransportIdentityHash()),
TransportUptime: time.Since(s.startedAt).Seconds(),
})
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
stats := s.transport.GetInterfaceStatsRPC()
resp := statusResponse{
TransportID: hex.EncodeToString(stats.TransportID),
Interfaces: make([]interfaceStatJSON, 0, len(stats.Interfaces)),
}
for _, ifc := range stats.Interfaces {
resp.Interfaces = append(resp.Interfaces, interfaceStatJSON{
Name: ifc.Name,
Type: ifc.Type,
Status: ifc.Status,
RXBytes: ifc.RXB,
TXBytes: ifc.TXB,
Bitrate: ifc.Bitrate,
Clients: ifc.Clients,
BlockedIPs: ifc.BlockedIPs,
BlockedIPList: ifc.BlockedIPList,
I2PConnectable: ifc.I2PConnectable,
I2PB32: ifc.I2PB32,
TunnelState: ifc.TunnelState,
I2PLastError: ifc.I2PLastError,
HeldAnnounces: ifc.HeldAnnounces,
AnnounceQueue: ifc.AnnounceQueue,
IFACFail: ifc.IFACFail,
HMACFail: ifc.HMACFail,
AnnounceSigFail: ifc.AnnounceSigFail,
UnpackFail: ifc.UnpackFail,
AnnounceDup: ifc.AnnounceDup,
PathRespSuppressed: ifc.PathRespSuppressed,
PathReqDup: ifc.PathReqDup,
PathReqNoCache: ifc.PathReqNoCache,
PathRespQueuedSkip: ifc.PathRespQueuedSkip,
LinkRelayUnknownIface: ifc.LinkRelayUnknownIface,
IntegrityFailRate: ifc.IntegrityFailRate,
StaleCloses: ifc.StaleCloses,
LinkStaleClose: ifc.LinkStaleClose,
KeepaliveTimeout: ifc.KeepaliveTimeout,
})
}
ps := stats.Protect
resp.Protect.Mode = ps.Mode
resp.Protect.Phase = ps.Phase
resp.Protect.Enforcement = ps.Enforcement
resp.Protect.Fingerprint = ps.Fingerprint
resp.Protect.SheddingMemory = ps.SheddingMemory
resp.Protect.TripCounts.PPS = ps.TripCounts.PPS
resp.Protect.TripCounts.BPS = ps.TripCounts.BPS
resp.Protect.TripCounts.Handler = ps.TripCounts.Handler
resp.Protect.TripCounts.Conn = ps.TripCounts.Conn
resp.Protect.TripCounts.Resource = ps.TripCounts.Resource
resp.Protect.TripCounts.Memory = ps.TripCounts.Memory
resp.Protect.TripCounts.Crypto = ps.TripCounts.Crypto
resp.Protect.TripCounts.Handshake = ps.TripCounts.Handshake
resp.Protect.TripCounts.CoolDown = ps.TripCounts.CoolDown
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handlePaths(w http.ResponseWriter, r *http.Request) {
entries := s.transport.GetPathTable(nil)
out := make([]pathTableEntryJSON, 0, len(entries))
for _, e := range entries {
out = append(out, pathTableEntryJSON{
Hash: hex.EncodeToString(e.Hash),
Via: hex.EncodeToString(e.Via),
Hops: e.Hops,
Expires: e.Expires,
Interface: e.Interface,
})
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) {
var req createSessionRequest
if r.Body != nil {
if err := decodeJSONBody(w, r, &req); err != nil && !errors.Is(err, io.EOF) {
if isBodyTooLarge(err) {
return
}
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
}
ident, err := loadOrCreateIdentity(req.IdentityPath)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("identity: %v", err))
return
}
sessionID, err := randomID()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to allocate session id")
return
}
sess := newSession(sessionID, ident)
s.mu.Lock()
s.sessions[sessionID] = sess
s.mu.Unlock()
writeJSON(w, http.StatusCreated, createSessionResponse{
SessionID: sessionID,
IdentityHash: ident.GetHexHash(),
})
}
// loadOrCreateIdentity loads the identity at path, creates and persists a
// new one there if path is set but does not exist yet, or generates an
// ephemeral in-memory identity when path is empty.
func loadOrCreateIdentity(path string) (*identity.Identity, error) {
if path == "" {
return identity.NewIdentity()
}
if _, err := os.Stat(path); err == nil {
return identity.LoadIdentityFile(path, nil)
}
ident, err := identity.NewIdentity()
if err != nil {
return nil, err
}
if err := ident.ToFile(path); err != nil {
return nil, err
}
return ident, nil
}
func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
s.mu.Lock()
sess, ok := s.sessions[id]
if ok {
delete(s.sessions, id)
}
s.mu.Unlock()
if !ok {
writeError(w, http.StatusNotFound, "session not found")
return
}
sess.close()
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handlePathRequest(w http.ResponseWriter, r *http.Request) {
if _, ok := s.session(r.PathValue("id")); !ok {
writeError(w, http.StatusNotFound, "session not found")
return
}
var req pathRequestRequest
if err := decodeJSONBody(w, r, &req); err != nil {
if isBodyTooLarge(err) {
return
}
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
destHash, err := hex.DecodeString(req.DestinationHash)
if err != nil || len(destHash) != 16 {
writeError(w, http.StatusBadRequest, "destination_hash must be 16 hex-encoded bytes")
return
}
if err := s.transport.RequestPath(destHash, "", nil, false); err != nil {
if errors.Is(err, common.ErrPathRequestThrottled) {
writeJSON(w, http.StatusTooManyRequests, pathRequestResponse{
WaitS: s.transport.PathRequestRetryAfter(destHash).Seconds(),
Error: err.Error(),
})
return
}
writeError(w, http.StatusInternalServerError, fmt.Sprintf("path request: %v", err))
return
}
writeJSON(w, http.StatusAccepted, pathRequestResponse{
WaitS: s.transport.PathResponseWindow(destHash).Seconds(),
})
}
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
sess, ok := s.session(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "session not found")
return
}
if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") || r.Header.Get("Sec-WebSocket-Key") == "" {
writeError(w, http.StatusBadRequest, "expected websocket upgrade request")
return
}
pending, err := beginWebSocketUpgrade(w, r)
if err != nil {
debug.Log(debug.DebugError, "controlapi: websocket upgrade failed", "error", err)
return
}
// Register and start the writer before flushing 101 so a peer that
// races ahead and triggers a broadcast cannot miss the event: the
// session already has a client, and writeLoop is already scheduled
// (gated until enableWrites so frames cannot precede the 101).
client := newWSClient(s, sess, pending.Conn())
if !sess.addClient(client) {
_ = pending.Conn().close()
return
}
client.startWriter()
if err := pending.Flush(); err != nil {
debug.Log(debug.DebugError, "controlapi: websocket handshake flush failed", "error", err)
client.close()
return
}
client.enableWrites()
client.run()
}
func (s *Server) session(id string) (*session, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
sess, ok := s.sessions[id]
return sess, ok
}
func (s *Server) subscribeAnnounces(c *wsClient) {
s.announceMu.Lock()
defer s.announceMu.Unlock()
s.announceSubs[c] = struct{}{}
}
func (s *Server) unsubscribeAnnounces(c *wsClient) {
s.announceMu.Lock()
defer s.announceMu.Unlock()
delete(s.announceSubs, c)
}
func (s *Server) broadcastAnnounce(evt announceEvent) {
s.announceMu.RLock()
defer s.announceMu.RUnlock()
for c := range s.announceSubs {
if c.matchesAnnounceFilter(evt.DestinationHash) {
c.send(evt)
}
}
}
func randomID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}