Code review fixes: app 2.9.0, server 2.2.0
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
This commit is contained in:
+74
-20
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// API serves the car. Every endpoint but /v1/health needs the ingest token.
|
||||
@@ -45,30 +46,49 @@ func (a *API) Routes() http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
// hasIngestToken accepts the shared token (compared in constant time; main refuses to start
|
||||
// caller is who sent an ingest request. A paired car's token is bound to the installation
|
||||
// id it was paired with; the shared INGEST_TOKEN may write and read for any.
|
||||
type caller struct {
|
||||
installationID string // empty for the shared token
|
||||
}
|
||||
|
||||
// mayUse reports whether the caller may write or read data stored under id.
|
||||
func (c caller) mayUse(id string) bool {
|
||||
return c.installationID == "" || strings.EqualFold(c.installationID, id)
|
||||
}
|
||||
|
||||
// errOtherInstallation is a 403: a paired car's token used for another car's data.
|
||||
var errOtherInstallation = errors.New("this token belongs to another installation; pair again after changing the installation id")
|
||||
|
||||
// authenticate 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 {
|
||||
func (a *API) authenticate(r *http.Request) (caller, bool) {
|
||||
header := r.Header.Get("Authorization")
|
||||
supplied, ok := strings.CutPrefix(header, "Bearer ")
|
||||
if !ok || supplied == "" {
|
||||
return false
|
||||
return caller{}, false
|
||||
}
|
||||
if len(a.IngestToken) >= minIngestTokenLength &&
|
||||
subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 {
|
||||
return true
|
||||
return caller{}, true
|
||||
}
|
||||
return a.Pairing.Authorized(supplied)
|
||||
d, ok := a.Pairing.Authorized(supplied)
|
||||
if !ok {
|
||||
return caller{}, false
|
||||
}
|
||||
return caller{installationID: d.InstallationID}, true
|
||||
}
|
||||
|
||||
type handler func(w http.ResponseWriter, r *http.Request, body []byte) error
|
||||
type handler func(w http.ResponseWriter, r *http.Request, c caller, 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.
|
||||
// validation failure is a 400 naming the field, another car's data a 403; 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) {
|
||||
c, ok := a.authenticate(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
@@ -81,7 +101,11 @@ func (a *API) ingest(h handler) http.HandlerFunc {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body is too large"})
|
||||
return
|
||||
}
|
||||
if err := h(w, r, body); err != nil {
|
||||
if err := h(w, r, c, body); err != nil {
|
||||
if errors.Is(err, errOtherInstallation) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var verr *ValidationError
|
||||
if errors.As(err, &verr) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": verr.Error()})
|
||||
@@ -93,29 +117,53 @@ func (a *API) ingest(h handler) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// maxDroppedListed caps the dropped values named in a telemetry response and log line.
|
||||
const maxDroppedListed = 20
|
||||
|
||||
// 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)
|
||||
// database never got. The answer says how many samples were stored and what was dropped;
|
||||
// a batch with nothing usable is still a 200, since sending it again would not help.
|
||||
func (a *API) telemetry(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
|
||||
batch, err := validateTelemetryBatch(body, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil {
|
||||
return err
|
||||
if !c.mayUse(batch.InstallationID) {
|
||||
return errOtherInstallation
|
||||
}
|
||||
a.Forwarder.Forward(batch.Samples)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
if len(batch.Samples) > 0 {
|
||||
if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil {
|
||||
return err
|
||||
}
|
||||
a.Forwarder.Forward(batch.Samples)
|
||||
}
|
||||
dropped := batch.Dropped
|
||||
if len(dropped) > maxDroppedListed {
|
||||
dropped = dropped[:maxDroppedListed]
|
||||
}
|
||||
if len(batch.Dropped) > 0 {
|
||||
slog.Warn("telemetry values dropped", "installation", batch.InstallationID,
|
||||
"count", len(batch.Dropped), "first", dropped)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"stored": len(batch.Samples),
|
||||
"droppedCount": len(batch.Dropped),
|
||||
"dropped": append([]string{}, dropped...),
|
||||
})
|
||||
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 {
|
||||
func (a *API) consumption(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
|
||||
chunk, err := validateConsumption(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.mayUse(chunk.InstallationID) {
|
||||
return errOtherInstallation
|
||||
}
|
||||
if err := a.Store.WriteConsumptionChunk(r.Context(), chunk); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -123,11 +171,14 @@ func (a *API) consumption(w http.ResponseWriter, r *http.Request, body []byte) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, body []byte) error {
|
||||
func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
|
||||
session, err := validateChargingSession(body, r.PathValue("sessionId"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.mayUse(session.InstallationID) {
|
||||
return errOtherInstallation
|
||||
}
|
||||
if err := a.Store.WriteChargingSession(r.Context(), session); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -137,11 +188,14 @@ func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, body []byt
|
||||
|
||||
// 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 {
|
||||
func (a *API) history(w http.ResponseWriter, r *http.Request, c caller, _ []byte) error {
|
||||
id, err := validUUID(r.PathValue("installationId"), "installationId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.mayUse(id) {
|
||||
return errOtherInstallation
|
||||
}
|
||||
chunks, err := a.Store.ReadConsumptionChunks(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user