Files
echolot/server/cmd/echolot-server/main.go
T
mrambossekandClaude Opus 5 c4f2a10790 app: show what the server reports as facts, not as inputs
The settings card offered three editable boxes and said nothing about the
server itself — which addresses a test will actually use, on which ports,
what it can measure. That is the part a person checks before trusting a
result, and "which address did this come from" is precisely the question
a report leaves open.

The server now publishes it. The profile's targets carried one IPv4 and a
TODO; it reports both families and both alternates, derived from the UDP
listen spec rather than configured separately, so the list cannot drift
from what is actually bound. No reservation means no alternate is
claimed: announcing a second address as the RFC 5780 alternate when none
was set aside would promise a redirect the server will not send.

The app renders them read-only, in a panel visibly distinct from the
fields above. An editable box that changes nothing is worse than no box,
and these are facts to read rather than settings to apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 08:14:42 +02:00

774 lines
28 KiB
Go

// 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 (
"bufio"
"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"
"net/netip"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
"echo-lot.app/server/internal/acmehttp"
"echo-lot.app/server/internal/adminauth"
"echo-lot.app/server/internal/adminui"
"echo-lot.app/server/internal/canarydns"
"echo-lot.app/server/internal/certreload"
"echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/config"
"echo-lot.app/server/internal/control"
"echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/oidc"
"echo-lot.app/server/internal/runs"
"echo-lot.app/server/internal/selftest"
"echo-lot.app/server/internal/selfupdate"
"echo-lot.app/server/internal/session"
"echo-lot.app/server/internal/store"
"echo-lot.app/server/internal/stun"
"echo-lot.app/server/internal/system"
"echo-lot.app/server/internal/tcpecho"
)
// 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.Help:
// Compatibility shim for one release.
//
// Serving became an explicit verb, but self-update is run by the *old* binary — so the
// repair added to the updater cannot fix the very update that installs the new one. A
// unit written before this change starts us with no arguments, and without this branch
// the service would simply stop working, unattended, on a host nobody is watching.
//
// Only when systemd started us: INVOCATION_ID is set by systemd for every service
// invocation and by nothing else, so a person at a terminal still gets usage. Remove
// this once no deployment predates --serve.
if os.Getenv("INVOCATION_ID") != "" {
slog.Warn("started by systemd with no verb — this unit predates --serve; " +
"repairing it and serving anyway")
if repaired, err := system.RepairExecStart(); err != nil {
slog.Error("could not repair the unit; fix ExecStart by hand", "err", err)
} else if repaired {
slog.Info("systemd unit updated to pass --serve")
}
return serve(cfg)
}
config.Usage(os.Stderr)
os.Exit(2)
return nil
case actions.Version:
fmt.Println(Version)
return nil
case actions.InstallSystemd:
// The unit runs this same binary in serve mode; host config comes
// from /etc/echolot-server.env. A self-update timer is installed
// only when an update API is configured.
return system.InstallSystemd(cfg.SelfUpdateAPI)
case actions.UninstallSystemd:
return system.UninstallSystemd()
case actions.SetAdminPassword:
return setAdminPassword(cfg)
case actions.MintEnrollToken != "":
return mintEnrollToken(cfg, actions.MintEnrollToken)
case actions.SelfUpdate:
return selfupdate.Run(cfg.SelfUpdateAPI, Version)
}
return serve(cfg)
}
// mintEnrollToken prints a §2.1 bootstrap link for a new device.
//
// The link is assembled here rather than by hand because it has to carry the public URL and the
// base64 SPKI pin percent-encoded correctly, and a pin wrong by one character fails later as an
// inscrutable TLS error rather than as a bad pin.
func mintEnrollToken(cfg *config.Config, note string) error {
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 fmt.Errorf("pin: %w", err)
}
tok, err := st.NewEnrollToken(24*time.Hour, note)
if err != nil {
return err
}
base := cfg.PublicControlURL
if base == "" {
return fmt.Errorf("set ECHOLOT_PUBLIC_URL so the link can say where to connect")
}
fmt.Println(control.EnrollmentURI(base, pin, tok))
// stderr, so piping the command somewhere yields the link alone.
fmt.Fprintln(os.Stderr, "\nSingle use, valid 24 hours. Treat it like a password until spent.")
return nil
}
// controlURL is the address devices connect to: the hostname that selects the pinned certificate.
//
// Falls back to the public URL when no separate control hostname is configured, so a server that
// does not share the admin port keeps answering discovery with something usable.
func controlURL(cfg *config.Config) string {
if cfg.ControlHostname == "" {
return cfg.PublicControlURL
}
return "https://" + cfg.ControlHostname
}
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)
dp := &dataplane.Server{Sessions: sessions}
// TCP echo shares the control cert for its elt-echo TLS variant.
tcpSrv := &tcpecho.Server{
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
}
caps := []string{"udp-probe", "delayed-echo", "connect-back", "http-echo", "downtrain", "big-send", "throughput"}
// Crafted fragments need a raw socket. Advertised only when one can actually be opened —
// a capability we cannot deliver turns a missing feature into a failed measurement.
rawFrag := dataplane.RawFragSupported()
if rawFrag {
caps = append(caps, "frag-send")
} else {
slog.Info("frag-send unavailable: no raw socket (needs CAP_NET_RAW)")
}
if len(config.Addrs(cfg.TCPListen)) > 0 {
caps = append(caps, "tcp-echo", "tls-echo")
}
// Uploaded-run storage. A failure here is not fatal: measurement still works, uploads just
// stay unavailable and say so in the profile.
runStore, err := runs.Open(cfg.StateDir, runs.Policy{
Mode: runs.Mode(cfg.UploadsMode),
MaxBytes: cfg.UploadMaxBytes,
RetentionDays: cfg.UploadRetentionDays,
MaxRunsPerDevice: cfg.UploadMaxRuns,
MinAnonymization: cfg.UploadMinAnon,
})
if err != nil {
slog.Warn("uploaded-run storage unavailable — uploads disabled", "err", err)
runStore = nil
} else {
slog.Info("uploads", "mode", cfg.UploadsMode, "min_anonymization", cfg.UploadMinAnon,
"retention_days", cfg.UploadRetentionDays, "max_runs_per_device", cfg.UploadMaxRuns)
}
// A malformed window is fatal rather than ignored: an operator who set a restriction must
// not end up running without one because of a typo.
appRange, err := compat.ParseRange(cfg.MinAppVersion, cfg.MaxAppVersion)
if err != nil {
return fmt.Errorf("app version window: %w", err)
}
slog.Info("client compatibility", "accepts_app", appRange.String(),
"protocol", control.ProtocolVersion, "schema", control.SchemaVersion)
// Identity is optional. Without an issuer the server simply has no sign-in, and
// uploads=account can never be satisfied — which is the honest outcome, not a silent
// downgrade to anonymous.
// One verifier per issuer. An IdP may mint a distinct issuer per application — Authentik
// derives it from the application slug — and a token's `iss` must match whoever signed it.
// Each verifier accepts only the client belonging to its own issuer, so a token minted for
// the phone cannot be replayed at the admin login and vice versa.
var idp, adminIdP *oidc.Verifier
appIssuer := cfg.OIDCAppIssuer
if appIssuer == "" {
appIssuer = cfg.OIDCIssuer // IdPs with one global issuer
}
if appIssuer != "" && cfg.OIDCAppClientID != "" {
idp = oidc.New(oidc.Config{
Issuer: appIssuer, AppClientID: cfg.OIDCAppClientID, AdminGroup: cfg.OIDCAdminGroup,
}, nil)
slog.Info("identity: app client", "issuer", appIssuer, "client_id", cfg.OIDCAppClientID)
}
if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
adminIdP = oidc.New(oidc.Config{
Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, AdminGroup: cfg.OIDCAdminGroup,
}, nil)
slog.Info("identity: admin client", "issuer", cfg.OIDCIssuer,
"client_id", cfg.OIDCClientID, "admin_group", cfg.OIDCAdminGroup)
if cfg.OIDCAdminGroup == "" {
slog.Warn("no admin group set: nobody will be an admin via OIDC " +
"(set ECHOLOT_OIDC_ADMIN_GROUP)")
}
}
if idp == nil && adminIdP == nil && cfg.UploadsMode == string(runs.ModeAccount) {
slog.Warn("uploads=account but no identity provider is configured — " +
"every upload will be refused")
}
ip4, ip6, ip4Alt, ip6Alt := cfg.MeasurementAddrs()
ctl := &control.Server{
IP4: ip4, IP6: ip6, IP4Alt: ip4Alt, IP6Alt: ip6Alt,
Store: st, Sessions: sessions, Name: cfg.Name,
UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)),
StunPort: mustPort(firstAddr(cfg.StunListen)), PinB64: pin, CertChain: cert.Certificate,
DelayedEcho: dp.SendDelayedEcho,
DownTrain: dp.DownTrain,
BigSend: dp.BigSend,
TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) },
Runs: runStore,
AppRange: appRange,
PublicControlURL: publicControlURL(cfg),
OIDC: idp,
AdminOIDC: adminIdP,
}
// Left nil when there is no raw socket, so the handler answers "not implemented" with a
// reason rather than failing somewhere deeper.
if rawFrag {
ctl.FragSend = dp.FragSend
}
ctl.DownThroughput = dp.DownThroughput
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
ctlAddrs := config.Addrs(cfg.ControlListen)
udpAddrs := config.Addrs(cfg.UDPListen)
errCh := make(chan error, len(ctlAddrs)+len(udpAddrs)+2)
// Control plane (HTTPS, pin-based trust) — one shared server, one
// listener per configured address; Shutdown closes them all.
ctlSrv := &http.Server{
Handler: ctl.Handler(),
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
ReadHeaderTimeout: 10 * time.Second,
}
for _, addr := range ctlAddrs {
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("control listen %s: %w", addr, err)
}
go func(a string, l net.Listener) {
errCh <- fmt.Errorf("control %s: %w", a, ctlSrv.ServeTLS(l, "", ""))
}(addr, ln)
}
// Self-test: prove the host is a clean measurement target. Sysctl audit is
// instant; the egress-MTU proof does network round trips, so publish the
// sysctl-only report immediately and swap in the full one when it lands.
var selftestPtr atomic.Pointer[selftest.Report]
initial := selftest.Report{Sysctls: selftest.Sysctls()}
selftestPtr.Store(&initial)
for _, c := range initial.Sysctls {
if c.Severity == selftest.Warn {
slog.Warn("sysctl not measurement-clean", "sysctl", c.Name, "got", c.Got, "want", c.Want, "why", c.Why)
}
}
go func() {
r := selftest.Run(config.Addrs(cfg.MTUProbeTargets))
selftestPtr.Store(&r)
for _, m := range r.EgressMTU {
if !m.FullMTU {
slog.Warn("egress MTU below 1500 — client MTU results measure THIS server, not the client",
"target", m.Target, "discovered_mtu", m.DiscoveredMTU, "err", m.Err)
}
}
slog.Info("self-test complete", "sysctl_ok", r.SysctlOK, "mtu_ok", r.MTUOK)
}()
ctl.ProvenGood = func() (mtuOK, sysctlOK bool) {
r := selftestPtr.Load()
return r.MTUOK, r.SysctlOK
}
// The smallest egress MTU we measured is the ceiling for DF-mode big_send: above it our own
// kernel refuses the datagram, which would otherwise look like a downstream path limit.
ctl.EgressMTU = func() int {
best := 0
for _, m := range selftestPtr.Load().EgressMTU {
if m.DiscoveredMTU > 0 && (best == 0 || m.DiscoveredMTU < best) {
best = m.DiscoveredMTU
}
}
return best
}
// The admin interface. Every route except /healthz requires a session — the old arrangement
// (no auth, kept safe by binding to loopback) failed the moment the address changed, and a
// binding address is a deployment detail rather than an access control.
secret, err := st.SessionSecret()
if err != nil {
return fmt.Errorf("admin session secret: %w", err)
}
adminSecure := cfg.AdminTLSCert != ""
ui := &adminui.Server{
Store: st,
Runs: runStore,
OIDC: adminIdP,
Sessions: adminauth.NewSessions(secret, 12*time.Hour),
Throttle: adminauth.NewThrottle(),
AdminUser: cfg.AdminUser,
BaseURL: cfg.AdminBaseURL,
ClientSecret: cfg.OIDCClientSecret,
Secure: adminSecure,
EnrollLink: ctl.EnrollmentLink,
// Where devices should connect, for /v1/discover. Derived from the control hostname so it
// cannot drift from the name that actually selects the pinned certificate.
ControlURL: controlURL(cfg),
ServerName: cfg.Name,
SelfTest: func() any { return selftestPtr.Load() },
Version: Version,
}
if st.LocalAdmin() == nil && adminIdP == nil {
slog.Warn("nobody can sign in to the admin UI: no break-glass password is set " +
"(--set-admin-password) and no identity provider is configured")
}
admin := ui.Handler()
// One listener per configured address, all serving the same handler.
//
// Multi-address rather than a wildcard because this host reserves addresses for measurement:
// binding 0.0.0.0 would put the admin UI on port 443 of the reserved pair, and their value
// comes precisely from nothing answering there. Explicit addresses are also what let the
// service and management addresses differ without a second process.
adminAddrs := config.Addrs(cfg.AdminListen)
if len(adminAddrs) == 0 {
return fmt.Errorf("admin: no listen address configured")
}
var adminTLS *tls.Config
if cfg.AdminTLSCert != "" {
// Terminated here rather than behind a reverse proxy: this binary already serves TLS for
// the control plane, so it is reuse rather than new machinery, and one process with one
// config file is the property that makes this pleasant to run. A proxy would also invite
// someone to later front the control plane too, which would break SPKI pinning.
reloader, err := certreload.New(cfg.AdminTLSCert, cfg.AdminTLSKey)
if err != nil {
return fmt.Errorf("admin TLS: %w", err)
}
adminTLS = reloader.TLSConfig()
if exp := reloader.NotAfter(); !exp.IsZero() {
slog.Info("admin UI TLS", "listen", adminAddrs, "cert_expires", exp.Format(time.RFC3339))
if time.Until(exp) < 14*24*time.Hour {
slog.Warn("admin certificate expires soon", "expires", exp.Format(time.RFC3339))
}
}
}
// Kept for shutdown: each listener gets its own server, and a graceful stop has to reach all
// of them or an in-flight admin request is cut off mid-response on every address but one.
var adminSrvs []*http.Server
// Sharing port 443 between two services that cannot share a certificate. The name in the TLS
// handshake picks the certificate, and the name in the request picks the handler; both have to
// agree or a client would get the pinned certificate and the admin UI behind it.
//
// The control plane keeps its own listener as well. Devices enrolled before this carry the old
// URL in their settings, and taking that away would strand every one of them for the sake of a
// port number.
ctlHandler := ctl.Handler()
sharedCert := cert
pickCert := func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if cfg.ControlHostname != "" && strings.EqualFold(hi.ServerName, cfg.ControlHostname) {
return &sharedCert, nil
}
if adminTLS != nil && adminTLS.GetCertificate != nil {
return adminTLS.GetCertificate(hi)
}
return &sharedCert, nil
}
route := func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if cfg.ControlHostname != "" && strings.EqualFold(host, cfg.ControlHostname) {
ctlHandler.ServeHTTP(w, r)
return
}
admin.ServeHTTP(w, r)
}
sharedTLS := &tls.Config{GetCertificate: pickCert, MinVersion: tls.VersionTLS12}
if cfg.ControlHostname != "" {
slog.Info("control plane shares the admin port",
"hostname", cfg.ControlHostname, "listen", adminAddrs)
}
for _, addr := range adminAddrs {
// Bound before the goroutine starts, so a bad address fails startup rather than being
// reported asynchronously after the process has already declared itself healthy.
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("admin listen %s: %w", addr, err)
}
srv := &http.Server{
Handler: http.HandlerFunc(route),
ReadHeaderTimeout: 10 * time.Second,
TLSConfig: sharedTLS,
}
adminSrvs = append(adminSrvs, srv)
go func(ln net.Listener, addr string) {
// Plaintext only where there is no certificate at all — checkAdminExposure has
// already refused that anywhere but loopback.
if adminTLS == nil && cfg.ControlHostname == "" {
errCh <- fmt.Errorf("admin %s: %w", addr, srv.Serve(ln))
return
}
errCh <- fmt.Errorf("admin %s: %w", addr, srv.ServeTLS(ln, "", ""))
}(ln, addr)
}
// ACME HTTP-01 responder. Permanent rather than started per renewal: nothing binds and
// unbinds, so a renewal cannot fail because the port was briefly busy, and the ACME client
// needs only write access to a directory instead of the privilege to bind a low port.
if cfg.ACMEHTTPListen != "" {
webroot := cfg.ACMEWebroot
if webroot == "" {
webroot = filepath.Join(cfg.StateDir, "acme")
}
if err := acmehttp.EnsureWebroot(webroot); err != nil {
return fmt.Errorf("acme webroot: %w", err)
}
acmeHandler := acmehttp.Handler(webroot, cfg.AdminBaseURL)
for _, addr := range config.Addrs(cfg.ACMEHTTPListen) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("acme-http listen %s: %w", addr, err)
}
srv := &http.Server{Handler: acmeHandler, ReadHeaderTimeout: 10 * time.Second}
go func(ln net.Listener, addr string) {
errCh <- fmt.Errorf("acme-http %s: %w", addr, srv.Serve(ln))
}(ln, addr)
}
// Every address the name may resolve to needs the responder: the CA picks one, and a
// challenge that lands on an unbound address fails a renewal rather than a request.
slog.Info("acme http-01 responder", "listen", config.Addrs(cfg.ACMEHTTPListen),
"webroot", webroot, "redirects_to", cfg.AdminBaseURL)
}
// UDP data plane — one socket per configured address. Distinct sockets
// (not wildcard) also guarantee responses leave from the address the
// request arrived on, which stun-5780 will rely on.
var udpConns []*net.UDPConn
for _, addr := range udpAddrs {
udpAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return fmt.Errorf("udp addr %s: %w", addr, err)
}
conn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
return fmt.Errorf("udp listen %s: %w", addr, err)
}
udpConns = append(udpConns, conn)
go func(a string, c *net.UDPConn) {
errCh <- fmt.Errorf("udp %s: %w", a, dp.Serve(c))
}(addr, conn)
}
// TCP echo (spec §4)
var tcpLns []net.Listener
for _, addr := range config.Addrs(cfg.TCPListen) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("tcp listen %s: %w", addr, err)
}
tcpLns = append(tcpLns, ln)
go func(a string, l net.Listener) {
errCh <- fmt.Errorf("tcp %s: %w", a, tcpSrv.Serve(l))
}(addr, ln)
}
// Optional cleartext HTTP-echo (spec §4 plaintext-path test) — only
// POST /v1/echo, no auth, no secrets. Off unless configured.
var httpEchoSrvs []*http.Server
for _, addr := range config.Addrs(cfg.HTTPEchoListen) {
hs := &http.Server{Addr: addr, Handler: ctl.EchoHandler(), ReadHeaderTimeout: 10 * time.Second}
httpEchoSrvs = append(httpEchoSrvs, hs)
go func(a string, srv *http.Server) { errCh <- fmt.Errorf("http-echo %s: %w", a, srv.ListenAndServe()) }(addr, hs)
}
// STUN (spec §4) — advertises stun-5780 only with ≥2 same-family addrs.
var stunSrv *stun.Server
if stunAddrs := config.Addrs(cfg.StunListen); len(stunAddrs) > 0 {
stunSrv, err = stun.Listen(stunAddrs)
if err != nil {
return fmt.Errorf("stun listen: %w", err)
}
go func() { errCh <- fmt.Errorf("stun: %w", stunSrv.Serve()) }()
if stunSrv.Has5780() {
ctl.Capabilities = append(caps, "stun-5780")
} else {
ctl.Capabilities = append(caps, "stun-basic")
}
} else {
ctl.Capabilities = caps
}
// Canary DNS (spec §6.1) — authoritative for CanaryZone, udp+tcp per addr.
var dnsUDP []*net.UDPConn
var dnsTCP []net.Listener
if dnsAddrs := config.Addrs(cfg.DNSListen); len(dnsAddrs) > 0 && cfg.CanaryZone != "" {
v4, v6 := firstByFamily(dnsAddrs)
cd := canarydns.New(cfg.CanaryZone, cfg.Name, v4, v6)
for _, addr := range dnsAddrs {
ua, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return fmt.Errorf("dns udp addr %s: %w", addr, err)
}
uc, err := net.ListenUDP("udp", ua)
if err != nil {
return fmt.Errorf("dns udp listen %s: %w", addr, err)
}
dnsUDP = append(dnsUDP, uc)
go func(a string, c *net.UDPConn) { errCh <- fmt.Errorf("dns-udp %s: %w", a, cd.ServeUDP(c)) }(addr, uc)
tl, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("dns tcp listen %s: %w", addr, err)
}
dnsTCP = append(dnsTCP, tl)
go func(a string, l net.Listener) { errCh <- fmt.Errorf("dns-tcp %s: %w", a, cd.ServeTCP(l)) }(addr, tl)
}
ctl.CanaryZone = cfg.CanaryZone
ctl.CanaryQueries = func(prefix string) any { return cd.RecentForPrefix(prefix) }
ctl.Capabilities = append(ctl.Capabilities, "canary-dns")
}
slog.Info("listening",
"control", ctlAddrs, "admin", adminAddrs, "udp", udpAddrs,
"tcp", config.Addrs(cfg.TCPListen), "stun", config.Addrs(cfg.StunListen),
"dns", config.Addrs(cfg.DNSListen), "capabilities", ctl.Capabilities)
select {
case <-ctx.Done():
slog.Info("shutting down")
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = ctlSrv.Shutdown(shutCtx)
for _, srv := range adminSrvs {
_ = srv.Shutdown(shutCtx)
}
for _, c := range udpConns {
_ = c.Close()
}
for _, l := range tcpLns {
_ = l.Close()
}
if stunSrv != nil {
stunSrv.Close()
}
for _, c := range dnsUDP {
_ = c.Close()
}
for _, l := range dnsTCP {
_ = l.Close()
}
for _, hs := range httpEchoSrvs {
_ = hs.Shutdown(shutCtx)
}
return nil
case err := <-errCh:
return err
}
}
// firstByFamily returns the first v4 and first v6 address from a list of
// "ip:port" specs — used for the canary zone's apex/NS answers.
func firstByFamily(addrs []string) (v4, v6 netip.Addr) {
for _, a := range addrs {
if ap, err := netip.ParseAddrPort(a); err == nil {
if ap.Addr().Unmap().Is4() && !v4.IsValid() {
v4 = ap.Addr().Unmap()
} else if ap.Addr().Is6() && !ap.Addr().Is4In6() && !v6.IsValid() {
v6 = ap.Addr()
}
}
}
return
}
func firstAddr(spec string) string {
if a := config.Addrs(spec); len(a) > 0 {
return a[0]
}
return ""
}
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)
}
// publicControlURL is where clients should reach this server's control plane.
//
// Configured wins; otherwise the first control listen address is used, which is correct for the
// plain case (bind an address, hand out that address). A wildcard bind has no single right answer,
// so it is left to the operator rather than guessed — a link pointing at 0.0.0.0 is worse than a
// link the operator was told to configure.
func publicControlURL(cfg *config.Config) string {
if cfg.PublicControlURL != "" {
return strings.TrimRight(cfg.PublicControlURL, "/")
}
addr := firstAddr(cfg.ControlListen)
if addr == "" {
return ""
}
if strings.HasPrefix(addr, ":") || strings.HasPrefix(addr, "0.0.0.0:") || strings.HasPrefix(addr, "[::]:") {
slog.Warn("control plane is bound to a wildcard address; set ECHOLOT_PUBLIC_URL "+
"so enrollment links point somewhere reachable", "listen", addr)
}
return "https://" + addr
}
// setAdminPassword stores the break-glass admin credential.
//
// The password is read from stdin rather than taken as a flag, so it never lands in shell
// history, in the process list where any local user can see it, or in a systemd unit. Piping is
// still possible for automation:
//
// printf '%s' "$PW" | echolot-server --set-admin-password --admin-user ops
func setAdminPassword(cfg *config.Config) error {
st, err := store.Open(cfg.StateDir)
if err != nil {
return fmt.Errorf("state store: %w", err)
}
fmt.Fprintf(os.Stderr, "New password for %q (input is not echoed if this is a terminal): ", cfg.AdminUser)
pw, err := readSecret()
if err != nil {
return err
}
fmt.Fprintln(os.Stderr)
cred, err := adminauth.NewCredential(cfg.AdminUser, pw)
if err != nil {
return err
}
if err := st.SetLocalAdmin(cred); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Break-glass admin %q set. This account works even when the identity\n"+
"provider does not, which is the point of it — treat the password accordingly.\n", cfg.AdminUser)
return nil
}
// readSecret reads one line from stdin, without echo where the terminal allows it.
func readSecret() (string, error) {
restore, _ := system.DisableEcho(os.Stdin)
if restore != nil {
defer restore()
}
r := bufio.NewReader(os.Stdin)
line, err := r.ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimSpace(line), nil
}