- Comma-separated ECHOLOT_{CONTROL,UDP,TCP}_LISTEN; one listener/socket per
address. Explicit binds matter on multi-IP hosts (a wildcard would also
claim the SSH-only management address) and per-address UDP sockets are
the substrate stun-5780 needs.
- systemd unit reads /etc/echolot-server.env (seeded once, never
overwritten); --install-systemd with --self-update-api also installs a
daily randomized update timer that try-restarts the service.
- selfupdate: SHA256SUMS verification is now mandatory before the atomic
replace (integrity, not authenticity — signing still TODO).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
5.6 KiB
Go
145 lines
5.6 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"
|
|
"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)
|
|
|
|
// 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_<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.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
|
|
}
|