// 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 // 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) 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//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 /acme. ACMEWebroot string // ECHOLOT_ACME_WEBROOT / --acme-webroot // Mode Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces) } // secretOr reads ECHOLOT_, or the contents of the file named by ECHOLOT__FILE. // // The file form exists because a secret in the environment is readable by anything that can see // /proc//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_ 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_ 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.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.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 /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 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 } // 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 }