server: multi-address listeners, env-file config, self-update timer + checksums
server-test / test (push) Successful in 24s
server-release / image (push) Successful in 5s
server-release / release (push) Successful in 26s

- 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>
This commit is contained in:
mrambossek
2026-07-31 19:40:36 +02:00
co-authored by Claude Opus 5
parent 4de3064f71
commit 43e1ba778a
5 changed files with 194 additions and 43 deletions
+17 -3
View File
@@ -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
+37 -3
View File
@@ -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)
+75 -17
View File
@@ -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
}