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:
+93
-34
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user