server: Go skeleton — control plane, UDP data plane, Docker + systemd modes

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>
This commit is contained in:
mrambossek
2026-07-30 13:09:08 +02:00
co-authored by Claude Opus 5
parent ee66648e3c
commit 8a80026d49
15 changed files with 1502 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
// 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
}