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:
+24
-3
@@ -5,6 +5,7 @@ import (
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -79,7 +80,7 @@ func (a *API) pairStart(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req, err := a.Pairing.Start(id)
|
||||
req, err := a.Pairing.Start(id, clientAddress(r))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -118,6 +119,25 @@ func (a *API) pairPoll(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// clientAddress is who is asking, for the per-client limits: the last X-Forwarded-For entry,
|
||||
// which is the one Traefik adds (anything left of it comes from the client and can be
|
||||
// made up), or the connection's own address without a proxy.
|
||||
func clientAddress(r *http.Request) string {
|
||||
if forwarded := r.Header.Values("X-Forwarded-For"); len(forwarded) > 0 {
|
||||
last := forwarded[len(forwarded)-1]
|
||||
if i := strings.LastIndex(last, ","); i >= 0 {
|
||||
last = last[i+1:]
|
||||
}
|
||||
if addr := strings.TrimSpace(last); addr != "" {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// ---------- Approval page ----------
|
||||
|
||||
type pageData struct {
|
||||
@@ -204,7 +224,7 @@ func (a *API) pairDecide(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
code := normalizeUserCode(r.PostForm.Get("code"))
|
||||
approve := r.PostForm.Get("action") != "deny"
|
||||
device, err := a.Pairing.Decide(code, r.PostForm.Get("password"), approve)
|
||||
device, err := a.Pairing.Decide(code, r.PostForm.Get("password"), clientAddress(r), approve)
|
||||
switch {
|
||||
case errors.Is(err, errWrongPassword):
|
||||
renderPage(w, http.StatusUnauthorized, pageData{Code: code, Error: "Wrong password."})
|
||||
@@ -231,6 +251,7 @@ func (a *API) pairDecide(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "The car could not be saved. Try again."})
|
||||
return
|
||||
}
|
||||
a.Pairing.Commit(code, device)
|
||||
slog.Info("car paired", "device", device.ID, "installation", device.InstallationID)
|
||||
renderPage(w, http.StatusOK, pageData{Done: true, Message: "Approved. The car finishes pairing on its own within a few seconds — you can close this page."})
|
||||
}
|
||||
@@ -246,7 +267,7 @@ func (a *API) adminAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return false
|
||||
}
|
||||
switch err := a.Pairing.CheckAdmin(supplied); {
|
||||
switch err := a.Pairing.CheckAdmin(supplied, clientAddress(r)); {
|
||||
case errors.Is(err, errTooManyFailed):
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user