Compare commits

..
Author SHA1 Message Date
mrambossekandClaude Fable 5 5d7f59a66a acme: answer HTTP-01 from the server itself, on port 80
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 34s
server-release / release (push) Successful in 35s
HTTP-01 always arrives on port 80 - the CA chooses the port, not the operator -
so it never collides with an admin UI on 443. The conflict only exists for
TLS-ALPN-01, which is the challenge type that does use 443.

Given that, the server keeps a permanent listener on 80 that answers challenges
from a webroot and redirects everything else to the admin UI. Same arrangement
as the webroot plugins for Apache and nginx, and better than letting the ACME
client bind 80 per renewal: nothing binds and unbinds, so a renewal cannot fail
because the port was briefly busy, and the client needs only write access to a
directory instead of the privilege to bind a low port. Port 80 also gets a use
it would want anyway.

The ACME client stays an external program. lego is also a Go library, but
importing it would put a large dependency tree into a server that deliberately
has none, and the CLI does the same job from a timer.

Tokens are validated by *shape* before any filesystem call, so traversal never
reaches the disk - a stronger guarantee than sanitising a path and trusting the
sanitiser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 18:16:09 +02:00
mrambossekandClaude Fable 5 6afcb131ef admin: terminate TLS in the binary, with a certificate that reloads itself
server-test / test (push) Successful in 33s
Direct rather than behind Caddy or nginx. This binary already serves TLS for the
control plane, so it is reuse rather than new machinery; one process with one
config file is most of what makes this thing pleasant to run; and a proxy on the
box would invite someone to eventually front the control plane too, which would
break SPKI pinning because clients pin that certificate's key.

The hard part of TLS is not termination, it is renewal - so the certificate is
re-read when the files change. No reload hook to write, and none to quietly stop
working months later and be noticed only after the certificate has expired. A
torn write (renewal tools write cert and key separately) keeps the previous
certificate rather than taking the listener down.

Not applied to the control plane, on purpose: clients pin that key, so replacing
it should cost an operator a moment's thought and a restart, not happen because
a file changed. Two listeners, two different right answers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:51:58 +02:00
mrambossekandClaude Fable 5 cd187f9ef5 config: the OIDC client secret, admin TLS, and a stop on plaintext admin
server-test / test (push) Successful in 34s
Two gaps found while answering where configuration lives.

The confidential admin client needs a secret and there was nowhere to put one -
I had added the issuer and both client ids but not the secret the admin login
actually needs. It now reads from ECHOLOT_OIDC_CLIENT_SECRET, and preferably
from ECHOLOT_OIDC_CLIENT_SECRET_FILE: a secret in the environment is readable by
anything that can see /proc/<pid>/environ and lands in every dump of the unit's
config, whereas a path is one file whose permissions an operator can reason
about. (/etc/echolot-server.env was also 0644; now 0600 on fmr.)

And the server now refuses to serve the admin UI in plaintext on a non-loopback
address. The session cookie is a bearer credential for everything the server can
do, and the OIDC authorization code arrives in a URL; in the clear, both belong
to anyone on the path - and on a globally routable address that is the internet.
A hard stop rather than a warning, because a warning in a log is not read by the
person who most needs it, and because the safe answers are cheap: bind to
loopback and tunnel, or supply a certificate. ECHOLOT_ADMIN_INSECURE=1 overrides
it, so the decision is made rather than stumbled into.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:49:26 +02:00
mrambossekandClaude Fable 5 3a4cb1c327 cli: survive the --serve transition when nobody is watching
server-release / image (push) Successful in 16s
server-test / test (push) Successful in 33s
server-release / release (push) Successful in 34s
Deploying v0.8.0 broke fmr, and the reason is a flaw I should have seen:
self-update is executed by the OLD binary, so the unit repair I put in the new
binary's updater cannot fix the very update that installs it. The unit kept its
argument-less ExecStart, the new binary answered that with usage and exit 2, and
the service went into a restart loop.

Fixed on fmr by hand, but that is not a fix for anyone else - and the whole
premise of an unattended self-update is that nobody is watching when it happens.

So: when started with no verb *and* systemd started us, the server repairs the
unit and serves anyway, loudly. systemd sets INVOCATION_ID for every service
invocation and nothing else does, so a person at a terminal still gets usage and
a non-zero exit. Marked as a one-release shim to remove once no deployment
predates --serve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:44:33 +02:00
mrambossekandClaude Fable 5 3cdbccee18 cli: serving is an explicit verb; no arguments prints usage
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 33s
server-release / release (push) Successful in 34s
Running an unfamiliar binary by name should tell you what it does, not bind a
dozen ports and start answering the internet. --serve (or --daemon) now does
that, and a bare invocation prints usage and exits 2 - non-zero on purpose, so a
service manager sees a failure rather than concluding the server ran and
finished cleanly.

The hazard this creates is worth spelling out, because it bites once and
silently: three places started the binary with no arguments - the systemd unit,
the unit template, and the Dockerfile - and --self-update replaces the binary
but never the unit. A routine update would therefore leave a service that cannot
start, discovered whenever the host next rebooted.

So the updater repairs it: after replacing the binary it appends --serve to an
ExecStart that has no flags, but only in a unit this program wrote (identified
by its description). Editing an operator's hand-written unit would be overreach;
leaving ours broken would be negligence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:42:20 +02:00
mrambossekandClaude Fable 5 80d2092f1b oidc: accept both the app's public client and the server's confidential one
server-test / test (push) Successful in 37s
Explaining public vs confidential clients surfaced a gap in my own design: I had
assumed a single client id, but there are two clients here with genuinely
different properties.

  the Android app     public + PKCE, because an APK cannot keep a secret
  the admin UI        confidential, because the server can keep one in
                      /etc/echolot-server.env and weakening it to public buys
                      nothing

So the audience check now accepts either registered client id - and only those
two. "Any client of this issuer" would let every other application registered
with the same IdP authenticate here, which is the entire reason the check
exists. Either id alone is enough to enable sign-in, since an operator may
register only the app or only the admin UI.

The profile advertises the *app's* client id, since that is what a phone should
authorize as.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:31:33 +02:00
mrambossekandClaude Fable 5 89a5ff9139 adminauth: a break-glass local admin alongside OIDC
server-test / test (push) Successful in 44s
If the IdP is misconfigured, unreachable, or the admin group is a typo, the
operator is locked out of their own server with no way back short of editing
JSON on disk. A fallback that only matters when everything else is broken is
exactly the thing you cannot add later - by then you cannot get in to add it.

Stored as PBKDF2-HMAC-SHA256 from the standard library (Go 1.24+ has it, so no
dependency), 600k iterations, per-credential salt. A password rather than a
bearer token on purpose: a break-glass credential is the one most likely to end
up in a backup or a config-management repo, and a hash survives that where a
token does not. There is no email reset flow and should not be -
--set-admin-password on the host is the reset, and whoever can run it already
has the machine.

The password is read from stdin, never a flag, so it stays out of shell history
and the process list; piping still works for automation.

Details the tests pin, each for a reason:
  - the username is compared in constant time too, or a fast rejection is a
    timing oracle for which usernames exist;
  - the *stored* iteration count is used, so raising the constant later does not
    lock out existing passwords;
  - the throttle grows with consecutive failures but stays bounded and forgives
    after a quiet minute - a break-glass credential an attacker can lock out is
    a denial of service against the one person who needs it;
  - sessions are MAC-checked before anything in them is read, and rotating the
    secret invalidates every one at once, which is how they are revoked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:12:54 +02:00
18 changed files with 1420 additions and 20 deletions
+3
View File
@@ -22,4 +22,7 @@ VOLUME ["/state"]
# the data plane must see real client source addresses/TTLs, and Docker's # the data plane must see real client source addresses/TTLs, and Docker's
# userland NAT would falsify exactly what this server exists to observe. # userland NAT would falsify exactly what this server exists to observe.
EXPOSE 8441/tcp 8442/udp 8443/tcp 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"] ENTRYPOINT ["/echolot-server"]
CMD ["--serve"]
+38
View File
@@ -146,3 +146,41 @@ 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 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 attaches static linux amd64/arm64 binaries (+ SHA256SUMS) to a release — the same
artifacts `--self-update` consumes. artifacts `--self-update` consumes.
## TLS for the admin UI
The binary terminates TLS itself; there is no reverse proxy in the design. It already serves TLS
for the control plane, so this is reuse rather than new machinery, and it keeps the "one process,
one config file" property. A proxy would also invite someone to eventually front the control plane
too — which would break SPKI pinning, because clients pin *that* certificate's key.
```
ECHOLOT_ADMIN_LISTEN=[2001:db8::2]:443
ECHOLOT_ADMIN_TLS_CERT=/etc/echolot/admin.pem
ECHOLOT_ADMIN_TLS_KEY=/etc/echolot/admin.key
ECHOLOT_ADMIN_BASE_URL=https://admin.example.net
```
Certificates come from any ACME client. **DNS-01 is the one to use here**: it needs no inbound
port 80, which matters on a host where 80 is awkward or already spoken for.
```sh
acme.sh --issue --dns dns_cf -d admin.example.net \
--key-file /etc/echolot/admin.key \
--fullchain-file /etc/echolot/admin.pem
```
**No reload hook is needed.** The certificate is re-read when the files change, so a renewal that
drops new files in place is picked up on the next handshake. That is deliberate: a reload hook is
the part of a renewal setup that quietly stops working, months later, and is noticed only once the
certificate has already expired. A torn write — renewal tools write cert and key separately — keeps
the previous certificate rather than failing the listener.
Serving the admin UI in plaintext on a non-loopback address is refused: the session cookie is a
bearer credential for everything the server can do, and the OIDC authorization code arrives in a
URL. Bind to loopback and use an SSH tunnel (`ssh -L 8444:localhost:8444 host`), supply a
certificate, or set `ECHOLOT_ADMIN_INSECURE=1` if you mean it.
The control-plane certificate is deliberately *not* hot-reloaded. Clients pin its public key, so
replacing it is a rotation an operator should have to think about, not something that happens
because a file changed.
+119 -2
View File
@@ -11,6 +11,7 @@
package main package main
import ( import (
"bufio"
"context" "context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
@@ -36,7 +37,10 @@ import (
"syscall" "syscall"
"time" "time"
"echo-lot.app/server/internal/acmehttp"
"echo-lot.app/server/internal/adminauth"
"echo-lot.app/server/internal/canarydns" "echo-lot.app/server/internal/canarydns"
"echo-lot.app/server/internal/certreload"
"echo-lot.app/server/internal/compat" "echo-lot.app/server/internal/compat"
"echo-lot.app/server/internal/config" "echo-lot.app/server/internal/config"
"echo-lot.app/server/internal/control" "echo-lot.app/server/internal/control"
@@ -70,6 +74,30 @@ func run() error {
control.Version = Version control.Version = Version
switch { 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: case actions.Version:
fmt.Println(Version) fmt.Println(Version)
return nil return nil
@@ -80,6 +108,8 @@ func run() error {
return system.InstallSystemd(cfg.SelfUpdateAPI) return system.InstallSystemd(cfg.SelfUpdateAPI)
case actions.UninstallSystemd: case actions.UninstallSystemd:
return system.UninstallSystemd() return system.UninstallSystemd()
case actions.SetAdminPassword:
return setAdminPassword(cfg)
case actions.SelfUpdate: case actions.SelfUpdate:
return selfupdate.Run(cfg.SelfUpdateAPI, Version) return selfupdate.Run(cfg.SelfUpdateAPI, Version)
} }
@@ -155,14 +185,16 @@ func serve(cfg *config.Config) error {
// uploads=account can never be satisfied — which is the honest outcome, not a silent // uploads=account can never be satisfied — which is the honest outcome, not a silent
// downgrade to anonymous. // downgrade to anonymous.
var idp *oidc.Verifier var idp *oidc.Verifier
if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" { if cfg.OIDCIssuer != "" && (cfg.OIDCClientID != "" || cfg.OIDCAppClientID != "") {
idp = oidc.New(oidc.Config{ idp = oidc.New(oidc.Config{
Issuer: cfg.OIDCIssuer, Issuer: cfg.OIDCIssuer,
ClientID: cfg.OIDCClientID, ClientID: cfg.OIDCClientID,
AppClientID: cfg.OIDCAppClientID,
AdminGroup: cfg.OIDCAdminGroup, AdminGroup: cfg.OIDCAdminGroup,
}, nil) }, nil)
slog.Info("identity provider configured", "issuer", cfg.OIDCIssuer, 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 == "" { if cfg.OIDCAdminGroup == "" {
slog.Warn("no admin group set: nobody will be an admin via OIDC " + slog.Warn("no admin group set: nobody will be an admin via OIDC " +
"(set ECHOLOT_OIDC_ADMIN_GROUP)") "(set ECHOLOT_OIDC_ADMIN_GROUP)")
@@ -284,7 +316,47 @@ func serve(cfg *config.Config) error {
}) })
}) })
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second} adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
if cfg.AdminTLSCert != "" {
// Terminated here rather than behind a reverse proxy: this binary already serves TLS for
// the control plane, so it is reuse rather than new machinery, and one process with one
// config file is the property that makes this pleasant to run. A proxy would also invite
// someone to later front the control plane too, which would break SPKI pinning.
reloader, err := certreload.New(cfg.AdminTLSCert, cfg.AdminTLSKey)
if err != nil {
return fmt.Errorf("admin TLS: %w", err)
}
adminSrv.TLSConfig = reloader.TLSConfig()
if exp := reloader.NotAfter(); !exp.IsZero() {
slog.Info("admin UI TLS", "listen", cfg.AdminListen, "cert_expires", exp.Format(time.RFC3339))
if time.Until(exp) < 14*24*time.Hour {
slog.Warn("admin certificate expires soon", "expires", exp.Format(time.RFC3339))
}
}
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServeTLS("", "")) }()
} else {
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }() go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
}
// ACME HTTP-01 responder. Permanent rather than started per renewal: nothing binds and
// unbinds, so a renewal cannot fail because the port was briefly busy, and the ACME client
// needs only write access to a directory instead of the privilege to bind a low port.
if cfg.ACMEHTTPListen != "" {
webroot := cfg.ACMEWebroot
if webroot == "" {
webroot = filepath.Join(cfg.StateDir, "acme")
}
if err := acmehttp.EnsureWebroot(webroot); err != nil {
return fmt.Errorf("acme webroot: %w", err)
}
acmeSrv := &http.Server{
Addr: cfg.ACMEHTTPListen,
Handler: acmehttp.Handler(webroot, cfg.AdminBaseURL),
ReadHeaderTimeout: 10 * time.Second,
}
slog.Info("acme http-01 responder", "listen", cfg.ACMEHTTPListen, "webroot", webroot,
"redirects_to", cfg.AdminBaseURL)
go func() { errCh <- fmt.Errorf("acme-http: %w", acmeSrv.ListenAndServe()) }()
}
// UDP data plane — one socket per configured address. Distinct sockets // UDP data plane — one socket per configured address. Distinct sockets
// (not wildcard) also guarantee responses leave from the address the // (not wildcard) also guarantee responses leave from the address the
@@ -516,3 +588,48 @@ func publicControlURL(cfg *config.Config) string {
} }
return "https://" + addr 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
}
+100
View File
@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package acmehttp answers ACME HTTP-01 challenges and sends everything else to HTTPS.
//
// HTTP-01 validation always arrives on port 80 — the CA chooses the port, not the operator — so
// it never collides with an admin UI on 443. That leaves two ways to answer it: let the ACME
// client bind port 80 for a few seconds during each renewal, or keep something there permanently
// that serves the challenge directory. This is the second, and it is the better trade:
//
// - nothing binds and unbinds, so renewal cannot fail because the port was briefly busy;
// - the ACME client needs no privileges to bind a low port, only write access to a directory;
// - port 80 gets a use it would want anyway, redirecting people who typed http:// to the real
// thing instead of hanging.
//
// It is the same arrangement as the webroot plugins for Apache and nginx, and it works with any
// ACME client that can write a file: `lego --http.webroot`, `certbot --webroot`, `acme.sh -w`.
//
// The ACME client stays an external program on purpose. lego is also a Go library, but importing
// it would put a large dependency tree into a server that deliberately has none — and the CLI does
// the same job from a timer.
package acmehttp
import (
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
)
// ChallengePath is the fixed prefix ACME uses. It is not configurable, by the specification.
const ChallengePath = "/.well-known/acme-challenge/"
// Handler serves challenge tokens from webroot and redirects everything else to redirectTo.
//
// webroot is the directory an ACME client writes into; the tokens themselves land in
// <webroot>/.well-known/acme-challenge/<token>, which is exactly what --http.webroot expects.
func Handler(webroot, redirectTo string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(ChallengePath, func(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.URL.Path, ChallengePath)
// Tokens are base64url from the CA. Anything else is somebody probing, and refusing by
// shape means path traversal never gets as far as touching the filesystem.
if token == "" || !validToken(token) {
http.NotFound(w, r)
return
}
body, err := os.ReadFile(filepath.Join(webroot, filepath.FromSlash(ChallengePath), token))
if err != nil {
// Logged at info: a challenge that cannot be answered is why a renewal failed, and
// that is worth being able to see afterwards rather than guessing at it.
slog.Info("acme challenge not found", "token", token, "webroot", webroot)
http.NotFound(w, r)
return
}
slog.Info("answered acme challenge", "token", token, "from", r.RemoteAddr)
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write(body)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if redirectTo == "" {
http.Error(w, "this port serves ACME challenges only", http.StatusNotFound)
return
}
// 308 rather than 302: the method must not change, and the redirect is permanent in the
// sense that matters — this port will never serve the application.
http.Redirect(w, r, strings.TrimRight(redirectTo, "/")+r.URL.RequestURI(), http.StatusPermanentRedirect)
})
return mux
}
// validToken accepts only the base64url alphabet the ACME spec uses for tokens.
//
// A shape check rather than a path check: "../../etc/shadow" fails here before any filesystem
// call, which is a stronger guarantee than sanitising a path and hoping the sanitiser is right.
func validToken(s string) bool {
if len(s) > 128 {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
default:
return false
}
}
// A bare "." or ".." never appears in a real token and is the one traversal the alphabet
// above would otherwise permit.
return s != "." && s != ".."
}
// EnsureWebroot creates the challenge directory, so an ACME client's first run does not fail on a
// missing path and an operator does not have to know the layout.
func EnsureWebroot(webroot string) error {
return os.MkdirAll(filepath.Join(webroot, filepath.FromSlash(ChallengePath)), 0o755)
}
+98
View File
@@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package acmehttp
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func serve(t *testing.T, redirectTo string) (http.Handler, string) {
t.Helper()
root := t.TempDir()
if err := EnsureWebroot(root); err != nil {
t.Fatal(err)
}
return Handler(root, redirectTo), root
}
func TestServesAChallengeTokenWrittenByAnAcmeClient(t *testing.T) {
h, root := serve(t, "https://admin.example.net")
// Exactly what `lego --http.webroot` writes.
token := "abc-123_XYZ"
want := "abc-123_XYZ.keyauthorization-part"
if err := os.WriteFile(filepath.Join(root, ".well-known", "acme-challenge", token), []byte(want), 0o644); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+token, nil))
if rec.Code != 200 {
t.Fatalf("challenge not served: %d", rec.Code)
}
if rec.Body.String() != want {
t.Fatalf("body = %q, want %q", rec.Body.String(), want)
}
}
// The token comes from the network and is used to build a path. Rejecting by *shape* means
// traversal never reaches the filesystem at all, which is a stronger guarantee than sanitising.
func TestTraversalNeverTouchesTheFilesystem(t *testing.T) {
h, root := serve(t, "https://admin.example.net")
secret := filepath.Join(filepath.Dir(root), "secret.txt")
if err := os.WriteFile(secret, []byte("do not serve me"), 0o600); err != nil {
t.Fatal(err)
}
for _, bad := range []string{
"../secret.txt",
"..%2Fsecret.txt",
"../../etc/passwd",
"..",
".",
"a/b",
"tok%20en", // a space arrives percent-encoded; a literal one is not a valid request line
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+bad, nil))
if rec.Code == 200 && rec.Body.String() == "do not serve me" {
t.Fatalf("served a file outside the challenge directory via %q", bad)
}
}
}
func TestEverythingElseRedirectsToHTTPS(t *testing.T) {
h, _ := serve(t, "https://admin.example.net")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/devices?page=2", nil))
if rec.Code != http.StatusPermanentRedirect {
t.Fatalf("code = %d, want 308", rec.Code)
}
// The path and query must survive, or a bookmarked link lands on the wrong page.
if got := rec.Header().Get("Location"); got != "https://admin.example.net/devices?page=2" {
t.Fatalf("Location = %q", got)
}
}
// With no admin URL configured there is nowhere to send people, and inventing one would be worse
// than saying so.
func TestNoRedirectTargetIsHonest(t *testing.T) {
h, _ := serve(t, "")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
}
func TestMissingTokenIsNotFound(t *testing.T) {
h, _ := serve(t, "https://admin.example.net")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", ChallengePath+"never-written", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", rec.Code)
}
}
+253
View File
@@ -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))
}
+203
View File
@@ -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)
}
}
+124
View File
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package certreload serves a TLS certificate that can be replaced on disk without a restart.
//
// The hard part of TLS is never termination — the stdlib does that — it is renewal. A certificate
// obtained from an ACME client expires every sixty days, and the usual arrangement is a renewal
// hook that reloads or restarts the service. That hook is the part that quietly fails: it works
// when it is written and then, months later, does not, and nobody notices until the certificate
// has already expired.
//
// So the certificate is re-read when the file changes. There is no hook to forget, no reload to
// coordinate, and a renewal that drops new files in place is picked up on the next handshake.
//
// Deliberately not used for the control plane. Clients pin that certificate's public key
// (probe-protocol.md §1), so swapping it at runtime would silently break every enrolled device —
// there the operator *should* have to think, and a restart is the least of what a key rotation
// costs. Two listeners, two different right answers.
package certreload
import (
"crypto/tls"
"fmt"
"os"
"sync"
"time"
)
// Reloader holds a certificate and refreshes it when the files on disk change.
type Reloader struct {
certPath, keyPath string
mu sync.RWMutex
cert *tls.Certificate
certMod time.Time
keyMod time.Time
checked time.Time
interval time.Duration
}
// New loads the pair once so a bad path fails at startup rather than at the first handshake,
// when the only symptom is a connection error at the far end.
func New(certPath, keyPath string) (*Reloader, error) {
r := &Reloader{certPath: certPath, keyPath: keyPath, interval: 30 * time.Second}
if err := r.load(); err != nil {
return nil, err
}
return r, nil
}
// TLSConfig returns a config that asks this reloader for the certificate on every handshake.
func (r *Reloader) TLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: r.getCertificate,
}
}
func (r *Reloader) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
r.maybeReload()
r.mu.RLock()
defer r.mu.RUnlock()
if r.cert == nil {
return nil, fmt.Errorf("no certificate loaded")
}
return r.cert, nil
}
// maybeReload stats the files at most once per interval.
//
// Rate-limited because this runs on every handshake: a busy listener would otherwise stat twice
// per connection, and a certificate that is thirty seconds stale has never mattered to anyone.
func (r *Reloader) maybeReload() {
r.mu.RLock()
fresh := time.Since(r.checked) < r.interval
r.mu.RUnlock()
if fresh {
return
}
r.mu.Lock()
r.checked = time.Now()
certMod, keyMod := modTime(r.certPath), modTime(r.keyPath)
unchanged := certMod.Equal(r.certMod) && keyMod.Equal(r.keyMod)
r.mu.Unlock()
if unchanged {
return
}
// A failed reload keeps the certificate already in memory. Renewal tools write the two files
// separately, so there is a window where the pair does not match; serving the previous
// certificate through that window is strictly better than serving none.
_ = r.load()
}
func (r *Reloader) load() error {
cert, err := tls.LoadX509KeyPair(r.certPath, r.keyPath)
if err != nil {
return fmt.Errorf("loading %s / %s: %w", r.certPath, r.keyPath, err)
}
r.mu.Lock()
defer r.mu.Unlock()
r.cert = &cert
r.certMod, r.keyMod = modTime(r.certPath), modTime(r.keyPath)
return nil
}
// NotAfter is when the loaded certificate expires, for the admin UI to show and for a startup
// warning. An expiry an operator can see is one they can act on before a browser tells them.
func (r *Reloader) NotAfter() time.Time {
r.mu.RLock()
defer r.mu.RUnlock()
if r.cert == nil || r.cert.Leaf == nil {
return time.Time{}
}
return r.cert.Leaf.NotAfter
}
func modTime(path string) time.Time {
fi, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return fi.ModTime()
}
@@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package certreload
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)
func writePair(t *testing.T, dir, cn string) (string, string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
cb := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
kb, _ := x509.MarshalECPrivateKey(key)
if err := os.WriteFile(certPath, cb, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600); err != nil {
t.Fatal(err)
}
return certPath, keyPath
}
func TestBadPathsFailAtStartupNotAtHandshake(t *testing.T) {
if _, err := New("/nonexistent/cert.pem", "/nonexistent/key.pem"); err == nil {
t.Fatal("a missing certificate was accepted; the failure would surface as an " +
"unexplained connection error at the client instead")
}
}
// The whole point: a renewal that drops new files in place is picked up without a restart and
// without a reload hook that can silently stop working.
func TestANewCertificateOnDiskIsPickedUp(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writePair(t, dir, "first")
r, err := New(certPath, keyPath)
if err != nil {
t.Fatal(err)
}
r.interval = 0 // check on every handshake, rather than waiting out the rate limit
got, err := r.getCertificate(nil)
if err != nil {
t.Fatal(err)
}
first := got.Leaf
time.Sleep(10 * time.Millisecond) // ensure a distinct mtime
writePair(t, dir, "second")
got, err = r.getCertificate(nil)
if err != nil {
t.Fatal(err)
}
if got.Leaf != nil && first != nil && got.Leaf.SerialNumber.Cmp(first.SerialNumber) == 0 {
t.Fatal("the replaced certificate was not picked up")
}
}
// Renewal tools write the certificate and the key separately, so there is a window where the two
// do not match. Serving the previous certificate through it beats serving none.
func TestAHalfWrittenPairKeepsTheOldCertificate(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writePair(t, dir, "good")
r, err := New(certPath, keyPath)
if err != nil {
t.Fatal(err)
}
r.interval = 0
time.Sleep(10 * time.Millisecond)
if err := os.WriteFile(certPath, []byte("-----BEGIN CERTIFICATE-----\ntruncated\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := r.getCertificate(nil)
if err != nil {
t.Fatalf("a torn write took the listener down: %v", err)
}
if got == nil {
t.Fatal("no certificate served during a torn write")
}
}
func TestExpiryIsVisible(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writePair(t, dir, "x")
r, err := New(certPath, keyPath)
if err != nil {
t.Fatal(err)
}
if got := r.NotAfter(); got.IsZero() || time.Until(got) > 48*time.Hour {
t.Fatalf("expiry not reported sensibly: %v", got)
}
}
+134 -3
View File
@@ -10,6 +10,8 @@ package config
import ( import (
"flag" "flag"
"fmt" "fmt"
"io"
"net"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -70,13 +72,54 @@ type Config struct {
// Identity provider. Empty issuer disables sign-in entirely; the server is a relying // Identity provider. Empty issuer disables sign-in entirely; the server is a relying
// party and never stores passwords. // party and never stores passwords.
OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer
OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id 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 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
// Secret for the *confidential* admin client. Prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE: a path
// keeps the secret out of the environment, where it is readable by anything that can see
// /proc/<pid>/environ and lands in every dump of the unit's configuration.
OIDCClientSecret string // ECHOLOT_OIDC_CLIENT_SECRET / _FILE
// Where the admin UI is reachable, used to build the OIDC redirect URI. Must match what is
// registered at the IdP exactly.
AdminBaseURL string // ECHOLOT_ADMIN_BASE_URL / --admin-base-url
// TLS for the admin listener. Without these it serves plaintext, which is only acceptable on
// loopback — see checkAdminExposure.
AdminTLSCert string // ECHOLOT_ADMIN_TLS_CERT / --admin-tls-cert
AdminTLSKey string // ECHOLOT_ADMIN_TLS_KEY / --admin-tls-key
// Deliberate override for serving the admin UI in plaintext off loopback, so that decision
// is made rather than stumbled into.
AdminInsecure bool // ECHOLOT_ADMIN_INSECURE / --admin-insecure
// Port-80 listener that answers ACME HTTP-01 challenges and redirects everything else to
// the admin UI. Empty disables it. HTTP-01 always arrives on port 80 — the CA picks the
// port — so this never collides with the admin UI on 443.
ACMEHTTPListen string // ECHOLOT_ACME_HTTP_LISTEN / --acme-http-listen
// Directory an ACME client writes challenge tokens into. Defaults to <state-dir>/acme.
ACMEWebroot string // ECHOLOT_ACME_WEBROOT / --acme-webroot
// Mode // Mode
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces) Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
} }
// secretOr reads ECHOLOT_<key>, or the contents of the file named by ECHOLOT_<key>_FILE.
//
// The file form exists because a secret in the environment is readable by anything that can see
// /proc/<pid>/environ and lands in every dump of the unit's configuration. A path costs nothing
// and keeps the value in one file whose permissions an operator can reason about.
func secretOr(key, def string) string {
if path := envOr(key+"_FILE", ""); path != "" {
if b, err := os.ReadFile(path); err == nil {
return strings.TrimSpace(string(b))
}
}
return envOr(key, def)
}
// envInt reads ECHOLOT_<key> as an integer with a fallback. // envInt reads ECHOLOT_<key> as an integer with a fallback.
func envInt(key string, def int) int { func envInt(key string, def int) int {
if v := envOr(key, ""); v != "" { if v := envOr(key, ""); v != "" {
@@ -122,8 +165,16 @@ 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.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.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.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.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.OIDCClientSecret, "oidc-client-secret", secretOr("OIDC_CLIENT_SECRET", ""), "secret for the confidential admin client; prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE")
fs.StringVar(&c.AdminBaseURL, "admin-base-url", envOr("ADMIN_BASE_URL", ""), "public URL of the admin UI, for the OIDC redirect (e.g. https://admin.example.net)")
fs.StringVar(&c.AdminTLSCert, "admin-tls-cert", envOr("ADMIN_TLS_CERT", ""), "TLS certificate for the admin listener")
fs.StringVar(&c.AdminTLSKey, "admin-tls-key", envOr("ADMIN_TLS_KEY", ""), "TLS key for the admin listener")
fs.BoolVar(&c.AdminInsecure, "admin-insecure", envOr("ADMIN_INSECURE", "") == "1", "allow the admin UI in plaintext off loopback (you are on your own)")
fs.StringVar(&c.ACMEHTTPListen, "acme-http-listen", envOr("ACME_HTTP_LISTEN", ""), "port-80 listener for ACME HTTP-01 challenges and http->https redirects")
fs.StringVar(&c.ACMEWebroot, "acme-webroot", envOr("ACME_WEBROOT", ""), "directory an ACME client writes challenges into (default <state-dir>/acme)")
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.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)") fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded") fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")
@@ -131,6 +182,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.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.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.SelfUpdate, "self-update", false, "check for a newer release, replace this binary, and exit")
fs.BoolVar(&a.Version, "version", false, "print version and exit") fs.BoolVar(&a.Version, "version", false, "print version and exit")
@@ -140,12 +197,78 @@ func Load(args []string) (*Config, *Actions, error) {
if !c.Docker { if !c.Docker {
c.Docker = inContainer() 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 a.Serve {
if err := c.checkAdminExposure(); err != nil {
return nil, nil, err
}
}
if c.Docker && (a.InstallSystemd || a.UninstallSystemd || a.SelfUpdate) { 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 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 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.
`)
}
// checkAdminExposure refuses to serve an unencrypted admin UI on a non-loopback address.
//
// The admin session cookie is a bearer credential for everything this server can do, and the OIDC
// authorization code arrives in a URL. In plaintext, both are readable by anyone on the path — and
// on a globally routable address "the path" means the internet. This is a hard stop rather than a
// warning because a warning in a log is not read by the person who most needs it, and because the
// two safe answers are cheap: bind to loopback and tunnel, or supply a certificate.
func (c *Config) checkAdminExposure() error {
if c.AdminTLSCert != "" || c.AdminInsecure {
return nil
}
for _, addr := range Addrs(c.AdminListen) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
continue
}
ip := net.ParseIP(strings.Trim(host, "[]"))
if host == "" || ip == nil || ip.IsLoopback() {
continue // loopback, or a name we cannot judge; RFC 8252 blesses loopback plaintext
}
return fmt.Errorf(
"refusing to serve the admin UI in plaintext on %s: the session cookie and the OIDC "+
"authorization code would cross the network in the clear.\n"+
" Fix it one of three ways:\n"+
" - bind to 127.0.0.1 and reach it over an SSH tunnel (no certificate needed)\n"+
" - set ECHOLOT_ADMIN_TLS_CERT and ECHOLOT_ADMIN_TLS_KEY\n"+
" - set ECHOLOT_ADMIN_INSECURE=1 if you genuinely mean it", addr)
}
return nil
}
// Addrs splits a comma-separated listen spec into individual addresses. // Addrs splits a comma-separated listen spec into individual addresses.
// Explicit per-address binds matter on multi-IP hosts: a wildcard bind // Explicit per-address binds matter on multi-IP hosts: a wildcard bind
// (":8443") would also claim addresses reserved for other purposes (e.g. an // (":8443") would also claim addresses reserved for other purposes (e.g. an
@@ -160,11 +283,19 @@ func Addrs(spec string) []string {
return out 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 { type Actions struct {
Serve bool
// Help is set when there is nothing to do: no verb was given.
Help bool
InstallSystemd bool InstallSystemd bool
UninstallSystemd bool UninstallSystemd bool
SelfUpdate bool SelfUpdate bool
SetAdminPassword bool
Version bool Version bool
} }
+2 -1
View File
@@ -830,7 +830,8 @@ func (s *Server) authInfo(ctx context.Context) map[string]any {
out := map[string]any{ out := map[string]any{
"enabled": true, "enabled": true,
"issuer": cfg.Issuer, "issuer": cfg.Issuer,
"client_id": cfg.ClientID, // 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 // 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 // protects the code exchange (RFC 7636), and the redirect comes back through the
// scheme the app already registers for enrollment links. // scheme the app already registers for enrollment links.
+33 -4
View File
@@ -97,8 +97,16 @@ func (a audience) contains(s string) bool {
type Config struct { type Config struct {
// Issuer is the IdP's base URL, e.g. https://auth.example.net/application/o/echolot/ // Issuer is the IdP's base URL, e.g. https://auth.example.net/application/o/echolot/
Issuer string 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 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. // 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 // 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". // not said who may administer the server has not said "everyone".
@@ -107,7 +115,27 @@ type Config struct {
Skew time.Duration 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. // Discovery is the subset of the provider metadata document that is used.
type Discovery struct { 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 — // 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. // accepting it lets any other client of the same IdP authenticate here.
if !c.Audience.contains(v.cfg.ClientID) { if !v.audienceAccepted(c.Audience) {
return fmt.Errorf("%w: addressed to %v, not to %q", ErrClaims, []string(c.Audience), v.cfg.ClientID) return fmt.Errorf("%w: addressed to %v, not to %v", ErrClaims,
[]string(c.Audience), v.acceptedAudiences())
} }
if c.Subject == "" { if c.Subject == "" {
return fmt.Errorf("%w: no subject", ErrClaims) return fmt.Errorf("%w: no subject", ErrClaims)
+38 -1
View File
@@ -115,7 +115,9 @@ func (i *testIdP) claims(extra map[string]any) map[string]any {
} }
func verifier(i *testIdP, adminGroup string) *Verifier { 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) { func TestAcceptsAGenuineToken(t *testing.T) {
@@ -286,3 +288,38 @@ func TestDisabledWithoutConfiguration(t *testing.T) {
t.Fatalf("want ErrDisabled, got %v", err) 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")
}
}
+10
View File
@@ -9,6 +9,7 @@ package selfupdate
import ( import (
"crypto/sha256" "crypto/sha256"
"echo-lot.app/server/internal/system"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -132,6 +133,15 @@ func Run(api, currentVersion string) error {
os.Remove(tmp) os.Remove(tmp)
return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err) 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) fmt.Printf("updated %s -> %s (%s); restart to run it\n", currentVersion, rel.TagName, self)
return nil return nil
} }
+52
View File
@@ -16,6 +16,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"echo-lot.app/server/internal/adminauth"
"time" "time"
) )
@@ -59,6 +61,12 @@ type Store struct {
type fileData struct { type fileData struct {
Tokens []EnrollToken `json:"tokens"` Tokens []EnrollToken `json:"tokens"`
Devices []Device `json:"devices"` 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) { func Open(stateDir string) (*Store, error) {
@@ -177,6 +185,50 @@ func (s *Store) DeleteDevice(id string) error {
return errors.New("no such device") 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 { func (s *Store) DeviceByCredential(cred string) *Device {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
+34
View File
@@ -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
}
+12
View File
@@ -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 }
+40 -1
View File
@@ -12,6 +12,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
) )
const ( const (
@@ -29,7 +30,7 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=%s ExecStart=%s --serve
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
StateDirectory=echolot-server StateDirectory=echolot-server
@@ -144,3 +145,41 @@ func UninstallSystemd() error {
fmt.Println("removed echolot-server units (state dir and env file left in place)") fmt.Println("removed echolot-server units (state dir and env file left in place)")
return nil 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
}