Telemetry no longer loses queued batches. The server drops a bad value, or a sample without a plausible time, instead of rejecting the batch, and answers 200 with what it dropped. The app sends only values inside the server's ranges (speed as a magnitude, so reversing is fine) and no samples before the head unit's clock is set. Server: - Pairing hands out and accepts a token only after the device is saved; a failed save leaves the request pending. - A paired token serves only the installation id it was paired with (403 otherwise); the shared INGEST_TOKEN still serves any. - Wrong-password lockouts and open pairing requests are limited per client address (last X-Forwarded-For entry), so a stranger cannot lock the owner out; a car starting again replaces its own request. - ABRP forwarding skips samples stamped ahead of the server's clock, which used to stop it until a restart. App: - Settings written to plaintext during a keystore failure move back into encrypted storage; the stored installation id wins. - The installation id is derived from ANDROID_ID on first start, so a reinstall finds its history; a stored or imported id still wins. - The scanned config file is deleted after import (it holds the token). - GPS stops while live telemetry is off or the server unconfigured, and pauses while parked and idle, keeping the fix the car parked with. - Range estimates use the monotonic clock. - One HTTP helper (ServerHttp) for telemetry, sync and pairing. - Last Turkish strings translated; mise logs shows the NilsMG4 tags. Grafana reads InfluxDB with its own read-only token (new grafana-influx Secret, created by hand as described in k8s/README.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
348 lines
11 KiB
Go
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
|
|
}
|