Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eaba6150b | ||
|
|
7bb54e1ec8 | ||
|
|
c7750fbf0b | ||
|
|
5d7f59a66a | ||
|
|
6afcb131ef | ||
|
|
cd187f9ef5 | ||
|
|
3a4cb1c327 | ||
|
|
3cdbccee18 | ||
|
|
80d2092f1b | ||
|
|
89a5ff9139 |
@@ -1002,3 +1002,55 @@ Also fixed: the Settings *Preview what an upload would send* button did nothing.
|
|||||||
`UiState.history`, which is empty until the History screen has been opened — the same root cause as
|
`UiState.history`, which is empty until the History screen has been opened — the same root cause as
|
||||||
the "0 run(s)" count. It now reads the archive directly, and says so when there is nothing to
|
the "0 run(s)" count. It now reads the archive directly, and says so when there is nothing to
|
||||||
preview rather than silently ignoring the tap.
|
preview rather than silently ignoring the tap.
|
||||||
|
|
||||||
|
### Security: the admin listener was publicly exposed for ~15 minutes (2026-08-01)
|
||||||
|
Moving the admin listener to `[::2]:443` for the UI exposed `/admin/enroll-tokens` and
|
||||||
|
`/admin/selftest` to the internet **with no authentication**. Anyone who could reach
|
||||||
|
`fmr.echo-lot.app` could mint enrolment tokens.
|
||||||
|
|
||||||
|
The listener was designed localhost-only — its own flag help says *"keep localhost"* — and that
|
||||||
|
assumption travelled with it when the address changed. The compounding error: `checkAdminExposure`,
|
||||||
|
added the same day, verifies **encryption** and says nothing about **authentication**. It passed,
|
||||||
|
and a green light on an adjacent property is worse than no check, because it invites you to stop
|
||||||
|
looking.
|
||||||
|
|
||||||
|
Closed by returning to loopback (the TLS and ACME work is retained, just not exposed). All 68 device
|
||||||
|
enrolments matched the timestamps of test runs, so there is no evidence of abuse — but the window
|
||||||
|
existed on a freshly published hostname and absence cannot be proven. 39 unused enrolment tokens
|
||||||
|
were purged, since any could have been minted by someone else and they cost nothing to replace, and
|
||||||
|
63 test devices removed.
|
||||||
|
|
||||||
|
**The admin listener does not become reachable again until it authenticates.** That reorders the UI
|
||||||
|
work: auth on the listener first, everything else after.
|
||||||
|
|
||||||
|
### Open: encrypted uploads, where the operator cannot read the data
|
||||||
|
Not built. Recorded because the shape is decided by a few early choices, and the current design
|
||||||
|
happens to leave the door open.
|
||||||
|
|
||||||
|
The goal: hand someone an account, let them upload, and be unable to read what they uploaded.
|
||||||
|
|
||||||
|
Sketch: a random per-account **master key**, generated on the first device and wrapped under a
|
||||||
|
key derived from a passphrase (PBKDF2-HMAC-SHA256 — stdlib on both sides). The wrapped key is
|
||||||
|
stored server-side as an opaque blob, so a new device signs in, fetches it, and unwraps locally;
|
||||||
|
the server never sees either key. Runs are encrypted client-side with AES-256-GCM, fresh nonce per
|
||||||
|
run. All of this is stdlib in Go and `javax.crypto` in Kotlin — no dependency either side.
|
||||||
|
|
||||||
|
Four consequences that decide whether it is worth it:
|
||||||
|
|
||||||
|
1. **What stays readable determines what the UI can do.** The server builds its index by *parsing*
|
||||||
|
the document — verdict, finding count, started_at. An opaque payload means the client supplies
|
||||||
|
that metadata or the index disappears, and with it retention-by-verdict and any "runs with
|
||||||
|
findings" view. The honest version supplies only run id, timestamp and size, and moves the rest
|
||||||
|
client-side.
|
||||||
|
2. **Lose the passphrase, lose the data.** That is the feature working, and also the support
|
||||||
|
burden. It needs a recovery code printed at setup, not a reset flow — there is nothing to reset.
|
||||||
|
3. **Metadata is not hidden.** The operator still sees which account uploaded, when, how often and
|
||||||
|
how large. "Cannot see it" is about content, not existence, and saying otherwise would oversell.
|
||||||
|
4. **It makes `min_anonymization` unenforceable** — a server cannot check a level it cannot read.
|
||||||
|
That is not a conflict so much as a redundancy: the anonymization floor exists to protect the
|
||||||
|
user from the operator, and encryption does that better. The two should not both be demanded of
|
||||||
|
one upload.
|
||||||
|
|
||||||
|
What keeps this possible: uploads are already stored byte-for-byte as received, and every index
|
||||||
|
field is derived in one function (`runs.Put`). The thing to avoid is admin features that *require*
|
||||||
|
reading content — those would have to be unbuilt later.
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
@@ -18,7 +19,6 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/json"
|
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -36,7 +36,11 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"echo-lot.app/server/internal/acmehttp"
|
||||||
|
"echo-lot.app/server/internal/adminauth"
|
||||||
|
"echo-lot.app/server/internal/adminui"
|
||||||
"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)
|
||||||
}
|
}
|
||||||
@@ -154,20 +184,33 @@ func serve(cfg *config.Config) error {
|
|||||||
// Identity is optional. Without an issuer the server simply has no sign-in, and
|
// Identity is optional. Without an issuer the server simply has no sign-in, and
|
||||||
// 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
|
// One verifier per issuer. An IdP may mint a distinct issuer per application — Authentik
|
||||||
if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
|
// derives it from the application slug — and a token's `iss` must match whoever signed it.
|
||||||
|
// Each verifier accepts only the client belonging to its own issuer, so a token minted for
|
||||||
|
// the phone cannot be replayed at the admin login and vice versa.
|
||||||
|
var idp, adminIdP *oidc.Verifier
|
||||||
|
appIssuer := cfg.OIDCAppIssuer
|
||||||
|
if appIssuer == "" {
|
||||||
|
appIssuer = cfg.OIDCIssuer // IdPs with one global issuer
|
||||||
|
}
|
||||||
|
if appIssuer != "" && cfg.OIDCAppClientID != "" {
|
||||||
idp = oidc.New(oidc.Config{
|
idp = oidc.New(oidc.Config{
|
||||||
Issuer: cfg.OIDCIssuer,
|
Issuer: appIssuer, AppClientID: cfg.OIDCAppClientID, AdminGroup: cfg.OIDCAdminGroup,
|
||||||
ClientID: cfg.OIDCClientID,
|
|
||||||
AdminGroup: cfg.OIDCAdminGroup,
|
|
||||||
}, nil)
|
}, nil)
|
||||||
slog.Info("identity provider configured", "issuer", cfg.OIDCIssuer,
|
slog.Info("identity: app client", "issuer", appIssuer, "client_id", cfg.OIDCAppClientID)
|
||||||
|
}
|
||||||
|
if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
|
||||||
|
adminIdP = oidc.New(oidc.Config{
|
||||||
|
Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, AdminGroup: cfg.OIDCAdminGroup,
|
||||||
|
}, nil)
|
||||||
|
slog.Info("identity: admin client", "issuer", cfg.OIDCIssuer,
|
||||||
"client_id", cfg.OIDCClientID, "admin_group", cfg.OIDCAdminGroup)
|
"client_id", cfg.OIDCClientID, "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)")
|
||||||
}
|
}
|
||||||
} else if cfg.UploadsMode == string(runs.ModeAccount) {
|
}
|
||||||
|
if idp == nil && adminIdP == nil && cfg.UploadsMode == string(runs.ModeAccount) {
|
||||||
slog.Warn("uploads=account but no identity provider is configured — " +
|
slog.Warn("uploads=account but no identity provider is configured — " +
|
||||||
"every upload will be refused")
|
"every upload will be refused")
|
||||||
}
|
}
|
||||||
@@ -184,6 +227,7 @@ func serve(cfg *config.Config) error {
|
|||||||
AppRange: appRange,
|
AppRange: appRange,
|
||||||
PublicControlURL: publicControlURL(cfg),
|
PublicControlURL: publicControlURL(cfg),
|
||||||
OIDC: idp,
|
OIDC: idp,
|
||||||
|
AdminOIDC: adminIdP,
|
||||||
}
|
}
|
||||||
// Left nil when there is no raw socket, so the handler answers "not implemented" with a
|
// Left nil when there is no raw socket, so the handler answers "not implemented" with a
|
||||||
// reason rather than failing somewhere deeper.
|
// reason rather than failing somewhere deeper.
|
||||||
@@ -253,38 +297,76 @@ func serve(cfg *config.Config) error {
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin/health (plain HTTP, localhost by default; spec §7)
|
// The admin interface. Every route except /healthz requires a session — the old arrangement
|
||||||
admin := http.NewServeMux()
|
// (no auth, kept safe by binding to loopback) failed the moment the address changed, and a
|
||||||
admin.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
// binding address is a deployment detail rather than an access control.
|
||||||
fmt.Fprintf(w, `{"ok":true,"version":%q}`, Version)
|
secret, err := st.SessionSecret()
|
||||||
})
|
|
||||||
admin.HandleFunc("GET /admin/selftest", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(selftestPtr.Load())
|
|
||||||
})
|
|
||||||
// TODO(spec §7): enrollment token management + device list. Until the
|
|
||||||
// admin UI exists, mint tokens with: echolot-admin (or curl on this
|
|
||||||
// listener once the endpoint lands).
|
|
||||||
admin.HandleFunc("POST /admin/enroll-tokens", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tok, err := st.NewEnrollToken(24*time.Hour, r.URL.Query().Get("note"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
return fmt.Errorf("admin session secret: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// The whole bootstrap, not just the token: this is what gets pasted or turned into a
|
adminSecure := cfg.AdminTLSCert != ""
|
||||||
// QR code, and assembling it here is what keeps an operator from transcribing a pin by
|
ui := &adminui.Server{
|
||||||
// hand — a pin wrong by one character fails as an inscrutable TLS error days later.
|
Store: st,
|
||||||
w.Header().Set("Content-Type", "application/json")
|
Runs: runStore,
|
||||||
enc := json.NewEncoder(w)
|
OIDC: adminIdP,
|
||||||
enc.SetEscapeHTML(false) // the link is full of / and =; escaping them helps nobody
|
Sessions: adminauth.NewSessions(secret, 12*time.Hour),
|
||||||
_ = enc.Encode(map[string]any{
|
Throttle: adminauth.NewThrottle(),
|
||||||
"token": tok,
|
AdminUser: cfg.AdminUser,
|
||||||
"expires_in_s": 86400,
|
BaseURL: cfg.AdminBaseURL,
|
||||||
"enroll_uri": ctl.EnrollmentLink(tok),
|
ClientSecret: cfg.OIDCClientSecret,
|
||||||
})
|
Secure: adminSecure,
|
||||||
})
|
EnrollLink: ctl.EnrollmentLink,
|
||||||
|
SelfTest: func() any { return selftestPtr.Load() },
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
if st.LocalAdmin() == nil && adminIdP == nil {
|
||||||
|
slog.Warn("nobody can sign in to the admin UI: no break-glass password is set " +
|
||||||
|
"(--set-admin-password) and no identity provider is configured")
|
||||||
|
}
|
||||||
|
admin := ui.Handler()
|
||||||
|
|
||||||
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 +598,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package adminui serves the operator's web interface.
|
||||||
|
//
|
||||||
|
// Everything here is behind authentication, without exception. The previous arrangement — an
|
||||||
|
// unauthenticated listener kept safe by binding to loopback — worked exactly until the address
|
||||||
|
// changed, and then failed silently and publicly. Binding address is a deployment detail; it is
|
||||||
|
// not an access control, and this package does not treat it as one.
|
||||||
|
//
|
||||||
|
// Rendered server-side with html/template and no JavaScript. The pages are lists and forms; a
|
||||||
|
// framework would add a build step, a dependency tree and an update treadmill to a program that
|
||||||
|
// currently has none of those.
|
||||||
|
package adminui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"echo-lot.app/server/internal/adminauth"
|
||||||
|
"echo-lot.app/server/internal/oidc"
|
||||||
|
"echo-lot.app/server/internal/runs"
|
||||||
|
"echo-lot.app/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookie = "echolot_admin"
|
||||||
|
stateCookie = "echolot_oidc"
|
||||||
|
csrfField = "csrf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server is the admin interface.
|
||||||
|
type Server struct {
|
||||||
|
Store *store.Store
|
||||||
|
Runs *runs.Store
|
||||||
|
OIDC *oidc.Verifier // admin client; nil when no IdP is configured
|
||||||
|
Sessions *adminauth.Sessions
|
||||||
|
Throttle *adminauth.Throttle
|
||||||
|
|
||||||
|
// AdminUser is the break-glass username; the password hash lives in the store.
|
||||||
|
AdminUser string
|
||||||
|
// BaseURL is where this UI is reachable, for building the OIDC redirect. Must match the URI
|
||||||
|
// registered at the IdP exactly.
|
||||||
|
BaseURL string
|
||||||
|
// ClientSecret authenticates the confidential admin client at the token endpoint.
|
||||||
|
ClientSecret string
|
||||||
|
// Secure marks cookies Secure. Off only for loopback HTTP, where there is no network to
|
||||||
|
// intercept and browsers refuse Secure cookies over plaintext anyway.
|
||||||
|
Secure bool
|
||||||
|
|
||||||
|
// EnrollLink builds the §2.1 bootstrap link for a token. Injected rather than rebuilt here,
|
||||||
|
// so the SPKI pin and public URL stay owned by the control server that actually knows them.
|
||||||
|
EnrollLink func(token string) string
|
||||||
|
// SelfTest and Version render on the dashboard.
|
||||||
|
SelfTest func() any
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler builds the routes. Only /healthz is reachable without a session.
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Unauthenticated: a health check that required a session would be no use to a monitor, and
|
||||||
|
// it discloses nothing beyond "the process is up".
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{"ok":true,"version":%q}`+"\n", s.Version)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /login", s.loginForm)
|
||||||
|
mux.HandleFunc("POST /login", s.loginSubmit)
|
||||||
|
mux.HandleFunc("GET /auth/start", s.oidcStart)
|
||||||
|
mux.HandleFunc("GET /admin/callback", s.oidcCallback)
|
||||||
|
mux.HandleFunc("POST /logout", s.logout)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /", s.guard(s.dashboard))
|
||||||
|
mux.HandleFunc("GET /devices", s.guard(s.devices))
|
||||||
|
mux.HandleFunc("POST /devices/{id}/revoke", s.guard(s.revokeDevice))
|
||||||
|
mux.HandleFunc("POST /enroll-tokens", s.guard(s.mintToken))
|
||||||
|
mux.HandleFunc("GET /runs", s.guard(s.runsList))
|
||||||
|
mux.HandleFunc("GET /runs/{device}/{id}", s.guard(s.runView))
|
||||||
|
mux.HandleFunc("POST /runs/{device}/{id}/delete", s.guard(s.runDelete))
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard requires a valid session, and checks CSRF on anything that changes state.
|
||||||
|
func (s *Server) guard(h func(http.ResponseWriter, *http.Request, *adminauth.Session)) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := s.session(r)
|
||||||
|
if sess == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
// SameSite=Lax already blocks cross-site form posts in current browsers, but this
|
||||||
|
// is the control that does not depend on the browser being current.
|
||||||
|
if !s.csrfOK(r, sess) {
|
||||||
|
http.Error(w, "stale form — reload the page and try again", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h(w, r, sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) session(r *http.Request) *adminauth.Session {
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sess, err := s.Sessions.Parse(c.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
// csrfToken derives a per-session token. Derived rather than stored so it needs no server-side
|
||||||
|
// state and cannot drift out of sync with the session it belongs to.
|
||||||
|
func (s *Server) csrfToken(sess *adminauth.Session) string {
|
||||||
|
sum := sha256.Sum256([]byte("csrf|" + sess.Subject + "|" + sess.Expires.String()))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(sum[:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) csrfOK(r *http.Request, sess *adminauth.Session) bool {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return r.PostFormValue(csrfField) == s.csrfToken(sess)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setSession(w http.ResponseWriter, subject, display string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: s.Sessions.Issue(subject, display),
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true, // the cookie is a bearer credential; script has no business reading it
|
||||||
|
Secure: s.Secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
|
||||||
|
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- local password ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The delay is applied before the answer, so a wrong guess costs time whether or not the
|
||||||
|
// username exists — the timing carries no information either way.
|
||||||
|
if d := s.Throttle.Delay(); d > 0 {
|
||||||
|
time.Sleep(d)
|
||||||
|
}
|
||||||
|
user := r.PostFormValue("username")
|
||||||
|
pass := r.PostFormValue("password")
|
||||||
|
|
||||||
|
cred := s.Store.LocalAdmin()
|
||||||
|
if cred == nil || !cred.Verify(user, pass) {
|
||||||
|
s.Throttle.Failed()
|
||||||
|
slog.Info("admin login failed", "user", user, "from", clientIP(r))
|
||||||
|
s.render(w, r, "login", map[string]any{
|
||||||
|
"Error": "Incorrect username or password.",
|
||||||
|
"OIDC": s.oidcAvailable(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Throttle.Succeeded()
|
||||||
|
slog.Info("admin login", "user", user, "method", "local", "from", clientIP(r))
|
||||||
|
s.setSession(w, "local:"+cred.Username, cred.Username)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- OIDC -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Server) oidcAvailable() bool {
|
||||||
|
return s.OIDC != nil && s.OIDC.Config().Enabled() && s.BaseURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidcStart redirects to the IdP with state and PKCE.
|
||||||
|
//
|
||||||
|
// PKCE even though this is a confidential client: it costs one hash and closes code interception
|
||||||
|
// independently of the secret, which is worth having when the redirect crosses a browser.
|
||||||
|
func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.oidcAvailable() {
|
||||||
|
http.Error(w, "no identity provider is configured on this server", http.StatusNotImplemented)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := s.OIDC.Discover(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "identity provider unreachable: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state, verifier := randomToken(), randomToken()
|
||||||
|
challenge := sha256.Sum256([]byte(verifier))
|
||||||
|
|
||||||
|
// state and the PKCE verifier ride in one short-lived cookie: the callback must prove it
|
||||||
|
// belongs to the browser that started the flow, or an attacker can feed us their own code.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: stateCookie, Value: state + "." + verifier, Path: "/",
|
||||||
|
HttpOnly: true, Secure: s.Secure, SameSite: http.SameSiteLaxMode, MaxAge: 600,
|
||||||
|
})
|
||||||
|
|
||||||
|
q := url.Values{
|
||||||
|
"response_type": {"code"},
|
||||||
|
"client_id": {s.OIDC.Config().ClientID},
|
||||||
|
"redirect_uri": {s.redirectURI()},
|
||||||
|
"scope": {"openid profile email"},
|
||||||
|
"state": {state},
|
||||||
|
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, d.AuthorizationEndpoint+"?"+q.Encode(), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) redirectURI() string {
|
||||||
|
return strings.TrimRight(s.BaseURL, "/") + "/admin/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.oidcAvailable() {
|
||||||
|
http.Error(w, "no identity provider configured", http.StatusNotImplemented)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(stateCookie)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "sign-in did not start here — try again from the login page", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", MaxAge: -1})
|
||||||
|
|
||||||
|
state, verifier, ok := strings.Cut(c.Value, ".")
|
||||||
|
if !ok || state == "" || r.URL.Query().Get("state") != state {
|
||||||
|
http.Error(w, "sign-in state did not match — start again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
http.Error(w, "no authorization code returned: "+r.URL.Query().Get("error"), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, err := s.exchange(r.Context(), code, verifier)
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("admin oidc exchange failed", "err", err, "from", clientIP(r))
|
||||||
|
http.Error(w, "could not complete sign-in", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := s.OIDC.Verify(r.Context(), idToken)
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("admin oidc token rejected", "err", err, "from", clientIP(r))
|
||||||
|
http.Error(w, "the identity token was not accepted", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.OIDC.IsAdmin(claims) {
|
||||||
|
// Named explicitly: "you signed in but you are not an admin" is a different problem from
|
||||||
|
// "your password is wrong", and the group is the thing to go and check.
|
||||||
|
slog.Info("admin access denied: not in group", "account", claims.AccountID(),
|
||||||
|
"want_group", s.OIDC.Config().AdminGroup, "have", claims.Groups)
|
||||||
|
http.Error(w, fmt.Sprintf(
|
||||||
|
"Signed in as %s, but that account is not in the %q group, so it cannot administer "+
|
||||||
|
"this server.", claims.Display(), s.OIDC.Config().AdminGroup), http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("admin login", "account", claims.AccountID(), "method", "oidc", "from", clientIP(r))
|
||||||
|
s.setSession(w, claims.AccountID(), claims.Display())
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange trades the authorization code for tokens at the IdP.
|
||||||
|
func (s *Server) exchange(ctx context.Context, code, verifier string) (string, error) {
|
||||||
|
d, err := s.OIDC.Discover(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"code": {code},
|
||||||
|
"redirect_uri": {s.redirectURI()},
|
||||||
|
"client_id": {s.OIDC.Config().ClientID},
|
||||||
|
"code_verifier": {verifier},
|
||||||
|
}
|
||||||
|
if s.ClientSecret != "" {
|
||||||
|
form.Set("client_secret", s.ClientSecret)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.TokenEndpoint,
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var tok struct {
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &tok); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if tok.IDToken == "" {
|
||||||
|
return "", fmt.Errorf("token endpoint returned no id_token")
|
||||||
|
}
|
||||||
|
return tok.IDToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP is for logs only. X-Forwarded-For is deliberately ignored: nothing is meant to sit in
|
||||||
|
// front of this listener, so a header claiming otherwise is a caller's assertion about itself.
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if i := strings.LastIndex(r.RemoteAddr, ":"); i > 0 {
|
||||||
|
return r.RemoteAddr[:i]
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package adminui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"echo-lot.app/server/internal/adminauth"
|
||||||
|
"echo-lot.app/server/internal/runs"
|
||||||
|
"echo-lot.app/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.session(r) != nil {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, r, "login", map[string]any{
|
||||||
|
"OIDC": s.oidcAvailable(),
|
||||||
|
"LocalSet": s.Store.LocalAdmin() != nil,
|
||||||
|
"AdminUser": s.AdminUser,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
devices := s.Store.Devices()
|
||||||
|
linked := 0
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.LinkedToAccount() {
|
||||||
|
linked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var selftest any
|
||||||
|
if s.SelfTest != nil {
|
||||||
|
selftest = s.SelfTest()
|
||||||
|
}
|
||||||
|
s.render(w, r, "dashboard", map[string]any{
|
||||||
|
"Session": sess,
|
||||||
|
"CSRF": s.csrfToken(sess),
|
||||||
|
"Devices": len(devices),
|
||||||
|
"Linked": linked,
|
||||||
|
"Runs": s.totalRuns(devices),
|
||||||
|
"SelfTest": selftest,
|
||||||
|
"Version": s.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) totalRuns(devices []store.Device) int {
|
||||||
|
if s.Runs == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, d := range devices {
|
||||||
|
n += len(s.Runs.List(d.ID))
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) devices(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
devices := s.Store.Devices()
|
||||||
|
// Newest first: the device someone is looking for is almost always the one just enrolled.
|
||||||
|
sort.Slice(devices, func(i, j int) bool { return devices[i].Enrolled.After(devices[j].Enrolled) })
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
store.Device
|
||||||
|
Runs int
|
||||||
|
}
|
||||||
|
rows := make([]row, 0, len(devices))
|
||||||
|
for _, d := range devices {
|
||||||
|
n := 0
|
||||||
|
if s.Runs != nil {
|
||||||
|
n = len(s.Runs.List(d.ID))
|
||||||
|
}
|
||||||
|
rows = append(rows, row{Device: d, Runs: n})
|
||||||
|
}
|
||||||
|
s.render(w, r, "devices", map[string]any{
|
||||||
|
"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows,
|
||||||
|
"Link": r.URL.Query().Get("link"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) revokeDevice(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if err := s.Store.DeleteDevice(id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Worth a log line: revoking a device is destructive, immediate, and someone will eventually
|
||||||
|
// want to know who did it and when.
|
||||||
|
slog.Info("device revoked", "device", id, "by", sess.Subject)
|
||||||
|
http.Redirect(w, r, "/devices", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) mintToken(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
tok, err := s.Store.NewEnrollToken(24*time.Hour, "admin-ui")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("enrolment token minted", "by", sess.Subject)
|
||||||
|
// The whole link, not the bare token: it carries the URL and the pin as well, and assembling
|
||||||
|
// those by hand is where an operator gets a pin wrong by one character.
|
||||||
|
http.Redirect(w, r, "/devices?link="+url.QueryEscape(s.EnrollLink(tok)), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrollLink is supplied by the caller so this package does not need the control server's pin.
|
||||||
|
var _ = 0
|
||||||
|
|
||||||
|
func (s *Server) runsList(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
type row struct {
|
||||||
|
runs.Meta
|
||||||
|
DeviceName string
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
for _, d := range s.Store.Devices() {
|
||||||
|
if s.Runs == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
name := d.Name
|
||||||
|
if name == "" {
|
||||||
|
name = d.ID
|
||||||
|
}
|
||||||
|
for _, m := range s.Runs.List(d.ID) {
|
||||||
|
rows = append(rows, row{Meta: m, DeviceName: name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].UploadedAt.After(rows[j].UploadedAt) })
|
||||||
|
if len(rows) > 200 {
|
||||||
|
rows = rows[:200] // a page, not the archive; the count is on the dashboard
|
||||||
|
}
|
||||||
|
s.render(w, r, "runs", map[string]any{"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runView(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
body, err := s.Runs.Get(r.PathValue("device"), r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Re-indented for reading, but otherwise exactly what was stored. An admin sees the document
|
||||||
|
// at the privacy level its uploader chose — there is nothing here that can un-redact it.
|
||||||
|
var pretty json.RawMessage = body
|
||||||
|
out, err := json.MarshalIndent(json.RawMessage(pretty), "", " ")
|
||||||
|
if err != nil {
|
||||||
|
out = body
|
||||||
|
}
|
||||||
|
s.render(w, r, "run", map[string]any{
|
||||||
|
"Session": sess, "CSRF": s.csrfToken(sess),
|
||||||
|
"Device": r.PathValue("device"), "ID": r.PathValue("id"),
|
||||||
|
"JSON": string(out),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runDelete(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
device, id := r.PathValue("device"), r.PathValue("id")
|
||||||
|
if err := s.Runs.Delete(device, id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("run deleted", "device", device, "run", id, "by", sess.Subject)
|
||||||
|
http.Redirect(w, r, "/runs", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package adminui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Templates are parsed once at start. html/template escapes by context, which is what makes it
|
||||||
|
// safe to render device names and finding text that ultimately arrived over a network.
|
||||||
|
var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
|
||||||
|
"kb": func(n int64) int64 { return n / 1024 },
|
||||||
|
}).Parse(baseHTML))
|
||||||
|
|
||||||
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
|
||||||
|
data["Page"] = page
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tpl.Execute(&buf, data); err != nil {
|
||||||
|
slog.Error("admin template", "page", page, "err", err)
|
||||||
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
// There is no script here and nothing loaded from anywhere else, so a strict policy costs
|
||||||
|
// nothing and closes injected-script attacks even if an escaping bug ever slips through.
|
||||||
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'")
|
||||||
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
_, _ = buf.WriteTo(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseHTML = `<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Echolot — {{.Page}}</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:dark}
|
||||||
|
body{font:15px/1.5 system-ui,sans-serif;margin:0;background:#14161a;color:#e6e6e6}
|
||||||
|
header{display:flex;gap:1.2rem;align-items:baseline;padding:.8rem 1.2rem;background:#1c1f25;border-bottom:1px solid #2b2f36}
|
||||||
|
header h1{font-size:1.1rem;margin:0;font-weight:600}
|
||||||
|
header nav a{color:#9ecbff;text-decoration:none;margin-right:1rem}
|
||||||
|
header .who{margin-left:auto;color:#9aa3ad;font-size:.9rem}
|
||||||
|
main{padding:1.2rem;max-width:70rem}
|
||||||
|
table{border-collapse:collapse;width:100%;margin:.6rem 0}
|
||||||
|
th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid #2b2f36;vertical-align:top}
|
||||||
|
th{color:#9aa3ad;font-weight:500;font-size:.85rem}
|
||||||
|
code,pre{font-family:ui-monospace,monospace;font-size:.85rem}
|
||||||
|
pre{background:#0f1114;padding:.8rem;border-radius:6px;overflow:auto;max-height:34rem}
|
||||||
|
.card{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:1rem;margin:.8rem 0}
|
||||||
|
.grid{display:flex;gap:1rem;flex-wrap:wrap}
|
||||||
|
.stat{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:.8rem 1.2rem;min-width:8rem}
|
||||||
|
.stat b{display:block;font-size:1.6rem;font-weight:600}
|
||||||
|
.stat span{color:#9aa3ad;font-size:.85rem}
|
||||||
|
button{font:inherit;background:#2d6cdf;color:#fff;border:0;border-radius:6px;padding:.4rem .8rem;cursor:pointer}
|
||||||
|
button.danger{background:#8b2f2f}
|
||||||
|
button.plain{background:#3a3f47}
|
||||||
|
input{font:inherit;background:#0f1114;color:#e6e6e6;border:1px solid #2b2f36;border-radius:6px;padding:.4rem .6rem}
|
||||||
|
.err{background:#3a1f1f;border:1px solid #7a3b3b;padding:.6rem .8rem;border-radius:6px}
|
||||||
|
.muted{color:#9aa3ad}
|
||||||
|
form.inline{display:inline}
|
||||||
|
</style></head><body>
|
||||||
|
{{if ne .Page "login"}}
|
||||||
|
<header>
|
||||||
|
<h1>Echolot</h1>
|
||||||
|
<nav><a href="/">Overview</a><a href="/devices">Devices</a><a href="/runs">Runs</a></nav>
|
||||||
|
<span class="who">{{.Session.Display}}
|
||||||
|
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
{{end}}
|
||||||
|
<main>
|
||||||
|
|
||||||
|
{{if eq .Page "login"}}
|
||||||
|
<h2>Sign in</h2>
|
||||||
|
{{with .Error}}<p class="err">{{.}}</p>{{end}}
|
||||||
|
{{if .OIDC}}
|
||||||
|
<p><a href="/auth/start"><button>Sign in with your identity provider</button></a></p>
|
||||||
|
<p class="muted">or use the break-glass account:</p>
|
||||||
|
{{end}}
|
||||||
|
{{if .LocalSet}}
|
||||||
|
<form method="post" action="/login" class="card">
|
||||||
|
<p><label>Username<br><input name="username" value="{{.AdminUser}}" autocomplete="username"></label></p>
|
||||||
|
<p><label>Password<br><input name="password" type="password" autocomplete="current-password"></label></p>
|
||||||
|
<p><button>Sign in</button></p>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="err">No break-glass admin is set. Run
|
||||||
|
<code>echolot-server --set-admin-password</code> on the host.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{else if eq .Page "dashboard"}}
|
||||||
|
<div class="grid">
|
||||||
|
<div class="stat"><b>{{.Devices}}</b><span>devices</span></div>
|
||||||
|
<div class="stat"><b>{{.Linked}}</b><span>signed in</span></div>
|
||||||
|
<div class="stat"><b>{{.Runs}}</b><span>stored runs</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Server</h3>
|
||||||
|
<p class="muted">version {{.Version}}</p>
|
||||||
|
{{with .SelfTest}}<pre>{{printf "%+v" .}}</pre>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{else if eq .Page "devices"}}
|
||||||
|
<h2>Devices</h2>
|
||||||
|
{{with .Link}}
|
||||||
|
<div class="card">
|
||||||
|
<p><b>Enrolment link</b> — single use, valid 24 hours. Treat it like a password until spent.</p>
|
||||||
|
<p><code>{{.}}</code></p>
|
||||||
|
<p class="muted">On a device with adb:<br>
|
||||||
|
<code>adb shell am start -a android.intent.action.VIEW -d "{{.}}"</code></p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/enroll-tokens">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<button>Create enrolment link</button>
|
||||||
|
</form>
|
||||||
|
<table>
|
||||||
|
<tr><th>Device</th><th>Name</th><th>Account</th><th>Enrolled</th><th>Runs</th><th></th></tr>
|
||||||
|
{{range .Rows}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.ID}}</code></td>
|
||||||
|
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||||
|
<td>{{if .LinkedToAccount}}{{.AccountName}}{{else}}<span class="muted">not signed in</span>{{end}}</td>
|
||||||
|
<td>{{.Enrolled.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td>{{.Runs}}</td>
|
||||||
|
<td><form method="post" action="/devices/{{.ID}}/revoke" class="inline">
|
||||||
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
|
<button class="danger">Revoke</button></form></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="6" class="muted">No devices enrolled.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{else if eq .Page "runs"}}
|
||||||
|
<h2>Uploaded runs</h2>
|
||||||
|
<p class="muted">Shown exactly as uploaded, at the privacy level the uploader chose. Nothing
|
||||||
|
here can un-redact a run.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Uploaded</th><th>Device</th><th>Verdict</th><th>Findings</th><th>Size</th><th>Level</th><th></th></tr>
|
||||||
|
{{range .Rows}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.UploadedAt.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td>{{.DeviceName}}</td>
|
||||||
|
<td>{{if .Verdict}}{{.Verdict}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||||
|
<td>{{.FindingCount}}</td>
|
||||||
|
<td>{{kb .SizeBytes}} kB</td>
|
||||||
|
<td>{{.Anonymization}}</td>
|
||||||
|
<td><a href="/runs/{{.DeviceID}}/{{.ID}}">open</a></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="7" class="muted">Nothing uploaded yet.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{else if eq .Page "run"}}
|
||||||
|
<h2>Run {{.ID}}</h2>
|
||||||
|
<form method="post" action="/runs/{{.Device}}/{{.ID}}/delete" class="inline">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<button class="danger">Delete this run</button>
|
||||||
|
</form>
|
||||||
|
<pre>{{.JSON}}</pre>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
</main></body></html>
|
||||||
|
`
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -70,13 +72,60 @@ 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)
|
||||||
|
// Issuer for the app's client, when the IdP gives each application its own.
|
||||||
|
//
|
||||||
|
// Authentik derives the issuer from the application slug, so two applications mean two
|
||||||
|
// issuers — and a token's `iss` must match the one that minted it. Empty means both clients
|
||||||
|
// share ECHOLOT_OIDC_ISSUER, which is what IdPs with a single global issuer do.
|
||||||
|
OIDCAppIssuer string // ECHOLOT_OIDC_APP_ISSUER / --oidc-app-issuer
|
||||||
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 +171,17 @@ 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.OIDCAppIssuer, "oidc-app-issuer", envOr("OIDC_APP_ISSUER", ""), "issuer for the app client when the IdP uses per-application issuers; empty = same as --oidc-issuer")
|
||||||
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 +189,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 +204,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 +290,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,12 @@ type Server struct {
|
|||||||
// Granted server->client sends (spec §5). Both consume an asymmetric grant.
|
// Granted server->client sends (spec §5). Both consume an asymmetric grant.
|
||||||
DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error)
|
DownTrain func(sess *session.Session, g *session.Grant, count, sizeBytes, intervalUs int) (int, error)
|
||||||
BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error)
|
BigSend func(sess *session.Session, g *session.Grant, sizes []int, df bool) ([]dataplane.BigSendResult, error)
|
||||||
// OIDC verifies ID tokens when the operator has configured an issuer (may be nil).
|
// OIDC verifies ID tokens presented by the *app* (may be nil).
|
||||||
OIDC *oidc.Verifier
|
OIDC *oidc.Verifier
|
||||||
|
// AdminOIDC verifies tokens from the admin UI's own client. Separate because an IdP may
|
||||||
|
// give each application its own issuer — Authentik derives it from the application slug —
|
||||||
|
// and a verifier pins exactly one issuer and the clients belonging to it.
|
||||||
|
AdminOIDC *oidc.Verifier
|
||||||
// Runs stores uploaded measurement documents (may be nil: uploads unsupported).
|
// Runs stores uploaded measurement documents (may be nil: uploads unsupported).
|
||||||
Runs *runs.Store
|
Runs *runs.Store
|
||||||
// FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil:
|
// FragSend emits one datagram as hand-built IP fragments in a chosen order (may be nil:
|
||||||
@@ -830,7 +834,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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
"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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user