Pure stdlib. Implements the spec's core: enrollment (single-use tokens), profile (SPKI pin, only real capabilities advertised), sessions with the §2.4 HKDF-SHA256 key schedule; UDP data plane with the 32-byte ELT1 header, 4-byte HMAC gate, 1024-wide anti-replay window, ECHO_RESP with observation block, TIMESYNC, and the §3.4 anti-amplification cap. Wire format has tests (roundtrip + silent-drop cases); enroll→profile→session smoke-tested live. Modes: container (autodetect /.dockerenv|/run/.containerenv|cgroup, or --docker/ECHOLOT_DOCKER=1; config via ECHOLOT_* env; distroless image; network_mode host required — Docker NAT would falsify observed sources) and native (--install-systemd/--uninstall-systemd with a hardened unit, opt-in --self-update from Gitea releases; refused in containers). CI: tests on any server/ push; server-v* tags build+push the image to the Gitea registry and attach linux amd64/arm64 binaries + SHA256SUMS to a release — the artifact self-update consumes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
5.1 KiB
Go
167 lines
5.1 KiB
Go
// 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
|
|
}
|