Echolot delegates identity to whatever IdP the operator already runs and stores no passwords - no hashing, no reset flow, no lockout policy, and no credential database to lose. For a tool people self-host next to other services, that is the difference between one more service and one more thing that can leak someone's password. Verification is stdlib-only, matching the server's no-dependency rule. Longer than jwt.Parse, and auditable in one sitting. The part that matters is the algorithm allow-list: taking `alg` from the token is the classic forgery, so it is fixed in code. Tests cover the real attacks against a genuine signer - a self-contained IdP with real keys, because a mock that returns success proves nothing about a verifier: alg=none, HS256/RS256 confusion, a payload swapped under a valid signature, a token addressed to another client, a token from another issuer, expired and future-dated tokens, and discovery that renames the issuer (which would otherwise have us fetch a stranger's keys believing they were the provider's). With no admin group configured nobody is an admin. An operator who has not said who may administer the server has not thereby said "anyone who can log in". Device and account stay separate concepts: enrollment admits a device (operator's token), signing in attributes it to a person (POST /v1/account/link, device credential plus ID token - both required, neither substitutes). uploads=account now means what it says instead of refusing everyone, and signing in does not override uploads=off. The profile advertises the sign-in configuration so the app can offer the button only when there is something behind it, and drive PKCE without anyone typing an issuer URL. A discovery failure is reported rather than hidden, so "configured but the provider is not answering" is distinguishable from "not configured". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
203 lines
10 KiB
Go
203 lines
10 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
|
|
|
|
// 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
|
|
OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group
|
|
|
|
// 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.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", ""), "OpenID Connect client id for this server")
|
|
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.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
|
|
}
|