enrollment: the server mints the §2.1 bootstrap link, the app consumes it
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 31s

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:
mrambossek
2026-08-01 12:06:22 +02:00
co-authored by Claude Fable 5
parent 8166611af1
commit ad85f3bfcd
15 changed files with 533 additions and 17 deletions
+24 -2
View File
@@ -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)
}