Compare commits

...
Author SHA1 Message Date
mrambossekandClaude Fable 5 3cdbccee18 cli: serving is an explicit verb; no arguments prints usage
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 33s
server-release / release (push) Successful in 34s
Running an unfamiliar binary by name should tell you what it does, not bind a
dozen ports and start answering the internet. --serve (or --daemon) now does
that, and a bare invocation prints usage and exits 2 - non-zero on purpose, so a
service manager sees a failure rather than concluding the server ran and
finished cleanly.

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

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

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

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

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

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

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

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

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

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

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