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:
co-authored by
Claude Opus 5
parent
ee66648e3c
commit
8a80026d49
@@ -0,0 +1,223 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// echolot-server: the probe server (spec: docs/probe-protocol.md).
|
||||
//
|
||||
// Modes:
|
||||
// - container ("docker mode"): autodetected (or --docker / ECHOLOT_DOCKER=1);
|
||||
// config comes from ECHOLOT_* env vars; systemd/self-update are refused.
|
||||
// - native: same flags/env, plus --install-systemd / --uninstall-systemd
|
||||
// and opt-in --self-update against a Gitea releases API.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"echo-lot.app/server/internal/config"
|
||||
"echo-lot.app/server/internal/control"
|
||||
"echo-lot.app/server/internal/dataplane"
|
||||
"echo-lot.app/server/internal/selfupdate"
|
||||
"echo-lot.app/server/internal/session"
|
||||
"echo-lot.app/server/internal/store"
|
||||
"echo-lot.app/server/internal/system"
|
||||
)
|
||||
|
||||
// Version is stamped via -ldflags "-X main.Version=v1.2.3" in CI.
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, actions, err := config.Load(os.Args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
control.Version = Version
|
||||
|
||||
switch {
|
||||
case actions.Version:
|
||||
fmt.Println(Version)
|
||||
return nil
|
||||
case actions.InstallSystemd:
|
||||
// The unit runs this same binary in serve mode with env-based config.
|
||||
return system.InstallSystemd(nil)
|
||||
case actions.UninstallSystemd:
|
||||
return system.UninstallSystemd()
|
||||
case actions.SelfUpdate:
|
||||
return selfupdate.Run(cfg.SelfUpdateAPI, Version)
|
||||
}
|
||||
return serve(cfg)
|
||||
}
|
||||
|
||||
func serve(cfg *config.Config) error {
|
||||
slog.Info("echolot-server starting", "version", Version, "mode",
|
||||
map[bool]string{true: "container", false: "native"}[cfg.Docker],
|
||||
"state_dir", cfg.StateDir)
|
||||
|
||||
st, err := store.Open(cfg.StateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("state store: %w", err)
|
||||
}
|
||||
cert, err := loadOrCreateCert(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls: %w", err)
|
||||
}
|
||||
pin, err := control.SpkiPinB64(cert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("control-plane certificate", "pin-sha256", pin)
|
||||
|
||||
sessions := session.NewManager(15 * time.Minute)
|
||||
ctl := &control.Server{
|
||||
Store: st, Sessions: sessions, Name: cfg.Name,
|
||||
UDPPort: mustPort(cfg.UDPListen), TCPPort: mustPort(cfg.TCPListen), PinB64: pin,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
errCh := make(chan error, 4)
|
||||
|
||||
// Control plane (HTTPS, pin-based trust)
|
||||
ctlSrv := &http.Server{
|
||||
Addr: cfg.ControlListen, Handler: ctl.Handler(),
|
||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() { errCh <- fmt.Errorf("control: %w", ctlSrv.ListenAndServeTLS("", "")) }()
|
||||
|
||||
// Admin/health (plain HTTP, localhost by default; spec §7)
|
||||
admin := http.NewServeMux()
|
||||
admin.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
fmt.Fprintf(w, `{"ok":true,"version":%q}`, Version)
|
||||
})
|
||||
// TODO(spec §7): enrollment token management + device list. Until the
|
||||
// admin UI exists, mint tokens with: echolot-admin (or curl on this
|
||||
// listener once the endpoint lands).
|
||||
admin.HandleFunc("POST /admin/enroll-tokens", func(w http.ResponseWriter, r *http.Request) {
|
||||
tok, err := st.NewEnrollToken(24*time.Hour, r.URL.Query().Get("note"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `{"token":%q,"expires_in_s":86400}`+"\n", tok)
|
||||
})
|
||||
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
|
||||
|
||||
// UDP data plane
|
||||
udpAddr, err := net.ResolveUDPAddr("udp", cfg.UDPListen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
udpConn, err := net.ListenUDP("udp", udpAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("udp listen: %w", err)
|
||||
}
|
||||
dp := &dataplane.Server{Sessions: sessions}
|
||||
go func() { errCh <- fmt.Errorf("udp: %w", dp.Serve(udpConn)) }()
|
||||
|
||||
slog.Info("listening",
|
||||
"control", cfg.ControlListen, "admin", cfg.AdminListen, "udp", cfg.UDPListen)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("shutting down")
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = ctlSrv.Shutdown(shutCtx)
|
||||
_ = adminSrv.Shutdown(shutCtx)
|
||||
_ = udpConn.Close()
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func mustPort(listen string) int {
|
||||
_, p, err := net.SplitHostPort(listen)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(p)
|
||||
return n
|
||||
}
|
||||
|
||||
// loadOrCreateCert loads the configured cert/key, or generates a self-signed
|
||||
// ECDSA P-256 cert into the state dir on first start. Self-signed is
|
||||
// first-class per spec §1 — clients pin the SPKI, they don't chase CAs.
|
||||
func loadOrCreateCert(cfg *config.Config) (tls.Certificate, error) {
|
||||
certPath, keyPath := cfg.TLSCert, cfg.TLSKey
|
||||
if certPath == "" {
|
||||
certPath = filepath.Join(cfg.StateDir, "tls-cert.pem")
|
||||
keyPath = filepath.Join(cfg.StateDir, "tls-key.pem")
|
||||
}
|
||||
if c, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil {
|
||||
return c, nil
|
||||
} else if cfg.TLSCert != "" {
|
||||
// Explicitly configured paths must exist — do not silently overwrite.
|
||||
return tls.Certificate{}, fmt.Errorf("loading configured cert %s: %w", certPath, err)
|
||||
}
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, big.NewInt(1).Lsh(big.NewInt(1), 62))
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "echolot-server"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
// Long-lived on purpose: trust is the SPKI pin from enrollment, not
|
||||
// cert validity; rotation happens via next_pins in the profile.
|
||||
NotAfter: time.Now().AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
keyDer, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
certPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPem := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDer})
|
||||
if err := os.WriteFile(certPath, certPem, 0o644); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPem, 0o600); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
slog.Info("generated self-signed certificate", "cert", certPath)
|
||||
return tls.X509KeyPair(certPem, keyPem)
|
||||
}
|
||||
Reference in New Issue
Block a user