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
371 lines
11 KiB
Go
371 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Pairing lets a car get its own ingest token without anyone typing it: the car asks for a
|
|
// pairing request and shows its code as a QR code, a phone opens the approval page, the
|
|
// owner signs in with the approval password and approves, and the car — polling meanwhile —
|
|
// receives a token once. Modelled on the OAuth device authorization grant (RFC 8628).
|
|
//
|
|
// Pending requests live in memory only: they last ten minutes, and the server runs as one
|
|
// replica. Approved devices are persisted (see Device) so their tokens survive a restart.
|
|
|
|
const (
|
|
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"
|
|
)
|
|
|
|
var (
|
|
errUnknownCode = errors.New("unknown or expired code")
|
|
errNotPending = errors.New("request already decided")
|
|
errTooManyFailed = errors.New("too many wrong passwords, try again in a minute")
|
|
errWrongPassword = errors.New("wrong password")
|
|
errTooMany = errors.New("too many pending pairing requests")
|
|
)
|
|
|
|
// Device is a paired car. Only the SHA-256 of its token is kept.
|
|
type Device struct {
|
|
ID string
|
|
InstallationID string
|
|
TokenHash string
|
|
CreatedAt time.Time
|
|
Revoked bool
|
|
}
|
|
|
|
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
|
|
)
|
|
|
|
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
|
|
deviceID string
|
|
}
|
|
|
|
// Pairing holds pending requests and the known devices. The clock is injectable for tests.
|
|
type Pairing struct {
|
|
ApprovalPassword string
|
|
Now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
byDevice map[string]*pairingRequest
|
|
byUser map[string]*pairingRequest
|
|
devices map[string]Device // by token hash
|
|
// 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 {
|
|
p := &Pairing{
|
|
ApprovalPassword: password,
|
|
Now: time.Now,
|
|
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
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (p *Pairing) Enabled() bool { return p != nil && p.ApprovalPassword != "" }
|
|
|
|
func randomString(n int) string {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err) // crypto/rand does not fail on supported platforms
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func newUserCode() string {
|
|
b := make([]byte, 6)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
code := make([]byte, 0, 7)
|
|
for i, v := range b {
|
|
if i == 3 {
|
|
code = append(code, '-')
|
|
}
|
|
// 256 % 31 leaves a slight bias; irrelevant for a 10-minute code that also needs
|
|
// the approval password.
|
|
code = append(code, userCodeAlphabet[int(v)%len(userCodeAlphabet)])
|
|
}
|
|
return string(code)
|
|
}
|
|
|
|
// normalizeUserCode accepts what a person types: any case, with or without the dash.
|
|
func normalizeUserCode(code string) string {
|
|
code = strings.ToUpper(strings.TrimSpace(code))
|
|
code = strings.ReplaceAll(code, "-", "")
|
|
code = strings.ReplaceAll(code, " ", "")
|
|
if len(code) != 6 {
|
|
return ""
|
|
}
|
|
return code[:3] + "-" + code[3:]
|
|
}
|
|
|
|
func hashToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (p *Pairing) expireLocked(now time.Time) {
|
|
for code, r := range p.byDevice {
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
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 {
|
|
r.userCode = newUserCode()
|
|
if _, taken := p.byUser[r.userCode]; !taken {
|
|
break
|
|
}
|
|
}
|
|
p.byDevice[r.deviceCode] = r
|
|
p.byUser[r.userCode] = r
|
|
out := *r
|
|
return &out, nil
|
|
}
|
|
|
|
// Lookup returns the pending request for a user code, for the approval page.
|
|
func (p *Pairing) Lookup(userCode string) (pairingRequest, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.expireLocked(p.Now())
|
|
r, ok := p.byUser[normalizeUserCode(userCode)]
|
|
if !ok {
|
|
return pairingRequest{}, errUnknownCode
|
|
}
|
|
return *r, nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
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[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 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, client, now); err != nil {
|
|
return Device{}, err
|
|
}
|
|
p.expireLocked(now)
|
|
r, ok := p.byUser[normalizeUserCode(userCode)]
|
|
if !ok {
|
|
return Device{}, errUnknownCode
|
|
}
|
|
if r.state != pairingPending {
|
|
return Device{}, errNotPending
|
|
}
|
|
if !approve {
|
|
r.state = pairingDenied
|
|
return Device{}, nil
|
|
}
|
|
token := randomString(32) // 43 characters
|
|
d := Device{
|
|
ID: randomString(9),
|
|
InstallationID: r.installationID,
|
|
TokenHash: hashToken(token),
|
|
CreatedAt: now,
|
|
}
|
|
r.state = pairingSaving
|
|
r.token = token
|
|
r.deviceID = d.ID
|
|
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. The request is pending again.
|
|
func (p *Pairing) Forget(userCode string, d Device) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if r, ok := p.byUser[normalizeUserCode(userCode)]; ok && r.deviceID == d.ID {
|
|
r.state = pairingPending
|
|
r.token = ""
|
|
r.deviceID = ""
|
|
}
|
|
}
|
|
|
|
// Poll is what the car calls: pending, the token (exactly once), denied, or unknown.
|
|
func (p *Pairing) Poll(deviceCode string) (state pairingState, token, deviceID string, err error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.expireLocked(p.Now())
|
|
r, ok := p.byDevice[deviceCode]
|
|
if !ok {
|
|
return 0, "", "", errUnknownCode
|
|
}
|
|
switch r.state {
|
|
case pairingSaving:
|
|
return pairingPending, "", "", nil
|
|
case pairingApproved, pairingDenied:
|
|
// Decided: hand the outcome out once, then forget the request.
|
|
p.dropLocked(r)
|
|
}
|
|
return r.state, r.token, r.deviceID, nil
|
|
}
|
|
|
|
// Authorized returns the paired, unrevoked device a token belongs to.
|
|
func (p *Pairing) Authorized(token string) (Device, bool) {
|
|
if p == nil || token == "" {
|
|
return Device{}, false
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
d, ok := p.devices[hashToken(token)]
|
|
if !ok || d.Revoked {
|
|
return Device{}, false
|
|
}
|
|
return d, true
|
|
}
|
|
|
|
func (p *Pairing) Devices() []Device {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
out := make([]Device, 0, len(p.devices))
|
|
for _, d := range p.devices {
|
|
out = append(out, d)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Revoke marks a device revoked and returns it for persisting.
|
|
func (p *Pairing) Revoke(id string) (Device, bool) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
for hash, d := range p.devices {
|
|
if d.ID == id {
|
|
d.Revoked = true
|
|
p.devices[hash] = d
|
|
return d, true
|
|
}
|
|
}
|
|
return Device{}, false
|
|
}
|
|
|
|
// CheckAdmin verifies the approval password for the device-management API, with the same
|
|
// failure counting as the approval page.
|
|
func (p *Pairing) CheckAdmin(password, client string) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.checkPasswordLocked(password, client, p.Now())
|
|
}
|