// 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) }