server: Go skeleton — control plane, UDP data plane, Docker + systemd modes

Pure stdlib. Implements the spec's core: enrollment (single-use tokens),
profile (SPKI pin, only real capabilities advertised), sessions with the
§2.4 HKDF-SHA256 key schedule; UDP data plane with the 32-byte ELT1
header, 4-byte HMAC gate, 1024-wide anti-replay window, ECHO_RESP with
observation block, TIMESYNC, and the §3.4 anti-amplification cap. Wire
format has tests (roundtrip + silent-drop cases); enroll→profile→session
smoke-tested live.

Modes: container (autodetect /.dockerenv|/run/.containerenv|cgroup, or
--docker/ECHOLOT_DOCKER=1; config via ECHOLOT_* env; distroless image;
network_mode host required — Docker NAT would falsify observed sources)
and native (--install-systemd/--uninstall-systemd with a hardened unit,
opt-in --self-update from Gitea releases; refused in containers).

CI: tests on any server/ push; server-v* tags build+push the image to the
Gitea registry and attach linux amd64/arm64 binaries + SHA256SUMS to a
release — the artifact self-update consumes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-07-30 13:09:08 +02:00
co-authored by Claude Opus 5
parent ee66648e3c
commit 8a80026d49
15 changed files with 1502 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# SPDX-FileCopyrightText: 2026 Echolot contributors
# SPDX-License-Identifier: GPL-3.0-or-later
# syntax=docker/dockerfile:1
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.Version=${VERSION}" \
-o /out/echolot-server ./cmd/echolot-server
# Distroless static: no shell, no package manager; the server is pure Go.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/echolot-server /echolot-server
# State (device store + generated TLS) must persist across container restarts.
ENV ECHOLOT_STATE_DIR=/state
VOLUME ["/state"]
# Ports are documentation only — run with network_mode: host (see compose.yaml):
# the data plane must see real client source addresses/TTLs, and Docker's
# userland NAT would falsify exactly what this server exists to observe.
EXPOSE 8441/tcp 8442/udp 8443/tcp
ENTRYPOINT ["/echolot-server"]
+67
View File
@@ -0,0 +1,67 @@
# echolot-server
The probe server ([spec](../docs/probe-protocol.md)). Pure Go, stdlib only, GPL-3.0-or-later.
**Skeleton status:** control plane (enroll / profile / sessions with the spec's HKDF key
schedule), UDP data plane (ECHO with observation block, TIMESYNC, HMAC gate, anti-replay,
anti-amplification — wire format covered by tests). Not yet: TCP/TLS echo, STUN, canary DNS,
actions, observations API, admin UI beyond token minting.
## Run in Docker (config via env)
```sh
docker compose up -d # see compose.yaml — network_mode: host is required
```
Host networking is not negotiable: behind Docker NAT the server would observe the proxy's
source addresses and TTLs instead of the client's — falsifying exactly what it measures.
Container mode is autodetected (`/.dockerenv` etc.); `--docker` / `ECHOLOT_DOCKER=1` forces it.
In this mode systemd install and self-update are refused — update by pulling a new image tag.
## Run native (systemd)
```sh
go build -o /usr/local/bin/echolot-server ./cmd/echolot-server
sudo /usr/local/bin/echolot-server --install-systemd # writes unit, enables, starts
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`).
### Self-update (opt-in, native only)
```sh
echolot-server --self-update \
--self-update-api https://git.example.net/api/v1/repos/owner/repo
```
Fetches the newest `server-v*` release asset for this OS/arch and atomically replaces the
binary; systemd's `Restart=` brings up the new version. Run it from a systemd timer for
unattended updates. TODO before enabling anywhere untrusted: signature verification of the
downloaded asset.
## First contact
```sh
# 1. mint an enrollment token (admin listener is loopback-only)
curl -s -X POST 'http://127.0.0.1:8444/admin/enroll-tokens?note=phone'
# 2. device enrolls with it (normally via the echolot:// QR code)
curl -sk -X POST https://<host>:8443/v1/enroll -H 'Authorization: Bearer <token>'
# 3. device fetches its profile
curl -sk https://<host>:8443/v1/profile -H 'Authorization: Bearer <credential>'
```
The SPKI pin clients must verify is logged at startup (`pin-sha256`).
## Development
```sh
go test ./... # includes wire-format tests for the UDP data plane
go vet ./...
```
CI (`.gitea/workflows/build-server.yml`): tests on every push touching `server/`;
tagging `server-v1.2.3` builds + pushes the container image to the Gitea registry and
attaches static linux amd64/arm64 binaries (+ SHA256SUMS) to a release — the same
artifacts `--self-update` consumes.
+223
View File
@@ -0,0 +1,223 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// echolot-server: the probe server (spec: docs/probe-protocol.md).
//
// Modes:
// - container ("docker mode"): autodetected (or --docker / ECHOLOT_DOCKER=1);
// config comes from ECHOLOT_* env vars; systemd/self-update are refused.
// - native: same flags/env, plus --install-systemd / --uninstall-systemd
// and opt-in --self-update against a Gitea releases API.
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"math/big"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
"echo-lot.app/server/internal/config"
"echo-lot.app/server/internal/control"
"echo-lot.app/server/internal/dataplane"
"echo-lot.app/server/internal/selfupdate"
"echo-lot.app/server/internal/session"
"echo-lot.app/server/internal/store"
"echo-lot.app/server/internal/system"
)
// Version is stamped via -ldflags "-X main.Version=v1.2.3" in CI.
var Version = "dev"
func main() {
if err := run(); err != nil {
slog.Error("fatal", "err", err)
os.Exit(1)
}
}
func run() error {
cfg, actions, err := config.Load(os.Args[1:])
if err != nil {
return err
}
control.Version = Version
switch {
case actions.Version:
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)
case actions.UninstallSystemd:
return system.UninstallSystemd()
case actions.SelfUpdate:
return selfupdate.Run(cfg.SelfUpdateAPI, Version)
}
return serve(cfg)
}
func serve(cfg *config.Config) error {
slog.Info("echolot-server starting", "version", Version, "mode",
map[bool]string{true: "container", false: "native"}[cfg.Docker],
"state_dir", cfg.StateDir)
st, err := store.Open(cfg.StateDir)
if err != nil {
return fmt.Errorf("state store: %w", err)
}
cert, err := loadOrCreateCert(cfg)
if err != nil {
return fmt.Errorf("tls: %w", err)
}
pin, err := control.SpkiPinB64(cert)
if err != nil {
return err
}
slog.Info("control-plane certificate", "pin-sha256", pin)
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,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 4)
// Control plane (HTTPS, pin-based trust)
ctlSrv := &http.Server{
Addr: cfg.ControlListen, 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("", "")) }()
// Admin/health (plain HTTP, localhost by default; spec §7)
admin := http.NewServeMux()
admin.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, `{"ok":true,"version":%q}`, Version)
})
// TODO(spec §7): enrollment token management + device list. Until the
// admin UI exists, mint tokens with: echolot-admin (or curl on this
// listener once the endpoint lands).
admin.HandleFunc("POST /admin/enroll-tokens", func(w http.ResponseWriter, r *http.Request) {
tok, err := st.NewEnrollToken(24*time.Hour, r.URL.Query().Get("note"))
if err != nil {
http.Error(w, err.Error(), 500)
return
}
fmt.Fprintf(w, `{"token":%q,"expires_in_s":86400}`+"\n", tok)
})
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
}
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)
select {
case <-ctx.Done():
slog.Info("shutting down")
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = ctlSrv.Shutdown(shutCtx)
_ = adminSrv.Shutdown(shutCtx)
_ = udpConn.Close()
return nil
case err := <-errCh:
return err
}
}
func mustPort(listen string) int {
_, p, err := net.SplitHostPort(listen)
if err != nil {
return 0
}
n, _ := strconv.Atoi(p)
return n
}
// loadOrCreateCert loads the configured cert/key, or generates a self-signed
// ECDSA P-256 cert into the state dir on first start. Self-signed is
// first-class per spec §1 — clients pin the SPKI, they don't chase CAs.
func loadOrCreateCert(cfg *config.Config) (tls.Certificate, error) {
certPath, keyPath := cfg.TLSCert, cfg.TLSKey
if certPath == "" {
certPath = filepath.Join(cfg.StateDir, "tls-cert.pem")
keyPath = filepath.Join(cfg.StateDir, "tls-key.pem")
}
if c, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil {
return c, nil
} else if cfg.TLSCert != "" {
// Explicitly configured paths must exist — do not silently overwrite.
return tls.Certificate{}, fmt.Errorf("loading configured cert %s: %w", certPath, err)
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}
serial, _ := rand.Int(rand.Reader, big.NewInt(1).Lsh(big.NewInt(1), 62))
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "echolot-server"},
NotBefore: time.Now().Add(-time.Hour),
// Long-lived on purpose: trust is the SPKI pin from enrollment, not
// cert validity; rotation happens via next_pins in the profile.
NotAfter: time.Now().AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
if err != nil {
return tls.Certificate{}, err
}
keyDer, err := x509.MarshalECPrivateKey(key)
if err != nil {
return tls.Certificate{}, err
}
if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
return tls.Certificate{}, err
}
certPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPem := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDer})
if err := os.WriteFile(certPath, certPem, 0o644); err != nil {
return tls.Certificate{}, err
}
if err := os.WriteFile(keyPath, keyPem, 0o600); err != nil {
return tls.Certificate{}, err
}
slog.Info("generated self-signed certificate", "cert", certPath)
return tls.X509KeyPair(certPem, keyPem)
}
+28
View File
@@ -0,0 +1,28 @@
# SPDX-FileCopyrightText: 2026 Echolot contributors
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Reference deployment. host networking is REQUIRED, not an optimization:
# behind Docker's NAT the observed source address, port-rebinding evidence and
# TTLs — the things the probe protocol measures — would be the proxy's, not
# the client's. With network_mode: host the ECHOLOT_*_LISTEN vars bind real
# host ports; pick free ones.
services:
echolot-server:
image: git.rambossek.at/echolot/echolot-server:latest
container_name: echolot-server
network_mode: host
restart: unless-stopped
environment:
ECHOLOT_NAME: homelab
ECHOLOT_CONTROL_LISTEN: ":8443"
ECHOLOT_UDP_LISTEN: ":8442"
ECHOLOT_TCP_LISTEN: ":8441"
# Admin stays loopback-only; reach it via SSH tunnel from the LAN host.
ECHOLOT_ADMIN_LISTEN: "127.0.0.1:8444"
# ECHOLOT_TLS_CERT / ECHOLOT_TLS_KEY: omit to self-sign into /state
# (clients pin the SPKI — self-signed is first-class).
volumes:
- echolot-state:/state
volumes:
echolot-state:
+3
View File
@@ -0,0 +1,3 @@
module echo-lot.app/server
go 1.24
+130
View File
@@ -0,0 +1,130 @@
// 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")
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.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
}
// 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
}
+166
View File
@@ -0,0 +1,166 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package control implements the control plane (spec §2): enrollment,
// profile, sessions. HTTPS with a possibly self-signed cert — clients trust
// the SPKI pin from enrollment, not a CA (spec §1).
package control
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"net/netip"
"strings"
"time"
"echo-lot.app/server/internal/session"
"echo-lot.app/server/internal/store"
)
// Version is stamped by the build (-ldflags "-X ...").
var Version = "dev"
type Server struct {
Store *store.Store
Sessions *session.Manager
Name string
// Targets/capabilities for the profile response. The skeleton offers only
// what it actually implements; the registry grows with the code.
UDPPort int
TCPPort int
// SPKI pin of the serving cert, for the profile's pins[] field.
PinB64 string
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/enroll", s.enroll)
mux.HandleFunc("GET /v1/profile", s.profile)
mux.HandleFunc("POST /v1/sessions", s.newSession)
mux.HandleFunc("DELETE /v1/sessions/{id}", s.deleteSession)
// TODO(spec §5, §6): /v1/sessions/{id}/actions, /v1/sessions/{id}/observations
// TODO(spec §4): POST /v1/echo, GET /v1/tls-reference
return mux
}
func bearer(r *http.Request) string {
h := r.Header.Get("Authorization")
if v, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(v)
}
return ""
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// enroll redeems a single-use enrollment token for a device credential (§2.1).
func (s *Server) enroll(w http.ResponseWriter, r *http.Request) {
tok := bearer(r)
if tok == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing enrollment token"})
return
}
var body struct {
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
dev, err := s.Store.Redeem(tok, body.Name)
if err != nil {
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
return
}
slog.Info("device enrolled", "device", dev.ID, "name", dev.Name)
writeJSON(w, http.StatusCreated, map[string]string{
"device_id": dev.ID,
"credential": dev.Credential, // returned exactly once
})
}
// profile is spec §2.2. Only implemented capabilities are advertised.
func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
dev := s.Store.DeviceByCredential(bearer(r))
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
host := r.Host
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
writeJSON(w, http.StatusOK, map[string]any{
"profile_version": 1,
"name": s.Name,
"server_version": Version,
// source_url makes GPL §6 compliance mechanical for operators of
// modified builds and gives clients provenance for the measurement.
"source_url": "", // TODO: stamp from build metadata
"capabilities": []string{"udp-probe"},
"targets": []map[string]any{{
"id": s.Name,
"ip4": host, // TODO: explicit configured addresses, v6, second STUN addr
"udp_port": s.UDPPort,
"tcp_port": s.TCPPort,
}},
"pins": []string{"pin-sha256:" + s.PinB64},
"next_pins": []string{},
"limits": map[string]any{"max_kbps": 50000, "max_session_s": 900},
})
}
// newSession is spec §2.4.
func (s *Server) newSession(w http.ResponseWriter, r *http.Request) {
cred := bearer(r)
dev := s.Store.DeviceByCredential(cred)
if dev == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
srcHost, _, _ := strings.Cut(r.RemoteAddr, ":")
src, _ := netip.ParseAddr(strings.Trim(srcHost, "[]"))
sess, salt, err := s.Sessions.New(dev.ID, cred, src)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "session creation failed"})
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"session_id": sess.ID,
"key_salt": base64.StdEncoding.EncodeToString(salt),
"epoch": sess.Epoch.Format(time.RFC3339),
"expires_s": int(time.Until(sess.Expires).Seconds()),
})
}
func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
if s.Store.DeviceByCredential(bearer(r)) == nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"})
return
}
if id := r.PathValue("id"); len(id) >= 16 {
s.Sessions.Delete(id)
}
w.WriteHeader(http.StatusNoContent)
}
// SpkiPinB64 computes the RFC 7469 pin (SHA-256 over the SPKI, base64) of the
// leaf certificate — what enrollment QR codes carry and profiles re-state.
func SpkiPinB64(cert tls.Certificate) (string, error) {
leaf := cert.Leaf
if leaf == nil {
parsed, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return "", err
}
leaf = parsed
}
sum := sha256.Sum256(leaf.RawSubjectPublicKeyInfo)
return base64.StdEncoding.EncodeToString(sum[:]), nil
}
+171
View File
@@ -0,0 +1,171 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package dataplane implements the binary UDP probe protocol (spec §3):
// 32-byte header, HMAC gate, anti-replay, ECHO with observation block.
// Skeleton scope: ECHO_REQ/ECHO_RESP and TIMESYNC only; trains, MTU probes
// and delayed echo land with the corresponding client tests.
package dataplane
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"log/slog"
"net"
"net/netip"
"time"
"echo-lot.app/server/internal/session"
)
const (
Magic = "ELT1"
HeaderSize = 32
TypeEchoReq = 0x01
TypeEchoResp = 0x02
TypeTimesyncReq = 0x07
TypeTimesyncRsp = 0x08
)
type Server struct {
Sessions *session.Manager
// Epoch for server-side t_rx/t_tx: process start; observation consumers
// only need differences plus the timesync exchange, not absolute time.
start time.Time
}
func (s *Server) Serve(conn *net.UDPConn) error {
s.start = time.Now()
buf := make([]byte, 65535)
oob := make([]byte, 0)
_ = oob // TODO: recvmsg w/ IP_RECVTOS+IP_RECVTTL via golang.org/x/net for TTL/DSCP/ECN observation
for {
n, raddr, err := conn.ReadFromUDPAddrPort(buf)
if err != nil {
return err
}
tRx := time.Since(s.start).Nanoseconds()
s.handle(conn, raddr, buf[:n], tRx)
}
}
// handle enforces spec §3.1/§3.4: unknown prefix, bad HMAC, expired session,
// replayed seq → silent drop, never a response.
func (s *Server) handle(conn *net.UDPConn, raddr netip.AddrPort, pkt []byte, tRxNs int64) {
if len(pkt) < HeaderSize || string(pkt[0:4]) != Magic {
return
}
typ := pkt[4]
payloadLen := binary.BigEndian.Uint16(pkt[6:8])
if int(HeaderSize+payloadLen) > len(pkt) {
return
}
var prefix [8]byte
copy(prefix[:], pkt[8:16])
seq := binary.BigEndian.Uint32(pkt[16:20])
sess := s.Sessions.ByWirePrefix(prefix)
if sess == nil {
return
}
mac := hmac.New(sha256.New, sess.Key[:])
mac.Write(pkt[0:28])
mac.Write(pkt[HeaderSize : HeaderSize+int(payloadLen)])
if !hmac.Equal(mac.Sum(nil)[:4], pkt[28:32]) {
return
}
if !sess.CheckSeq(seq) {
return
}
sess.NoteDataSource(raddr)
switch typ {
case TypeEchoReq:
s.echoResp(conn, raddr, sess, pkt, seq, tRxNs)
case TypeTimesyncReq:
s.timesyncResp(conn, raddr, sess, pkt, seq, tRxNs)
default:
slog.Debug("unhandled data-plane type", "type", typ)
}
}
// Observation block (spec §3.3), fixed 40 bytes appended to the RESP header:
// 0 8 t_rx_ns (server clock, process epoch)
// 8 8 t_tx_ns
// 16 16 observed source IP (v4-mapped when v4)
// 32 2 observed source port
// 34 1 received TTL (0xFF = not observed yet; needs recvmsg cmsgs)
// 35 1 received DSCP/ECN byte (0xFF = not observed)
// 36 4 received size
func observation(tRxNs, tTxNs int64, src netip.AddrPort, rcvd int) []byte {
b := make([]byte, 40)
binary.BigEndian.PutUint64(b[0:8], uint64(tRxNs))
binary.BigEndian.PutUint64(b[8:16], uint64(tTxNs))
a16 := src.Addr().As16()
copy(b[16:32], a16[:])
binary.BigEndian.PutUint16(b[32:34], src.Port())
b[34], b[35] = 0xFF, 0xFF
binary.BigEndian.PutUint32(b[36:40], uint32(rcvd))
return b
}
// echoResp mirrors the request header (type flipped), appends the observation
// block, and re-HMACs with the session key. Anti-amplification: the response
// is capped at the request size (spec §3.4) — the observation block replaces
// padding rather than growing the datagram; if the request was smaller than
// header+observation, the block is truncated to fit.
func (s *Server) echoResp(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, req []byte, seq uint32, tRxNs int64) {
obs := observation(tRxNs, time.Since(s.start).Nanoseconds(), raddr, len(req))
max := len(req)
if max < HeaderSize {
return
}
payload := obs
if HeaderSize+len(payload) > max {
payload = payload[:max-HeaderSize]
}
s.send(conn, raddr, sess, TypeEchoResp, seq, payload)
}
// timesyncResp: payload = client t1 (echoed back) + t2 (rx) + t3 (tx), spec §3.2.
func (s *Server) timesyncResp(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, req []byte, seq uint32, tRxNs int64) {
payload := make([]byte, 24)
copy(payload[0:8], req[20:28]) // client's t_ns from the request header
binary.BigEndian.PutUint64(payload[8:16], uint64(tRxNs))
binary.BigEndian.PutUint64(payload[16:24], uint64(time.Since(s.start).Nanoseconds()))
s.send(conn, raddr, sess, TypeTimesyncRsp, seq, payload)
}
func (s *Server) send(conn *net.UDPConn, raddr netip.AddrPort, sess *session.Session, typ byte, seq uint32, payload []byte) {
pkt := make([]byte, HeaderSize+len(payload))
copy(pkt[0:4], Magic)
pkt[4] = typ
binary.BigEndian.PutUint16(pkt[6:8], uint16(len(payload)))
idBytes := sess.ID[:16] // hex chars of the 8-byte prefix
for i := 0; i < 8; i++ {
pkt[8+i] = hexByte(idBytes[i*2], idBytes[i*2+1])
}
binary.BigEndian.PutUint32(pkt[16:20], seq)
binary.BigEndian.PutUint64(pkt[20:28], uint64(time.Since(s.start).Nanoseconds()))
copy(pkt[HeaderSize:], payload)
mac := hmac.New(sha256.New, sess.Key[:])
mac.Write(pkt[0:28])
mac.Write(payload)
copy(pkt[28:32], mac.Sum(nil)[:4])
_, _ = conn.WriteToUDPAddrPort(pkt, raddr)
}
func hexByte(hi, lo byte) byte {
h := func(c byte) byte {
switch {
case c >= '0' && c <= '9':
return c - '0'
case c >= 'a' && c <= 'f':
return c - 'a' + 10
}
return 0
}
return h(hi)<<4 | h(lo)
}
+144
View File
@@ -0,0 +1,144 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package dataplane
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"net"
"net/netip"
"testing"
"time"
"echo-lot.app/server/internal/session"
)
// craft builds a spec-§3.1 packet for a session the way the Android client will.
func craft(t *testing.T, sess *session.Session, typ byte, seq uint32, payload []byte) []byte {
t.Helper()
pkt := make([]byte, HeaderSize+len(payload))
copy(pkt[0:4], Magic)
pkt[4] = typ
binary.BigEndian.PutUint16(pkt[6:8], uint16(len(payload)))
prefix, err := hex.DecodeString(sess.ID[:16])
if err != nil {
t.Fatal(err)
}
copy(pkt[8:16], prefix)
binary.BigEndian.PutUint32(pkt[16:20], seq)
binary.BigEndian.PutUint64(pkt[20:28], uint64(time.Now().UnixNano()))
copy(pkt[HeaderSize:], payload)
mac := hmac.New(sha256.New, sess.Key[:])
mac.Write(pkt[0:28])
mac.Write(payload)
copy(pkt[28:32], mac.Sum(nil)[:4])
return pkt
}
func startServer(t *testing.T) (*session.Manager, netip.AddrPort) {
t.Helper()
mgr := session.NewManager(time.Minute)
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
go (&Server{Sessions: mgr}).Serve(conn)
return mgr, conn.LocalAddr().(*net.UDPAddr).AddrPort()
}
func TestEchoRoundtripObservationAndAntiAmplification(t *testing.T) {
mgr, addr := startServer(t)
sess, _, err := mgr.New("dev1", "credential-ikm", netip.MustParseAddr("127.0.0.1"))
if err != nil {
t.Fatal(err)
}
client, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(addr))
if err != nil {
t.Fatal(err)
}
defer client.Close()
client.SetDeadline(time.Now().Add(2 * time.Second))
padding := make([]byte, 64) // request: 32 hdr + 64 payload = 96 bytes
req := craft(t, sess, TypeEchoReq, 1, padding)
if _, err := client.Write(req); err != nil {
t.Fatal(err)
}
resp := make([]byte, 1500)
n, err := client.Read(resp)
if err != nil {
t.Fatalf("no ECHO_RESP: %v", err)
}
if n > len(req) {
t.Fatalf("anti-amplification violated: resp %d > req %d", n, len(req))
}
if resp[4] != TypeEchoResp {
t.Fatalf("type = %#x, want ECHO_RESP", resp[4])
}
// Verify the server's HMAC with the same derived key.
mac := hmac.New(sha256.New, sess.Key[:])
mac.Write(resp[0:28])
mac.Write(resp[HeaderSize:n])
if !hmac.Equal(mac.Sum(nil)[:4], resp[28:32]) {
t.Fatal("response HMAC does not verify")
}
// Observation block: observed source port must match our socket.
obs := resp[HeaderSize:n]
if len(obs) < 40 {
t.Fatalf("observation block truncated to %d (req was big enough for 40)", len(obs))
}
gotPort := binary.BigEndian.Uint16(obs[32:34])
wantPort := client.LocalAddr().(*net.UDPAddr).AddrPort().Port()
if gotPort != wantPort {
t.Fatalf("observed port %d, want %d", gotPort, wantPort)
}
if rcvd := binary.BigEndian.Uint32(obs[36:40]); rcvd != uint32(len(req)) {
t.Fatalf("observed size %d, want %d", rcvd, len(req))
}
}
func TestDropsReplayBadHmacAndUnknownPrefix(t *testing.T) {
mgr, addr := startServer(t)
sess, _, err := mgr.New("dev1", "credential-ikm", netip.MustParseAddr("127.0.0.1"))
if err != nil {
t.Fatal(err)
}
client, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(addr))
if err != nil {
t.Fatal(err)
}
defer client.Close()
// Valid packet once...
req := craft(t, sess, TypeEchoReq, 7, make([]byte, 48))
client.SetDeadline(time.Now().Add(2 * time.Second))
client.Write(req)
buf := make([]byte, 1500)
if _, err := client.Read(buf); err != nil {
t.Fatalf("first send should get a response: %v", err)
}
drop := func(name string, pkt []byte) {
t.Helper()
client.SetDeadline(time.Now().Add(300 * time.Millisecond))
client.Write(pkt)
if _, err := client.Read(buf); err == nil {
t.Fatalf("%s: got a response, want silent drop", name)
}
}
// ...replayed seq: silence.
drop("replay", req)
// Bad HMAC: silence.
bad := craft(t, sess, TypeEchoReq, 8, make([]byte, 48))
bad[31] ^= 0xFF
drop("bad hmac", bad)
// Unknown session prefix: silence.
unk := craft(t, sess, TypeEchoReq, 9, make([]byte, 48))
unk[8] ^= 0xFF
drop("unknown prefix", unk)
}
+99
View File
@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package selfupdate replaces the running binary with the newest release
// asset from a Gitea repo. Native mode only and strictly opt-in (twice: the
// API base must be configured AND --self-update passed / timer enabled).
// Containers update by pulling a new image tag instead.
package selfupdate
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
type release struct {
TagName string `json:"tag_name"`
Assets []asset `json:"assets"`
}
type asset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
}
// Run checks <api>/releases/latest for an asset named
// echolot-server_<GOOS>_<GOARCH> newer than currentVersion and atomically
// replaces the current executable. The caller (or systemd Restart=) handles
// the restart; we never exec ourselves.
func Run(api, currentVersion string) error {
if api == "" {
return fmt.Errorf("self-update disabled: no --self-update-api / ECHOLOT_SELF_UPDATE_API configured")
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(strings.TrimRight(api, "/") + "/releases/latest")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("release API: %s", resp.Status)
}
var rel release
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return err
}
if rel.TagName == "" || rel.TagName == currentVersion {
fmt.Printf("already current (%s)\n", currentVersion)
return nil
}
want := fmt.Sprintf("echolot-server_%s_%s", runtime.GOOS, runtime.GOARCH)
var url string
for _, a := range rel.Assets {
if a.Name == want {
url = a.URL
break
}
}
if url == "" {
return fmt.Errorf("release %s has no asset %q", rel.TagName, want)
}
self, err := os.Executable()
if err != nil {
return err
}
self, _ = filepath.EvalSymlinks(self)
tmp := self + ".update"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
if err != nil {
return err
}
dl, err := client.Get(url)
if err != nil {
f.Close()
os.Remove(tmp)
return err
}
_, err = io.Copy(f, 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 err := os.Rename(tmp, self); err != nil {
os.Remove(tmp)
return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err)
}
fmt.Printf("updated %s -> %s (%s); restart to run it\n", currentVersion, rel.TagName, self)
return nil
}
+125
View File
@@ -0,0 +1,125 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package session implements spec §2.4 sessions and the §2.4 key schedule:
// HKDF-SHA256(ikm=device_credential, salt=key_salt, info="echolot-v1/"+session_id).
package session
import (
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/netip"
"sync"
"time"
)
type Session struct {
ID string // opaque hex; first 8 bytes (16 hex chars) are the wire prefix
Key [32]byte // derived, never crosses the wire
Epoch time.Time // server wall clock at creation (spec: RFC3339 in the response)
Expires time.Time
Device string // device ID
// Observed source of the session-creating request — the ONLY address
// reflected/generated traffic may target (spec §2.5).
ControlSource netip.Addr
// Last data-plane source seen with a valid HMAC (NAT rebinding evidence).
mu sync.Mutex
dataSource netip.AddrPort
// Replay window (spec §3.1: 1024-wide seq window). Highest seq seen plus
// a bitmask of the 1024 preceding.
maxSeq uint32
window [16]uint64
}
// KeySalt returns nothing — the salt is not retained after derivation; it is
// generated in New and returned once for the response body.
type Manager struct {
mu sync.Mutex
byPrefix map[string]*Session // key: first 16 hex chars of ID
ttl time.Duration
}
func NewManager(ttl time.Duration) *Manager {
return &Manager{byPrefix: map[string]*Session{}, ttl: ttl}
}
// New creates a session for a device credential per the spec key schedule.
// Returns the session and the one-time key_salt for the response.
func (m *Manager) New(deviceID, credential string, controlSource netip.Addr) (*Session, []byte, error) {
idBytes := make([]byte, 16)
if _, err := rand.Read(idBytes); err != nil {
return nil, nil, err
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return nil, nil, err
}
id := hex.EncodeToString(idBytes)
key, err := hkdf.Key(sha256.New, []byte(credential), salt, "echolot-v1/"+id, 32)
if err != nil {
return nil, nil, err
}
s := &Session{
ID: id,
Epoch: time.Now().UTC(),
Expires: time.Now().Add(m.ttl),
Device: deviceID,
ControlSource: controlSource,
}
copy(s.Key[:], key)
m.mu.Lock()
m.byPrefix[id[:16]] = s
m.mu.Unlock()
return s, salt, nil
}
// ByWirePrefix resolves the 8-byte on-the-wire prefix (as raw bytes).
func (m *Manager) ByWirePrefix(prefix [8]byte) *Session {
m.mu.Lock()
defer m.mu.Unlock()
s := m.byPrefix[hex.EncodeToString(prefix[:])]
if s == nil || time.Now().After(s.Expires) {
return nil
}
return s
}
func (m *Manager) Delete(id string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.byPrefix, id[:16])
}
// CheckSeq enforces the 1024-wide anti-replay window. Returns false for
// replays and for packets older than the window.
func (s *Session) CheckSeq(seq uint32) bool {
s.mu.Lock()
defer s.mu.Unlock()
switch {
case seq > s.maxSeq:
shift := seq - s.maxSeq
for i := uint32(0); i < shift && i < 1024; i++ {
idx := (s.maxSeq + 1 + i) % 1024
s.window[idx/64] &^= 1 << (idx % 64)
}
s.maxSeq = seq
case s.maxSeq-seq >= 1024:
return false
}
idx := seq % 1024
if s.window[idx/64]&(1<<(idx%64)) != 0 {
return false
}
s.window[idx/64] |= 1 << (idx % 64)
return true
}
// NoteDataSource records the latest verified data-plane source.
func (s *Session) NoteDataSource(ap netip.AddrPort) {
s.mu.Lock()
s.dataSource = ap
s.mu.Unlock()
}
+140
View File
@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package store persists enrollment tokens and device credentials as a single
// JSON file in the state dir. Deliberately boring: the expected scale is a
// handful of devices per homelab server; a database would be ceremony.
package store
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
"time"
)
type EnrollToken struct {
// SHA-256 of the token, hex. Plaintext exists only in the admin's hands.
Hash string `json:"hash"`
Expires time.Time `json:"expires"`
Used bool `json:"used"`
Note string `json:"note,omitempty"`
}
type Device struct {
ID string `json:"id"`
// SHA-256 of the bearer credential, hex. The credential itself is also
// the HKDF ikm for session keys (spec §2.4), so the server needs it in
// cleartext at session time — kept alongside, file mode 0600.
// TODO(hardening): move cleartext creds to a separate keyring file.
CredentialHash string `json:"credential_hash"`
Credential string `json:"credential"`
Enrolled time.Time `json:"enrolled"`
Name string `json:"name,omitempty"`
}
type Store struct {
mu sync.Mutex
path string
data fileData
}
type fileData struct {
Tokens []EnrollToken `json:"tokens"`
Devices []Device `json:"devices"`
}
func Open(stateDir string) (*Store, error) {
if err := os.MkdirAll(stateDir, 0o700); err != nil {
return nil, err
}
s := &Store{path: filepath.Join(stateDir, "devices.json")}
b, err := os.ReadFile(s.path)
switch {
case errors.Is(err, os.ErrNotExist):
return s, nil
case err != nil:
return nil, err
}
return s, json.Unmarshal(b, &s.data)
}
func (s *Store) save() error {
b, err := json.MarshalIndent(s.data, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func hashOf(tok string) string {
h := sha256.Sum256([]byte(tok))
return hex.EncodeToString(h[:])
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// NewEnrollToken mints a single-use token (returned in cleartext once).
func (s *Store) NewEnrollToken(ttl time.Duration, note string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
tok := randomHex(24)
s.data.Tokens = append(s.data.Tokens, EnrollToken{
Hash: hashOf(tok), Expires: time.Now().Add(ttl), Note: note,
})
return tok, s.save()
}
// Redeem consumes a valid enrollment token and mints a device credential.
func (s *Store) Redeem(token, name string) (*Device, error) {
s.mu.Lock()
defer s.mu.Unlock()
h := hashOf(token)
for i := range s.data.Tokens {
t := &s.data.Tokens[i]
if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(h)) == 1 {
if t.Used || time.Now().After(t.Expires) {
return nil, errors.New("token used or expired")
}
t.Used = true
d := Device{
ID: randomHex(8),
Credential: randomHex(32),
Enrolled: time.Now().UTC(),
Name: name,
}
d.CredentialHash = hashOf(d.Credential)
s.data.Devices = append(s.data.Devices, d)
return &d, s.save()
}
}
return nil, errors.New("unknown token")
}
// DeviceByCredential authenticates a bearer credential.
func (s *Store) DeviceByCredential(cred string) *Device {
s.mu.Lock()
defer s.mu.Unlock()
h := hashOf(cred)
for i := range s.data.Devices {
if subtle.ConstantTimeCompare([]byte(s.data.Devices[i].CredentialHash), []byte(h)) == 1 {
d := s.data.Devices[i]
return &d
}
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// 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.
package system
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
const unitPath = "/etc/systemd/system/echolot-server.service"
const unitTemplate = `[Unit]
Description=Echolot probe server
Documentation=https://echo-lot.app
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=%s
Restart=on-failure
RestartSec=5
StateDirectory=echolot-server
Environment=ECHOLOT_STATE_DIR=/var/lib/echolot-server
# Hardening — the server needs sockets and its state dir, nothing else.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/echolot-server
PrivateTmp=true
[Install]
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 {
if runtime.GOOS != "linux" {
return fmt.Errorf("--install-systemd is Linux-only (this is %s)", runtime.GOOS)
}
self, err := os.Executable()
if err != nil {
return err
}
self, err = filepath.EvalSymlinks(self)
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 {
return fmt.Errorf("writing %s (need root?): %w", unitPath, err)
}
for _, cmd := range [][]string{
{"systemctl", "daemon-reload"},
{"systemctl", "enable", "--now", "echolot-server.service"},
} {
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)
return nil
}
func UninstallSystemd() error {
if runtime.GOOS != "linux" {
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.service").Run()
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return err
}
_ = exec.Command("systemctl", "daemon-reload").Run()
fmt.Println("removed echolot-server.service (state dir left in place)")
return nil
}