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
+240
View File
@@ -0,0 +1,240 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"drivervault/apiserver/internal/config"
"drivervault/apiserver/internal/ocpp"
"drivervault/apiserver/internal/pb"
)
// This test exercises the two PocketBase-touching control paths end-to-end
// through the real Handler + middleware chain: step-up re-authentication for a
// destructive action (reset), and best-effort audit persistence to the
// control_audit collection. A stand-in PocketBase (fakePB) serves exactly the
// endpoints the flow hits; a simulated charge point connects to the live CSMS so
// the command has a real session to reach.
const (
e2eUserID = "u1"
e2eUserMail = "owner@test.local"
e2ePassword = "correct-horse-battery"
e2eSerial = "CP-TEST"
e2eToken = "charger-control-token-abc"
e2eBearer = "user-bearer-token"
)
// fakePB records audit creates and answers the identity, user-record,
// re-auth and service-auth calls the control flow makes.
type fakePB struct {
mu sync.Mutex
audits []map[string]any
userPluginSettings json.RawMessage
}
func (f *fakePB) handler(t *testing.T) http.Handler {
mux := http.NewServeMux()
// Service-account auth (pb.Client.Authenticate).
mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"token": "svc-token"})
})
// Identify the bearer (withAuth → identify → AuthRefresh).
mux.HandleFunc("POST /api/collections/users/auth-refresh", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
writeJSON(w, 401, map[string]any{})
return
}
writeJSON(w, 200, map[string]any{"record": map[string]any{
"id": e2eUserID, "email": e2eUserMail, "name": "Owner", "role": "user", "organization": "",
}})
})
// User record (userPluginSettings + callerForUser both GET this).
mux.HandleFunc("GET /api/collections/users/records/"+e2eUserID, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"id": e2eUserID, "email": e2eUserMail, "role": "user", "organization": "",
"pluginSettings": f.userPluginSettings,
})
})
// Step-up re-auth (reauthenticate → LoginWithPassword).
mux.HandleFunc("POST /api/collections/users/auth-with-password", func(w http.ResponseWriter, r *http.Request) {
var body struct{ Identity, Password string }
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Identity == e2eUserMail && body.Password == e2ePassword {
writeJSON(w, 200, map[string]any{"token": "reauth-ok", "record": map[string]any{"id": e2eUserID}})
return
}
writeJSON(w, 400, map[string]any{"message": "Failed to authenticate."})
})
// Durable audit sink.
mux.HandleFunc("POST /api/collections/control_audit/records", func(w http.ResponseWriter, r *http.Request) {
var rec map[string]any
_ = json.NewDecoder(r.Body).Decode(&rec)
f.mu.Lock()
f.audits = append(f.audits, rec)
f.mu.Unlock()
writeJSON(w, 200, map[string]any{"id": "audit-rec"})
})
return mux
}
func (f *fakePB) auditsWith(action, result string) int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, a := range f.audits {
if a["action"] == action && strings.Contains(toStr(a["result"]), result) {
n++
}
}
return n
}
func toStr(v any) string {
s, _ := v.(string)
return s
}
func TestControlStepUpAndAuditE2E(t *testing.T) {
tokenHashHex := hashToken(e2eToken)
fake := &fakePB{
userPluginSettings: json.RawMessage(`{"ankerSolix":{"enabled":true,"config":{"controlMode":"own"},` +
`"controlChargers":{"` + e2eSerial + `":{"tokenHash":"` + tokenHashHex + `","tokenHint":"` + tokenHint(e2eToken) + `"}}}}`),
}
pbSrv := httptest.NewServer(fake.handler(t))
defer pbSrv.Close()
// Enable the anker-solix plugin globally via a plugins.json.
dir := t.TempDir()
pluginsFile := filepath.Join(dir, "plugins.json")
if err := os.WriteFile(pluginsFile, []byte(`{"anker-solix":{"enabled":true}}`), 0o600); err != nil {
t.Fatal(err)
}
s := New(config.Config{
UsersCollection: "users",
PluginsFile: pluginsFile,
OCPPRequireTLS: false, // httptest is plaintext; TLS enforcement covered elsewhere
}, pb.New(pbSrv.URL, "admin@test.local", "pw"))
if err := s.plugins.Load(); err != nil {
t.Fatalf("load plugins: %v", err)
}
// Seed the control-token index (mirrors what generating a token does).
s.control.setUser(e2eUserID, map[string]ankerChargerBinding{
e2eSerial: {TokenHash: tokenHashHex, TokenHint: tokenHint(e2eToken)},
})
appSrv := httptest.NewServer(s.Handler())
defer appSrv.Close()
// Connect a simulated charger to the live CSMS (own mode).
stop := connectCharger(t, "ws"+strings.TrimPrefix(appSrv.URL, "http")+"/ocpp/"+e2eSerial, e2eSerial, e2eToken)
defer stop()
// Wait for the session to register.
if !waitFor(2*time.Second, func() bool { _, ok := s.ocpp.SessionFor(e2eSerial); return ok }) {
t.Fatal("charger session never registered on the CSMS")
}
// 1. Destructive reset without confirm → 400.
if code, _ := postControl(t, appSrv.URL, "reset", map[string]any{}); code != http.StatusBadRequest {
t.Errorf("reset without confirm: status %d, want 400", code)
}
// 2. Confirmed reset with the WRONG password → 401 (step-up fails).
if code, _ := postControl(t, appSrv.URL, "reset", map[string]any{"confirm": true, "password": "wrong"}); code != http.StatusUnauthorized {
t.Errorf("reset with wrong password: status %d, want 401", code)
}
// 3. Confirmed reset with the CORRECT password → 200 and the charger accepts.
code, body := postControl(t, appSrv.URL, "reset", map[string]any{"confirm": true, "password": e2ePassword})
if code != http.StatusOK {
t.Fatalf("reset with correct password: status %d body %s, want 200", code, body)
}
if got := body["status"]; got != "Accepted" {
t.Errorf("reset status = %v, want Accepted", got)
}
// The audit sink must have recorded the failed re-auth and the accepted reset
// (persisted asynchronously, so poll).
if !waitFor(2*time.Second, func() bool { return fake.auditsWith("reset", "reauth-failed") >= 1 }) {
t.Error("expected a control_audit record for the failed re-auth")
}
if !waitFor(2*time.Second, func() bool { return fake.auditsWith("reset", "Accepted") >= 1 }) {
t.Error("expected a control_audit record for the accepted reset")
}
}
// postControl issues an authenticated control command and returns the status and
// decoded JSON body.
func postControl(t *testing.T, base, action string, payload map[string]any) (int, map[string]any) {
t.Helper()
b, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost,
base+"/api/integrations/anker-solix/chargers/"+e2eSerial+"/"+action, bytes.NewReader(b))
req.Header.Set("Authorization", e2eBearer)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s: %v", action, err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
// connectCharger dials the CSMS as an OCPP charge point and answers every inbound
// control CALL with {status:"Accepted"}. Returns a stop function.
func connectCharger(t *testing.T, url, serial, token string) func() {
t.Helper()
h := http.Header{}
h.Set("Authorization", ocpp.BasicAuthHeader(serial, token))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := ocpp.Dial(ctx, url, []string{"ocpp1.6"}, h)
if err != nil {
t.Fatalf("charger dial: %v", err)
}
go func() {
for {
data, err := conn.ReadMessage()
if err != nil {
return
}
msg, err := ocpp.DecodeMessage(data)
if err != nil || msg.Type != ocpp.MessageTypeCall {
continue
}
out, _ := ocpp.EncodeCallResult(msg.ID, map[string]any{"status": "Accepted"})
_ = conn.WriteMessage(out)
}
}()
return func() { _ = conn.Close() }
}
func waitFor(d time.Duration, cond func() bool) bool {
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(10 * time.Millisecond)
}
return cond()
}
@@ -60,11 +60,14 @@ type ankerConfig struct {
ControlMode string `json:"controlMode"`
}
// ankerChargerBinding is the per-charger OCPP control token an operator installs
// into the charger (as the OCPP Basic-auth password) so it may connect to the
// DriverVault CSMS. It is user-layer only — not a cascade credential.
// ankerChargerBinding records a per-charger OCPP control token. The operator
// installs the token into the charger (as its OCPP Basic-auth password); we keep
// only its SHA-256 hash and a short hint, never the plaintext — the token is
// shown to the owner exactly once, at generation. User-layer only, not a cascade
// credential.
type ankerChargerBinding struct {
Token string `json:"token"`
TokenHash string `json:"tokenHash,omitempty"` // sha256(token), lowercase hex
TokenHint string `json:"tokenHint,omitempty"` // last 4 chars, for the UI
AddedAt string `json:"addedAt,omitempty"`
}
@@ -3,6 +3,7 @@ package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
@@ -30,39 +31,43 @@ type ankerControlBinding struct {
Serial string
}
// controlIndex maps an OCPP control token to its owning user + charger. It is an
// in-memory cache, seeded when a token is (re)generated and rebuilt from
// PocketBase the first time an unknown token connects (or lazily at startup).
// controlIndex maps a control token's SHA-256 hash to its owning user + charger.
// Only hashes are held (in memory and at rest), so a leak of the index or the
// stored settings never yields a usable charger credential. It is seeded when a
// token is (re)generated and rebuilt from PocketBase the first time an unknown
// token connects (or lazily at startup).
type controlIndex struct {
mu sync.RWMutex
byToken map[string]ankerControlBinding
byHash map[string]ankerControlBinding
built bool
}
func newControlIndex() *controlIndex {
return &controlIndex{byToken: map[string]ankerControlBinding{}}
return &controlIndex{byHash: map[string]ankerControlBinding{}}
}
// lookup resolves the presented plaintext token by hashing it and matching a
// stored hash — so the plaintext is never compared or retained.
func (ci *controlIndex) lookup(token string) (ankerControlBinding, bool) {
ci.mu.RLock()
defer ci.mu.RUnlock()
b, ok := ci.byToken[token]
b, ok := ci.byHash[hashToken(token)]
return b, ok
}
// setUser replaces all of a user's token entries with the given charger set, so
// a regenerated token invalidates the previous one.
// a regenerated or revoked token immediately stops resolving.
func (ci *controlIndex) setUser(userID string, chargers map[string]ankerChargerBinding) {
ci.mu.Lock()
defer ci.mu.Unlock()
for tok, b := range ci.byToken {
for hash, b := range ci.byHash {
if b.UserID == userID {
delete(ci.byToken, tok)
delete(ci.byHash, hash)
}
}
for serial, cb := range chargers {
if cb.Token != "" {
ci.byToken[cb.Token] = ankerControlBinding{UserID: userID, Serial: serial}
if cb.TokenHash != "" {
ci.byHash[cb.TokenHash] = ankerControlBinding{UserID: userID, Serial: serial}
}
}
}
@@ -103,6 +108,111 @@ func (s *Server) ensureControlIndex(ctx context.Context) {
s.control.mu.Unlock()
}
// ---- rate limiting -----------------------------------------------------------
// rateLimiter is a simple fixed-window limiter keyed by an arbitrary string
// (here user id + charger serial). It bounds how fast control commands can be
// issued so a valid session can't hammer Start/Stop/Reset at a physical charger.
//
// It is intentionally in-memory and resets on restart: the window is one minute,
// so a restart clears at most a sub-minute budget, and persisting per-key
// counters would add write amplification for negligible security value.
type rateLimiter struct {
mu sync.Mutex
window time.Duration
max int
hits map[string]*rlEntry
}
type rlEntry struct {
start time.Time
count int
}
func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{window: window, max: max, hits: map[string]*rlEntry{}}
}
func (rl *rateLimiter) allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
e := rl.hits[key]
if e == nil || now.Sub(e.start) > rl.window {
rl.hits[key] = &rlEntry{start: now, count: 1}
return true
}
if e.count >= rl.max {
return false
}
e.count++
return true
}
// ---- audit -------------------------------------------------------------------
// auditControl emits a single structured audit line for a control-plane event.
// Control commands move a physical actuator, so every one records who did what
// to which charger, with the outcome. (Durable persistence to PocketBase is a
// planned follow-up; this greppable line is the first-cut trail.)
func (s *Server) auditControl(who *callerIdentity, serial, action string, params map[string]any, result string, actErr error) {
userID, orgID := "", ""
if who != nil {
userID, orgID = who.ID, who.OrgID
}
entry := map[string]any{
"audit": "anker-control",
"ts": time.Now().UTC().Format(time.RFC3339),
"serial": serial,
"action": action,
"result": result,
"userId": userID,
}
if orgID != "" {
entry["orgId"] = orgID
}
if len(params) > 0 {
entry["params"] = params
}
if actErr != nil {
entry["error"] = actErr.Error()
}
b, _ := json.Marshal(entry)
log.Printf("AUDIT %s", b)
// Durable, best-effort: persist to the control_audit collection if present.
// The structured log line above is the fallback when it is not, so a failure
// here never blocks or fails the control action.
if !s.pb.Configured() {
return
}
rec := map[string]any{
"user_id": userID,
"org_id": orgID,
"serial": serial,
"action": action,
"result": result,
}
auditParams := params
if actErr != nil {
auditParams = map[string]any{"error": actErr.Error()}
for k, v := range params {
auditParams[k] = v
}
}
if len(auditParams) > 0 {
rec["params"] = auditParams
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.pb.Create(ctx, colControlAudit, rec, nil); err != nil {
log.Printf("audit: could not persist control event (serial=%s action=%s): %v", serial, action, err)
}
}()
}
// ---- OCPP WebSocket endpoint -------------------------------------------------
// handleOCPPConnect is the WebSocket endpoint a charger dials out to. It
@@ -111,6 +221,17 @@ func (s *Server) ensureControlIndex(ctx context.Context) {
// registers the session (own) or bridges it to Anker's cloud (proxy).
func (s *Server) handleOCPPConnect(w http.ResponseWriter, r *http.Request) {
serial := r.PathValue("serial")
// A plaintext ws:// connection would carry the charger's Basic-auth token in
// the clear, so reject it unless TLS was explicitly made optional (dev).
s.mu.RLock()
requireTLS := s.cfg.OCPPRequireTLS
s.mu.RUnlock()
if requireTLS && !requestIsSecure(r) {
writeError(w, http.StatusForbidden, "OCPP connections must use TLS (wss)")
return
}
user, token, ok := r.BasicAuth()
if !ok || token == "" {
w.Header().Set("WWW-Authenticate", `Basic realm="ocpp"`)
@@ -165,7 +286,9 @@ func (s *Server) handleOCPPConnect(w http.ResponseWriter, r *http.Request) {
if _, err := s.ocpp.Accept(serial, ocpp.ModeOwn, conn, nil); err != nil {
log.Printf("ocpp: accept own %s: %v", serial, err)
_ = conn.Close()
return
}
s.auditControl(who, serial, "ocpp.connect.own", nil, "accepted", nil)
return
}
@@ -179,7 +302,9 @@ func (s *Server) handleOCPPConnect(w http.ResponseWriter, r *http.Request) {
log.Printf("ocpp: accept proxy %s: %v", serial, err)
_ = conn.Close()
_ = up.Close()
return
}
s.auditControl(who, serial, "ocpp.connect.proxy", nil, "accepted", nil)
}
// callerForUser builds a callerIdentity (and returns the pluginSettings blob) for
@@ -223,6 +348,9 @@ func (s *Server) ankerProxyUpstream(ctx context.Context, res ankerResolution, se
if u == "" {
return "", "", errors.New("no OCPP endpoint URL in the Anker response")
}
if !ankerUpstreamAllowed(u) {
return "", "", errors.New("Anker returned an OCPP endpoint on an untrusted host")
}
return u, "", nil
}
@@ -247,8 +375,8 @@ func (s *Server) handleAnkerControlStatus(w http.ResponseWriter, r *http.Request
"controlMode": res.eff.ControlMode,
"available": res.available && res.orgEnabled && res.enabled,
"endpoint": s.ocppEndpoint(r, sn),
"hasToken": binding.Token != "",
"token": binding.Token, // shown to the owner for charger provisioning
"hasToken": binding.TokenHash != "",
"tokenHint": binding.TokenHint, // last-4 only; the token is shown once at generation
}
if sess, ok := s.ocpp.SessionFor(sn); ok {
body["connected"] = true
@@ -284,7 +412,11 @@ func (s *Server) handleAnkerControlToken(w http.ResponseWriter, r *http.Request)
if as.ControlChargers == nil {
as.ControlChargers = map[string]ankerChargerBinding{}
}
as.ControlChargers[sn] = ankerChargerBinding{Token: token, AddedAt: time.Now().UTC().Format(time.RFC3339)}
as.ControlChargers[sn] = ankerChargerBinding{
TokenHash: hashToken(token),
TokenHint: tokenHint(token),
AddedAt: time.Now().UTC().Format(time.RFC3339),
}
updated = as.ControlChargers
})
if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
@@ -293,6 +425,9 @@ func (s *Server) handleAnkerControlToken(w http.ResponseWriter, r *http.Request)
return
}
s.control.setUser(who.ID, updated)
s.auditControl(who, sn, "token.generate", nil, "ok", nil)
// The plaintext token is returned exactly once — it is never stored, and the
// status endpoint only ever exposes the last-4 hint afterwards.
writeJSON(w, http.StatusOK, map[string]any{
"serial": sn,
"token": token,
@@ -300,14 +435,58 @@ func (s *Server) handleAnkerControlToken(w http.ResponseWriter, r *http.Request)
})
}
// handleAnkerControlRevoke deletes a charger's control token, so it can no longer
// connect until a new token is generated.
func (s *Server) handleAnkerControlRevoke(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if !s.pb.Configured() {
writeError(w, http.StatusServiceUnavailable, "integration settings not configured on the server")
return
}
sn := r.PathValue("sn")
userRaw := s.userPluginSettings(r.Context(), who.ID)
var updated map[string]ankerChargerBinding
newDoc := mergeAnker(userRaw, func(as *ankerStored) {
delete(as.ControlChargers, sn)
updated = as.ControlChargers
})
if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
map[string]any{"pluginSettings": newDoc}, nil); err != nil {
writePBError(w, err)
return
}
s.control.setUser(who.ID, updated)
// Drop any live session for this charger so an already-connected charger using
// the revoked token is disconnected too.
if sess, ok := s.ocpp.SessionFor(sn); ok {
sess.Close()
}
s.auditControl(who, sn, "token.revoke", nil, "ok", nil)
writeJSON(w, http.StatusOK, map[string]any{"serial": sn, "revoked": true})
}
// handleAnkerControlAction issues one OCPP command to a connected charger.
func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request) {
sess, ok := s.ankerControlSession(w, r)
if !ok {
return
}
who := caller(r)
sn := r.PathValue("sn")
action := r.PathValue("action")
// Rate limit per user+charger so a valid session can't hammer the actuator.
if !s.ctlRL.allow(who.ID + "|" + sn) {
s.auditControl(who, sn, action, nil, "rate-limited", nil)
writeError(w, http.StatusTooManyRequests, "too many control commands; please slow down")
return
}
var body struct {
IdTag string `json:"idTag"`
ConnectorID int `json:"connectorId"`
@@ -318,11 +497,30 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
RequestedMessage string `json:"requestedMessage"`
Key string `json:"key"`
Value string `json:"value"`
Confirm bool `json:"confirm"`
Password string `json:"password"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body)
}
// Destructive actions (reboot, cable unlock) demand an explicit confirmation
// AND a fresh re-authentication (the caller re-enters their password), so they
// can't fire by accident, from a replayed benign request, or from a session
// left open on an unattended device.
if isDestructiveAction(action) {
if !body.Confirm {
s.auditControl(who, sn, action, nil, "unconfirmed", nil)
writeError(w, http.StatusBadRequest, "this action is destructive; resend with confirm:true")
return
}
if !s.reauthenticate(r.Context(), who, body.Password) {
s.auditControl(who, sn, action, nil, "reauth-failed", nil)
writeError(w, http.StatusUnauthorized, "re-enter your password to confirm this action")
return
}
}
ctx := r.Context()
var (
status string
@@ -362,6 +560,14 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, "unknown control action: "+action)
return
}
// Audit the outcome (actor, charger, action, the meaningful params, result).
outcome := status
if err != nil {
outcome = "error"
}
s.auditControl(who, sn, action, controlAuditParams(action, body.ConnectorID, body.Amps, body.Hard, body.Operative), outcome, err)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
@@ -373,6 +579,50 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, resp)
}
// isDestructiveAction reports whether a control action reboots the charger or
// releases the cable lock — actions that require an explicit confirm:true and a
// password re-authentication.
func isDestructiveAction(action string) bool {
return action == "reset" || action == "unlock"
}
// reauthenticate verifies the caller's password against PocketBase (a sudo-style
// step-up). It returns false on any failure — missing password, unknown email,
// unconfigured service account, or wrong password.
func (s *Server) reauthenticate(ctx context.Context, who *callerIdentity, password string) bool {
if who == nil || who.Email == "" || password == "" || !s.pb.Configured() {
return false
}
_, status, err := s.pb.LoginWithPassword(ctx, s.usersCollection(), who.Email, password)
return err == nil && status == http.StatusOK
}
// controlAuditParams collects the parameters worth recording for an audited
// control action (none are sensitive).
func controlAuditParams(action string, connectorID int, amps float64, hard bool, operative *bool) map[string]any {
p := map[string]any{}
switch action {
case "limit":
p["amps"] = amps
p["connectorId"] = connectorID
case "reset":
p["hard"] = hard
case "availability":
p["connectorId"] = connectorID
if operative != nil {
p["operative"] = *operative
}
case "start", "stop", "unlock", "clear-limit":
if connectorID > 0 {
p["connectorId"] = connectorID
}
}
if len(p) == 0 {
return nil
}
return p
}
// ankerControlSession applies the full gate (cascade + control mode + a live
// session the caller actually owns) and returns the charger's session.
func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*ocpp.Session, bool) {
@@ -402,7 +652,7 @@ func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*o
}
// The caller must own this charger (have a token bound to it), so a serial
// alone can't be used to reach someone else's charger.
if ankerBindingFor(userRaw, sn).Token == "" {
if ankerBindingFor(userRaw, sn).TokenHash == "" {
writeError(w, http.StatusNotFound, "no control token for this charger; generate one first")
return nil, false
}
@@ -416,15 +666,48 @@ func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*o
// ---- helpers -----------------------------------------------------------------
// ocppEndpoint builds the ws(s):// URL the operator points the charger at.
// ocppEndpoint builds the ws(s):// URL the operator points the charger at. When
// OCPP_PUBLIC_URL is configured it is used verbatim (scheme normalized to
// ws/wss), which is safer than trusting request headers; otherwise it is derived
// from the request.
func (s *Server) ocppEndpoint(r *http.Request, sn string) string {
s.mu.RLock()
publicURL := s.cfg.OCPPPublicURL
s.mu.RUnlock()
if publicURL != "" {
base := publicURL
if rest, ok := strings.CutPrefix(base, "https://"); ok {
base = "wss://" + rest
} else if rest, ok := strings.CutPrefix(base, "http://"); ok {
base = "ws://" + rest
}
return strings.TrimRight(base, "/") + "/ocpp/" + url.PathEscape(sn)
}
scheme := "ws"
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
if requestIsSecure(r) {
scheme = "wss"
}
return scheme + "://" + r.Host + "/ocpp/" + url.PathEscape(sn)
}
// requestIsSecure reports whether a request arrived over TLS, either directly or
// via a TLS-terminating reverse proxy (X-Forwarded-Proto). The header is only
// trustworthy behind such a proxy — the deployment model here (see the plan).
func requestIsSecure(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
// ankerUpstreamAllowed reports whether a proxy-mode upstream URL points at an
// Anker host, so a spoofed ocpp-info response can't steer the proxy elsewhere.
func ankerUpstreamAllowed(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
return host == "anker.com" || strings.HasSuffix(host, ".anker.com")
}
// ankerBindingFor reads one charger's stored control binding from a user's
// pluginSettings blob.
func ankerBindingFor(userRaw json.RawMessage, sn string) ankerChargerBinding {
@@ -441,6 +724,22 @@ func genControlToken() string {
return hex.EncodeToString(b[:])
}
// hashToken returns the lowercase hex SHA-256 of a control token. Only this hash
// is ever stored or indexed.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// tokenHint returns the last 4 characters of a token, shown in the UI so an owner
// can tell which token is installed without exposing it.
func tokenHint(token string) string {
if len(token) <= 4 {
return token
}
return token[len(token)-4:]
}
// extractWSURL walks an arbitrary JSON value and returns the first ws:// or
// wss:// string it finds.
func extractWSURL(raw json.RawMessage) string {
@@ -1,9 +1,13 @@
package api
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
"time"
"drivervault/apiserver/internal/pb"
)
func TestNormalizeControlMode(t *testing.T) {
@@ -23,14 +27,20 @@ func TestNormalizeControlMode(t *testing.T) {
}
}
// binding builds a hashed binding the way handleAnkerControlToken does, so the
// index tests exercise the real hash-at-rest path.
func binding(token string) ankerChargerBinding {
return ankerChargerBinding{TokenHash: hashToken(token), TokenHint: tokenHint(token)}
}
func TestControlIndexSetUserInvalidatesOldToken(t *testing.T) {
ci := newControlIndex()
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "tok-old"}})
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok-old")})
if _, ok := ci.lookup("tok-old"); !ok {
t.Fatal("tok-old should resolve after first set")
}
// Regenerate: same charger, new token. The old token must stop resolving.
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "tok-new"}})
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok-new")})
if _, ok := ci.lookup("tok-old"); ok {
t.Error("tok-old should be invalidated after regeneration")
}
@@ -40,27 +50,136 @@ func TestControlIndexSetUserInvalidatesOldToken(t *testing.T) {
}
}
func TestControlIndexRevoke(t *testing.T) {
ci := newControlIndex()
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok")})
// Revoke = setUser with the charger removed.
ci.setUser("u1", map[string]ankerChargerBinding{})
if _, ok := ci.lookup("tok"); ok {
t.Error("token should not resolve after revocation")
}
}
func TestControlIndexIsolatesUsers(t *testing.T) {
ci := newControlIndex()
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "a"}})
ci.setUser("u2", map[string]ankerChargerBinding{"SN2": {Token: "b"}})
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("a")})
ci.setUser("u2", map[string]ankerChargerBinding{"SN2": binding("b")})
// Re-setting u1 must not touch u2's token.
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "a2"}})
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("a2")})
if _, ok := ci.lookup("b"); !ok {
t.Error("u2's token should be untouched when u1 is updated")
}
}
func TestControlIndexStoresNoPlaintext(t *testing.T) {
ci := newControlIndex()
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("super-secret-token")})
// The plaintext must never appear as an index key.
ci.mu.RLock()
defer ci.mu.RUnlock()
if _, ok := ci.byHash["super-secret-token"]; ok {
t.Fatal("index must key on the hash, never the plaintext token")
}
}
func TestAnkerBindingFor(t *testing.T) {
raw := json.RawMessage(`{"ankerSolix":{"controlChargers":{"SN1":{"token":"xyz"}}}}`)
if got := ankerBindingFor(raw, "SN1").Token; got != "xyz" {
t.Errorf("binding token = %q, want xyz", got)
raw := json.RawMessage(`{"ankerSolix":{"controlChargers":{"SN1":{"tokenHash":"abc","tokenHint":"1234"}}}}`)
if got := ankerBindingFor(raw, "SN1").TokenHash; got != "abc" {
t.Errorf("binding hash = %q, want abc", got)
}
if got := ankerBindingFor(raw, "other").Token; got != "" {
t.Errorf("unknown charger should have empty token, got %q", got)
if got := ankerBindingFor(raw, "other").TokenHash; got != "" {
t.Errorf("unknown charger should have empty hash, got %q", got)
}
if got := ankerBindingFor(nil, "SN1").TokenHash; got != "" {
t.Errorf("nil settings should have empty hash, got %q", got)
}
}
func TestHashAndHint(t *testing.T) {
tok := "0123456789abcdef"
if h := hashToken(tok); len(h) != 64 || h == tok {
t.Errorf("hashToken should be a 64-char hex digest distinct from input, got %q", h)
}
if hashToken("a") == hashToken("b") {
t.Error("distinct tokens must hash differently")
}
if got := tokenHint(tok); got != "cdef" {
t.Errorf("tokenHint = %q, want cdef", got)
}
if got := tokenHint("ab"); got != "ab" {
t.Errorf("short-token hint = %q, want ab", got)
}
}
func TestIsDestructiveAction(t *testing.T) {
for _, a := range []string{"reset", "unlock"} {
if !isDestructiveAction(a) {
t.Errorf("%q should be destructive", a)
}
}
for _, a := range []string{"start", "stop", "limit", "clear-limit", "availability", "trigger", "config"} {
if isDestructiveAction(a) {
t.Errorf("%q should not be destructive", a)
}
}
}
func TestRateLimiter(t *testing.T) {
rl := newRateLimiter(2, time.Minute)
if !rl.allow("k") || !rl.allow("k") {
t.Fatal("first two calls should be allowed")
}
if rl.allow("k") {
t.Error("third call in the window should be blocked")
}
if !rl.allow("other") {
t.Error("a different key must have its own budget")
}
}
func TestReauthenticateGuards(t *testing.T) {
// With PocketBase unconfigured, re-auth must fail closed regardless of input,
// and never treat a missing password/email as success.
s := &Server{pb: pb.New("", "", "")}
ctx := context.Background()
cases := []struct {
name string
who *callerIdentity
pass string
}{
{"nil caller", nil, "pw"},
{"empty email", &callerIdentity{ID: "u1"}, "pw"},
{"empty password", &callerIdentity{ID: "u1", Email: "a@b.c"}, ""},
{"pb unconfigured", &callerIdentity{ID: "u1", Email: "a@b.c"}, "pw"},
}
for _, c := range cases {
if s.reauthenticate(ctx, c.who, c.pass) {
t.Errorf("%s: reauthenticate should fail closed", c.name)
}
}
}
func TestAnkerUpstreamAllowed(t *testing.T) {
ok := []string{
"wss://ankerpower-api-eu.anker.com/ocpp/CP1",
"wss://ankerpower-api.anker.com/x",
"ws://anker.com/y",
}
bad := []string{
"wss://evil.example.com/ocpp/CP1",
"wss://anker.com.evil.net/x",
"not a url at all ::::",
"",
}
for _, u := range ok {
if !ankerUpstreamAllowed(u) {
t.Errorf("%q should be allowed", u)
}
}
for _, u := range bad {
if ankerUpstreamAllowed(u) {
t.Errorf("%q should be rejected", u)
}
if got := ankerBindingFor(nil, "SN1").Token; got != "" {
t.Errorf("nil settings should have empty token, got %q", got)
}
}
+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
@@ -61,3 +61,28 @@ func TestOCPPRouteAndControlAuth(t *testing.T) {
}
}
}
// TestOCPPRequireTLS confirms a plaintext charger connection is rejected when TLS
// is required, and that a TLS-terminating proxy (X-Forwarded-Proto) satisfies the
// check (falling through to the auth gate).
func TestOCPPRequireTLS(t *testing.T) {
s := New(config.Config{UsersCollection: "users", OCPPRequireTLS: true}, pb.New("", "", ""))
srv := httptest.NewServer(s.Handler())
defer srv.Close()
wsBase := "ws" + strings.TrimPrefix(srv.URL, "http")
// Plaintext ws:// with no forwarded-proto → rejected before auth (403).
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, nil); err == nil ||
!strings.Contains(err.Error(), "403") {
t.Fatalf("plaintext connect under require-TLS: want HTTP 403, got %v", err)
}
// Behind a TLS-terminating proxy the connection is accepted past the TLS gate
// and then fails auth (401), proving the gate ordering.
h := http.Header{}
h.Set("X-Forwarded-Proto", "https")
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, h); err == nil ||
!strings.Contains(err.Error(), "401") {
t.Fatalf("forwarded-https connect: want HTTP 401 (past TLS gate), got %v", err)
}
}
+25
View File
@@ -28,6 +28,14 @@ type Config struct {
// log in to the panel to configure it.
PocketBaseAdminEmail string
PocketBaseAdminPassword string
// OCPP control endpoint (Anker Solix charger control). OCPPRequireTLS rejects
// charger connections that did not arrive over TLS (a plaintext ws:// carries
// the charger's Basic-auth token in the clear); disable only for local dev.
// OCPPPublicURL, when set, is the canonical ws(s):// base an operator points
// the charger at, instead of deriving it from request headers.
OCPPRequireTLS bool
OCPPPublicURL string
}
// EnvFile is the .env path (relative to the working directory) that Load reads
@@ -53,6 +61,23 @@ func Load() Config {
PluginsFile: getenv("PLUGINS_FILE", "plugins.json"),
PocketBaseAdminEmail: firstEnv("POCKETBASE_ADMIN_EMAIL", "PB_ADMIN_EMAIL"),
PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"),
OCPPRequireTLS: boolEnv("OCPP_REQUIRE_TLS", true),
OCPPPublicURL: strings.TrimRight(getenv("OCPP_PUBLIC_URL", ""), "/"),
}
}
// boolEnv reads a boolean environment variable, accepting the common truthy and
// falsey spellings and falling back to def when unset or unrecognized.
func boolEnv(key string, def bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "":
return def
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return def
}
}
+3
View File
@@ -116,6 +116,9 @@ func (s *Session) Serial() string { return s.serial }
// Mode returns the control mode (own|proxy).
func (s *Session) Mode() string { return s.mode }
// Close terminates the session (used when a charger's control token is revoked).
func (s *Session) Close() { s.close(nil) }
// Snapshot returns the current status.
func (s *Session) Snapshot() Status {
s.mu.Lock()
+23 -1
View File
@@ -383,6 +383,21 @@ const DESIRED = {
F.select("permission", ["read", "write"], true),
F.autodate("created", true, false),
],
// Append-only audit trail for OCPP charger control (start/stop/limit/reset/
// unlock/…, token generate/revoke, and charger connects). Actor/org are stored
// as plain text ids (not relations) so the trail survives user or org deletion.
// Written best-effort by the API Server (internal/api/integrations_ankersolix_control.go);
// if this collection is absent, control still works and only the structured log
// line remains.
control_audit: [
F.text("user_id"),
F.text("org_id"),
F.text("serial"),
F.text("action", true),
F.text("result"),
F.json("params", 10000),
F.autodate("created", true, false),
],
// Tenants that users belong to. A superadmin spans all of them; an admin
// manages only their own.
organizations: [
@@ -438,6 +453,11 @@ const INDEXES = {
reminders: ["CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"],
// Read as "this car's checks, newest first" every time.
technical_checks: ["CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"],
// Audit is queried "this charger's events, newest first" and "this user's events".
control_audit: [
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
"CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",
],
};
async function main() {
@@ -466,6 +486,7 @@ async function main() {
"maintenance_entries",
"car_documents",
"reminders",
"control_audit",
]) {
if (collections.some((c) => c.name === name)) continue;
await createCollection(token, name, DESIRED[name], format, idByName);
@@ -490,6 +511,7 @@ async function main() {
"maintenance_entries",
"car_documents",
"reminders",
"control_audit",
]) {
await reconcileFields(token, name, DESIRED[name], format, idByName);
}
@@ -497,7 +519,7 @@ async function main() {
console.log(
"\nDone. Collections ready: organizations, users, cars, service_records,\n" +
"technical_checks, parts, car_shares, fuel_entries, maintenance_entries,\n" +
"car_documents, reminders.",
"car_documents, reminders, control_audit.",
);
console.log(
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +
+2
View File
@@ -247,6 +247,8 @@ export const api = {
getAnkerControl: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control`),
ankerControlToken: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/token`, { method: "POST" }),
ankerControlRevoke: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/token`, { method: "DELETE" }),
ankerControlAction: (sn, action, body = {}) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/${action}`, {
method: "POST",
+5
View File
@@ -61,6 +61,8 @@
"applyLimit": "Apply limit",
"clearLimit": "Clear limit",
"reset": "Reset charger",
"resetConfirm": "Reboot this charger now? Any active charging session will be interrupted. Re-enter your password to confirm.",
"resetPassword": "Your account password",
"connectHint": "Enter your charger's serial and refresh. The charger must be connected to DriverVault's OCPP backend (set up in Settings → Integrations)."
},
"stations": {
@@ -253,6 +255,9 @@
"controlGenerate": "Generate token",
"controlEndpoint": "OCPP endpoint",
"controlToken": "Auth token",
"controlTokenOnce": "Copy this token now — it's shown only once and can't be retrieved later.",
"controlRevoke": "Revoke token",
"controlRevokeConfirm": "Revoke this charger's control token? It will disconnect and can't reconnect until you generate a new one.",
"controlConnected": "Connected to control backend",
"controlDisconnected": "Not connected"
},
+43 -1
View File
@@ -99,6 +99,30 @@ async function doAction(action, body) {
}
}
// Reset reboots the charger — a destructive action the server gates behind an
// explicit confirmation AND a password re-authentication (step-up). Reveal the
// inline password prompt; the actual call happens in confirmReset().
const resetPrompt = ref(false);
const resetPassword = ref("");
function askReset() {
ctlError.value = "";
resetPassword.value = "";
resetPrompt.value = true;
}
async function confirmReset() {
if (!resetPassword.value) return;
resetPrompt.value = false;
await doAction("reset", { hard: false, confirm: true, password: resetPassword.value });
resetPassword.value = "";
}
function cancelReset() {
resetPrompt.value = false;
resetPassword.value = "";
}
onMounted(async () => {
await loadCtlMode();
await refreshCtl();
@@ -215,9 +239,27 @@ onMounted(async () => {
</div>
</div>
<button class="dh-btn dh-btn-ghost mt-3 w-full" :disabled="ctlBusy === 'reset'" @click="doAction('reset', { hard: false })">
<button v-if="!resetPrompt" class="dh-btn dh-btn-ghost mt-3 w-full" :disabled="ctlBusy === 'reset'" @click="askReset">
{{ t("charging.control.reset") }}
</button>
<!-- Step-up: destructive reset requires re-entering the password. -->
<div v-else class="mt-3 rounded-control border border-danger/40 bg-danger-soft p-3">
<p class="text-xs font-medium text-danger">{{ t("charging.control.resetConfirm") }}</p>
<input
v-model="resetPassword"
type="password"
autocomplete="current-password"
class="dh-input mt-2"
:placeholder="t('charging.control.resetPassword')"
@keyup.enter="confirmReset"
/>
<div class="mt-2 flex gap-2">
<button class="dh-btn dh-btn-ghost grow" @click="cancelReset">{{ t("common.cancel") }}</button>
<button class="dh-btn dh-btn-danger grow" :disabled="!resetPassword || ctlBusy === 'reset'" @click="confirmReset">
{{ t("charging.control.reset") }}
</button>
</div>
</div>
</template>
<p v-else class="mt-3 text-xs text-muted">{{ t("charging.control.connectHint") }}</p>
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
+41 -4
View File
@@ -450,9 +450,10 @@ const ankerControlMode = computed(() => anker.value?.controlMode || "off");
// --- Anker Solix OCPP control (per-charger provisioning + connection status) ---
const ankerCtlSerial = ref("");
const ankerCtl = ref(null); // { endpoint, token, connected, status, ... }
const ankerCtl = ref(null); // { endpoint, hasToken, tokenHint, connected, status, ... }
const ankerCtlLoading = ref(false);
const ankerCtlError = ref("");
const ankerNewToken = ref(""); // freshly generated token, shown once
async function loadAnkerControl() {
const sn = ankerCtlSerial.value.trim();
@@ -472,14 +473,36 @@ async function generateAnkerToken() {
const sn = ankerCtlSerial.value.trim();
if (!sn) return;
ankerCtlError.value = "";
ankerNewToken.value = "";
try {
await api.ankerControlToken(sn);
// The token is returned exactly once — capture it here to show the operator.
const res = await api.ankerControlToken(sn);
ankerNewToken.value = res.token || "";
await loadAnkerControl();
} catch (e) {
ankerCtlError.value = e.message;
}
}
async function revokeAnkerToken() {
const sn = ankerCtlSerial.value.trim();
if (!sn) return;
if (!confirm(t("settings.integrations.controlRevokeConfirm"))) return;
ankerCtlError.value = "";
ankerNewToken.value = "";
try {
await api.ankerControlRevoke(sn);
await loadAnkerControl();
} catch (e) {
ankerCtlError.value = e.message;
}
}
// Clear the one-time token reveal whenever the operator switches charger.
watch(ankerCtlSerial, () => {
ankerNewToken.value = "";
});
function applyAnkerView(body) {
anker.value = body;
if (ankerScope.value === "org" && !body.canEditOrg) ankerScope.value = "user";
@@ -1134,6 +1157,20 @@ onBeforeUnmount(() => {
<button class="dh-btn dh-btn-primary" :disabled="!ankerCtlSerial.trim()" @click="generateAnkerToken">
{{ t("settings.integrations.controlGenerate") }}
</button>
<button
v-if="ankerCtl && ankerCtl.hasToken"
class="dh-btn dh-btn-ghost !text-danger"
:disabled="!ankerCtlSerial.trim()"
@click="revokeAnkerToken"
>
{{ t("settings.integrations.controlRevoke") }}
</button>
</div>
<!-- The token is shown exactly once, right after generation. -->
<div v-if="ankerNewToken" class="mt-3 rounded-control border border-warning/40 bg-warning-soft px-3 py-2">
<p class="text-xs font-semibold text-warning">{{ t("settings.integrations.controlTokenOnce") }}</p>
<code class="data mt-1 block break-all text-sm text-body">{{ ankerNewToken }}</code>
</div>
<div v-if="ankerCtl" class="mt-3 grid gap-2 text-sm">
@@ -1141,9 +1178,9 @@ onBeforeUnmount(() => {
<span class="text-muted">{{ t("settings.integrations.controlEndpoint") }}: </span>
<code class="data break-all text-body">{{ ankerCtl.endpoint }}</code>
</div>
<div v-if="ankerCtl.token">
<div v-if="ankerCtl.hasToken">
<span class="text-muted">{{ t("settings.integrations.controlToken") }}: </span>
<code class="data break-all text-body">{{ ankerCtl.token }}</code>
<code class="data text-body">{{ ankerCtl.tokenHint }}</code>
</div>
<div class="flex items-center gap-2">
<span class="dh-badge" :class="ankerCtl.connected ? 'dh-badge-success' : 'dh-badge-warning'">