protocol: enrollment links carry the public name, not the endpoint

Sharing port 443 between the admin UI and the control plane forces two
hostnames — one port and one name is one certificate, and the two need
different ones. That difference had been leaking into every enrollment
link, so an operator handed out fmr-1.echo-lot.app when the thing they
and their users know is fmr.echo-lot.app.

The link now carries the public name and the app asks GET /v1/discover
where to actually connect. The endpoint is plumbing: it exists to select
a certificate, and nobody needs to see it.

Discovery hands out an address and never a pin. The pin stays in the
link. Fetching it over an ordinary TLS connection would make pinning
worth exactly what the certificate authorities are worth, and pinning is
there to survive one the operator does not control — a root injected by
corporate device management, say, which is unremarkable on the networks
this tool gets pointed at. With the pin pre-shared, an intercepted
discovery can only send a device somewhere the pin will not match: an
outage, not a compromise.

Optional on both sides. A server that does not answer, or a link that
already names the control endpoint, works unchanged — enrollment must not
start failing because a lookup did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 00:13:27 +02:00
co-authored by Claude Opus 5
parent a720e84411
commit 082a2314ef
6 changed files with 120 additions and 2 deletions
+15
View File
@@ -150,6 +150,17 @@ func mintEnrollToken(cfg *config.Config, note string) error {
return nil
}
// controlURL is the address devices connect to: the hostname that selects the pinned certificate.
//
// Falls back to the public URL when no separate control hostname is configured, so a server that
// does not share the admin port keeps answering discovery with something usable.
func controlURL(cfg *config.Config) string {
if cfg.ControlHostname == "" {
return cfg.PublicControlURL
}
return "https://" + cfg.ControlHostname
}
func serve(cfg *config.Config) error {
slog.Info("echolot-server starting", "version", Version, "mode",
map[bool]string{true: "container", false: "native"}[cfg.Docker],
@@ -350,6 +361,10 @@ func serve(cfg *config.Config) error {
ClientSecret: cfg.OIDCClientSecret,
Secure: adminSecure,
EnrollLink: ctl.EnrollmentLink,
// Where devices should connect, for /v1/discover. Derived from the control hostname so it
// cannot drift from the name that actually selects the pinned certificate.
ControlURL: controlURL(cfg),
ServerName: cfg.Name,
SelfTest: func() any { return selftestPtr.Load() },
Version: Version,
}
+23
View File
@@ -61,6 +61,10 @@ type Server struct {
// 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
// ControlURL is where devices should actually connect, handed out by /v1/discover so the
// enrollment link can show the public name instead. ServerName is for display.
ControlURL string
ServerName string
// SelfTest and Version render on the dashboard.
SelfTest func() any
Version string
@@ -77,6 +81,25 @@ func (s *Server) Handler() http.Handler {
fmt.Fprintf(w, `{"ok":true,"version":%q}`+"\n", s.Version)
})
// Unauthenticated on purpose, and deliberately says almost nothing: where the control plane
// is, and nothing about who may talk to it.
//
// This exists so an enrollment link can carry the name a person recognises while the app
// still connects to the name that selects the pinned certificate. It hands out an address,
// never a pin — the pin travels in the link itself. Serving the pin here would collapse
// pinning to whatever the CA system says, and pinning exists precisely to survive a
// certificate authority the operator does not control.
//
// So the worst an intercepted discovery can do is send a device to the wrong host, where the
// pin check fails. That is a denial of service, not a compromise.
mux.HandleFunc("GET /v1/discover", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"control_url": s.ControlURL,
"name": s.ServerName,
})
})
mux.HandleFunc("GET /login", s.loginForm)
mux.HandleFunc("POST /login", s.loginSubmit)
mux.HandleFunc("GET /auth/start", s.oidcStart)