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
+49 -19
View File
@@ -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 {