Letting the kernel fragment an oversized datagram answers one question — do
fragments get through. It cannot answer the more interesting one, because the
kernel always emits them in order, first one first.
The classic middlebox fault is exactly about that ordering. Only the first
fragment carries the UDP header, and therefore the ports; a stateful firewall
or NAT that has not seen it has no flow to match the rest against, and many
drop them. That is invisible to any in-order test and shows up in the field as
"large DNS answers fail on this network" or "the tunnel breaks when the MTU
drops" — it works until the network reorders, then fails intermittently, which
is the hardest kind of fault to chase.
So the server now builds the fragments itself (raw socket, IP_HDRINCL) and
controls their order: in_order as a baseline, reversed, and first-fragment-last.
The datagram is assembled and signed whole before being cut up, so what the
client reassembles is indistinguishable from an ordinary packet — otherwise it
would be measuring our sender rather than the path.
Two details that would silently produce wrong answers:
- The UDP checksum is computed rather than left zero. A zero-checksum datagram
is dropped by some middleboxes, and that drop would be recorded as a
fragmentation failure, which is the wrong conclusion entirely.
- Fragment offsets are in 8-byte units, so non-final fragments are rounded to
a multiple of 8. A 100-byte fragment is not an error, it is a datagram no
host will ever reassemble.
frag-send is advertised only when a raw socket can actually be opened — checked
by opening one, since a permission model has more ways to say no than a
capability bit has to say yes.
Fragment header arithmetic is unit-tested (reassembly coverage, MF flags, shared
IP ID, 8-byte offsets, checksum verification), cross-compiled and run on Linux
since the code is build-tagged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
495 lines
17 KiB
Go
495 lines
17 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 (
|
|
"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/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/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.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.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"}
|
|
// 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)
|
|
|
|
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),
|
|
}
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|