enrollment: the server mints the §2.1 bootstrap link, the app consumes it
POST /admin/enroll-tokens now returns the whole link, not just the token: echolot://enroll?v=1&u=<control URL>&p=pin-sha256:<b64>&t=<token> The server is the only party that knows all three parts at once, and the part an operator gets wrong by hand is the base64 pin — which does not fail loudly, it just never matches, surfacing days later as an inscrutable TLS error. The app takes the link from a paste or from an echolot:// deep link (QR scan), and writes URL, pin and credential together or not at all. One trap the tests pin: an unencoded "+" in a query string decodes to a space, so a hand-assembled link arrives with a pin wrong by one character. Base64 has no spaces, so they are restored — unambiguous, and it cannot damage a correctly encoded pin. Also fixes a spec divergence: §2.1 names the field device_credential and the first implementation shipped "credential". Both are sent now and the client prefers the spec's; the alias goes once nothing reads it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8166611af1
commit
ad85f3bfcd
@@ -20,6 +20,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -72,6 +73,10 @@ type Server struct {
|
||||
// server, not the client.
|
||||
ProvenGood func() (mtuOK, sysctlOK bool)
|
||||
|
||||
// PublicControlURL is where clients reach this server, for the enrollment link (§2.1).
|
||||
// Empty means "derive from the address we are listening on", which is right for a plain
|
||||
// deployment and wrong behind a proxy or a name — hence the override.
|
||||
PublicControlURL string
|
||||
// AppRange is the app-version window this server will serve. Zero value means the built-in
|
||||
// default (see DefaultAppRange).
|
||||
AppRange compat.Range
|
||||
@@ -456,8 +461,12 @@ func (s *Server) enroll(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
slog.Info("device enrolled", "device", dev.ID, "name", dev.Name)
|
||||
writeJSON(w, http.StatusCreated, map[string]string{
|
||||
"device_id": dev.ID,
|
||||
"credential": dev.Credential, // returned exactly once
|
||||
"device_id": dev.ID,
|
||||
// The spec (§2.1) names this device_credential; the first implementation shipped
|
||||
// "credential". Both are sent while deployed 0.5.x clients still read the old name;
|
||||
// the client prefers the spec's. Drop "credential" once nothing reads it.
|
||||
"device_credential": dev.Credential, // returned exactly once
|
||||
"credential": dev.Credential, // deprecated alias, see above
|
||||
})
|
||||
}
|
||||
|
||||
@@ -662,3 +671,16 @@ func maxOrEmpty(r compat.Range) string {
|
||||
}
|
||||
return r.Max.String()
|
||||
}
|
||||
|
||||
// EnrollmentLink builds the §2.1 bootstrap string for a freshly minted token.
|
||||
//
|
||||
// The server assembles it rather than the operator, because it is the only party that knows all
|
||||
// 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 "echolot://enroll?v=1" +
|
||||
"&u=" + url.QueryEscape(strings.TrimRight(u, "/")) +
|
||||
"&p=" + url.QueryEscape("pin-sha256:"+s.PinB64) +
|
||||
"&t=" + url.QueryEscape(token)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user