Files
mg4/server/validate.go
T
nilsandClaude Opus 5.5 9f184362f0 Server 2.4.0, app 2.11.1: ABRP forwarding removed from the server
The car sends live data to ABRP itself, and the car and the server go
over the same mobile connection: when the car cannot reach ABRP, it
cannot reach the server either, so the server's fallback bought
nothing. The forwarder, its ABRP_* settings and abrpSentUtc are gone;
the server only stores. The app stops sending abrpSentUtc (the server
ignores unknown fields, so 2.11.0 keeps working) and its texts no longer
promise a server fallback.

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

348 lines
11 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"math"
"regexp"
"sort"
"time"
)
// Request validation for the ingest endpoints, ported rule for rule from the TypeScript
// server. The car is the only writer and it is trusted, but a bad field is written once
// and read forever: every number is bounded, and an out-of-range value is rejected rather
// than stored and quietly plotted.
// ValidationError is a 400: the message may name the bad field.
type ValidationError struct{ msg string }
func (e *ValidationError) Error() string { return e.msg }
func invalid(format string, args ...any) error {
return &ValidationError{msg: fmt.Sprintf(format, args...)}
}
var (
uuidPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
isoDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
)
const (
maxSamples = 500
maxCurvePoints = 720
maxSafeInteger = 1<<53 - 1
utcMin = 1_000_000_000 // epoch seconds: 2001…
utcMax = 9_999_999_999 // …2286; milliseconds would be far above
totalsMaxAbs = 10_000_000
chargingLossPct = 10
)
// object is a decoded JSON object whose numbers stay json.Number, so integers and
// floats can be told apart and nothing is rounded on the way in.
type object map[string]any
func decodeObject(body []byte) (object, error) {
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
var value any
if err := dec.Decode(&value); err != nil {
return nil, invalid("body is not valid JSON")
}
if dec.More() {
return nil, invalid("body is not valid JSON")
}
return asObject(value, "body")
}
func asObject(value any, name string) (object, error) {
o, ok := value.(map[string]any)
if !ok {
if name == "body" {
return nil, invalid("body is required")
}
return nil, invalid("%s is invalid", name)
}
return object(o), nil
}
func (o object) has(key string) bool {
v, ok := o[key]
return ok && v != nil
}
func number(value any, name string) (float64, error) {
n, ok := value.(json.Number)
if !ok {
return 0, invalid("%s is invalid", name)
}
f, err := n.Float64()
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return 0, invalid("%s is invalid", name)
}
return f, nil
}
func bounded(value any, min, max float64, name string) (float64, error) {
f, err := number(value, name)
if err != nil || f < min || f > max {
return 0, invalid("%s is invalid", name)
}
return f, nil
}
// integer accepts a whole, non-negative number up to 2^53-1 — what JavaScript and the
// car's JSON can carry exactly.
func integer(value any, name string) (int64, error) {
f, err := number(value, name)
if err != nil || f != math.Trunc(f) || f < 0 || f > maxSafeInteger {
return 0, invalid("%s is invalid", name)
}
return int64(f), nil
}
func validDate(value any, name string) (time.Time, error) {
ms, err := integer(value, name)
if err != nil {
return time.Time{}, err
}
return time.UnixMilli(ms).UTC(), nil
}
func validUUID(value any, name string) (string, error) {
s, ok := value.(string)
if !ok || !uuidPattern.MatchString(s) {
return "", invalid("%s is invalid", name)
}
return s, nil
}
func optionalBounded(o object, key string, min, max float64, name string) (*float64, error) {
if !o.has(key) {
return nil, nil
}
f, err := bounded(o[key], min, max, name)
if err != nil {
return nil, err
}
return &f, nil
}
func validTotals(value any, name string) (Totals, error) {
o, err := asObject(value, name)
if err != nil {
return Totals{}, err
}
var t Totals
for _, field := range []struct {
key string
dst *float64
}{{"km", &t.Km}, {"kwh", &t.Kwh}, {"hours", &t.Hours}, {"socDrop", &t.SocDrop}} {
f, err := number(o[field.key], name+"."+field.key)
if err != nil || math.Abs(f) > totalsMaxAbs {
return Totals{}, invalid("%s.%s is invalid", name, field.key)
}
*field.dst = f
}
return t, nil
}
func validateConsumption(body []byte) (ConsumptionChunk, error) {
o, err := decodeObject(body)
if err != nil {
return ConsumptionChunk{}, err
}
date, ok := o["date"].(string)
if !ok || !isoDatePattern.MatchString(date) {
return ConsumptionChunk{}, invalid("date is invalid")
}
id, err := validUUID(o["installationId"], "installationId")
if err != nil {
return ConsumptionChunk{}, err
}
recordedAt, err := validDate(o["recordedAt"], "recordedAt")
if err != nil {
return ConsumptionChunk{}, err
}
day, err := validTotals(o["day"], "day")
if err != nil {
return ConsumptionChunk{}, err
}
// A lifetime from an older app is still validated, so a malformed one is still a 400,
// but it is not stored: lifetime is derived from the day totals when read.
if o.has("lifetime") {
if _, err := validTotals(o["lifetime"], "lifetime"); err != nil {
return ConsumptionChunk{}, err
}
}
soh, err := optionalBounded(o, "sohPercent", 0, 100, "sohPercent")
if err != nil {
return ConsumptionChunk{}, err
}
return ConsumptionChunk{InstallationID: id, Date: date, RecordedAt: recordedAt, Day: day, SohPercent: soh}, nil
}
func validateChargingSession(body []byte, pathSessionID string) (ChargingSession, error) {
o, err := decodeObject(body)
if err != nil {
return ChargingSession{}, err
}
sessionID, err := validUUID(o["sessionId"], "sessionId")
if err != nil {
return ChargingSession{}, err
}
if sessionID != pathSessionID {
return ChargingSession{}, invalid("sessionId does not match path")
}
rawCurve, ok := o["curve"].([]any)
if !ok || len(rawCurve) > maxCurvePoints {
return ChargingSession{}, invalid("curve is invalid")
}
curve := make([]CurvePoint, 0, len(rawCurve))
for _, entry := range rawCurve {
p, err := asObject(entry, "curve")
if err != nil {
return ChargingSession{}, err
}
ts, err := validDate(p["timestampMs"], "curve.timestampMs")
if err != nil {
return ChargingSession{}, err
}
power, err := bounded(p["powerKw"], 0, 1000, "curve.powerKw")
if err != nil {
return ChargingSession{}, err
}
soc, err := optionalBounded(p, "socPercent", 0, 100, "curve.socPercent")
if err != nil {
return ChargingSession{}, err
}
curve = append(curve, CurvePoint{Timestamp: ts, PowerKw: power, SocPercent: soc})
}
s := ChargingSession{SessionID: sessionID, Curve: curve, ChargingLossPercent: chargingLossPct}
if s.EnergyKwh, err = bounded(o["energyKwh"], 0, 1000, "energyKwh"); err != nil {
return ChargingSession{}, err
}
if s.PricePerKwh, err = bounded(o["pricePerKwh"], 0, 1_000_000, "pricePerKwh"); err != nil {
return ChargingSession{}, err
}
if s.InstallationID, err = validUUID(o["installationId"], "installationId"); err != nil {
return ChargingSession{}, err
}
if s.StartedAt, err = validDate(o["startedAt"], "startedAt"); err != nil {
return ChargingSession{}, err
}
if s.EndedAt, err = validDate(o["endedAt"], "endedAt"); err != nil {
return ChargingSession{}, err
}
if s.DurationSeconds, err = bounded(o["durationSeconds"], 0, 604800, "durationSeconds"); err != nil {
return ChargingSession{}, err
}
if s.StartSocPercent, err = optionalBounded(o, "startSocPercent", 0, 100, "startSocPercent"); err != nil {
return ChargingSession{}, err
}
if s.EndSocPercent, err = optionalBounded(o, "endSocPercent", 0, 100, "endSocPercent"); err != nil {
return ChargingSession{}, err
}
// The car's own loss and grid figures are not trusted: the server applies its fixed 10 %.
s.GridEnergyKwh = s.EnergyKwh * 1.1
s.TotalCost = s.GridEnergyKwh * s.PricePerKwh
s.IsDemo = o["isDemo"] == true
return s, nil
}
// validateTelemetryBatch checks a batch of live samples in ABRP's tlm field names and
// units. Unknown fields are dropped rather than rejected, so a newer app can add one
// without a server release; a flag may be 0/1 or a boolean.
//
// Only a malformed request is a 400. A bad value inside a sample costs that value, and a
// sample without a usable time costs that sample: the car drops a batch the server
// rejects, and one sensor reading out of range must not take hours of queued driving with
// it. What was dropped is listed in Dropped, for the response and the log.
func validateTelemetryBatch(body []byte, now time.Time) (TelemetryBatch, error) {
o, err := decodeObject(body)
if err != nil {
return TelemetryBatch{}, err
}
id, err := validUUID(o["installationId"], "installationId")
if err != nil {
return TelemetryBatch{}, err
}
raw, ok := o["samples"].([]any)
if !ok || len(raw) == 0 || len(raw) > maxSamples {
return TelemetryBatch{}, invalid("samples is invalid")
}
batch := TelemetryBatch{InstallationID: id, Samples: make([]TelemetrySample, 0, len(raw))}
latest := now.Add(maxClockAhead).Unix()
for i, entry := range raw {
name := fmt.Sprintf("samples[%d]", i)
sample, dropped, ok := validateSample(entry, name, latest)
batch.Dropped = append(batch.Dropped, dropped...)
if ok {
batch.Samples = append(batch.Samples, sample)
}
}
return batch, nil
}
// maxClockAhead is how far in the future a sample may be stamped. The head unit sets its
// clock from GPS; a sample from further ahead has a wrong time, and stored there it would
// pose as the car's latest state until that time came.
const maxClockAhead = time.Hour
// validateSample keeps every valid field of one sample. It returns false when the sample
// as a whole is unusable: not an object, or no plausible time. dropped names what was left
// out.
func validateSample(entry any, name string, latestUTC int64) (sample TelemetrySample, dropped []string, ok bool) {
o, err := asObject(entry, name)
if err != nil {
return TelemetrySample{}, []string{name}, false
}
utc, err := integer(o["utc"], name+".utc")
if err != nil || utc < utcMin || utc > utcMax || utc > latestUTC {
return TelemetrySample{}, []string{name + ".utc"}, false
}
sample = TelemetrySample{UTC: utc, Numbers: map[string]float64{}, Flags: map[string]bool{}}
for field, limits := range telemetryNumbers {
if !o.has(field) {
continue
}
f, err := bounded(o[field], limits[0], limits[1], name+"."+field)
if err != nil {
dropped = append(dropped, name+"."+field)
continue
}
sample.Numbers[field] = f
}
for _, field := range telemetryFlags {
if !o.has(field) {
continue
}
switch v := o[field].(type) {
case bool:
sample.Flags[field] = v
case json.Number:
switch v.String() {
case "1":
sample.Flags[field] = true
case "0":
sample.Flags[field] = false
default:
dropped = append(dropped, name+"."+field)
}
default:
dropped = append(dropped, name+"."+field)
}
}
// A position is only a position as a pair.
_, hasLat := sample.Numbers["lat"]
_, hasLon := sample.Numbers["lon"]
if hasLat != hasLon {
delete(sample.Numbers, "lat")
delete(sample.Numbers, "lon")
dropped = append(dropped, name+".lat/lon")
}
sort.Strings(dropped)
return sample, dropped, true
}