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:
mrambossek
2026-08-01 23:16:23 +02:00
co-authored by Claude Opus 5
parent 8001234e8b
commit e0428b4c84
4 changed files with 69 additions and 11 deletions
+34
View File
@@ -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],