server: enroll against fmr.echo-lot.app; mint links from the CLI
The control plane now advertises the same name the web UI answers on. That is safe because the client authenticates by SPKI pin and explicitly does not verify the hostname — "pin is the trust, not the name" — so no certificate covers or needs to cover either name. The per-host name still means something, though, and the rule it encodes has to survive: pinning binds a client to one server's key, so fmr may be a CNAME to exactly one host and never a multi-address service record. A second server gets enrolled as fmr-2 explicitly, because a client that reaches a different key does not fail over, it fails. Minting a link was broken and had been since the authenticated admin UI replaced the old admin API: enroll-link.sh still posted to 127.0.0.1:8444/admin/enroll-tokens, an endpoint that no longer exists on a listener that no longer binds loopback. Rather than add a second unauthenticated door — which is how the old one ended up briefly reachable from the network — the binary mints its own link. Whoever can run it against the state directory already holds every privilege the server has, so authenticating them to themselves would be theatre. EnrollmentURI is shared with the running server's EnrollmentLink rather than reimplemented. Two copies of that encoding would eventually disagree, and the failure mode is a pin that looks right and surfaces as an inscrutable TLS error rather than as a bad pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8001234e8b
commit
e0428b4c84
@@ -5,8 +5,13 @@
|
|||||||
# Mints an enrollment link on the probe server and prints it — as text, as a QR code if
|
# 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.
|
# `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
|
# The link is minted by the server binary on the host rather than over HTTP. The admin API this
|
||||||
# single-use bearer token: treat it like a password until it is redeemed.
|
# 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]
|
# Usage: echolot-app/scripts/enroll-link.sh [note]
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -14,13 +19,18 @@ set -euo pipefail
|
|||||||
SSH_HOST="${ECHOLOT_SSH:-claude-echolot}"
|
SSH_HOST="${ECHOLOT_SSH:-claude-echolot}"
|
||||||
NOTE="${1:-manual}"
|
NOTE="${1:-manual}"
|
||||||
|
|
||||||
MINTED=$(ssh -o BatchMode=yes "$SSH_HOST" \
|
# The env file is sourced rather than assumed: the state directory and the public URL live there,
|
||||||
"curl -s -X POST 'http://127.0.0.1:8444/admin/enroll-tokens?note=$NOTE'")
|
# 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
|
if [ -z "$URI" ]; then
|
||||||
echo "server returned no enroll_uri (needs server-v0.5.4+):" >&2
|
echo "could not mint a link — needs a server with --mint-enroll-token (v0.9.7+)." >&2
|
||||||
echo "$MINTED" >&2
|
echo "raw response:" >&2
|
||||||
|
printf '%s\n' "$RAW" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -110,12 +110,46 @@ func run() error {
|
|||||||
return system.UninstallSystemd()
|
return system.UninstallSystemd()
|
||||||
case actions.SetAdminPassword:
|
case actions.SetAdminPassword:
|
||||||
return setAdminPassword(cfg)
|
return setAdminPassword(cfg)
|
||||||
|
case actions.MintEnrollToken != "":
|
||||||
|
return mintEnrollToken(cfg, actions.MintEnrollToken)
|
||||||
case actions.SelfUpdate:
|
case actions.SelfUpdate:
|
||||||
return selfupdate.Run(cfg.SelfUpdateAPI, Version)
|
return selfupdate.Run(cfg.SelfUpdateAPI, Version)
|
||||||
}
|
}
|
||||||
return serve(cfg)
|
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 {
|
func serve(cfg *config.Config) error {
|
||||||
slog.Info("echolot-server starting", "version", Version, "mode",
|
slog.Info("echolot-server starting", "version", Version, "mode",
|
||||||
map[bool]string{true: "container", false: "native"}[cfg.Docker],
|
map[bool]string{true: "container", false: "native"}[cfg.Docker],
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ func Load(args []string) (*Config, *Actions, error) {
|
|||||||
fs.BoolVar(&a.SetAdminPassword, "set-admin-password", false,
|
fs.BoolVar(&a.SetAdminPassword, "set-admin-password", false,
|
||||||
"set the break-glass admin password (username as --admin-user, password read from stdin) and exit")
|
"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(&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.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")
|
||||||
|
|
||||||
@@ -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
|
// is a usage error rather than success — otherwise a service manager sees a clean exit and
|
||||||
// concludes the server ran and finished.
|
// concludes the server ran and finished.
|
||||||
if !a.Serve && !a.InstallSystemd && !a.UninstallSystemd && !a.SelfUpdate &&
|
if !a.Serve && !a.InstallSystemd && !a.UninstallSystemd && !a.SelfUpdate &&
|
||||||
!a.SetAdminPassword && !a.Version {
|
!a.SetAdminPassword && !a.Version && a.MintEnrollToken == "" {
|
||||||
a.Help = true
|
a.Help = true
|
||||||
}
|
}
|
||||||
if a.Serve {
|
if a.Serve {
|
||||||
@@ -334,6 +335,13 @@ type Actions struct {
|
|||||||
UninstallSystemd bool
|
UninstallSystemd bool
|
||||||
SelfUpdate bool
|
SelfUpdate bool
|
||||||
SetAdminPassword 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
|
Version bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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 {
|
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" +
|
return "echolot://enroll?v=1" +
|
||||||
"&u=" + url.QueryEscape(strings.TrimRight(u, "/")) +
|
"&u=" + url.QueryEscape(strings.TrimRight(publicURL, "/")) +
|
||||||
"&p=" + url.QueryEscape("pin-sha256:"+s.PinB64) +
|
"&p=" + url.QueryEscape("pin-sha256:"+pinB64) +
|
||||||
"&t=" + url.QueryEscape(token)
|
"&t=" + url.QueryEscape(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user