// 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" "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) // 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 // Mode Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces) } // 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.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.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 }