Files
echolot/server/internal/config/config.go
T
mrambossekandClaude Opus 5 5b5a54db7d server: reserve measurement addresses; serve the UI on both families
fmr keeps .151/::151 for measurement. Their diagnostic value is entirely
in their listening state being known: a TLS handshake that completes on a
port nothing listens on proves interception, with no competing
explanation. One stray bind turns that proof into a shrug, and nothing
about the failure is visible — the run still says the network is clean.

CheckReserved refuses to start when a listener would take one. Wildcards
are refused outright, because that is how this actually happens: every
listener defaults to ":port" and the next one added gets copied from an
existing default, claiming every address without anyone deciding to.

Reserved is not silent, though. The first version of the guard would have
refused the live config's UDP and canary-DNS binds on .151, which are
deliberate — as is STUN's RFC 5780 alternate. Reserving an address and
then forbidding the measurements that need it defeats the purpose. The
rule is narrower: no services, and never ports 80 or 443.

The adb-beacon receiver was wildcard-bound to 0.0.0.0:443, holding port
443 on every IPv4 address including the reserved one, so the IPv4
interception test had been compromised for as long as it had run. It is
disabled; restore with systemctl enable --now echolot-adb-beacon. This
also marks the guard's limit: it governs this server's listeners, and a
process outside its config can still pollute a reserved address.

The admin UI and ACME responder were single-address, which is why the UI
could only live on ::2 and why the server was reachable over IPv6 alone —
the thing that made it look nonexistent from a phone without working
IPv6. Both now take address lists like every other listener.

Verified from outside: .150/::150/::2 answer on 443 with a valid cert for
fmr.echo-lot.app, .151/::151 are closed on 80 and 443, and canary DNS is
still up on .151.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:17:29 +02:00

372 lines
20 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package config resolves server configuration with the precedence
// flags > environment > defaults. In Docker mode (detected or forced with
// --docker) env vars are the expected source; native installs typically use
// flags baked into the systemd unit by --install-systemd.
package config
import (
"flag"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
)
// Config holds everything the daemon needs. Every field maps to exactly one
// env var (ECHOLOT_*) and one flag; keep the three in sync when adding fields.
type Config struct {
// Control plane (HTTPS + JSON). TLS is pin-based per spec §1: self-signed
// is first-class, so cert+key are generated if the paths don't exist.
ControlListen string // ECHOLOT_CONTROL_LISTEN / --control-listen
TLSCert string // ECHOLOT_TLS_CERT / --tls-cert
TLSKey string // ECHOLOT_TLS_KEY / --tls-key
// Data plane
UDPListen string // ECHOLOT_UDP_LISTEN / --udp-listen (spec default port 8442)
TCPListen string // ECHOLOT_TCP_LISTEN / --tcp-listen (spec default port 8441)
StunListen string // ECHOLOT_STUN_LISTEN / --stun-listen (spec default 3478; empty disables)
DNSListen string // ECHOLOT_DNS_LISTEN / --dns-listen (canary zone; empty disables)
CanaryZone string // ECHOLOT_CANARY_ZONE / --canary-zone (e.g. c.echo-lot.app)
// Optional cleartext HTTP-echo listener (spec §4 plaintext-path test).
// Default empty = off; it exposes only POST /v1/echo, no auth, no secrets.
HTTPEchoListen string // ECHOLOT_HTTP_ECHO_LISTEN / --http-echo-listen
// Comma-separated anchors for the egress-MTU self-proof (host or ip).
MTUProbeTargets string // ECHOLOT_MTU_PROBE_TARGETS / --mtu-probe-targets
// Admin UI / health listener (spec §7: localhost-only by default)
AdminListen string // ECHOLOT_ADMIN_LISTEN / --admin-listen
// ReservedAddrs are IPs reserved for measurement: addresses whose listening state must stay
// known, so that "nothing answered on port 443" is a fact about the network rather than a
// fact about this server's configuration. Enforced by CheckReserved.
ReservedAddrs string // ECHOLOT_RESERVED_ADDRS / --reserved-addrs
// State directory: device store, generated TLS material.
StateDir string // ECHOLOT_STATE_DIR / --state-dir
// Profile identity
Name string // ECHOLOT_NAME / --name (profile "name", e.g. "homelab")
// Self-update (native mode only; ignored in containers — images update
// by pulling a new tag). Empty = disabled. Value: Gitea repo API base,
// e.g. https://git.example.net/api/v1/repos/owner/repo
SelfUpdateAPI string // ECHOLOT_SELF_UPDATE_API / --self-update-api
// Uploaded-run storage. The default is "anonymous": any enrolled device may upload,
// which is what a self-hosted server wants. Operators of shared servers turn it down.
UploadsMode string // ECHOLOT_UPLOADS / --uploads (off|anonymous|account)
UploadMaxBytes int64 // ECHOLOT_UPLOAD_MAX_BYTES / --upload-max-bytes
UploadRetentionDays int // ECHOLOT_UPLOAD_RETENTION_DAYS / --upload-retention-days
UploadMaxRuns int // ECHOLOT_UPLOAD_MAX_RUNS / --upload-max-runs (per device)
UploadMinAnon string // ECHOLOT_UPLOAD_MIN_ANONYMIZATION / --upload-min-anonymization
// Client compatibility window. Bounds are SemVer; an empty maximum means unbounded. The
// defaults sit at breaking boundaries, so shipping a patch never requires changing them.
MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version
MaxAppVersion string // ECHOLOT_MAX_APP_VERSION / --max-app-version (exclusive)
// Where clients reach the control plane, for enrollment links. Empty = derive from the
// first control listen address.
PublicControlURL string // ECHOLOT_PUBLIC_URL / --public-url
// Identity provider. Empty issuer disables sign-in entirely; the server is a relying
// party and never stores passwords.
OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer
OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id (confidential, admin UI)
OIDCAppClientID string // ECHOLOT_OIDC_APP_CLIENT_ID / --oidc-app-client-id (public, the phone app)
// Issuer for the app's client, when the IdP gives each application its own.
//
// Authentik derives the issuer from the application slug, so two applications mean two
// issuers — and a token's `iss` must match the one that minted it. Empty means both clients
// share ECHOLOT_OIDC_ISSUER, which is what IdPs with a single global issuer do.
OIDCAppIssuer string // ECHOLOT_OIDC_APP_ISSUER / --oidc-app-issuer
OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group
// Break-glass admin username; the password lives hashed in the state store.
AdminUser string // ECHOLOT_ADMIN_USER / --admin-user
// Secret for the *confidential* admin client. Prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE: a path
// keeps the secret out of the environment, where it is readable by anything that can see
// /proc/<pid>/environ and lands in every dump of the unit's configuration.
OIDCClientSecret string // ECHOLOT_OIDC_CLIENT_SECRET / _FILE
// Where the admin UI is reachable, used to build the OIDC redirect URI. Must match what is
// registered at the IdP exactly.
AdminBaseURL string // ECHOLOT_ADMIN_BASE_URL / --admin-base-url
// TLS for the admin listener. Without these it serves plaintext, which is only acceptable on
// loopback — see checkAdminExposure.
AdminTLSCert string // ECHOLOT_ADMIN_TLS_CERT / --admin-tls-cert
AdminTLSKey string // ECHOLOT_ADMIN_TLS_KEY / --admin-tls-key
// Deliberate override for serving the admin UI in plaintext off loopback, so that decision
// is made rather than stumbled into.
AdminInsecure bool // ECHOLOT_ADMIN_INSECURE / --admin-insecure
// Port-80 listener that answers ACME HTTP-01 challenges and redirects everything else to
// the admin UI. Empty disables it. HTTP-01 always arrives on port 80 — the CA picks the
// port — so this never collides with the admin UI on 443.
ACMEHTTPListen string // ECHOLOT_ACME_HTTP_LISTEN / --acme-http-listen
// Directory an ACME client writes challenge tokens into. Defaults to <state-dir>/acme.
ACMEWebroot string // ECHOLOT_ACME_WEBROOT / --acme-webroot
// Mode
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
}
// secretOr reads ECHOLOT_<key>, or the contents of the file named by ECHOLOT_<key>_FILE.
//
// The file form exists because a secret in the environment is readable by anything that can see
// /proc/<pid>/environ and lands in every dump of the unit's configuration. A path costs nothing
// and keeps the value in one file whose permissions an operator can reason about.
func secretOr(key, def string) string {
if path := envOr(key+"_FILE", ""); path != "" {
if b, err := os.ReadFile(path); err == nil {
return strings.TrimSpace(string(b))
}
}
return envOr(key, def)
}
// envInt reads ECHOLOT_<key> as an integer with a fallback.
func envInt(key string, def int) int {
if v := envOr(key, ""); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
// envOr reads ECHOLOT_<key> with a fallback.
func envOr(key, def string) string {
if v, ok := os.LookupEnv("ECHOLOT_" + key); ok {
return v
}
return def
}
// Load parses flags/env. Returns the config plus the action flags that make
// the process do something other than serve (install/uninstall/update).
func Load(args []string) (*Config, *Actions, error) {
fs := flag.NewFlagSet("echolot-server", flag.ContinueOnError)
c := &Config{}
a := &Actions{}
fs.StringVar(&c.ControlListen, "control-listen", envOr("CONTROL_LISTEN", ":8443"), "control-plane HTTPS listen address(es), comma-separated")
fs.StringVar(&c.TLSCert, "tls-cert", envOr("TLS_CERT", ""), "TLS cert path (empty: self-signed in state dir)")
fs.StringVar(&c.TLSKey, "tls-key", envOr("TLS_KEY", ""), "TLS key path (empty: self-signed in state dir)")
fs.StringVar(&c.UDPListen, "udp-listen", envOr("UDP_LISTEN", ":8442"), "UDP data-plane listen address(es), comma-separated")
fs.StringVar(&c.TCPListen, "tcp-listen", envOr("TCP_LISTEN", ":8441"), "TCP echo listen address(es), comma-separated")
fs.StringVar(&c.StunListen, "stun-listen", envOr("STUN_LISTEN", ":3478"), "STUN listen address(es), comma-separated; empty disables (spec §4)")
fs.StringVar(&c.DNSListen, "dns-listen", envOr("DNS_LISTEN", ""), "canary-DNS listen address(es) udp+tcp/53, comma-separated; empty disables (spec §6.1)")
fs.StringVar(&c.CanaryZone, "canary-zone", envOr("CANARY_ZONE", ""), "authoritative canary zone, e.g. c.echo-lot.app")
fs.StringVar(&c.HTTPEchoListen, "http-echo-listen", envOr("HTTP_ECHO_LISTEN", ""), "optional CLEARTEXT http-echo listen address(es); empty disables (spec §4)")
fs.StringVar(&c.MTUProbeTargets, "mtu-probe-targets", envOr("MTU_PROBE_TARGETS", "1.1.1.1,2606:4700:4700::1111"), "egress-MTU self-proof anchors, comma-separated")
fs.StringVar(&c.AdminListen, "admin-listen", envOr("ADMIN_LISTEN", "127.0.0.1:8444"), "admin/health listen address (keep localhost)")
fs.StringVar(&c.ReservedAddrs, "reserved-addrs", envOr("RESERVED_ADDRS", ""), "comma-separated IPs reserved for measurement; no listener but the STUN alternate may bind them")
fs.StringVar(&c.StateDir, "state-dir", envOr("STATE_DIR", defaultStateDir()), "state directory (device store, generated TLS)")
fs.StringVar(&c.Name, "name", envOr("NAME", "echolot"), "server profile name")
fs.StringVar(&c.SelfUpdateAPI, "self-update-api", envOr("SELF_UPDATE_API", ""), "Gitea repo API base for self-update; empty disables")
fs.StringVar(&c.UploadsMode, "uploads", envOr("UPLOADS", "anonymous"), "who may upload measurement runs: off|anonymous|account")
fs.Int64Var(&c.UploadMaxBytes, "upload-max-bytes", int64(envInt("UPLOAD_MAX_BYTES", 4<<20)), "largest accepted uploaded run, bytes")
fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables")
fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables")
fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict")
fs.StringVar(&c.OIDCIssuer, "oidc-issuer", envOr("OIDC_ISSUER", ""), "OpenID Connect issuer URL; empty disables sign-in")
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "confidential OIDC client id for the admin UI")
fs.StringVar(&c.OIDCAppClientID, "oidc-app-client-id", envOr("OIDC_APP_CLIENT_ID", ""), "public OIDC client id used by the Android app (PKCE)")
fs.StringVar(&c.OIDCAppIssuer, "oidc-app-issuer", envOr("OIDC_APP_ISSUER", ""), "issuer for the app client when the IdP uses per-application issuers; empty = same as --oidc-issuer")
fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC")
fs.StringVar(&c.OIDCClientSecret, "oidc-client-secret", secretOr("OIDC_CLIENT_SECRET", ""), "secret for the confidential admin client; prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE")
fs.StringVar(&c.AdminBaseURL, "admin-base-url", envOr("ADMIN_BASE_URL", ""), "public URL of the admin UI, for the OIDC redirect (e.g. https://admin.example.net)")
fs.StringVar(&c.AdminTLSCert, "admin-tls-cert", envOr("ADMIN_TLS_CERT", ""), "TLS certificate for the admin listener")
fs.StringVar(&c.AdminTLSKey, "admin-tls-key", envOr("ADMIN_TLS_KEY", ""), "TLS key for the admin listener")
fs.BoolVar(&c.AdminInsecure, "admin-insecure", envOr("ADMIN_INSECURE", "") == "1", "allow the admin UI in plaintext off loopback (you are on your own)")
fs.StringVar(&c.ACMEHTTPListen, "acme-http-listen", envOr("ACME_HTTP_LISTEN", ""), "port-80 listener for ACME HTTP-01 challenges and http->https redirects")
fs.StringVar(&c.ACMEWebroot, "acme-webroot", envOr("ACME_WEBROOT", ""), "directory an ACME client writes challenges into (default <state-dir>/acme)")
fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443")
fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")
fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)")
fs.BoolVar(&a.InstallSystemd, "install-systemd", false, "install a systemd unit for this binary and exit")
fs.BoolVar(&a.UninstallSystemd, "uninstall-systemd", false, "remove the systemd unit and exit")
var daemon bool
fs.BoolVar(&a.Serve, "serve", false, "run the server (bind listeners and answer requests)")
fs.BoolVar(&daemon, "daemon", false, "alias for --serve")
fs.BoolVar(&a.SetAdminPassword, "set-admin-password", false,
"set the break-glass admin password (username as --admin-user, password read from stdin) and exit")
fs.StringVar(&c.AdminUser, "admin-user", envOr("ADMIN_USER", "admin"), "username for the break-glass admin")
fs.BoolVar(&a.SelfUpdate, "self-update", false, "check for a newer release, replace this binary, and exit")
fs.BoolVar(&a.Version, "version", false, "print version and exit")
if err := fs.Parse(args); err != nil {
return nil, nil, err
}
if !c.Docker {
c.Docker = inContainer()
}
a.Serve = a.Serve || daemon
// No verb at all means the caller has not said what they want. Usage is the answer, and it
// is a usage error rather than success — otherwise a service manager sees a clean exit and
// concludes the server ran and finished.
if !a.Serve && !a.InstallSystemd && !a.UninstallSystemd && !a.SelfUpdate &&
!a.SetAdminPassword && !a.Version {
a.Help = true
}
if a.Serve {
if err := c.checkAdminExposure(); err != nil {
return nil, nil, err
}
if err := c.CheckReserved(c.Listeners()); err != nil {
return nil, nil, err
}
}
if c.Docker && (a.InstallSystemd || a.UninstallSystemd || a.SelfUpdate) {
return nil, nil, fmt.Errorf("systemd/self-update actions are native-mode only (container detected; override with ECHOLOT_DOCKER=0 if this is wrong)")
}
return c, a, nil
}
// Usage prints the verbs first and the tuning flags second, because the question someone has
// when they run this by name is "what does it do", not "what can I set".
func Usage(w io.Writer) {
fmt.Fprint(w, `echolot-server — the Echolot probe server
USAGE
echolot-server --serve run the server
echolot-server --version print the version
echolot-server --install-systemd install and enable a systemd unit
echolot-server --uninstall-systemd remove it
echolot-server --self-update replace this binary with the latest release
echolot-server --set-admin-password set the break-glass admin password (stdin)
echolot-server --help full flag list
Every flag can also be set as an environment variable: --control-listen becomes
ECHOLOT_CONTROL_LISTEN. In a container, configuration comes from the environment.
Running with no verb prints this and exits non-zero: starting to serve the internet
should be something you asked for.
`)
}
// checkAdminExposure refuses to serve an unencrypted admin UI on a non-loopback address.
//
// The admin session cookie is a bearer credential for everything this server can do, and the OIDC
// authorization code arrives in a URL. In plaintext, both are readable by anyone on the path — and
// on a globally routable address "the path" means the internet. This is a hard stop rather than a
// warning because a warning in a log is not read by the person who most needs it, and because the
// two safe answers are cheap: bind to loopback and tunnel, or supply a certificate.
func (c *Config) checkAdminExposure() error {
if c.AdminTLSCert != "" || c.AdminInsecure {
return nil
}
for _, addr := range Addrs(c.AdminListen) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
continue
}
ip := net.ParseIP(strings.Trim(host, "[]"))
if host == "" || ip == nil || ip.IsLoopback() {
continue // loopback, or a name we cannot judge; RFC 8252 blesses loopback plaintext
}
return fmt.Errorf(
"refusing to serve the admin UI in plaintext on %s: the session cookie and the OIDC "+
"authorization code would cross the network in the clear.\n"+
" Fix it one of three ways:\n"+
" - bind to 127.0.0.1 and reach it over an SSH tunnel (no certificate needed)\n"+
" - set ECHOLOT_ADMIN_TLS_CERT and ECHOLOT_ADMIN_TLS_KEY\n"+
" - set ECHOLOT_ADMIN_INSECURE=1 if you genuinely mean it", addr)
}
return nil
}
// Listeners enumerates every configured listen spec, for CheckReserved.
//
// Kept as one list here rather than checked at each call site, so a listener added later is
// caught by the compiler when this function is updated — and, more to the point, so that the
// person adding one sees the reserved-address rule exists at all.
func (c *Config) Listeners() []Listener {
return []Listener{
// The instrument: these belong on the reserved addresses as much as anywhere.
{Name: "control-listen", Spec: c.ControlListen, Measurement: true},
{Name: "udp-listen", Spec: c.UDPListen, Measurement: true},
{Name: "tcp-listen", Spec: c.TCPListen, Measurement: true},
{Name: "dns-listen", Spec: c.DNSListen, Measurement: true},
{Name: "stun-listen", Spec: c.StunListen, Measurement: true},
// http-echo is deliberately not marked as measurement: it is cleartext HTTP, so on a
// reserved address it would be the very listener that ruins the port-80 test.
{Name: "http-echo-listen", Spec: c.HTTPEchoListen},
// Services. These have no business on an address kept for measuring.
{Name: "admin-listen", Spec: c.AdminListen},
{Name: "acme-http-listen", Spec: c.ACMEHTTPListen},
}
}
// Addrs splits a comma-separated listen spec into individual addresses.
// Explicit per-address binds matter on multi-IP hosts: a wildcard bind
// (":8443") would also claim addresses reserved for other purposes (e.g. an
// SSH-only management IP).
func Addrs(spec string) []string {
var out []string
for _, a := range strings.Split(spec, ",") {
if a = strings.TrimSpace(a); a != "" {
out = append(out, a)
}
}
return out
}
// Actions are the verbs. Serving is one of them, and it is explicit: running the binary with no
// arguments prints usage rather than binding a dozen ports and starting to answer the internet.
// Someone typing the name of an unfamiliar program on a terminal should be told what it does, not
// have it start doing it.
type Actions struct {
Serve bool
// Help is set when there is nothing to do: no verb was given.
Help bool
InstallSystemd bool
UninstallSystemd bool
SelfUpdate bool
SetAdminPassword bool
Version bool
}
func defaultStateDir() string {
if os.Geteuid() == 0 {
return "/var/lib/echolot-server"
}
home, err := os.UserHomeDir()
if err != nil {
return "./echolot-state"
}
return home + "/.local/share/echolot-server"
}
// inContainer detects Docker/Podman/K8s without being asked: /.dockerenv,
// /run/.containerenv (podman), or a container hint in /proc/1/cgroup
// (cgroup v1 era) / KUBERNETES_SERVICE_HOST. Best-effort — --docker and
// ECHOLOT_DOCKER always win.
func inContainer() bool {
for _, marker := range []string{"/.dockerenv", "/run/.containerenv"} {
if _, err := os.Stat(marker); err == nil {
return true
}
}
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
return true
}
if b, err := os.ReadFile("/proc/1/cgroup"); err == nil {
s := string(b)
if strings.Contains(s, "docker") || strings.Contains(s, "containerd") || strings.Contains(s, "kubepods") {
return true
}
}
return false
}