diff --git a/API Server/internal/api/control_e2e_test.go b/API Server/internal/api/control_e2e_test.go new file mode 100644 index 0000000..0dbd821 --- /dev/null +++ b/API Server/internal/api/control_e2e_test.go @@ -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() +} diff --git a/API Server/internal/api/integrations_ankersolix.go b/API Server/internal/api/integrations_ankersolix.go index 3509c9f..0ec51ef 100644 --- a/API Server/internal/api/integrations_ankersolix.go +++ b/API Server/internal/api/integrations_ankersolix.go @@ -60,12 +60,15 @@ 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"` - AddedAt string `json:"addedAt,omitempty"` + 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"` } // ankerStored is what we persist per user/org under pluginSettings.ankerSolix. diff --git a/API Server/internal/api/integrations_ankersolix_control.go b/API Server/internal/api/integrations_ankersolix_control.go index 0f645fb..aa4d00f 100644 --- a/API Server/internal/api/integrations_ankersolix_control.go +++ b/API Server/internal/api/integrations_ankersolix_control.go @@ -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 - built bool + mu sync.RWMutex + 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 { diff --git a/API Server/internal/api/integrations_ankersolix_test.go b/API Server/internal/api/integrations_ankersolix_test.go index 81dd668..d9b410e 100644 --- a/API Server/internal/api/integrations_ankersolix_test.go +++ b/API Server/internal/api/integrations_ankersolix_test.go @@ -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").Token; got != "" { - t.Errorf("nil settings should have empty token, 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) + } } } diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 77796cf..f9bf25a 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -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 diff --git a/API Server/internal/api/server_ocpp_test.go b/API Server/internal/api/server_ocpp_test.go index cd65391..6bfa8dd 100644 --- a/API Server/internal/api/server_ocpp_test.go +++ b/API Server/internal/api/server_ocpp_test.go @@ -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) + } +} diff --git a/API Server/internal/config/config.go b/API Server/internal/config/config.go index 9d83c15..9a08cc8 100644 --- a/API Server/internal/config/config.go +++ b/API Server/internal/config/config.go @@ -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 } } diff --git a/API Server/internal/ocpp/session.go b/API Server/internal/ocpp/session.go index cb5a4fc..268f066 100644 --- a/API Server/internal/ocpp/session.go +++ b/API Server/internal/ocpp/session.go @@ -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() diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 29c7c75..9a56222 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -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" + diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index 1684976..3a67c6f 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -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", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index e7be865..c73f4a9 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -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" }, diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index e86d3e5..22f207a 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -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 () => { - + +
+

{{ t("charging.control.resetConfirm") }}

+ +
+ + +
+

{{ t("charging.control.connectHint") }}

{{ ctlError }}

diff --git a/Web App/web/src/views/Settings.vue b/Web App/web/src/views/Settings.vue index 21ea9d1..8a0b703 100644 --- a/Web App/web/src/views/Settings.vue +++ b/Web App/web/src/views/Settings.vue @@ -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(() => { + + + + +
+

{{ t("settings.integrations.controlTokenOnce") }}

+ {{ ankerNewToken }}
@@ -1141,9 +1178,9 @@ onBeforeUnmount(() => { {{ t("settings.integrations.controlEndpoint") }}: {{ ankerCtl.endpoint }}
-
+
{{ t("settings.integrations.controlToken") }}: - {{ ankerCtl.token }} + ••••{{ ankerCtl.tokenHint }}