// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later // Package control implements the control plane (spec §2): enrollment, // profile, sessions. HTTPS with a possibly self-signed cert — clients trust // the SPKI pin from enrollment, not a CA (spec §1). package control import ( "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/base64" "encoding/json" "log/slog" "net/http" "net/netip" "strings" "time" "echo-lot.app/server/internal/session" "echo-lot.app/server/internal/store" ) // Version is stamped by the build (-ldflags "-X ..."). var Version = "dev" type Server struct { Store *store.Store Sessions *session.Manager Name string // Targets/capabilities for the profile response. The skeleton offers only // what it actually implements; the registry grows with the code. UDPPort int TCPPort int // SPKI pin of the serving cert, for the profile's pins[] field. PinB64 string } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /v1/enroll", s.enroll) mux.HandleFunc("GET /v1/profile", s.profile) mux.HandleFunc("POST /v1/sessions", s.newSession) mux.HandleFunc("DELETE /v1/sessions/{id}", s.deleteSession) // TODO(spec §5, §6): /v1/sessions/{id}/actions, /v1/sessions/{id}/observations // TODO(spec §4): POST /v1/echo, GET /v1/tls-reference return mux } func bearer(r *http.Request) string { h := r.Header.Get("Authorization") if v, ok := strings.CutPrefix(h, "Bearer "); ok { return strings.TrimSpace(v) } return "" } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } // enroll redeems a single-use enrollment token for a device credential (§2.1). func (s *Server) enroll(w http.ResponseWriter, r *http.Request) { tok := bearer(r) if tok == "" { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing enrollment token"}) return } var body struct { Name string `json:"name"` } _ = json.NewDecoder(r.Body).Decode(&body) // body is optional dev, err := s.Store.Redeem(tok, body.Name) if err != nil { writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) return } slog.Info("device enrolled", "device", dev.ID, "name", dev.Name) writeJSON(w, http.StatusCreated, map[string]string{ "device_id": dev.ID, "credential": dev.Credential, // returned exactly once }) } // profile is spec §2.2. Only implemented capabilities are advertised. func (s *Server) profile(w http.ResponseWriter, r *http.Request) { dev := s.Store.DeviceByCredential(bearer(r)) if dev == nil { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) return } host := r.Host if i := strings.LastIndex(host, ":"); i > 0 { host = host[:i] } writeJSON(w, http.StatusOK, map[string]any{ "profile_version": 1, "name": s.Name, "server_version": Version, // source_url makes GPL §6 compliance mechanical for operators of // modified builds and gives clients provenance for the measurement. "source_url": "", // TODO: stamp from build metadata "capabilities": []string{"udp-probe"}, "targets": []map[string]any{{ "id": s.Name, "ip4": host, // TODO: explicit configured addresses, v6, second STUN addr "udp_port": s.UDPPort, "tcp_port": s.TCPPort, }}, "pins": []string{"pin-sha256:" + s.PinB64}, "next_pins": []string{}, "limits": map[string]any{"max_kbps": 50000, "max_session_s": 900}, }) } // newSession is spec §2.4. func (s *Server) newSession(w http.ResponseWriter, r *http.Request) { cred := bearer(r) dev := s.Store.DeviceByCredential(cred) if dev == nil { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) return } srcHost, _, _ := strings.Cut(r.RemoteAddr, ":") src, _ := netip.ParseAddr(strings.Trim(srcHost, "[]")) sess, salt, err := s.Sessions.New(dev.ID, cred, src) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "session creation failed"}) return } writeJSON(w, http.StatusCreated, map[string]any{ "session_id": sess.ID, "key_salt": base64.StdEncoding.EncodeToString(salt), "epoch": sess.Epoch.Format(time.RFC3339), "expires_s": int(time.Until(sess.Expires).Seconds()), }) } func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) { if s.Store.DeviceByCredential(bearer(r)) == nil { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) return } if id := r.PathValue("id"); len(id) >= 16 { s.Sessions.Delete(id) } w.WriteHeader(http.StatusNoContent) } // SpkiPinB64 computes the RFC 7469 pin (SHA-256 over the SPKI, base64) of the // leaf certificate — what enrollment QR codes carry and profiles re-state. func SpkiPinB64(cert tls.Certificate) (string, error) { leaf := cert.Leaf if leaf == nil { parsed, err := x509.ParseCertificate(cert.Certificate[0]) if err != nil { return "", err } leaf = parsed } sum := sha256.Sum256(leaf.RawSubjectPublicKeyInfo) return base64.StdEncoding.EncodeToString(sum[:]), nil }