Files
mg4/server/api.go
T
nilsandClaude Opus 5.5 d5f68e1bd9 Server 2.1.0: pair cars by QR code instead of typing the token
Modelled on the OAuth device authorization grant (RFC 8628) and on
only when APPROVAL_PASSWORD is set (at least 12 characters):

- POST /v1/pair/start opens a request for an installation and returns
  a short user code and the /pair URL for the car's QR code;
  POST /v1/pair/poll returns pending, the car's own token (once),
  denied or expired. Requests live 10 minutes, in memory.
- GET/POST /pair is a script-free approval page for the phone
  (strict CSP, no framing). Five wrong passwords lock approvals for a
  minute.
- Each car gets its own 43-character token; only its SHA-256 is stored
  (measurement "device"), and the device is persisted before the car
  can collect the token. Tokens are loaded at startup, so a restart
  keeps paired cars working; the server refuses to start if they cannot
  be loaded.
- GET /v1/devices and POST /v1/devices/{id}/revoke, authenticated with
  the approval password, list and revoke cars.
- The shared INGEST_TOKEN keeps working.

31 tests (race detector clean), govulncheck clean. Tested end to end
against InfluxDB 2.7.12: pairing, wrong password, single collection,
persistence across restarts, revocation (also across a restart), and
that InfluxDB holds only the hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 16:16:19 +02:00

171 lines
5.7 KiB
Go

package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
)
// API serves the car. Every endpoint but /v1/health needs the ingest token.
//
// POST /v1/telemetry live samples; stored, newest forwarded to ABRP
// PUT /v1/consumption/daily one delta chunk of a day's consumption
// PUT /v1/charging-sessions/{sessionId} one finished DC charging session
// GET /v1/consumption/history/{installationId} the car reads its own totals back
// GET /v1/health unauthenticated liveness probe
//
// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing
// (pairing_http.go).
type API struct {
Store Store
IngestToken string
Forwarder *Forwarder
Pairing *Pairing
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
// derived from the request when empty.
PublicURL string
}
const maxBodyBytes = 512 * 1024
func (a *API) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("POST /v1/telemetry", a.ingest(a.telemetry))
mux.HandleFunc("PUT /v1/consumption/daily", a.ingest(a.consumption))
mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession))
mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history))
a.pairingRoutes(mux)
return mux
}
// hasIngestToken accepts the shared token (compared in constant time; main refuses to start
// with one under 24 characters, so an empty or short one never matches) or a paired car's
// own, unrevoked token.
func (a *API) hasIngestToken(r *http.Request) bool {
header := r.Header.Get("Authorization")
supplied, ok := strings.CutPrefix(header, "Bearer ")
if !ok || supplied == "" {
return false
}
if len(a.IngestToken) >= minIngestTokenLength &&
subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 {
return true
}
return a.Pairing.Authorized(supplied)
}
type handler func(w http.ResponseWriter, r *http.Request, body []byte) error
// ingest wraps a handler with the token check, the body limit and error mapping: a
// validation failure is a 400 naming the field; anything else is a 500 whose detail
// stays in the server log.
func (a *API) ingest(h handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !a.hasIngestToken(r) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body could not be read"})
return
}
if len(body) > maxBodyBytes {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body is too large"})
return
}
if err := h(w, r, body); err != nil {
var verr *ValidationError
if errors.As(err, &verr) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": verr.Error()})
return
}
slog.Error("request failed", "method", r.Method, "path", r.URL.Path, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
}
}
// telemetry stores the batch first; only once the write succeeded is the newest sample
// handed to ABRP, so the car's retry of a failed request cannot leave ABRP with data the
// database never got.
func (a *API) telemetry(w http.ResponseWriter, r *http.Request, body []byte) error {
batch, err := validateTelemetryBatch(body)
if err != nil {
return err
}
if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil {
return err
}
a.Forwarder.Forward(batch.Samples)
w.WriteHeader(http.StatusNoContent)
return nil
}
// consumption stores one delta chunk at its recordedAt. Reads add a day's chunks up, so a
// chunk the car re-sends from its offline cache replaces itself instead of counting twice.
func (a *API) consumption(w http.ResponseWriter, r *http.Request, body []byte) error {
chunk, err := validateConsumption(body)
if err != nil {
return err
}
if err := a.Store.WriteConsumptionChunk(r.Context(), chunk); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, body []byte) error {
session, err := validateChargingSession(body, r.PathValue("sessionId"))
if err != nil {
return err
}
if err := a.Store.WriteChargingSession(r.Context(), session); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
// history is the car's read-back of its own uploaded totals, so an app reinstall does not
// blank its Consumption tab. Up to 400 days, newest first, plus the lifetime sum of all.
func (a *API) history(w http.ResponseWriter, r *http.Request, _ []byte) error {
id, err := validUUID(r.PathValue("installationId"), "installationId")
if err != nil {
return err
}
chunks, err := a.Store.ReadConsumptionChunks(r.Context(), id)
if err != nil {
return err
}
days := aggregateDays(chunks)
var lifetime Totals
if len(days) > 0 {
lifetime = days[0].Lifetime
}
if len(days) > 400 {
days = days[:400]
}
writeJSON(w, http.StatusOK, struct {
Records []DailyConsumption `json:"records"`
Lifetime Totals `json:"lifetime"`
}{Records: days, Lifetime: lifetime})
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
slog.Warn("response not written", "error", err)
}
}