Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a4cb1c327 | ||
|
|
3cdbccee18 | ||
|
|
80d2092f1b | ||
|
|
89a5ff9139 |
@@ -22,4 +22,7 @@ VOLUME ["/state"]
|
||||
# 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
|
||||
# The verb is explicit here too, so `docker run <image>` serves and `docker run <image> --help`
|
||||
# still works by overriding the command.
|
||||
ENTRYPOINT ["/echolot-server"]
|
||||
CMD ["--serve"]
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
@@ -36,6 +37,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"echo-lot.app/server/internal/adminauth"
|
||||
"echo-lot.app/server/internal/canarydns"
|
||||
"echo-lot.app/server/internal/compat"
|
||||
"echo-lot.app/server/internal/config"
|
||||
@@ -70,6 +72,30 @@ func run() error {
|
||||
control.Version = Version
|
||||
|
||||
switch {
|
||||
case actions.Help:
|
||||
// Compatibility shim for one release.
|
||||
//
|
||||
// Serving became an explicit verb, but self-update is run by the *old* binary — so the
|
||||
// repair added to the updater cannot fix the very update that installs the new one. A
|
||||
// unit written before this change starts us with no arguments, and without this branch
|
||||
// the service would simply stop working, unattended, on a host nobody is watching.
|
||||
//
|
||||
// Only when systemd started us: INVOCATION_ID is set by systemd for every service
|
||||
// invocation and by nothing else, so a person at a terminal still gets usage. Remove
|
||||
// this once no deployment predates --serve.
|
||||
if os.Getenv("INVOCATION_ID") != "" {
|
||||
slog.Warn("started by systemd with no verb — this unit predates --serve; " +
|
||||
"repairing it and serving anyway")
|
||||
if repaired, err := system.RepairExecStart(); err != nil {
|
||||
slog.Error("could not repair the unit; fix ExecStart by hand", "err", err)
|
||||
} else if repaired {
|
||||
slog.Info("systemd unit updated to pass --serve")
|
||||
}
|
||||
return serve(cfg)
|
||||
}
|
||||
config.Usage(os.Stderr)
|
||||
os.Exit(2)
|
||||
return nil
|
||||
case actions.Version:
|
||||
fmt.Println(Version)
|
||||
return nil
|
||||
@@ -80,6 +106,8 @@ func run() error {
|
||||
return system.InstallSystemd(cfg.SelfUpdateAPI)
|
||||
case actions.UninstallSystemd:
|
||||
return system.UninstallSystemd()
|
||||
case actions.SetAdminPassword:
|
||||
return setAdminPassword(cfg)
|
||||
case actions.SelfUpdate:
|
||||
return selfupdate.Run(cfg.SelfUpdateAPI, Version)
|
||||
}
|
||||
@@ -155,14 +183,16 @@ func serve(cfg *config.Config) error {
|
||||
// uploads=account can never be satisfied — which is the honest outcome, not a silent
|
||||
// downgrade to anonymous.
|
||||
var idp *oidc.Verifier
|
||||
if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
|
||||
if cfg.OIDCIssuer != "" && (cfg.OIDCClientID != "" || cfg.OIDCAppClientID != "") {
|
||||
idp = oidc.New(oidc.Config{
|
||||
Issuer: cfg.OIDCIssuer,
|
||||
ClientID: cfg.OIDCClientID,
|
||||
AdminGroup: cfg.OIDCAdminGroup,
|
||||
Issuer: cfg.OIDCIssuer,
|
||||
ClientID: cfg.OIDCClientID,
|
||||
AppClientID: cfg.OIDCAppClientID,
|
||||
AdminGroup: cfg.OIDCAdminGroup,
|
||||
}, nil)
|
||||
slog.Info("identity provider configured", "issuer", cfg.OIDCIssuer,
|
||||
"client_id", cfg.OIDCClientID, "admin_group", cfg.OIDCAdminGroup)
|
||||
"admin_client_id", cfg.OIDCClientID, "app_client_id", cfg.OIDCAppClientID,
|
||||
"admin_group", cfg.OIDCAdminGroup)
|
||||
if cfg.OIDCAdminGroup == "" {
|
||||
slog.Warn("no admin group set: nobody will be an admin via OIDC " +
|
||||
"(set ECHOLOT_OIDC_ADMIN_GROUP)")
|
||||
@@ -516,3 +546,48 @@ func publicControlURL(cfg *config.Config) string {
|
||||
}
|
||||
return "https://" + addr
|
||||
}
|
||||
|
||||
// setAdminPassword stores the break-glass admin credential.
|
||||
//
|
||||
// The password is read from stdin rather than taken as a flag, so it never lands in shell
|
||||
// history, in the process list where any local user can see it, or in a systemd unit. Piping is
|
||||
// still possible for automation:
|
||||
//
|
||||
// printf '%s' "$PW" | echolot-server --set-admin-password --admin-user ops
|
||||
func setAdminPassword(cfg *config.Config) error {
|
||||
st, err := store.Open(cfg.StateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("state store: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "New password for %q (input is not echoed if this is a terminal): ", cfg.AdminUser)
|
||||
pw, err := readSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
cred, err := adminauth.NewCredential(cfg.AdminUser, pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.SetLocalAdmin(cred); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Break-glass admin %q set. This account works even when the identity\n"+
|
||||
"provider does not, which is the point of it — treat the password accordingly.\n", cfg.AdminUser)
|
||||
return nil
|
||||
}
|
||||
|
||||
// readSecret reads one line from stdin, without echo where the terminal allows it.
|
||||
func readSecret() (string, error) {
|
||||
restore, _ := system.DisableEcho(os.Stdin)
|
||||
if restore != nil {
|
||||
defer restore()
|
||||
}
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package adminauth handles who may administer the server.
|
||||
//
|
||||
// Two ways in, deliberately:
|
||||
//
|
||||
// - **OIDC**, the normal one. Identity lives in the operator's own IdP.
|
||||
// - **A local admin password**, the break-glass one. If the IdP is misconfigured, unreachable,
|
||||
// or the operator fat-fingered the admin group, they would otherwise be locked out of their
|
||||
// own server with no way back in short of editing JSON on disk. A fallback that only works
|
||||
// when everything else is broken is exactly the thing you cannot add later, because by then
|
||||
// you cannot get in to add it.
|
||||
//
|
||||
// The local password is stored as PBKDF2-HMAC-SHA256, from the standard library (Go 1.24+), with
|
||||
// a per-credential salt. Not because password login is encouraged — it is the fallback — but
|
||||
// because a break-glass credential is precisely the one most likely to end up in a backup or a
|
||||
// config-management repo, and a hash survives that where a bearer token does not.
|
||||
//
|
||||
// There is no email reset flow and there should not be: `--set-admin-password` on the host *is*
|
||||
// the reset, and anyone who can run it already has the machine.
|
||||
package adminauth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// iterations follows OWASP's guidance for PBKDF2-HMAC-SHA256. Deliberately slow: this credential
|
||||
// is used a handful of times in a server's life, so the cost is invisible to the operator and
|
||||
// meaningful to anyone grinding a stolen hash.
|
||||
const iterations = 600_000
|
||||
|
||||
const (
|
||||
saltLen = 16
|
||||
keyLen = 32
|
||||
)
|
||||
|
||||
// Credential is a stored local admin password.
|
||||
type Credential struct {
|
||||
Username string `json:"username"`
|
||||
Salt string `json:"salt"` // hex
|
||||
Hash string `json:"hash"` // hex
|
||||
Iterations int `json:"iterations"`
|
||||
Updated string `json:"updated,omitempty"`
|
||||
}
|
||||
|
||||
// NewCredential derives a stored credential from a plaintext password.
|
||||
func NewCredential(username, password string) (Credential, error) {
|
||||
if strings.TrimSpace(username) == "" {
|
||||
return Credential{}, errors.New("username must not be empty")
|
||||
}
|
||||
// Twelve is not a policy so much as a floor: this is the one account that can reach
|
||||
// everything, and it is not rate-limited by a human being's patience.
|
||||
if len(password) < 12 {
|
||||
return Credential{}, errors.New("password must be at least 12 characters")
|
||||
}
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
key, err := pbkdf2.Key(sha256.New, password, salt, iterations, keyLen)
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
return Credential{
|
||||
Username: username,
|
||||
Salt: hex.EncodeToString(salt),
|
||||
Hash: hex.EncodeToString(key),
|
||||
Iterations: iterations,
|
||||
Updated: time.Now().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify checks a username and password against this credential.
|
||||
//
|
||||
// Both comparisons are constant-time, including the username: a fast rejection on an unknown
|
||||
// username is a timing oracle for which usernames exist. The stored iteration count is used
|
||||
// rather than the current constant, so raising the constant does not lock out existing passwords.
|
||||
func (c Credential) Verify(username, password string) bool {
|
||||
if c.Username == "" || c.Hash == "" {
|
||||
return false
|
||||
}
|
||||
salt, err := hex.DecodeString(c.Salt)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := hex.DecodeString(c.Hash)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
iter := c.Iterations
|
||||
if iter <= 0 {
|
||||
iter = iterations
|
||||
}
|
||||
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
userOK := subtle.ConstantTimeCompare([]byte(c.Username), []byte(username)) == 1
|
||||
passOK := subtle.ConstantTimeCompare(got, want) == 1
|
||||
return userOK && passOK
|
||||
}
|
||||
|
||||
// Throttle slows repeated failures against the local password.
|
||||
//
|
||||
// The local admin is a single well-known account guarding everything, so an unthrottled login
|
||||
// form is an offline-speed guessing oracle that happens to be online. This is deliberately crude
|
||||
// — a delay that grows with consecutive failures and resets on success — because the goal is to
|
||||
// make guessing impractical, not to build a lockout system that an operator can trap themselves
|
||||
// with. It never locks permanently: a break-glass credential that can be locked out by an
|
||||
// attacker is a denial of service against the person who needs it most.
|
||||
type Throttle struct {
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
last time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewThrottle() *Throttle { return &Throttle{now: time.Now} }
|
||||
|
||||
// Delay is how long the caller should wait before answering, given the failures so far.
|
||||
func (t *Throttle) Delay() time.Duration {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
// A quiet minute forgives everything, so an operator returning later is not punished for
|
||||
// somebody else's earlier attempts.
|
||||
if !t.last.IsZero() && t.now().Sub(t.last) > time.Minute {
|
||||
t.failures = 0
|
||||
}
|
||||
switch {
|
||||
case t.failures == 0:
|
||||
return 0
|
||||
case t.failures < 3:
|
||||
return 250 * time.Millisecond
|
||||
case t.failures < 6:
|
||||
return time.Second
|
||||
default:
|
||||
return 3 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Throttle) Failed() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.failures++
|
||||
t.last = t.now()
|
||||
}
|
||||
|
||||
func (t *Throttle) Succeeded() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.failures = 0
|
||||
}
|
||||
|
||||
// ---- sessions ---------------------------------------------------------------------------
|
||||
|
||||
// Session is an authenticated admin, however they proved it.
|
||||
type Session struct {
|
||||
// Subject is the account id: "local:<username>" or "<issuer>#<sub>" from OIDC.
|
||||
Subject string
|
||||
// Display is what the UI shows.
|
||||
Display string
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// Sessions mints and checks signed session cookies.
|
||||
//
|
||||
// The cookie carries its own contents and a MAC, so there is no server-side session table to
|
||||
// grow, expire, or lose on restart — and equally no way to revoke one early, which is why they
|
||||
// are short-lived. The secret is persisted, so an operator's session survives a service restart;
|
||||
// regenerating it (deleting it from the state file) invalidates every session at once, which is
|
||||
// the revocation mechanism.
|
||||
type Sessions struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewSessions(secret []byte, ttl time.Duration) *Sessions {
|
||||
if ttl <= 0 {
|
||||
ttl = 12 * time.Hour
|
||||
}
|
||||
return &Sessions{secret: append([]byte(nil), secret...), ttl: ttl}
|
||||
}
|
||||
|
||||
// NewSecret makes a fresh signing secret for first start.
|
||||
func NewSecret() ([]byte, error) {
|
||||
b := make([]byte, 32)
|
||||
_, err := rand.Read(b)
|
||||
return b, err
|
||||
}
|
||||
|
||||
var ErrSession = errors.New("session is not valid")
|
||||
|
||||
// Issue returns the cookie value for a newly authenticated admin.
|
||||
func (s *Sessions) Issue(subject, display string) string {
|
||||
exp := time.Now().Add(s.ttl).Unix()
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." +
|
||||
base64.RawURLEncoding.EncodeToString([]byte(display)) + "." +
|
||||
strconv.FormatInt(exp, 10)
|
||||
return payload + "." + s.mac(payload)
|
||||
}
|
||||
|
||||
// Parse checks a cookie value and returns the session it encodes.
|
||||
func (s *Sessions) Parse(value string) (*Session, error) {
|
||||
i := strings.LastIndex(value, ".")
|
||||
if i < 0 {
|
||||
return nil, ErrSession
|
||||
}
|
||||
payload, sig := value[:i], value[i+1:]
|
||||
// MAC first, always. Nothing in the payload is believed — not even its shape — before the
|
||||
// signature has been checked.
|
||||
if !hmac.Equal([]byte(sig), []byte(s.mac(payload))) {
|
||||
return nil, ErrSession
|
||||
}
|
||||
parts := strings.Split(payload, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrSession
|
||||
}
|
||||
subject, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, ErrSession
|
||||
}
|
||||
display, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, ErrSession
|
||||
}
|
||||
exp, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
return nil, ErrSession
|
||||
}
|
||||
if time.Now().After(time.Unix(exp, 0)) {
|
||||
return nil, fmt.Errorf("%w: expired", ErrSession)
|
||||
}
|
||||
return &Session{Subject: string(subject), Display: string(display), Expires: time.Unix(exp, 0)}, nil
|
||||
}
|
||||
|
||||
func (s *Sessions) mac(payload string) string {
|
||||
m := hmac.New(sha256.New, s.secret)
|
||||
m.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package adminauth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PBKDF2 at 600k iterations is slow on purpose, so these use a reduced count where the test is
|
||||
// about logic rather than cost.
|
||||
func fastCredential(t *testing.T, user, pass string) Credential {
|
||||
t.Helper()
|
||||
c, err := NewCredential(user, pass)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestVerifyAcceptsOnlyTheRightPair(t *testing.T) {
|
||||
c := fastCredential(t, "admin", "correct-horse-battery")
|
||||
|
||||
if !c.Verify("admin", "correct-horse-battery") {
|
||||
t.Fatal("the correct credentials were rejected")
|
||||
}
|
||||
for _, tc := range []struct{ user, pass string }{
|
||||
{"admin", "wrong-password-here"},
|
||||
{"admin", ""},
|
||||
{"root", "correct-horse-battery"},
|
||||
{"", "correct-horse-battery"},
|
||||
{"ADMIN", "correct-horse-battery"}, // usernames are not case-folded
|
||||
} {
|
||||
if c.Verify(tc.user, tc.pass) {
|
||||
t.Errorf("accepted %q/%q", tc.user, tc.pass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two credentials with the same password must not share a hash, or one cracked password reveals
|
||||
// every reuse of it and a precomputed table works against all of them.
|
||||
func TestSaltsDiffer(t *testing.T) {
|
||||
a := fastCredential(t, "admin", "the-same-password-x")
|
||||
b := fastCredential(t, "admin", "the-same-password-x")
|
||||
if a.Salt == b.Salt {
|
||||
t.Fatal("two credentials share a salt")
|
||||
}
|
||||
if a.Hash == b.Hash {
|
||||
t.Fatal("the same password produced the same hash twice")
|
||||
}
|
||||
// Both must still verify — a salt that is not actually used would also produce differing
|
||||
// hashes if it were mixed in wrongly.
|
||||
if !a.Verify("admin", "the-same-password-x") || !b.Verify("admin", "the-same-password-x") {
|
||||
t.Fatal("a salted credential does not verify")
|
||||
}
|
||||
}
|
||||
|
||||
// The stored iteration count is used rather than the current constant, so raising the constant
|
||||
// later does not silently lock out every existing password.
|
||||
func TestOldIterationCountsStillVerify(t *testing.T) {
|
||||
c := fastCredential(t, "admin", "a-perfectly-fine-pw")
|
||||
c.Iterations = iterations // as stored
|
||||
if !c.Verify("admin", "a-perfectly-fine-pw") {
|
||||
t.Fatal("credential does not verify with its stored iteration count")
|
||||
}
|
||||
// A credential written before the field existed must not be treated as zero-iteration.
|
||||
c.Iterations = 0
|
||||
if !c.Verify("admin", "a-perfectly-fine-pw") {
|
||||
t.Fatal("a credential with no recorded iteration count failed to verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeakInputsAreRefusedAtCreation(t *testing.T) {
|
||||
if _, err := NewCredential("", "long-enough-password"); err == nil {
|
||||
t.Error("an empty username was accepted")
|
||||
}
|
||||
if _, err := NewCredential("admin", "short"); err == nil {
|
||||
t.Error("a short password was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyCredentialNeverVerifies(t *testing.T) {
|
||||
var zero Credential
|
||||
if zero.Verify("", "") {
|
||||
t.Fatal("a server with no local admin configured accepted empty credentials")
|
||||
}
|
||||
if zero.Verify("admin", "anything") {
|
||||
t.Fatal("an unset credential verified")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sessions ----------------------------------------------------------------------------
|
||||
|
||||
func TestSessionRoundTrip(t *testing.T) {
|
||||
secret, _ := NewSecret()
|
||||
s := NewSessions(secret, time.Hour)
|
||||
got, err := s.Parse(s.Issue("local:admin", "Admin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Subject != "local:admin" || got.Display != "Admin" {
|
||||
t.Fatalf("session did not round-trip: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie carries its own contents, so the MAC is the only thing standing between a user and
|
||||
// promoting themselves. Every tampered form must fail.
|
||||
func TestTamperedSessionsAreRejected(t *testing.T) {
|
||||
secret, _ := NewSecret()
|
||||
s := NewSessions(secret, time.Hour)
|
||||
good := s.Issue("local:admin", "Admin")
|
||||
|
||||
parts := strings.Split(good, ".")
|
||||
tampered := []string{
|
||||
"",
|
||||
"garbage",
|
||||
good + "x", // signature altered
|
||||
strings.Replace(good, parts[0], "Zm9v", 1), // subject swapped
|
||||
strings.Join(parts[:len(parts)-1], "."), // signature removed
|
||||
parts[0] + "." + parts[1] + "." + parts[2], // signature removed, well-formed payload
|
||||
}
|
||||
for _, v := range tampered {
|
||||
if _, err := s.Parse(v); err == nil {
|
||||
t.Errorf("accepted a tampered session: %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsFromAnotherSecretAreRejected(t *testing.T) {
|
||||
a, _ := NewSecret()
|
||||
b, _ := NewSecret()
|
||||
issued := NewSessions(a, time.Hour).Issue("local:admin", "Admin")
|
||||
if _, err := NewSessions(b, time.Hour).Parse(issued); err == nil {
|
||||
t.Fatal("a session signed with a different secret was accepted — rotating the secret " +
|
||||
"must invalidate every existing session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredSessionsAreRejected(t *testing.T) {
|
||||
secret, _ := NewSecret()
|
||||
// A negative TTL is not reachable through NewSessions, so issue with a real one and check
|
||||
// the boundary via a session that has already run out.
|
||||
s := NewSessions(secret, time.Millisecond)
|
||||
v := s.Issue("local:admin", "Admin")
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if _, err := s.Parse(v); err == nil {
|
||||
t.Fatal("an expired session was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- throttle ------------------------------------------------------------------------------
|
||||
|
||||
func TestThrottleGrowsWithFailuresAndResetsOnSuccess(t *testing.T) {
|
||||
tr := NewThrottle()
|
||||
if d := tr.Delay(); d != 0 {
|
||||
t.Fatalf("a first attempt was delayed by %v", d)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
tr.Failed()
|
||||
}
|
||||
first := tr.Delay()
|
||||
for i := 0; i < 6; i++ {
|
||||
tr.Failed()
|
||||
}
|
||||
later := tr.Delay()
|
||||
if !(later > first && first > 0) {
|
||||
t.Fatalf("delay did not grow with failures: %v then %v", first, later)
|
||||
}
|
||||
tr.Succeeded()
|
||||
if d := tr.Delay(); d != 0 {
|
||||
t.Fatalf("a successful login did not clear the throttle: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// A break-glass credential that an attacker can lock out is a denial of service against the one
|
||||
// person who needs it. The delay must stay bounded rather than becoming a lockout.
|
||||
func TestThrottleNeverLocksOutPermanently(t *testing.T) {
|
||||
tr := NewThrottle()
|
||||
for i := 0; i < 1000; i++ {
|
||||
tr.Failed()
|
||||
}
|
||||
if d := tr.Delay(); d > 10*time.Second {
|
||||
t.Fatalf("throttle became a lockout: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottleForgivesAfterAQuietPeriod(t *testing.T) {
|
||||
tr := NewThrottle()
|
||||
now := time.Now()
|
||||
tr.now = func() time.Time { return now }
|
||||
for i := 0; i < 10; i++ {
|
||||
tr.Failed()
|
||||
}
|
||||
if tr.Delay() == 0 {
|
||||
t.Fatal("failures did not register")
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
if d := tr.Delay(); d != 0 {
|
||||
t.Fatalf("an operator returning later was still throttled: %v", d)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ package config
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -69,9 +70,13 @@ type Config struct {
|
||||
|
||||
// Identity provider. Empty issuer disables sign-in entirely; the server is a relying
|
||||
// party and never stores passwords.
|
||||
OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer
|
||||
OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id
|
||||
OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group
|
||||
OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer
|
||||
OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id (confidential, admin UI)
|
||||
OIDCAppClientID string // ECHOLOT_OIDC_APP_CLIENT_ID / --oidc-app-client-id (public, the phone app)
|
||||
OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group
|
||||
|
||||
// Break-glass admin username; the password lives hashed in the state store.
|
||||
AdminUser string // ECHOLOT_ADMIN_USER / --admin-user
|
||||
|
||||
// Mode
|
||||
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
|
||||
@@ -122,7 +127,8 @@ func Load(args []string) (*Config, *Actions, error) {
|
||||
fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables")
|
||||
fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict")
|
||||
fs.StringVar(&c.OIDCIssuer, "oidc-issuer", envOr("OIDC_ISSUER", ""), "OpenID Connect issuer URL; empty disables sign-in")
|
||||
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "OpenID Connect client id for this server")
|
||||
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "confidential OIDC client id for the admin UI")
|
||||
fs.StringVar(&c.OIDCAppClientID, "oidc-app-client-id", envOr("OIDC_APP_CLIENT_ID", ""), "public OIDC client id used by the Android app (PKCE)")
|
||||
fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC")
|
||||
fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443")
|
||||
fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
|
||||
@@ -131,6 +137,12 @@ func Load(args []string) (*Config, *Actions, error) {
|
||||
|
||||
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")
|
||||
var daemon bool
|
||||
fs.BoolVar(&a.Serve, "serve", false, "run the server (bind listeners and answer requests)")
|
||||
fs.BoolVar(&daemon, "daemon", false, "alias for --serve")
|
||||
fs.BoolVar(&a.SetAdminPassword, "set-admin-password", false,
|
||||
"set the break-glass admin password (username as --admin-user, password read from stdin) and exit")
|
||||
fs.StringVar(&c.AdminUser, "admin-user", envOr("ADMIN_USER", "admin"), "username for the break-glass admin")
|
||||
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")
|
||||
|
||||
@@ -140,12 +152,42 @@ func Load(args []string) (*Config, *Actions, error) {
|
||||
if !c.Docker {
|
||||
c.Docker = inContainer()
|
||||
}
|
||||
a.Serve = a.Serve || daemon
|
||||
// No verb at all means the caller has not said what they want. Usage is the answer, and it
|
||||
// is a usage error rather than success — otherwise a service manager sees a clean exit and
|
||||
// concludes the server ran and finished.
|
||||
if !a.Serve && !a.InstallSystemd && !a.UninstallSystemd && !a.SelfUpdate &&
|
||||
!a.SetAdminPassword && !a.Version {
|
||||
a.Help = true
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Usage prints the verbs first and the tuning flags second, because the question someone has
|
||||
// when they run this by name is "what does it do", not "what can I set".
|
||||
func Usage(w io.Writer) {
|
||||
fmt.Fprint(w, `echolot-server — the Echolot probe server
|
||||
|
||||
USAGE
|
||||
echolot-server --serve run the server
|
||||
echolot-server --version print the version
|
||||
echolot-server --install-systemd install and enable a systemd unit
|
||||
echolot-server --uninstall-systemd remove it
|
||||
echolot-server --self-update replace this binary with the latest release
|
||||
echolot-server --set-admin-password set the break-glass admin password (stdin)
|
||||
echolot-server --help full flag list
|
||||
|
||||
Every flag can also be set as an environment variable: --control-listen becomes
|
||||
ECHOLOT_CONTROL_LISTEN. In a container, configuration comes from the environment.
|
||||
|
||||
Running with no verb prints this and exits non-zero: starting to serve the internet
|
||||
should be something you asked for.
|
||||
`)
|
||||
}
|
||||
|
||||
// Addrs splits a comma-separated listen spec into individual addresses.
|
||||
// Explicit per-address binds matter on multi-IP hosts: a wildcard bind
|
||||
// (":8443") would also claim addresses reserved for other purposes (e.g. an
|
||||
@@ -160,11 +202,19 @@ func Addrs(spec string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// Actions are one-shot verbs that exit instead of serving.
|
||||
// Actions are the verbs. Serving is one of them, and it is explicit: running the binary with no
|
||||
// arguments prints usage rather than binding a dozen ports and starting to answer the internet.
|
||||
// Someone typing the name of an unfamiliar program on a terminal should be told what it does, not
|
||||
// have it start doing it.
|
||||
type Actions struct {
|
||||
Serve bool
|
||||
// Help is set when there is nothing to do: no verb was given.
|
||||
Help bool
|
||||
|
||||
InstallSystemd bool
|
||||
UninstallSystemd bool
|
||||
SelfUpdate bool
|
||||
SetAdminPassword bool
|
||||
Version bool
|
||||
}
|
||||
|
||||
|
||||
@@ -828,9 +828,10 @@ func (s *Server) authInfo(ctx context.Context) map[string]any {
|
||||
}
|
||||
cfg := s.OIDC.Config()
|
||||
out := map[string]any{
|
||||
"enabled": true,
|
||||
"issuer": cfg.Issuer,
|
||||
"client_id": cfg.ClientID,
|
||||
"enabled": true,
|
||||
"issuer": cfg.Issuer,
|
||||
// The app's client, not the server's: this is what a phone should authorize as.
|
||||
"client_id": cfg.AppClientID,
|
||||
// The app is a public client on a phone: no secret can be kept, so PKCE is what
|
||||
// protects the code exchange (RFC 7636), and the redirect comes back through the
|
||||
// scheme the app already registers for enrollment links.
|
||||
|
||||
@@ -97,8 +97,16 @@ func (a audience) contains(s string) bool {
|
||||
type Config struct {
|
||||
// Issuer is the IdP's base URL, e.g. https://auth.example.net/application/o/echolot/
|
||||
Issuer string
|
||||
// ClientID is this server's registered client. Tokens must be addressed to it.
|
||||
// ClientID is this server's own registered client — confidential, used for the admin UI's
|
||||
// browser login, where a secret can genuinely be kept in the host's config.
|
||||
ClientID string
|
||||
// AppClientID is the mobile app's registered client. It is a separate, *public* client
|
||||
// because an APK cannot keep a secret, so it uses PKCE instead.
|
||||
//
|
||||
// Both are accepted as audiences, and they must be listed rather than merged: a token is
|
||||
// addressed to a specific client, and accepting "any client of this issuer" would let every
|
||||
// other application registered with the same IdP authenticate here.
|
||||
AppClientID string
|
||||
// AdminGroup, when set, is the group claim a person must hold to reach the admin UI.
|
||||
// Empty means no one is an admin via OIDC, which is the safe default: an operator who has
|
||||
// not said who may administer the server has not said "everyone".
|
||||
@@ -107,7 +115,27 @@ type Config struct {
|
||||
Skew time.Duration
|
||||
}
|
||||
|
||||
func (c Config) Enabled() bool { return c.Issuer != "" && c.ClientID != "" }
|
||||
func (c Config) Enabled() bool { return c.Issuer != "" && (c.ClientID != "" || c.AppClientID != "") }
|
||||
|
||||
// acceptedAudiences is every client id this server answers for.
|
||||
func (v *Verifier) acceptedAudiences() []string {
|
||||
out := make([]string, 0, 2)
|
||||
for _, id := range []string{v.cfg.ClientID, v.cfg.AppClientID} {
|
||||
if id != "" {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v *Verifier) audienceAccepted(aud audience) bool {
|
||||
for _, id := range v.acceptedAudiences() {
|
||||
if aud.contains(id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Discovery is the subset of the provider metadata document that is used.
|
||||
type Discovery struct {
|
||||
@@ -362,8 +390,9 @@ func (v *Verifier) checkClaims(c Claims) error {
|
||||
}
|
||||
// A token addressed to a different client is a valid token that was not meant for us —
|
||||
// accepting it lets any other client of the same IdP authenticate here.
|
||||
if !c.Audience.contains(v.cfg.ClientID) {
|
||||
return fmt.Errorf("%w: addressed to %v, not to %q", ErrClaims, []string(c.Audience), v.cfg.ClientID)
|
||||
if !v.audienceAccepted(c.Audience) {
|
||||
return fmt.Errorf("%w: addressed to %v, not to %v", ErrClaims,
|
||||
[]string(c.Audience), v.acceptedAudiences())
|
||||
}
|
||||
if c.Subject == "" {
|
||||
return fmt.Errorf("%w: no subject", ErrClaims)
|
||||
|
||||
@@ -115,7 +115,9 @@ func (i *testIdP) claims(extra map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
func verifier(i *testIdP, adminGroup string) *Verifier {
|
||||
return New(Config{Issuer: i.URL, ClientID: "echolot", AdminGroup: adminGroup}, i.Client())
|
||||
return New(Config{
|
||||
Issuer: i.URL, ClientID: "echolot", AppClientID: "echolot-app", AdminGroup: adminGroup,
|
||||
}, i.Client())
|
||||
}
|
||||
|
||||
func TestAcceptsAGenuineToken(t *testing.T) {
|
||||
@@ -286,3 +288,38 @@ func TestDisabledWithoutConfiguration(t *testing.T) {
|
||||
t.Fatalf("want ErrDisabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two clients, because the phone and the admin UI have different properties: an APK cannot keep a
|
||||
// secret (public + PKCE) while the server can (confidential). Both must be accepted — but only
|
||||
// those two. "Any client of this issuer" would let every other application registered with the
|
||||
// same IdP authenticate here, which is the whole reason the audience check exists.
|
||||
func TestBothRegisteredClientsAreAccepted(t *testing.T) {
|
||||
idp := newIdP(t)
|
||||
v := verifier(idp, "")
|
||||
|
||||
for _, aud := range []any{"echolot", "echolot-app", []string{"echolot-app", "other"}} {
|
||||
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": aud}))
|
||||
if _, err := v.Verify(context.Background(), tok); err != nil {
|
||||
t.Errorf("aud %v was refused: %v", aud, err)
|
||||
}
|
||||
}
|
||||
// A third application at the same issuer is still not us.
|
||||
tok := idp.sign(t, "RS256", "rsa-1", idp.claims(map[string]any{"aud": "someone-elses-app"}))
|
||||
if _, err := v.Verify(context.Background(), tok); !errors.Is(err, ErrClaims) {
|
||||
t.Fatalf("a third client's token was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Either client id alone is enough to make sign-in usable: an operator may register only the app
|
||||
// (no admin UI login) or only the server.
|
||||
func TestEitherClientIDAloneEnablesSignIn(t *testing.T) {
|
||||
if !(Config{Issuer: "https://i", ClientID: "a"}).Enabled() {
|
||||
t.Error("a server-only configuration was reported disabled")
|
||||
}
|
||||
if !(Config{Issuer: "https://i", AppClientID: "b"}).Enabled() {
|
||||
t.Error("an app-only configuration was reported disabled")
|
||||
}
|
||||
if (Config{Issuer: "https://i"}).Enabled() {
|
||||
t.Error("an issuer with no client at all was reported enabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ package selfupdate
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"echo-lot.app/server/internal/system"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -132,6 +133,15 @@ func Run(api, currentVersion string) error {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err)
|
||||
}
|
||||
// Serving became an explicit verb, and a unit written before that change starts this binary
|
||||
// with no arguments - which now prints usage and exits non-zero. The unit is not part of what
|
||||
// an update replaces, so it is repaired here rather than left to fail at the next restart,
|
||||
// which might be a reboot months from now.
|
||||
if repaired, err := system.RepairExecStart(); err != nil {
|
||||
fmt.Println("WARNING: could not update the systemd unit for --serve:", err)
|
||||
} else if repaired {
|
||||
fmt.Println("updated the systemd unit to pass --serve (serving is now an explicit verb)")
|
||||
}
|
||||
fmt.Printf("updated %s -> %s (%s); restart to run it\n", currentVersion, rel.TagName, self)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"echo-lot.app/server/internal/adminauth"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -59,6 +61,12 @@ type Store struct {
|
||||
type fileData struct {
|
||||
Tokens []EnrollToken `json:"tokens"`
|
||||
Devices []Device `json:"devices"`
|
||||
// The break-glass admin. Absent until an operator sets one.
|
||||
LocalAdmin *adminauth.Credential `json:"local_admin,omitempty"`
|
||||
// Signing secret for admin session cookies. Persisted so sessions survive a restart;
|
||||
// deleting it from the state file invalidates every session at once, which is how an
|
||||
// operator revokes them.
|
||||
SessionSecret string `json:"session_secret,omitempty"`
|
||||
}
|
||||
|
||||
func Open(stateDir string) (*Store, error) {
|
||||
@@ -177,6 +185,50 @@ func (s *Store) DeleteDevice(id string) error {
|
||||
return errors.New("no such device")
|
||||
}
|
||||
|
||||
// SetLocalAdmin stores (or replaces) the break-glass admin password.
|
||||
func (s *Store) SetLocalAdmin(c adminauth.Credential) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data.LocalAdmin = &c
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// LocalAdmin returns the configured break-glass admin, or nil.
|
||||
func (s *Store) LocalAdmin() *adminauth.Credential {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.data.LocalAdmin == nil {
|
||||
return nil
|
||||
}
|
||||
c := *s.data.LocalAdmin
|
||||
return &c
|
||||
}
|
||||
|
||||
// ClearLocalAdmin removes the break-glass admin.
|
||||
func (s *Store) ClearLocalAdmin() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data.LocalAdmin = nil
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// SessionSecret returns the admin session signing secret, creating one on first use.
|
||||
func (s *Store) SessionSecret() ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.data.SessionSecret != "" {
|
||||
if b, err := hex.DecodeString(s.data.SessionSecret); err == nil && len(b) >= 32 {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
b, err := adminauth.NewSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.data.SessionSecret = hex.EncodeToString(b)
|
||||
return b, s.save()
|
||||
}
|
||||
|
||||
func (s *Store) DeviceByCredential(cred string) *Device {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//go:build linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// DisableEcho turns off terminal echo while a password is typed, returning a function that puts
|
||||
// the terminal back. Both are best-effort: when stdin is a pipe (the automation case) there is
|
||||
// no terminal to change and nothing to restore.
|
||||
func DisableEcho(f *os.File) (func(), error) {
|
||||
fd := f.Fd()
|
||||
var t syscall.Termios
|
||||
if _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd,
|
||||
syscall.TCGETS, uintptr(unsafe.Pointer(&t)), 0, 0, 0); errno != 0 {
|
||||
return nil, errno // not a terminal; nothing to do
|
||||
}
|
||||
original := t
|
||||
t.Lflag &^= syscall.ECHO
|
||||
if _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd,
|
||||
syscall.TCSETS, uintptr(unsafe.Pointer(&t)), 0, 0, 0); errno != 0 {
|
||||
return nil, errno
|
||||
}
|
||||
return func() {
|
||||
_, _, _ = syscall.Syscall6(syscall.SYS_IOCTL, fd,
|
||||
syscall.TCSETS, uintptr(unsafe.Pointer(&original)), 0, 0, 0)
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//go:build !linux
|
||||
|
||||
package system
|
||||
|
||||
import "os"
|
||||
|
||||
// DisableEcho is a no-op off Linux: the password is still read, just echoed. Better than
|
||||
// refusing to run — an operator on a Mac still needs to set the break-glass password.
|
||||
func DisableEcho(*os.File) (func(), error) { return nil, nil }
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -29,7 +30,7 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%s
|
||||
ExecStart=%s --serve
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StateDirectory=echolot-server
|
||||
@@ -144,3 +145,41 @@ func UninstallSystemd() error {
|
||||
fmt.Println("removed echolot-server units (state dir and env file left in place)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RepairExecStart brings an already-installed unit up to date with the current invocation.
|
||||
//
|
||||
// Serving became an explicit verb (--serve), which means every unit written before that change
|
||||
// would start the binary with no arguments — and the binary now answers that with usage and a
|
||||
// non-zero exit. A self-update replaces the binary but never the unit, so without this a routine
|
||||
// update would leave a service that cannot start, discovered whenever the host next reboots.
|
||||
//
|
||||
// Only a unit this program wrote is touched, identified by its description line. Editing an
|
||||
// operator's hand-written unit would be overreach; leaving ours broken would be negligence.
|
||||
func RepairExecStart() (repaired bool, err error) {
|
||||
b, err := os.ReadFile(unitPath)
|
||||
if err != nil {
|
||||
return false, nil // no unit installed: nothing to repair, and not an error
|
||||
}
|
||||
text := string(b)
|
||||
if !strings.Contains(text, "Echolot probe server") {
|
||||
return false, nil // somebody else's unit
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
changed := false
|
||||
for i, ln := range lines {
|
||||
t := strings.TrimSpace(ln)
|
||||
// Only the serving unit's ExecStart; the timer's own line already carries its verb.
|
||||
if strings.HasPrefix(t, "ExecStart=") && !strings.Contains(t, "--") {
|
||||
lines[i] = ln + " --serve"
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return false, nil
|
||||
}
|
||||
if err := os.WriteFile(unitPath, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
|
||||
return false, fmt.Errorf("updating %s: %w", unitPath, err)
|
||||
}
|
||||
_ = exec.Command("systemctl", "daemon-reload").Run()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user