Stdlib DNS responder (no external deps): parses single-question queries
with EDNS OPT (bufsize, DO, ECS), serves the spec's frozen reference
records (ttl-{5,60,3600,86400} A/AAAA/TXT, many-rr 8×A in order, big-txt
~1800B), and per-query <nonce>.<session>.<zone> answers in 192.0.2.0/24.
UDP truncation sets TC past 512 (or the EDNS bufsize); TCP never
truncates — the EDNS-bufsize / TCP-fallback test. Every query is logged
(qname, resolver, transport, EDNS, ECS, case) and surfaced per session
prefix in GET /v1/sessions/{id}/observations as dns_canary. Profile gains
canary_zone + the canary-dns capability when configured.
Wire format validated against an independent client (correct rcodes,
answer counts, TC behavior, full EDNS response); unit tests cover
references, truncation-vs-EDNS, logging, NXDOMAIN.
Versioning: patch-first convention recorded in CLAUDE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
357 lines
11 KiB
Go
357 lines
11 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/pem"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
|
|
"echo-lot.app/server/internal/canarydns"
|
|
"echo-lot.app/server/internal/config"
|
|
"echo-lot.app/server/internal/control"
|
|
"echo-lot.app/server/internal/dataplane"
|
|
"echo-lot.app/server/internal/selfupdate"
|
|
"echo-lot.app/server/internal/session"
|
|
"echo-lot.app/server/internal/store"
|
|
"echo-lot.app/server/internal/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}
|
|
tcpSrv := &tcpecho.Server{}
|
|
|
|
caps := []string{"udp-probe", "delayed-echo", "connect-back"}
|
|
if len(config.Addrs(cfg.TCPListen)) > 0 {
|
|
caps = append(caps, "tcp-echo")
|
|
}
|
|
|
|
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,
|
|
DelayedEcho: dp.SendDelayedEcho,
|
|
TCPRecent: func(ip string) any { return tcpSrv.RecentFor(ip) },
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Admin/health (plain HTTP, localhost by default; spec §7)
|
|
admin := http.NewServeMux()
|
|
admin.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
fmt.Fprintf(w, `{"ok":true,"version":%q}`, Version)
|
|
})
|
|
// TODO(spec §7): enrollment token management + device list. Until the
|
|
// admin UI exists, mint tokens with: echolot-admin (or curl on this
|
|
// listener once the endpoint lands).
|
|
admin.HandleFunc("POST /admin/enroll-tokens", func(w http.ResponseWriter, r *http.Request) {
|
|
tok, err := st.NewEnrollToken(24*time.Hour, r.URL.Query().Get("note"))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
fmt.Fprintf(w, `{"token":%q,"expires_in_s":86400}`+"\n", tok)
|
|
})
|
|
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
|
|
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
|
|
|
|
// UDP data plane — 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)
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
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)
|
|
}
|