From 8a80026d49de544fc0283607ad441acd18bf42c2 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Thu, 30 Jul 2026 13:09:08 +0200 Subject: [PATCH] =?UTF-8?q?server:=20Go=20skeleton=20=E2=80=94=20control?= =?UTF-8?q?=20plane,=20UDP=20data=20plane,=20Docker=20+=20systemd=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/build-server.yml | 76 ++++++++ docs/build-status.md | 17 ++ server/Dockerfile | 25 +++ server/README.md | 67 +++++++ server/cmd/echolot-server/main.go | 223 +++++++++++++++++++++++ server/compose.yaml | 28 +++ server/go.mod | 3 + server/internal/config/config.go | 130 +++++++++++++ server/internal/control/control.go | 166 +++++++++++++++++ server/internal/dataplane/udp.go | 171 +++++++++++++++++ server/internal/dataplane/udp_test.go | 144 +++++++++++++++ server/internal/selfupdate/selfupdate.go | 99 ++++++++++ server/internal/session/session.go | 125 +++++++++++++ server/internal/store/store.go | 140 ++++++++++++++ server/internal/system/systemd.go | 88 +++++++++ 15 files changed, 1502 insertions(+) create mode 100644 .gitea/workflows/build-server.yml create mode 100644 server/Dockerfile create mode 100644 server/README.md create mode 100644 server/cmd/echolot-server/main.go create mode 100644 server/compose.yaml create mode 100644 server/go.mod create mode 100644 server/internal/config/config.go create mode 100644 server/internal/control/control.go create mode 100644 server/internal/dataplane/udp.go create mode 100644 server/internal/dataplane/udp_test.go create mode 100644 server/internal/selfupdate/selfupdate.go create mode 100644 server/internal/session/session.go create mode 100644 server/internal/store/store.go create mode 100644 server/internal/system/systemd.go diff --git a/.gitea/workflows/build-server.yml b/.gitea/workflows/build-server.yml new file mode 100644 index 0000000..822ae47 --- /dev/null +++ b/.gitea/workflows/build-server.yml @@ -0,0 +1,76 @@ +# Server CI: tests on every push touching server/; on server-v* tags builds +# and pushes the container image to the Gitea registry AND attaches static +# binaries to a Gitea release (the artifact --self-update consumes). +# +# Tags are namespaced (server-v1.2.3) so app releases (v*) and server +# releases don't trigger each other's pipelines. +# +# Required secrets: none beyond the built-in GITHUB_TOKEN — it can push to +# the registry of its own repo and create releases. + +name: server +on: + push: + paths: ["server/**", ".gitea/workflows/build-server.yml"] + tags: ["server-v*.*.*"] + +jobs: + test: + runs-on: ubuntu-latest + defaults: { run: { working-directory: server } } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: "1.26", cache-dependency-path: server/go.mod } + - run: go vet ./... + - run: go test ./... + + release: + if: startsWith(github.ref, 'refs/tags/server-v') + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: "1.26", cache-dependency-path: server/go.mod } + + - name: Derive version + registry coords + id: meta + run: | + echo "version=${GITHUB_REF_NAME#server-}" >> "$GITHUB_OUTPUT" + HOST="${GITHUB_SERVER_URL#https://}" + echo "image=$HOST/${GITHUB_REPOSITORY,,}-server" >> "$GITHUB_OUTPUT" + + - name: Build static binaries (linux amd64+arm64) + working-directory: server + run: | + for arch in amd64 arm64; do + CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -trimpath \ + -ldflags "-s -w -X main.Version=${{ steps.meta.outputs.version }}" \ + -o "../dist/echolot-server_linux_${arch}" ./cmd/echolot-server + done + (cd ../dist && sha256sum * > SHA256SUMS) + + - name: Build + push image + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login "${GITHUB_SERVER_URL#https://}" -u "$GITHUB_ACTOR" --password-stdin + docker build server \ + --build-arg VERSION=${{ steps.meta.outputs.version }} \ + -t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}" \ + -t "${{ steps.meta.outputs.image }}:latest" + docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}" + docker push "${{ steps.meta.outputs.image }}:latest" + + - name: Create release + attach binaries + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + run: | + REL=$(curl -sf -X POST "$API/releases" \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}") + ID=$(echo "$REL" | jq -r .id) + for f in dist/*; do + curl -sf -X POST "$API/releases/$ID/assets?name=$(basename "$f")" \ + -H "Authorization: token $TOKEN" -F "attachment=@$f" + done diff --git a/docs/build-status.md b/docs/build-status.md index 9c6d8c0..a40c647 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -98,6 +98,23 @@ them); `ip monitor` 2 s window caught nothing on a quiet net — consider longer capture; IP_MTU read via getsockoptInt reflection unavailable — reader belongs in the errqueue shim if that lands. +## Go server skeleton — `server/` (2026-07-30) +Pure-Go stdlib implementation of the spec's core: control plane (enroll with single-use tokens, +profile with SPKI pin, sessions with the §2.4 HKDF key schedule) + UDP data plane (ELT1 header, +HMAC gate, 1024-wide anti-replay, ECHO_RESP with observation block, TIMESYNC, §3.4 +anti-amplification). Wire format covered by `go test` (roundtrip, replay/bad-HMAC/unknown-prefix +silent drops); full enroll→profile→session flow smoke-tested live. Two run modes: **container** +(autodetected via /.dockerenv etc. or `--docker`; config from `ECHOLOT_*` env; distroless image, +`network_mode: host` required — Docker NAT would falsify the observed sources the protocol +measures) and **native** (`--install-systemd`/`--uninstall-systemd`, opt-in `--self-update` +against a Gitea releases API). CI: `.gitea/workflows/build-server.yml` — tests on push; +`server-v*` tags push the image to the Gitea registry + attach linux amd64/arm64 binaries that +self-update consumes. Not yet: TCP/TLS echo, STUN, canary DNS, actions, observations API, +real admin UI, self-update signature verification. + +Prober build numbering: ascending `versionCode` shown on screen + as `proberBuild` in the JSON +report; current deployed build = **2**. Bump on every deployed change. + ## Next steps 1. Build locally, run on several physical devices (varied Android versions/vendors), collect the JSON reports — especially the real per-device Shizuku dump formats. diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..f2c0e07 --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..0a3ae73 --- /dev/null +++ b/server/README.md @@ -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://:8443/v1/enroll -H 'Authorization: Bearer ' +# 3. device fetches its profile +curl -sk https://:8443/v1/profile -H 'Authorization: Bearer ' +``` + +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. diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go new file mode 100644 index 0000000..a39fd09 --- /dev/null +++ b/server/cmd/echolot-server/main.go @@ -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) +} diff --git a/server/compose.yaml b/server/compose.yaml new file mode 100644 index 0000000..5d7c07e --- /dev/null +++ b/server/compose.yaml @@ -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: diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..f64a134 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,3 @@ +module echo-lot.app/server + +go 1.24 diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..c6a1278 --- /dev/null +++ b/server/internal/config/config.go @@ -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_ 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 +} diff --git a/server/internal/control/control.go b/server/internal/control/control.go new file mode 100644 index 0000000..e4a177e --- /dev/null +++ b/server/internal/control/control.go @@ -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 +} diff --git a/server/internal/dataplane/udp.go b/server/internal/dataplane/udp.go new file mode 100644 index 0000000..28c5c05 --- /dev/null +++ b/server/internal/dataplane/udp.go @@ -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) +} diff --git a/server/internal/dataplane/udp_test.go b/server/internal/dataplane/udp_test.go new file mode 100644 index 0000000..4edac0d --- /dev/null +++ b/server/internal/dataplane/udp_test.go @@ -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) +} diff --git a/server/internal/selfupdate/selfupdate.go b/server/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..2896744 --- /dev/null +++ b/server/internal/selfupdate/selfupdate.go @@ -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 /releases/latest for an asset named +// echolot-server__ 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 +} diff --git a/server/internal/session/session.go b/server/internal/session/session.go new file mode 100644 index 0000000..099ca44 --- /dev/null +++ b/server/internal/session/session.go @@ -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() +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go new file mode 100644 index 0000000..2c317fe --- /dev/null +++ b/server/internal/store/store.go @@ -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 +} diff --git a/server/internal/system/systemd.go b/server/internal/system/systemd.go new file mode 100644 index 0000000..7ee5b6e --- /dev/null +++ b/server/internal/system/systemd.go @@ -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 +}