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:
2026-10-18 00:36:19 +02:00
co-authored by Claude Opus 5.5
parent 9bebf56b3a
commit a5db5604d3
35 changed files with 962 additions and 323 deletions
+93 -34
View File
@@ -21,12 +21,19 @@ import (
// replica. Approved devices are persisted (see Device) so their tokens survive a restart.
const (
pairingTTL = 10 * time.Minute
pairingInterval = 5 * time.Second
maxPendingPairing = 50
// After this many wrong approval passwords in a window, approvals pause for the window.
maxFailedLogins = 5
failedLoginsWindow = time.Minute
pairingTTL = 10 * time.Minute
pairingInterval = 5 * time.Second
// Pending requests per client address, and in all. A car needs one; a client that opens
// more only crowds out its own, and the total bounds memory.
maxPendingPerClient = 3
maxPendingPairing = 1000
// After this many wrong approval passwords from one client address in a window, that
// address waits for the window. Other addresses are not affected, so a stranger guessing
// cannot lock the owner out. The global cap only bounds guessing spread over many
// addresses; it is far above anything a person types.
maxFailedLogins = 5
maxFailedLoginsGlobal = 100
failedLoginsWindow = time.Minute
// Unambiguous characters only: no 0/O, 1/I/L, so a code reads right off a car screen.
userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
)
@@ -52,6 +59,9 @@ type pairingState int
const (
pairingPending pairingState = iota
// pairingSaving: approved, and the device is being persisted. The car still sees
// pending, so it cannot collect a token whose device might not be saved.
pairingSaving
pairingApproved
pairingDenied
)
@@ -60,6 +70,7 @@ type pairingRequest struct {
deviceCode string
userCode string
installationID string
client string // address that opened the request
expires time.Time
state pairingState
token string // set on approval, handed out once
@@ -75,7 +86,9 @@ type Pairing struct {
byDevice map[string]*pairingRequest
byUser map[string]*pairingRequest
devices map[string]Device // by token hash
failures []time.Time
// Wrong passwords: per client address, and all of them together.
failures map[string][]time.Time
globalFailures []time.Time
}
func NewPairing(password string, devices []Device) *Pairing {
@@ -85,6 +98,7 @@ func NewPairing(password string, devices []Device) *Pairing {
byDevice: map[string]*pairingRequest{},
byUser: map[string]*pairingRequest{},
devices: map[string]Device{},
failures: map[string][]time.Time{},
}
for _, d := range devices {
p.devices[d.TokenHash] = d
@@ -137,25 +151,43 @@ func hashToken(token string) string {
func (p *Pairing) expireLocked(now time.Time) {
for code, r := range p.byDevice {
if now.After(r.expires) {
// A request being saved is finished by Commit or Forget, not expired under them.
if now.After(r.expires) && r.state != pairingSaving {
delete(p.byDevice, code)
delete(p.byUser, r.userCode)
}
}
}
// Start opens a pairing request for a car.
func (p *Pairing) Start(installationID string) (*pairingRequest, error) {
func (p *Pairing) dropLocked(r *pairingRequest) {
delete(p.byDevice, r.deviceCode)
delete(p.byUser, r.userCode)
}
// Start opens a pairing request for a car. A new request replaces the same installation's
// undecided one; a client already holding maxPendingPerClient requests gets errTooMany.
func (p *Pairing) Start(installationID, client string) (*pairingRequest, error) {
p.mu.Lock()
defer p.mu.Unlock()
now := p.Now()
p.expireLocked(now)
if len(p.byDevice) >= maxPendingPairing {
perClient := 0
for _, r := range p.byDevice {
if r.state == pairingPending && strings.EqualFold(r.installationID, installationID) {
p.dropLocked(r)
continue
}
if r.client == client {
perClient++
}
}
if perClient >= maxPendingPerClient || len(p.byDevice) >= maxPendingPairing {
return nil, errTooMany
}
r := &pairingRequest{
deviceCode: randomString(32),
installationID: installationID,
client: client,
expires: now.Add(pairingTTL),
}
for {
@@ -182,32 +214,46 @@ func (p *Pairing) Lookup(userCode string) (pairingRequest, error) {
return *r, nil
}
// checkPassword counts failures and refuses everything while too many are recent.
func (p *Pairing) checkPasswordLocked(password string, now time.Time) error {
recent := p.failures[:0]
for _, t := range p.failures {
func recentOnly(times []time.Time, now time.Time) []time.Time {
recent := times[:0]
for _, t := range times {
if now.Sub(t) < failedLoginsWindow {
recent = append(recent, t)
}
}
p.failures = recent
if len(p.failures) >= maxFailedLogins {
return recent
}
// checkPasswordLocked counts failures per client address and refuses a client while it
// has too many recent ones (or while too many arrive from everywhere at once).
func (p *Pairing) checkPasswordLocked(password, client string, now time.Time) error {
for c, times := range p.failures {
if recent := recentOnly(times, now); len(recent) == 0 {
delete(p.failures, c)
} else {
p.failures[c] = recent
}
}
p.globalFailures = recentOnly(p.globalFailures, now)
if len(p.failures[client]) >= maxFailedLogins || len(p.globalFailures) >= maxFailedLoginsGlobal {
return errTooManyFailed
}
if subtle.ConstantTimeCompare([]byte(password), []byte(p.ApprovalPassword)) != 1 {
p.failures = append(p.failures, now)
p.failures[client] = append(p.failures[client], now)
p.globalFailures = append(p.globalFailures, now)
return errWrongPassword
}
return nil
}
// Decide approves or denies a pending request. On approval it returns the new device, which
// the caller must persist before the car can collect the token.
func (p *Pairing) Decide(userCode, password string, approve bool) (Device, error) {
// the caller must persist and then Commit (or Forget, if saving failed): until Commit the
// car keeps seeing pending and the token is not accepted anywhere.
func (p *Pairing) Decide(userCode, password, client string, approve bool) (Device, error) {
p.mu.Lock()
defer p.mu.Unlock()
now := p.Now()
if err := p.checkPasswordLocked(password, now); err != nil {
if err := p.checkPasswordLocked(password, client, now); err != nil {
return Device{}, err
}
p.expireLocked(now)
@@ -229,20 +275,29 @@ func (p *Pairing) Decide(userCode, password string, approve bool) (Device, error
TokenHash: hashToken(token),
CreatedAt: now,
}
r.state = pairingApproved
r.state = pairingSaving
r.token = token
r.deviceID = d.ID
p.devices[d.TokenHash] = d
return d, nil
}
// Commit finishes an approval once its device is persisted: the token now works, and the
// car collects it at its next poll.
func (p *Pairing) Commit(userCode string, d Device) {
p.mu.Lock()
defer p.mu.Unlock()
p.devices[d.TokenHash] = d
if r, ok := p.byUser[normalizeUserCode(userCode)]; ok && r.deviceID == d.ID {
r.state = pairingApproved
}
}
// Forget undoes an approval whose device could not be persisted, so no token is handed out
// that would stop working at the next restart.
// that would stop working at the next restart. The request is pending again.
func (p *Pairing) Forget(userCode string, d Device) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.devices, d.TokenHash)
if r, ok := p.byUser[normalizeUserCode(userCode)]; ok {
if r, ok := p.byUser[normalizeUserCode(userCode)]; ok && r.deviceID == d.ID {
r.state = pairingPending
r.token = ""
r.deviceID = ""
@@ -259,23 +314,27 @@ func (p *Pairing) Poll(deviceCode string) (state pairingState, token, deviceID s
return 0, "", "", errUnknownCode
}
switch r.state {
case pairingSaving:
return pairingPending, "", "", nil
case pairingApproved, pairingDenied:
// Decided: hand the outcome out once, then forget the request.
delete(p.byDevice, r.deviceCode)
delete(p.byUser, r.userCode)
p.dropLocked(r)
}
return r.state, r.token, r.deviceID, nil
}
// Authorized reports whether a token belongs to a paired, unrevoked device.
func (p *Pairing) Authorized(token string) bool {
// Authorized returns the paired, unrevoked device a token belongs to.
func (p *Pairing) Authorized(token string) (Device, bool) {
if p == nil || token == "" {
return false
return Device{}, false
}
p.mu.Lock()
defer p.mu.Unlock()
d, ok := p.devices[hashToken(token)]
return ok && !d.Revoked
if !ok || d.Revoked {
return Device{}, false
}
return d, true
}
func (p *Pairing) Devices() []Device {
@@ -304,8 +363,8 @@ func (p *Pairing) Revoke(id string) (Device, bool) {
// CheckAdmin verifies the approval password for the device-management API, with the same
// failure counting as the approval page.
func (p *Pairing) CheckAdmin(password string) error {
func (p *Pairing) CheckAdmin(password, client string) error {
p.mu.Lock()
defer p.mu.Unlock()
return p.checkPasswordLocked(password, p.Now())
return p.checkPasswordLocked(password, client, p.Now())
}