package main import ( "errors" "html/template" "io" "log/slog" "net/http" "net/url" "strings" "time" ) // HTTP side of pairing. // // POST /v1/pair/start car: open a request {installationId} → codes and URL // POST /v1/pair/poll car: collect the outcome {deviceCode} → 202 / 200 token / 403 / 410 // GET /pair[?code=XXX-XXX] phone: approval page // POST /pair phone: approve or deny (needs the approval password) // GET /v1/devices list paired cars Bearer // POST /v1/devices/{id}/revoke revoke a car's token Bearer // // Start and poll need no token — the car does not have one yet. What they hand out is // worthless until a person who knows the approval password approves it. func (a *API) pairingRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/pair/start", a.pairStart) mux.HandleFunc("POST /v1/pair/poll", a.pairPoll) mux.HandleFunc("GET /pair", a.pairPage) mux.HandleFunc("POST /pair", a.pairDecide) mux.HandleFunc("GET /v1/devices", a.listDevices) mux.HandleFunc("POST /v1/devices/{id}/revoke", a.revokeDevice) } func (a *API) pairingDisabled(w http.ResponseWriter) bool { if a.Pairing.Enabled() { return false } writeJSON(w, http.StatusNotFound, map[string]string{"error": "pairing is not enabled"}) return true } func readSmallJSON(r *http.Request) (object, error) { body, err := io.ReadAll(io.LimitReader(r.Body, 4097)) if err != nil || len(body) > 4096 { return nil, invalid("body is too large") } return decodeObject(body) } // publicURL is where the phone should go: PUBLIC_URL when set, otherwise what the request // came in on (behind Traefik, the forwarded scheme and host). func (a *API) publicURL(r *http.Request) string { if a.PublicURL != "" { return strings.TrimRight(a.PublicURL, "/") } scheme := "http" if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { scheme = "https" } host := r.Header.Get("X-Forwarded-Host") if host == "" { host = r.Host } return scheme + "://" + host } func (a *API) pairStart(w http.ResponseWriter, r *http.Request) { if a.pairingDisabled(w) { return } o, err := readSmallJSON(r) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } id, err := validUUID(o["installationId"], "installationId") if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } req, err := a.Pairing.Start(id) if err != nil { writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()}) return } base := a.publicURL(r) + "/pair" writeJSON(w, http.StatusCreated, map[string]any{ "deviceCode": req.deviceCode, "userCode": req.userCode, "verificationUri": base, "verificationUriComplete": base + "?code=" + url.QueryEscape(req.userCode), "expiresIn": int(pairingTTL / time.Second), "interval": int(pairingInterval / time.Second), }) } func (a *API) pairPoll(w http.ResponseWriter, r *http.Request) { if a.pairingDisabled(w) { return } o, err := readSmallJSON(r) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } code, _ := o["deviceCode"].(string) state, token, deviceID, err := a.Pairing.Poll(code) switch { case err != nil: writeJSON(w, http.StatusGone, map[string]string{"error": "expired_token"}) case state == pairingPending: writeJSON(w, http.StatusAccepted, map[string]string{"status": "authorization_pending"}) case state == pairingDenied: writeJSON(w, http.StatusForbidden, map[string]string{"error": "access_denied"}) default: writeJSON(w, http.StatusOK, map[string]string{"token": token, "deviceId": deviceID}) } } // ---------- Approval page ---------- type pageData struct { Code string Message string Error string Done bool } var pairTemplate = template.Must(template.New("pair").Parse(` Pair a car — Nils' MG4

Pair a car

{{if .Done}}

{{.Message}}

{{else}}

Check that this code matches the one on the car's screen, then approve with the approval password.

{{if .Code}}
{{.Code}}
{{else}} {{end}} {{if .Error}}

{{.Error}}

{{end}}
{{end}}
`)) func renderPage(w http.ResponseWriter, status int, data pageData) { h := w.Header() h.Set("Content-Type", "text/html; charset=utf-8") // No scripts at all, no framing, forms only back to this page. h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "no-referrer") h.Set("Cache-Control", "no-store") w.WriteHeader(status) if err := pairTemplate.Execute(w, data); err != nil { slog.Warn("pair page not rendered", "error", err) } } func (a *API) pairPage(w http.ResponseWriter, r *http.Request) { if !a.Pairing.Enabled() { http.NotFound(w, r) return } data := pageData{} if raw := r.URL.Query().Get("code"); raw != "" { if req, err := a.Pairing.Lookup(raw); err == nil && req.state == pairingPending { data.Code = req.userCode } else { data.Error = "This code is unknown or has expired. Start pairing again on the car." } } renderPage(w, http.StatusOK, data) } func (a *API) pairDecide(w http.ResponseWriter, r *http.Request) { if !a.Pairing.Enabled() { http.NotFound(w, r) return } r.Body = http.MaxBytesReader(w, r.Body, 4096) if err := r.ParseForm(); err != nil { renderPage(w, http.StatusBadRequest, pageData{Error: "The form could not be read."}) return } code := normalizeUserCode(r.PostForm.Get("code")) approve := r.PostForm.Get("action") != "deny" device, err := a.Pairing.Decide(code, r.PostForm.Get("password"), approve) switch { case errors.Is(err, errWrongPassword): renderPage(w, http.StatusUnauthorized, pageData{Code: code, Error: "Wrong password."}) return case errors.Is(err, errTooManyFailed): renderPage(w, http.StatusTooManyRequests, pageData{Code: code, Error: err.Error() + "."}) return case errors.Is(err, errUnknownCode), errors.Is(err, errNotPending): renderPage(w, http.StatusNotFound, pageData{Error: "This code is unknown, expired or already used. Start pairing again on the car."}) return case err != nil: renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "Something went wrong."}) return } if !approve { renderPage(w, http.StatusOK, pageData{Done: true, Message: "Denied. The car was not paired."}) return } // Persist before the car can collect the token: a token that vanished at the next // restart would look like a pairing that worked. if err := a.Store.SaveDevice(r.Context(), device); err != nil { a.Pairing.Forget(code, device) slog.Error("device not saved", "error", err) renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "The car could not be saved. Try again."}) return } slog.Info("car paired", "device", device.ID, "installation", device.InstallationID) renderPage(w, http.StatusOK, pageData{Done: true, Message: "Approved. The car finishes pairing on its own within a few seconds — you can close this page."}) } // ---------- Device management ---------- func (a *API) adminAuthorized(w http.ResponseWriter, r *http.Request) bool { if a.pairingDisabled(w) { return false } supplied, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return false } switch err := a.Pairing.CheckAdmin(supplied); { case errors.Is(err, errTooManyFailed): writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()}) return false case err != nil: writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return false } return true } type deviceJSON struct { ID string `json:"id"` InstallationID string `json:"installationId"` CreatedAt time.Time `json:"createdAt"` Revoked bool `json:"revoked"` } func (a *API) listDevices(w http.ResponseWriter, r *http.Request) { if !a.adminAuthorized(w, r) { return } out := []deviceJSON{} for _, d := range a.Pairing.Devices() { out = append(out, deviceJSON{d.ID, d.InstallationID, d.CreatedAt, d.Revoked}) } writeJSON(w, http.StatusOK, map[string]any{"devices": out}) } func (a *API) revokeDevice(w http.ResponseWriter, r *http.Request) { if !a.adminAuthorized(w, r) { return } d, ok := a.Pairing.Revoke(r.PathValue("id")) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such device"}) return } if err := a.Store.SaveDevice(r.Context(), d); err != nil { slog.Error("revocation not saved", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "revoked until restart only; saving failed"}) return } slog.Info("car revoked", "device", d.ID) w.WriteHeader(http.StatusNoContent) }