The phone pages' code field gets one inline script, allowed by its SHA-256 hash in the CSP; a test checks the served script against the header. Image pinned by digest in k8s/server.yaml (not applied). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
341 lines
13 KiB
Go
341 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"html/template"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"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 <APPROVAL_PASSWORD>
|
|
// POST /v1/devices/{id}/revoke revoke a car's token Bearer <APPROVAL_PASSWORD>
|
|
//
|
|
// 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, clientAddress(r))
|
|
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})
|
|
}
|
|
}
|
|
|
|
// clientAddress is who is asking, for the per-client limits: the last X-Forwarded-For entry,
|
|
// which is the one Traefik adds (anything left of it comes from the client and can be
|
|
// made up), or the connection's own address without a proxy.
|
|
func clientAddress(r *http.Request) string {
|
|
if forwarded := r.Header.Values("X-Forwarded-For"); len(forwarded) > 0 {
|
|
last := forwarded[len(forwarded)-1]
|
|
if i := strings.LastIndex(last, ","); i >= 0 {
|
|
last = last[i+1:]
|
|
}
|
|
if addr := strings.TrimSpace(last); addr != "" {
|
|
return addr
|
|
}
|
|
}
|
|
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
|
return host
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
// ---------- Approval page ----------
|
|
|
|
type pageData struct {
|
|
Code string
|
|
Message string
|
|
Error string
|
|
Done bool
|
|
}
|
|
|
|
// pageStyle is shared by the phone pages (pairing, settings): dark, MG red.
|
|
const pageStyle = `
|
|
:root{color-scheme:dark}
|
|
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#000;color:#F4F3EF;font:16px/1.5 system-ui,sans-serif}
|
|
main{width:min(92vw,420px);background:#121212;border:1px solid #363B3D;border-radius:16px;padding:28px}
|
|
h1{margin:0 0 4px;font-size:22px}p{color:#BDC4C6;margin:8px 0 18px}
|
|
.code{font:600 34px/1.2 ui-monospace,monospace;letter-spacing:4px;color:#FF9A9E;text-align:center;margin:18px 0}
|
|
label{display:block;font-size:14px;color:#BDC4C6;margin:14px 0 6px}
|
|
input{box-sizing:border-box;width:100%;padding:14px;border-radius:10px;border:1px solid #363B3D;background:#000;color:#F4F3EF;font-size:18px}
|
|
.row{display:flex;gap:10px;margin-top:20px}
|
|
button{flex:1;padding:14px;border-radius:10px;border:0;font-size:17px;font-weight:600;cursor:pointer}
|
|
.approve{background:#E0424C;color:#1A0B0C}.deny{background:#2A1416;color:#FF9A9E;border:1px solid #3A1215}
|
|
.error{color:#FFB4AB}.ok{color:#A4DCC1}
|
|
#code{text-transform:uppercase;letter-spacing:2px;font-family:ui-monospace,monospace}
|
|
`
|
|
|
|
// codeInput is the typed-code field of both phone pages. Its script upper-cases what is typed and
|
|
// puts the dash in after the third character; without the script the server still accepts the
|
|
// code in any case, with or without the dash (normalizeUserCode).
|
|
const codeInput = `<input id="code" name="code" autocapitalize="characters" autocomplete="off" autocorrect="off" spellcheck="false" maxlength="7" placeholder="XXX-XXX" required>
|
|
<script>` + codeScript + `</script>`
|
|
|
|
const codeScript = `document.getElementById('code').addEventListener('input',function(e){` +
|
|
`var v=this.value.toUpperCase().replace(/[^A-Z0-9]/g,'').slice(0,6);` +
|
|
`var del=e.inputType&&e.inputType.indexOf('delete')===0;` +
|
|
`this.value=v.length>3||(v.length===3&&!del)?v.slice(0,3)+'-'+v.slice(3):v;});`
|
|
|
|
// pageCSP allows no script but codeScript, pinned by its hash, and forms only back to this server.
|
|
var pageCSP = "default-src 'none'; script-src 'sha256-" + scriptHash(codeScript) +
|
|
"'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
|
|
|
|
func scriptHash(script string) string {
|
|
sum := sha256.Sum256([]byte(script))
|
|
return base64.StdEncoding.EncodeToString(sum[:])
|
|
}
|
|
|
|
var pairTemplate = template.Must(template.New("pair").Parse(`<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Pair a car — Nils' MG4</title>
|
|
<style>` + pageStyle + `</style></head><body><main>
|
|
<h1>Pair a car</h1>
|
|
{{if .Done}}
|
|
<p class="ok">{{.Message}}</p>
|
|
{{else}}
|
|
<p>Check that this code matches the one on the car's screen, then approve with the approval password.</p>
|
|
<form method="post" action="/pair" autocomplete="on">
|
|
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
|
|
{{else}}<label for="code">Code shown on the car</label>
|
|
` + codeInput + `{{end}}
|
|
<input type="text" name="username" value="mg4-approval" autocomplete="username" hidden readonly>
|
|
<label for="password">Approval password</label>
|
|
<input id="password" name="password" type="password" autocomplete="current-password" required {{if .Code}}autofocus{{end}}>
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
<div class="row"><button class="approve" name="action" value="approve">Approve</button>
|
|
<button class="deny" name="action" value="deny" formnovalidate>Deny</button></div>
|
|
</form>
|
|
{{end}}
|
|
</main></body></html>`))
|
|
|
|
func renderPage(w http.ResponseWriter, status int, data pageData) {
|
|
h := w.Header()
|
|
h.Set("Content-Type", "text/html; charset=utf-8")
|
|
h.Set("Content-Security-Policy", pageCSP)
|
|
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"), clientAddress(r), 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
|
|
}
|
|
a.Pairing.Commit(code, device)
|
|
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, clientAddress(r)); {
|
|
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)
|
|
}
|