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,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
|
||||
}
|
||||
Reference in New Issue
Block a user