diff --git a/echolot-app/scripts/enroll-link.sh b/echolot-app/scripts/enroll-link.sh index 23281d1..7e9b3ef 100644 --- a/echolot-app/scripts/enroll-link.sh +++ b/echolot-app/scripts/enroll-link.sh @@ -5,8 +5,13 @@ # Mints an enrollment link on the probe server and prints it — as text, as a QR code if # `qrencode` is around, and as an adb command if a device is attached. # -# The admin listener is localhost-only by design, so this goes over SSH. The link carries a -# single-use bearer token: treat it like a password until it is redeemed. +# The link is minted by the server binary on the host rather than over HTTP. The admin API this +# used to call is gone: the admin UI that replaced it is authenticated, as it should be, and +# adding a second unauthenticated door on loopback is what briefly exposed the old one to the +# network. A root shell on the host needs no authentication anyway — whoever has one already has +# every privilege the server has. +# +# The link carries a single-use bearer token: treat it like a password until it is redeemed. # # Usage: echolot-app/scripts/enroll-link.sh [note] set -euo pipefail @@ -14,13 +19,18 @@ set -euo pipefail SSH_HOST="${ECHOLOT_SSH:-claude-echolot}" NOTE="${1:-manual}" -MINTED=$(ssh -o BatchMode=yes "$SSH_HOST" \ - "curl -s -X POST 'http://127.0.0.1:8444/admin/enroll-tokens?note=$NOTE'") +# The env file is sourced rather than assumed: the state directory and the public URL live there, +# and minting against the wrong state directory would produce a token the running server has +# never heard of. +REMOTE='set -a; . /etc/echolot/server.env; set +a; + exec /usr/local/bin/echolot-server --mint-enroll-token' +RAW=$(ssh -o BatchMode=yes "$SSH_HOST" "sudo sh -c \"$REMOTE '$NOTE'\"" 2>/dev/null || true) +URI=$(printf '%s' "$RAW" | tr -d '\r' | grep -m1 '^echolot://enroll' || true) -URI=$(printf '%s' "$MINTED" | python -c 'import json,sys;print(json.load(sys.stdin).get("enroll_uri",""))') if [ -z "$URI" ]; then - echo "server returned no enroll_uri (needs server-v0.5.4+):" >&2 - echo "$MINTED" >&2 + echo "could not mint a link — needs a server with --mint-enroll-token (v0.9.7+)." >&2 + echo "raw response:" >&2 + printf '%s\n' "$RAW" >&2 exit 1 fi diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index a0fa721..4913e6a 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -110,12 +110,46 @@ func run() error { return system.UninstallSystemd() case actions.SetAdminPassword: return setAdminPassword(cfg) + case actions.MintEnrollToken != "": + return mintEnrollToken(cfg, actions.MintEnrollToken) case actions.SelfUpdate: return selfupdate.Run(cfg.SelfUpdateAPI, Version) } return serve(cfg) } +// mintEnrollToken prints a §2.1 bootstrap link for a new device. +// +// The link is assembled here rather than by hand because it has to carry the public URL and the +// base64 SPKI pin percent-encoded correctly, and a pin wrong by one character fails later as an +// inscrutable TLS error rather than as a bad pin. +func mintEnrollToken(cfg *config.Config, note string) error { + st, err := store.Open(cfg.StateDir) + if err != nil { + return fmt.Errorf("state store: %w", err) + } + cert, err := loadOrCreateCert(cfg) + if err != nil { + return fmt.Errorf("tls: %w", err) + } + pin, err := control.SpkiPinB64(cert) + if err != nil { + return fmt.Errorf("pin: %w", err) + } + tok, err := st.NewEnrollToken(24*time.Hour, note) + if err != nil { + return err + } + base := cfg.PublicControlURL + if base == "" { + return fmt.Errorf("set ECHOLOT_PUBLIC_URL so the link can say where to connect") + } + fmt.Println(control.EnrollmentURI(base, pin, tok)) + // stderr, so piping the command somewhere yields the link alone. + fmt.Fprintln(os.Stderr, "\nSingle use, valid 24 hours. Treat it like a password until spent.") + return nil +} + func serve(cfg *config.Config) error { slog.Info("echolot-server starting", "version", Version, "mode", map[bool]string{true: "container", false: "native"}[cfg.Docker], diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 762c35f..fb9222d 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -201,6 +201,7 @@ func Load(args []string) (*Config, *Actions, error) { 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.StringVar(&a.MintEnrollToken, "mint-enroll-token", "", "mint a single-use enrollment link (argument is a note for the audit log) 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") @@ -215,7 +216,7 @@ func Load(args []string) (*Config, *Actions, error) { // 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.SetAdminPassword && !a.Version && a.MintEnrollToken == "" { a.Help = true } if a.Serve { @@ -334,6 +335,13 @@ type Actions struct { UninstallSystemd bool SelfUpdate bool SetAdminPassword bool + // MintEnrollToken is the note to record against a freshly minted enrollment link. + // + // A local action rather than an HTTP endpoint: whoever can run this binary against the state + // directory already has every privilege the server has, so authenticating them to themselves + // would be theatre — and an unauthenticated endpoint on loopback is how the admin API was + // briefly reachable from the network by accident. + MintEnrollToken string Version bool } diff --git a/server/internal/control/control.go b/server/internal/control/control.go index 1263f39..b84dd9a 100644 --- a/server/internal/control/control.go +++ b/server/internal/control/control.go @@ -825,10 +825,16 @@ func maxOrEmpty(r compat.Range) string { // three parts at once — its own URL, its own SPKI pin, and the token. An operator copying a pin // by hand is the step that goes wrong, and a pin wrong by one character does not fail loudly. func (s *Server) EnrollmentLink(token string) string { - u := s.PublicControlURL + return EnrollmentURI(s.PublicControlURL, s.PinB64, token) +} + +// EnrollmentURI is the same assembly without a running server, for the mint-a-link CLI action. +// Shared rather than reimplemented: two copies of this encoding would eventually disagree, and +// the failure mode is a pin that looks right and produces an inscrutable TLS error. +func EnrollmentURI(publicURL, pinB64, token string) string { return "echolot://enroll?v=1" + - "&u=" + url.QueryEscape(strings.TrimRight(u, "/")) + - "&p=" + url.QueryEscape("pin-sha256:"+s.PinB64) + + "&u=" + url.QueryEscape(strings.TrimRight(publicURL, "/")) + + "&p=" + url.QueryEscape("pin-sha256:"+pinB64) + "&t=" + url.QueryEscape(token) }