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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-07-30 13:09:08 +02:00
co-authored by Claude Opus 5
parent ee66648e3c
commit 8a80026d49
15 changed files with 1502 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package selfupdate replaces the running binary with the newest release
// asset from a Gitea repo. Native mode only and strictly opt-in (twice: the
// API base must be configured AND --self-update passed / timer enabled).
// Containers update by pulling a new image tag instead.
package selfupdate
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
type release struct {
TagName string `json:"tag_name"`
Assets []asset `json:"assets"`
}
type asset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
}
// Run checks <api>/releases/latest for an asset named
// echolot-server_<GOOS>_<GOARCH> newer than currentVersion and atomically
// replaces the current executable. The caller (or systemd Restart=) handles
// the restart; we never exec ourselves.
func Run(api, currentVersion string) error {
if api == "" {
return fmt.Errorf("self-update disabled: no --self-update-api / ECHOLOT_SELF_UPDATE_API configured")
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(strings.TrimRight(api, "/") + "/releases/latest")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("release API: %s", resp.Status)
}
var rel release
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return err
}
if rel.TagName == "" || rel.TagName == currentVersion {
fmt.Printf("already current (%s)\n", currentVersion)
return nil
}
want := fmt.Sprintf("echolot-server_%s_%s", runtime.GOOS, runtime.GOARCH)
var url string
for _, a := range rel.Assets {
if a.Name == want {
url = a.URL
break
}
}
if url == "" {
return fmt.Errorf("release %s has no asset %q", rel.TagName, want)
}
self, err := os.Executable()
if err != nil {
return err
}
self, _ = filepath.EvalSymlinks(self)
tmp := self + ".update"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
if err != nil {
return err
}
dl, err := client.Get(url)
if err != nil {
f.Close()
os.Remove(tmp)
return err
}
_, err = io.Copy(f, dl.Body)
dl.Body.Close()
f.Close()
if err != nil {
os.Remove(tmp)
return err
}
// TODO(security): verify a detached signature/checksum asset before the
// rename — a Gitea compromise currently equals code execution here.
if err := os.Rename(tmp, self); err != nil {
os.Remove(tmp)
return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err)
}
fmt.Printf("updated %s -> %s (%s); restart to run it\n", currentVersion, rel.TagName, self)
return nil
}