Files
mg4/server/pairing_http.go
nilsandClaude Opus 5.5 d711e0cfda App 2.12.0, server 2.5.0: enter settings on the phone
The ABRP credentials are long to type on the car. "Enter on phone"
(Live data tab, and Settings next to the charging price) shows a QR
code; it opens a form on the server where the phone sends the ABRP API
key and user token, charging price and usable capacity, confirmed with
the approval password. The car polls with its own token and collects
the values once, then saves them through the same checks as the fields
on screen.

Same device-flow shape as pairing. The server keeps the values in memory
only, at most ten minutes, hands them out once with no-store, and never
logs them or puts them back into the page; a paired token only reaches
its own installation's request. Pairing's QR dialog is now shared
(PhoneQrDialog), and the phone pages share one stylesheet.

The config file also takes charging_price and usable_kwh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 12:59:03 +02:00

319 lines
12 KiB
Go

package main
import (
"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, no scripts.
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}
`
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>
<input id="code" name="code" autocapitalize="characters" autocomplete="off" placeholder="XXX-XXX" required>{{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")
// 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"), 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)
}