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:
co-authored by
Claude Opus 5
parent
a720e84411
commit
082a2314ef
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user