Deploying v0.8.0 broke fmr, and the reason is a flaw I should have seen: self-update is executed by the OLD binary, so the unit repair I put in the new binary's updater cannot fix the very update that installs it. The unit kept its argument-less ExecStart, the new binary answered that with usage and exit 2, and the service went into a restart loop. Fixed on fmr by hand, but that is not a fix for anyone else - and the whole premise of an unattended self-update is that nobody is watching when it happens. So: when started with no verb *and* systemd started us, the server repairs the unit and serves anyway, loudly. systemd sets INVOCATION_ID for every service invocation and nothing else does, so a person at a terminal still gets usage and a non-zero exit. Marked as a one-release shim to remove once no deployment predates --serve. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
594 lines
20 KiB
Go
594 lines
20 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/json"
|
|
"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/adminauth"
|
|
"echo-lot.app/server/internal/canarydns"
|
|
"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.
|
|
var idp *oidc.Verifier
|
|
if cfg.OIDCIssuer != "" && (cfg.OIDCClientID != "" || cfg.OIDCAppClientID != "") {
|
|
idp = oidc.New(oidc.Config{
|
|
Issuer: cfg.OIDCIssuer,
|
|
ClientID: cfg.OIDCClientID,
|
|
AppClientID: cfg.OIDCAppClientID,
|
|
AdminGroup: cfg.OIDCAdminGroup,
|
|
}, nil)
|
|
slog.Info("identity provider configured", "issuer", cfg.OIDCIssuer,
|
|
"admin_client_id", cfg.OIDCClientID, "app_client_id", cfg.OIDCAppClientID,
|
|
"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)")
|
|
}
|
|
} else if 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,
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
admin.HandleFunc("GET /admin/selftest", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(selftestPtr.Load())
|
|
})
|
|
// 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
|
|
}
|
|
// The whole bootstrap, not just the token: this is what gets pasted or turned into a
|
|
// QR code, and assembling it here is what keeps an operator from transcribing a pin by
|
|
// hand — a pin wrong by one character fails as an inscrutable TLS error days later.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false) // the link is full of / and =; escaping them helps nobody
|
|
_ = enc.Encode(map[string]any{
|
|
"token": tok,
|
|
"expires_in_s": 86400,
|
|
"enroll_uri": ctl.EnrollmentLink(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 — 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
|
|
}
|