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:
co-authored by
Claude Opus 4.8
parent
a1519f6e89
commit
19a7d48feb
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user