Harden Anker Solix OCPP control (token hashing, step-up, audit, TLS)

Security pass over the OCPP charger-control feature added in a1519f6, since
remotely actuating a physical charger is a real side effect.

Token hygiene:
  - Per-charger control tokens are stored as SHA-256 hashes + a last-4 hint,
    never plaintext. The token is shown once at generation; the status endpoint
    returns only the hint. Added a revoke endpoint that also drops any live
    session using the revoked token.

Step-up + confirmation:
  - Destructive actions (reset, unlock) require confirm:true AND a password
    re-authentication (verified against PocketBase). The Charging UI collects the
    password inline for reset.
  - Per user+charger rate limit (30/min) on control commands.

Transport + provenance:
  - OCPP_REQUIRE_TLS (default on) rejects plaintext ws:// charger connections;
    OCPP_PUBLIC_URL pins the advertised endpoint instead of trusting request
    headers.
  - Proxy-mode upstream URL is validated against a *.anker.com allowlist, so a
    spoofed ocpp-info response can't redirect the proxy.

Durable audit:
  - New control_audit PocketBase collection (added to setup-pocketbase.mjs);
    every control action, token generate/revoke and charger connect is persisted
    best-effort in addition to a structured log line.

Startup:
  - The control-token index is warmed from PocketBase on startup so a charger
    reconnecting after a restart resolves immediately.

Tests:
  - Unit tests for token hashing/eviction/revoke (no plaintext at rest),
    rate limiter, destructive-action classifier, upstream allowlist, TLS
    enforcement, and re-auth guards. A full-stack E2E (control_e2e_test.go)
    drives the real Handler with a stand-in PocketBase and a simulated charge
    point, proving step-up (400/401/200) and audit persistence end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-18 21:03:05 +02:00
co-authored by Claude Opus 4.8
parent a1519f6e89
commit 19a7d48feb
13 changed files with 883 additions and 44 deletions
+19 -2
View File
@@ -131,6 +131,7 @@ const (
colMaintenance = "maintenance_entries"
colDocuments = "car_documents"
colReminders = "reminders"
colControlAudit = "control_audit"
)
// Server wires together the HTTP handlers and their dependencies.
@@ -146,6 +147,7 @@ type Server struct {
// clear error when a charger is not connected.
ocpp *ocpp.CSMS
control *controlIndex // token -> owning user/charger for the /ocpp endpoint
ctlRL *rateLimiter // per user+charger control-command rate limit
}
// New constructs a Server around an already-built PocketBase client.
@@ -156,11 +158,25 @@ func New(cfg config.Config, client *pb.Client) *Server {
plugins: plugins.NewManager(cfg.PluginsFile),
ocpp: ocpp.NewCSMS(func(f string, a ...any) { log.Printf("ocpp: "+f, a...) }),
control: newControlIndex(),
ctlRL: newRateLimiter(30, time.Minute), // 30 control commands / min / charger
}
}
// StartPlugins loads persisted plugin state and initialises enabled plugins.
func (s *Server) StartPlugins() error { return s.plugins.Load() }
// StartPlugins loads persisted plugin state and initialises enabled plugins. It
// also warms the OCPP control-token index from PocketBase (its source of truth),
// so the first charger to reconnect after a restart resolves immediately instead
// of triggering a lazy rebuild mid-handshake. The warm-up is best-effort and
// non-blocking; if PocketBase is not yet configured it no-ops and the lazy path
// rebuilds on first connect.
func (s *Server) StartPlugins() error {
err := s.plugins.Load()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
s.ensureControlIndex(ctx)
}()
return err
}
// Stop releases server-held resources (plugin instances and OCPP sessions).
func (s *Server) Stop(ctx context.Context) {
@@ -306,6 +322,7 @@ func (s *Server) Handler() http.Handler {
// integrations_ankersolix_control.go.
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/control", s.handleAnkerControlStatus)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlToken)
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlRevoke)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/{action}", s.handleAnkerControlAction)
// OCPP WebSocket endpoint the charger dials out to (own/proxy modes). It sits