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,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