diff --git a/server/README.md b/server/README.md index 216fda3..fcc9b8a 100644 --- a/server/README.md +++ b/server/README.md @@ -65,7 +65,22 @@ sudo /usr/local/bin/echolot-server --uninstall-systemd ``` Config precedence: flags > `ECHOLOT_*` env > defaults. Every flag has an env twin -(`--udp-listen` ↔ `ECHOLOT_UDP_LISTEN`). +(`--udp-listen` ↔ `ECHOLOT_UDP_LISTEN`). Host config lives in `/etc/echolot-server.env` +(seeded by `--install-systemd`, never overwritten). + +**Multi-IP hosts:** listen specs are comma-separated, and you should bind explicit addresses — +a wildcard bind would also claim management-only IPs: + +```sh +ECHOLOT_CONTROL_LISTEN=203.0.113.10:8443,[2001:db8::10]:8443 +ECHOLOT_UDP_LISTEN=203.0.113.10:8442,203.0.113.11:8442,[2001:db8::10]:8442,[2001:db8::11]:8442 +``` + +Passing `--self-update-api` to `--install-systemd` additionally installs a daily randomized +self-update timer (`echolot-server-update.timer`) that restarts the service after a successful +update. Updates are checksum-verified against the release's `SHA256SUMS` (integrity, not +authenticity — signature verification remains TODO before treating the update source as +untrusted). ### Self-update (opt-in, native only) diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index a39fd09..c0e1929 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -63,8 +63,10 @@ func run() error { fmt.Println(Version) return nil case actions.InstallSystemd: - // The unit runs this same binary in serve mode with env-based config. - return system.InstallSystemd(nil) + // The unit runs this same binary in serve mode; host config comes + // from /etc/echolot-server.env. A self-update timer is installed + // only when an update API is configured. + return system.InstallSystemd(cfg.SelfUpdateAPI) case actions.UninstallSystemd: return system.UninstallSystemd() case actions.SelfUpdate: @@ -95,20 +97,31 @@ func serve(cfg *config.Config) error { sessions := session.NewManager(15 * time.Minute) ctl := &control.Server{ Store: st, Sessions: sessions, Name: cfg.Name, - UDPPort: mustPort(cfg.UDPListen), TCPPort: mustPort(cfg.TCPListen), PinB64: pin, + UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)), PinB64: pin, } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - errCh := make(chan error, 4) + ctlAddrs := config.Addrs(cfg.ControlListen) + udpAddrs := config.Addrs(cfg.UDPListen) + errCh := make(chan error, len(ctlAddrs)+len(udpAddrs)+2) - // Control plane (HTTPS, pin-based trust) + // Control plane (HTTPS, pin-based trust) — one shared server, one + // listener per configured address; Shutdown closes them all. ctlSrv := &http.Server{ - Addr: cfg.ControlListen, Handler: ctl.Handler(), + Handler: ctl.Handler(), TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, ReadHeaderTimeout: 10 * time.Second, } - go func() { errCh <- fmt.Errorf("control: %w", ctlSrv.ListenAndServeTLS("", "")) }() + for _, addr := range ctlAddrs { + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("control listen %s: %w", addr, err) + } + go func(a string, l net.Listener) { + errCh <- fmt.Errorf("control %s: %w", a, ctlSrv.ServeTLS(l, "", "")) + }(addr, ln) + } // Admin/health (plain HTTP, localhost by default; spec §7) admin := http.NewServeMux() @@ -129,20 +142,28 @@ func serve(cfg *config.Config) error { adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second} go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }() - // UDP data plane - udpAddr, err := net.ResolveUDPAddr("udp", cfg.UDPListen) - if err != nil { - return err + // UDP data plane — one socket per configured address. Distinct sockets + // (not wildcard) also guarantee responses leave from the address the + // request arrived on, which stun-5780 will rely on. + var udpConns []*net.UDPConn + for _, addr := range udpAddrs { + udpAddr, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + return fmt.Errorf("udp addr %s: %w", addr, err) + } + conn, err := net.ListenUDP("udp", udpAddr) + if err != nil { + return fmt.Errorf("udp listen %s: %w", addr, err) + } + udpConns = append(udpConns, conn) + dp := &dataplane.Server{Sessions: sessions} + go func(a string, c *net.UDPConn) { + errCh <- fmt.Errorf("udp %s: %w", a, dp.Serve(c)) + }(addr, conn) } - udpConn, err := net.ListenUDP("udp", udpAddr) - if err != nil { - return fmt.Errorf("udp listen: %w", err) - } - dp := &dataplane.Server{Sessions: sessions} - go func() { errCh <- fmt.Errorf("udp: %w", dp.Serve(udpConn)) }() slog.Info("listening", - "control", cfg.ControlListen, "admin", cfg.AdminListen, "udp", cfg.UDPListen) + "control", ctlAddrs, "admin", cfg.AdminListen, "udp", udpAddrs) select { case <-ctx.Done(): @@ -151,13 +172,22 @@ func serve(cfg *config.Config) error { defer cancel() _ = ctlSrv.Shutdown(shutCtx) _ = adminSrv.Shutdown(shutCtx) - _ = udpConn.Close() + for _, c := range udpConns { + _ = c.Close() + } return nil case err := <-errCh: return err } } +func firstAddr(spec string) string { + if a := config.Addrs(spec); len(a) > 0 { + return a[0] + } + return "" +} + func mustPort(listen string) int { _, p, err := net.SplitHostPort(listen) if err != nil { diff --git a/server/internal/config/config.go b/server/internal/config/config.go index c6a1278..4e68f5c 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -60,11 +60,11 @@ func Load(args []string) (*Config, *Actions, error) { c := &Config{} a := &Actions{} - fs.StringVar(&c.ControlListen, "control-listen", envOr("CONTROL_LISTEN", ":8443"), "control-plane HTTPS listen address") + 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") - fs.StringVar(&c.TCPListen, "tcp-listen", envOr("TCP_LISTEN", ":8441"), "TCP echo listen address") + 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") @@ -88,6 +88,20 @@ func Load(args []string) (*Config, *Actions, error) { 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 diff --git a/server/internal/selfupdate/selfupdate.go b/server/internal/selfupdate/selfupdate.go index abf4ca6..7f44782 100644 --- a/server/internal/selfupdate/selfupdate.go +++ b/server/internal/selfupdate/selfupdate.go @@ -8,6 +8,8 @@ package selfupdate import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -69,6 +71,35 @@ func Run(api, currentVersion string) error { return fmt.Errorf("release %s has no asset %q", rel.TagName, want) } + // The release must carry SHA256SUMS; refuse to update without it. This + // protects download integrity (truncation, proxy mangling). It is NOT a + // defense against a compromised Gitea — both files come from the same + // place; a detached signature would be needed for that (still TODO). + var sums string + for _, a := range rel.Assets { + if a.Name == "SHA256SUMS" { + resp, err := client.Get(a.URL) + if err != nil { + return fmt.Errorf("fetching SHA256SUMS: %w", err) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + if err != nil { + return err + } + sums = string(b) + } + } + wantSum := "" + for _, line := range strings.Split(sums, "\n") { + if fields := strings.Fields(line); len(fields) == 2 && fields[1] == want { + wantSum = fields[0] + } + } + if wantSum == "" { + return fmt.Errorf("release %s has no SHA256SUMS entry for %q — refusing to update", rel.TagName, want) + } + self, err := os.Executable() if err != nil { return err @@ -85,15 +116,18 @@ func Run(api, currentVersion string) error { os.Remove(tmp) return err } - _, err = io.Copy(f, dl.Body) + h := sha256.New() + _, err = io.Copy(io.MultiWriter(f, h), dl.Body) dl.Body.Close() f.Close() if err != nil { os.Remove(tmp) return err } - // TODO(security): verify a detached signature/checksum asset before the - // rename — a Gitea compromise currently equals code execution here. + if got := hex.EncodeToString(h.Sum(nil)); got != wantSum { + os.Remove(tmp) + return fmt.Errorf("checksum mismatch for %s: got %s want %s", want, got, wantSum) + } if err := os.Rename(tmp, self); err != nil { os.Remove(tmp) return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err) diff --git a/server/internal/system/systemd.go b/server/internal/system/systemd.go index 7ee5b6e..4aa6fa1 100644 --- a/server/internal/system/systemd.go +++ b/server/internal/system/systemd.go @@ -2,8 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Package system implements native-host lifecycle: systemd unit install / -// uninstall. Linux-only by nature; on other OSes the commands fail with a -// clear message rather than pretending. +// uninstall, plus an optional self-update timer. Linux-only by nature; on +// other OSes the commands fail with a clear message rather than pretending. package system import ( @@ -14,7 +14,12 @@ import ( "runtime" ) -const unitPath = "/etc/systemd/system/echolot-server.service" +const ( + unitPath = "/etc/systemd/system/echolot-server.service" + updateUnitPath = "/etc/systemd/system/echolot-server-update.service" + updateTimerPath = "/etc/systemd/system/echolot-server-update.timer" + envFilePath = "/etc/echolot-server.env" +) const unitTemplate = `[Unit] Description=Echolot probe server @@ -29,6 +34,8 @@ Restart=on-failure RestartSec=5 StateDirectory=echolot-server Environment=ECHOLOT_STATE_DIR=/var/lib/echolot-server +# Host-specific config (listen addresses etc.) lives here, not in the unit: +EnvironmentFile=-%s # Hardening — the server needs sockets and its state dir, nothing else. NoNewPrivileges=true ProtectSystem=strict @@ -40,9 +47,43 @@ PrivateTmp=true WantedBy=multi-user.target ` -// InstallSystemd writes the unit for THIS binary (absolute path), reloads -// systemd, and enables the service. Idempotent. -func InstallSystemd(extraArgs []string) error { +const updateUnitTemplate = `[Unit] +Description=Echolot server self-update +After=network-online.target + +[Service] +Type=oneshot +ExecStart=%s --self-update --self-update-api=%s +# The updater only replaces the binary; the restart activates it. +ExecStartPost=/usr/bin/systemctl try-restart echolot-server.service +` + +const updateTimerTemplate = `[Unit] +Description=Daily Echolot server self-update check + +[Timer] +OnCalendar=daily +RandomizedDelaySec=1h +Persistent=true + +[Install] +WantedBy=timers.target +` + +const envFileTemplate = `# Echolot server host configuration (systemd EnvironmentFile). +# Bind explicit addresses on multi-IP hosts — a wildcard would also claim +# management-only addresses. Comma-separated lists are supported. +#ECHOLOT_CONTROL_LISTEN=203.0.113.10:8443,[2001:db8::10]:8443 +#ECHOLOT_UDP_LISTEN=203.0.113.10:8442,[2001:db8::10]:8442 +#ECHOLOT_TCP_LISTEN=203.0.113.10:8441,[2001:db8::10]:8441 +#ECHOLOT_ADMIN_LISTEN=127.0.0.1:8444 +#ECHOLOT_NAME=my-server +` + +// InstallSystemd writes the unit(s) for THIS binary (absolute path), reloads +// systemd, and enables the service. When selfUpdateAPI is non-empty, a daily +// self-update timer is installed alongside. Idempotent. +func InstallSystemd(selfUpdateAPI string) error { if runtime.GOOS != "linux" { return fmt.Errorf("--install-systemd is Linux-only (this is %s)", runtime.GOOS) } @@ -54,22 +95,36 @@ func InstallSystemd(extraArgs []string) error { if err != nil { return err } - execStart := self - for _, a := range extraArgs { - execStart += " " + a - } - if err := os.WriteFile(unitPath, []byte(fmt.Sprintf(unitTemplate, execStart)), 0o644); err != nil { + if err := os.WriteFile(unitPath, []byte(fmt.Sprintf(unitTemplate, self, envFilePath)), 0o644); err != nil { return fmt.Errorf("writing %s (need root?): %w", unitPath, err) } - for _, cmd := range [][]string{ + // Seed the env file once; never overwrite an existing one. + if _, err := os.Stat(envFilePath); os.IsNotExist(err) { + _ = os.WriteFile(envFilePath, []byte(envFileTemplate), 0o644) + } + cmds := [][]string{ {"systemctl", "daemon-reload"}, {"systemctl", "enable", "--now", "echolot-server.service"}, - } { + } + if selfUpdateAPI != "" { + if err := os.WriteFile(updateUnitPath, + []byte(fmt.Sprintf(updateUnitTemplate, self, selfUpdateAPI)), 0o644); err != nil { + return err + } + if err := os.WriteFile(updateTimerPath, []byte(updateTimerTemplate), 0o644); err != nil { + return err + } + cmds = append(cmds, []string{"systemctl", "enable", "--now", "echolot-server-update.timer"}) + } + for _, cmd := range cmds { if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil { return fmt.Errorf("%v: %s: %w", cmd, out, err) } } - fmt.Printf("installed + started echolot-server.service (ExecStart=%s)\n", execStart) + fmt.Printf("installed echolot-server.service (ExecStart=%s, config: %s)\n", self, envFilePath) + if selfUpdateAPI != "" { + fmt.Println("installed echolot-server-update.timer (daily, randomized)") + } return nil } @@ -78,11 +133,14 @@ func UninstallSystemd() error { return fmt.Errorf("--uninstall-systemd is Linux-only (this is %s)", runtime.GOOS) } // Stop/disable first; ignore "not loaded" errors so uninstall is idempotent. + _ = exec.Command("systemctl", "disable", "--now", "echolot-server-update.timer").Run() _ = exec.Command("systemctl", "disable", "--now", "echolot-server.service").Run() - if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) { - return err + for _, p := range []string{unitPath, updateUnitPath, updateTimerPath} { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return err + } } _ = exec.Command("systemctl", "daemon-reload").Run() - fmt.Println("removed echolot-server.service (state dir left in place)") + fmt.Println("removed echolot-server units (state dir and env file left in place)") return nil }