Files
echolot/server/internal/config/config.go
T
mrambossekandClaude Fable 5 ad85f3bfcd
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 31s
enrollment: the server mints the §2.1 bootstrap link, the app consumes it
POST /admin/enroll-tokens now returns the whole link, not just the token:

  echolot://enroll?v=1&u=<control URL>&p=pin-sha256:<b64>&t=<token>

The server is the only party that knows all three parts at once, and the part
an operator gets wrong by hand is the base64 pin — which does not fail loudly,
it just never matches, surfacing days later as an inscrutable TLS error. The
app takes the link from a paste or from an echolot:// deep link (QR scan), and
writes URL, pin and credential together or not at all.

One trap the tests pin: an unencoded "+" in a query string decodes to a space,
so a hand-assembled link arrives with a pin wrong by one character. Base64 has
no spaces, so they are restored — unambiguous, and it cannot damage a correctly
encoded pin.

Also fixes a spec divergence: §2.1 names the field device_credential and the
first implementation shipped "credential". Both are sent now and the client
prefers the spec's; the alias goes once nothing reads it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:06:22 +02:00

194 lines
9.5 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"
"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
// Mode
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
}
// 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.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.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")
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()
}
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
}
// 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 one-shot verbs that exit instead of serving.
type Actions struct {
InstallSystemd bool
UninstallSystemd bool
SelfUpdate 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
}