big_send now forces the Don't-Fragment bit for the whole burst by default, so the largest size that arrives IS the downstream path MTU rather than "fragments got through" — two different measurements the schema already separates. Sizes above our own egress MTU (from the startup self-test) are refused up front and reported as max_df_bytes, because absence caused by our kernel must not be read as a limit of the client's path. Uploads: one JSON file per run under the state dir, with the policy the operator actually cares about — who may upload (off / anonymous / account), how large, how long to keep, and the least anonymization accepted. The profile advertises all of it so the app can present the switch honestly instead of discovering the rules by failing. `account` refuses today rather than falling back to anonymous: picking the strict setting before OIDC lands must not silently mean the loose one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
182 lines
8.5 KiB
Go
182 lines
8.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
|
|
|
|
// 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.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
|
|
}
|