Replaces the unauthenticated admin mux. Everything but /healthz requires a session, and that is the point: the previous arrangement relied on binding to loopback, which worked exactly until the address changed and then failed silently and publicly. A binding address is a deployment detail, not an access control, and this package does not treat it as one. Two ways in. OIDC through the confidential client, with state and PKCE - PKCE even here, because it costs one hash and closes code interception independently of the secret. And the break-glass password, throttled, for when the IdP is the thing that is broken. Signing in without the admin group is refused with the group named, because "you are not an admin" is a different problem from "your password is wrong" and the remedy is elsewhere. Sessions are MAC-checked cookies: HttpOnly, SameSite=Lax, Secure when TLS is on. CSRF tokens are derived from the session rather than stored, so there is no server-side table to keep in sync, and they are required on every state-changing POST - SameSite already blocks cross-site posts in current browsers, but this is the control that does not depend on the browser being current. Server-rendered with html/template and no JavaScript: the pages are lists and forms, and a framework would add a build step, a dependency tree and an update treadmill to a program that has none of those. The CSP is default-src 'none' accordingly. Pages: overview, devices (with revocation and enrolment-link minting), uploaded runs and a run viewer. Revocations and deletions are logged with who did them. Runs are shown exactly as uploaded, at the privacy level their uploader chose - nothing in the UI can un-redact one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
646 lines
23 KiB
Go
646 lines
23 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.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)
|
|
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")
|
|
}
|
|
|
|
ctl := &control.Server{
|
|
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,
|
|
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()
|
|
|
|
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
|
|
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)
|
|
}
|
|
adminSrv.TLSConfig = reloader.TLSConfig()
|
|
if exp := reloader.NotAfter(); !exp.IsZero() {
|
|
slog.Info("admin UI TLS", "listen", cfg.AdminListen, "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))
|
|
}
|
|
}
|
|
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServeTLS("", "")) }()
|
|
} else {
|
|
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
acmeSrv := &http.Server{
|
|
Addr: cfg.ACMEHTTPListen,
|
|
Handler: acmehttp.Handler(webroot, cfg.AdminBaseURL),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
slog.Info("acme http-01 responder", "listen", cfg.ACMEHTTPListen, "webroot", webroot,
|
|
"redirects_to", cfg.AdminBaseURL)
|
|
go func() { errCh <- fmt.Errorf("acme-http: %w", acmeSrv.ListenAndServe()) }()
|
|
}
|
|
|
|
// 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", cfg.AdminListen, "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)
|
|
_ = adminSrv.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
|
|
}
|