Same endpoints, request formats, validation rules and InfluxDB layout as
the Next.js server, so the car needs no change: POST /v1/telemetry,
PUT /v1/consumption/daily, PUT /v1/charging-sessions/{id}, GET
/v1/consumption/history/{id}, GET /v1/health. The dashboard, login and
the /v1/history reads are gone; Grafana takes over the dashboards.
- Go 1.26 standard library (net/http routing, log/slog) plus the
official InfluxDB 2 client. Fails closed at startup on a missing
InfluxDB setting or an ingest token under 24 characters.
- ABRP forwarder ported: newest fresh sample, rate limited, never
queued, credentials only in the form body.
- 22 tests: validation, aggregation, forwarding, config, and the
handlers through a fake store. govulncheck clean (x/net 0.60.0,
toolchain go1.26.9).
- Image: static binary on distroless, about 11 MB, UID 65532, port
8080; the build runs vet and the tests.
- Dev shell: Go, gopls and govulncheck instead of Node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
328 lines
9.6 KiB
Go
328 lines
9.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"regexp"
|
|
"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; every known field is bounded; a flag may be 0/1 or a boolean.
|
|
func validateTelemetryBatch(body []byte) (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")
|
|
}
|
|
samples := make([]TelemetrySample, 0, len(raw))
|
|
for i, entry := range raw {
|
|
sample, err := validateSample(entry, fmt.Sprintf("samples[%d]", i))
|
|
if err != nil {
|
|
return TelemetryBatch{}, err
|
|
}
|
|
samples = append(samples, sample)
|
|
}
|
|
return TelemetryBatch{InstallationID: id, Samples: samples}, nil
|
|
}
|
|
|
|
func validateSample(entry any, name string) (TelemetrySample, error) {
|
|
o, err := asObject(entry, name)
|
|
if err != nil {
|
|
return TelemetrySample{}, err
|
|
}
|
|
utc, err := integer(o["utc"], name+".utc")
|
|
if err != nil || utc < utcMin || utc > utcMax {
|
|
return TelemetrySample{}, invalid("%s.utc is invalid", name)
|
|
}
|
|
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 {
|
|
return TelemetrySample{}, err
|
|
}
|
|
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:
|
|
return TelemetrySample{}, invalid("%s.%s is invalid", name, field)
|
|
}
|
|
default:
|
|
return TelemetrySample{}, invalid("%s.%s is invalid", name, field)
|
|
}
|
|
}
|
|
// A position is only a position as a pair.
|
|
_, hasLat := sample.Numbers["lat"]
|
|
_, hasLon := sample.Numbers["lon"]
|
|
if hasLat != hasLon {
|
|
return TelemetrySample{}, invalid("%s.lat and lon must be sent together", name)
|
|
}
|
|
return sample, nil
|
|
}
|