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:
co-authored by
Claude Opus 5
parent
ee66648e3c
commit
8a80026d49
@@ -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
|
||||||
@@ -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
|
capture; IP_MTU read via getsockoptInt reflection unavailable — reader belongs in the errqueue
|
||||||
shim if that lands.
|
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
|
## Next steps
|
||||||
1. Build locally, run on several physical devices (varied Android versions/vendors), collect the
|
1. Build locally, run on several physical devices (varied Android versions/vendors), collect the
|
||||||
JSON reports — especially the real per-device Shizuku dump formats.
|
JSON reports — especially the real per-device Shizuku dump formats.
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module echo-lot.app/server
|
||||||
|
|
||||||
|
go 1.24
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user