Files
DriverVault/API Server/internal/api/integrations_ankersolix_control.go
T
tajniak81andClaude Opus 5 f7472bada3 Reach the charger where it is, instead of waiting for it to call
OCPP asks the charger to dial us: a public endpoint, a TLS certificate, and a
route in through the customer's router. Our own handler then demanded two more
things the V1 does not offer — TLS on a charger that connects over ws://, and
Basic auth credentials the Anker app has no field for — so every connection was
turned away before the upgrade.

Anker publishes a Modbus TCP register map for this charger, and it inverts the
problem: we dial the charger, on its own network, with no inbound reachability
to arrange. That works for a charger behind a router that OCPP cannot reach at
all.

internal/modbus is the protocol, hand-rolled against the spec like the MQTT and
WebSocket clients beside it. The plugin's modbus.go is the V1's map: the same
0-8 status enum the cloud already reports, per-phase measurements, and the
writable registers behind start, stop, current limit, boost and phase mode. A
new "modbus" control mode routes the existing control endpoints down it, so the
REST surface, the rate limit, the confirmation step and the audit trail are the
ones already there.

The commands the register map has no equivalent for say so by name rather than
failing as unknown, and a current below the charger's 6 A floor is refused
because it pauses the charge rather than slowing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:03:27 +02:00

840 lines
28 KiB
Go

package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"drivervault/apiserver/internal/ocpp"
)
// This file is the control half of the Anker Solix integration: the OCPP
// WebSocket endpoint a charger dials out to, the per-charger control-token index
// that authenticates it, and the REST endpoints the web app calls to issue OCPP
// commands. Monitoring (the read-only cloud plugin) lives in
// integrations_ankersolix.go and the OCPP protocol itself in internal/ocpp.
// ---- control-token index -----------------------------------------------------
// ankerControlBinding is what a per-charger control token resolves to.
type ankerControlBinding struct {
UserID string
Serial string
}
// 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
byHash map[string]ankerControlBinding
built bool
}
func newControlIndex() *controlIndex {
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.byHash[hashToken(token)]
return b, ok
}
// setUser replaces all of a user's token entries with the given charger set, so
// 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 hash, b := range ci.byHash {
if b.UserID == userID {
delete(ci.byHash, hash)
}
}
for serial, cb := range chargers {
if cb.TokenHash != "" {
ci.byHash[cb.TokenHash] = ankerControlBinding{UserID: userID, Serial: serial}
}
}
}
// ensureControlIndex builds the token index from every user's pluginSettings the
// first time it is needed. Best effort: a failure leaves built=false so a later
// connect retries.
func (s *Server) ensureControlIndex(ctx context.Context) {
s.control.mu.RLock()
built := s.control.built
s.control.mu.RUnlock()
if built || !s.pb.Configured() {
return
}
q := url.Values{}
q.Set("perPage", "500")
q.Set("fields", "id,pluginSettings")
lr, err := s.pb.List(ctx, s.usersCollection(), q)
if err != nil {
return
}
var items []struct {
ID string `json:"id"`
PluginSettings json.RawMessage `json:"pluginSettings"`
}
_ = json.Unmarshal(lr.Items, &items)
for _, it := range items {
var doc ankerSettingsDoc
if len(it.PluginSettings) > 0 {
_ = json.Unmarshal(it.PluginSettings, &doc)
}
if len(doc.AnkerSolix.ControlChargers) > 0 {
s.control.setUser(it.ID, doc.AnkerSolix.ControlChargers)
}
}
s.control.mu.Lock()
s.control.built = true
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
// authenticates the charger with OCPP Basic auth (username = serial, password =
// the per-charger control token), resolves the owner's control mode, and either
// 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"`)
writeError(w, http.StatusUnauthorized, "charger authentication required")
return
}
binding, found := s.control.lookup(token)
if !found {
s.ensureControlIndex(r.Context())
binding, found = s.control.lookup(token)
}
if !found || binding.Serial != serial || (user != "" && user != serial) {
writeError(w, http.StatusUnauthorized, "invalid charger credentials")
return
}
who, userRaw, err := s.callerForUser(r.Context(), binding.UserID)
if err != nil || who == nil {
writeError(w, http.StatusForbidden, "charger owner unavailable")
return
}
res := s.resolveAnker(r.Context(), who, userRaw)
if !res.available || !res.orgEnabled || !res.enabled {
writeError(w, http.StatusForbidden, "the charger owner has not enabled the integration")
return
}
mode := res.eff.ControlMode
if mode != ankerControlOwn && mode != ankerControlProxy {
writeError(w, http.StatusForbidden, "control mode is off for this charger")
return
}
// Resolve the proxy upstream before upgrading so a failure is a clean HTTP
// error rather than a dropped WebSocket.
var upstreamURL, upstreamAuth string
if mode == ankerControlProxy {
upstreamURL, upstreamAuth, err = s.ankerProxyUpstream(r.Context(), res, serial)
if err != nil {
writeError(w, http.StatusBadGateway, "cannot reach Anker upstream for proxy mode: "+err.Error())
return
}
}
conn, err := ocpp.Upgrade(w, r)
if err != nil {
// The response may already be hijacked; just log and drop.
log.Printf("ocpp: upgrade failed for %s: %v", serial, err)
return
}
if mode == ankerControlOwn {
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
}
up, err := ocpp.DialUpstream(r.Context(), upstreamURL, upstreamAuth)
if err != nil {
log.Printf("ocpp: proxy upstream dial failed for %s: %v", serial, err)
_ = conn.Close()
return
}
if _, err := s.ocpp.Accept(serial, ocpp.ModeProxy, conn, up); err != nil {
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
// a user id, so the OCPP endpoint can resolve the cascade without a bearer token.
func (s *Server) callerForUser(ctx context.Context, userID string) (*callerIdentity, json.RawMessage, error) {
if userID == "" || !s.pb.Configured() {
return nil, nil, errors.New("not configured")
}
var rec struct {
ID string `json:"id"`
Role string `json:"role"`
Organization string `json:"organization"`
PluginSettings json.RawMessage `json:"pluginSettings"`
}
if err := s.pb.GetOne(ctx, s.usersCollection(), userID, &rec); err != nil {
return nil, nil, err
}
role := rec.Role
if role == "" {
role = roleUser
}
return &callerIdentity{ID: rec.ID, Role: role, OrgID: rec.Organization}, rec.PluginSettings, nil
}
// ankerProxyUpstream asks the read-only cloud plugin for the charger's OCPP
// endpoint (its "ocpp-info" capability) and extracts a ws(s):// URL from the
// reply. Anker's own per-charger auth key is not available to us, so no
// Authorization header is set — if the upstream requires one it will reject the
// dial, surfaced as a clear error.
func (s *Server) ankerProxyUpstream(ctx context.Context, res ankerResolution, serial string) (string, string, error) {
if strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "" {
return "", "", errors.New("Anker account credentials are required for proxy mode")
}
cfg := map[string]string{"email": res.eff.Email, "password": res.eff.Password, "country": res.eff.Country}
payload, _ := json.Marshal(map[string]string{"sn": serial})
raw, err := s.plugins.InvokeWith(ctx, ankerPlugin, cfg, "ocpp-info", payload)
if err != nil {
return "", "", err
}
u := extractWSURL(raw)
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
}
// ---- control REST endpoints --------------------------------------------------
// handleAnkerControlStatus reports the control state for one charger: the
// resolved mode, whether it is connected to our CSMS, the provisioning endpoint
// and token, and a live status snapshot when connected.
func (s *Server) handleAnkerControlStatus(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
sn := r.PathValue("sn")
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveAnker(r.Context(), who, userRaw)
binding := ankerBindingFor(userRaw, sn)
body := map[string]any{
"serial": sn,
"controlMode": res.eff.ControlMode,
"available": res.available && res.orgEnabled && res.enabled,
"endpoint": s.ocppEndpoint(r, sn),
"hasToken": binding.TokenHash != "",
"tokenHint": binding.TokenHint, // last-4 only; the token is shown once at generation
"modbusHost": binding.ModbusHost,
"modbusPort": binding.ModbusPort,
}
// In Modbus mode "connected" is something we find out by asking, not by
// having been dialled: the charger holds no session with us between commands.
if res.eff.ControlMode == ankerControlModbus {
snap, ok := s.ankerModbusSnapshot(r.Context(), binding)
body["connected"] = ok
if ok {
body["status"] = snap
} else if strings.TrimSpace(binding.ModbusHost) == "" {
body["detail"] = "No local address saved yet. Enable Modbus TCP in the Anker app under Settings > Integrations, then save the address it shows."
} else {
body["detail"] = "The charger did not answer on " + ankerModbusConfig(binding).Address() + ". Check that it is powered on, on this network, and that Modbus TCP is still enabled in the Anker app."
}
writeJSON(w, http.StatusOK, body)
return
}
if sess, ok := s.ocpp.SessionFor(sn); ok {
body["connected"] = true
body["status"] = sess.Snapshot()
} else {
body["connected"] = false
}
writeJSON(w, http.StatusOK, body)
}
// handleAnkerControlToken (re)generates the per-charger control token the
// operator installs into the charger, and updates the token index.
func (s *Server) handleAnkerControlToken(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")
if strings.TrimSpace(sn) == "" {
writeError(w, http.StatusBadRequest, "missing charger serial")
return
}
token := genControlToken()
userRaw := s.userPluginSettings(r.Context(), who.ID)
var updated map[string]ankerChargerBinding
newDoc := mergeAnker(userRaw, func(as *ankerStored) {
if as.ControlChargers == nil {
as.ControlChargers = map[string]ankerChargerBinding{}
}
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,
map[string]any{"pluginSettings": newDoc}, nil); err != nil {
writePBError(w, err)
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,
"endpoint": s.ocppEndpoint(r, sn),
})
}
// 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})
}
// ankerControlBody is the union of everything a control action may be given.
// Which fields matter depends on the action, and on the transport: the OCPP
// actions take connector ids and transaction ids, the Modbus ones do not.
type ankerControlBody struct {
IdTag string `json:"idTag"`
ConnectorID int `json:"connectorId"`
TransactionID int `json:"transactionId"`
Amps float64 `json:"amps"`
Operative *bool `json:"operative"`
Hard bool `json:"hard"`
RequestedMessage string `json:"requestedMessage"`
Key string `json:"key"`
Value string `json:"value"`
Confirm bool `json:"confirm"`
Password string `json:"password"`
On *bool `json:"on"` // boost
PhaseMode *int `json:"phase"` // 0 automatic, 1 single, 2 three
Seconds int `json:"seconds"` // Modbus control timeout
}
// handleAnkerControlAction issues one command to a charger, over whichever
// transport the resolved control mode selects.
func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request) {
who, res, binding, ok := s.ankerControlGate(w, r)
if !ok {
return
}
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 ankerControlBody
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
}
}
if res.eff.ControlMode == ankerControlModbus {
s.ankerModbusAction(w, r, who, binding, sn, action, body)
return
}
sess, ok := s.ocpp.SessionFor(sn)
if !ok {
writeError(w, http.StatusConflict, "charger is not connected to the control backend")
return
}
ctx := r.Context()
var (
status string
result any
err error
)
switch action {
case "start":
status, err = sess.RemoteStartTransaction(ctx, body.IdTag, body.ConnectorID)
case "stop":
status, err = sess.RemoteStopTransaction(ctx, body.TransactionID)
case "limit":
status, err = sess.SetCurrentLimit(ctx, body.ConnectorID, body.Amps)
case "clear-limit":
status, err = sess.ClearChargingProfile(ctx, body.ConnectorID)
case "availability":
operative := true
if body.Operative != nil {
operative = *body.Operative
}
status, err = sess.ChangeAvailability(ctx, body.ConnectorID, operative)
case "reset":
status, err = sess.Reset(ctx, body.Hard)
case "unlock":
status, err = sess.UnlockConnector(ctx, body.ConnectorID)
case "trigger":
status, err = sess.TriggerMessage(ctx, body.RequestedMessage, body.ConnectorID)
case "config":
if body.Key != "" {
status, err = sess.ChangeConfiguration(ctx, body.Key, body.Value)
} else {
var conf ocpp.GetConfigurationResult
conf, err = sess.GetConfiguration(ctx, nil)
result = conf
}
default:
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
}
resp := map[string]any{"status": status}
if result != nil {
resp["result"] = result
}
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
}
// ankerControlGate applies everything a control request must satisfy before any
// transport is chosen: the cascade, a control mode that is not off, and a
// charger the caller actually owns. It answers the request itself on refusal.
//
// What "owns" means depends on the mode, because the two transports bind a
// charger differently: OCPP by the control token the charger authenticates
// with, Modbus by the local address we dial. Requiring a token in Modbus mode
// would demand a credential that path never uses.
func (s *Server) ankerControlGate(w http.ResponseWriter, r *http.Request) (*callerIdentity, ankerResolution, ankerChargerBinding, bool) {
var (
res ankerResolution
binding ankerChargerBinding
)
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return nil, res, binding, false
}
sn := r.PathValue("sn")
userRaw := s.userPluginSettings(r.Context(), who.ID)
res = s.resolveAnker(r.Context(), who, userRaw)
switch {
case !res.available:
writeError(w, http.StatusForbidden, "the Anker Solix integration is disabled by the administrator")
return nil, res, binding, false
case !res.orgEnabled:
writeError(w, http.StatusForbidden, "the Anker Solix integration is disabled for your organization")
return nil, res, binding, false
case !res.enabled:
writeError(w, http.StatusForbidden, "enable the Anker Solix integration in Settings first")
return nil, res, binding, false
}
if res.eff.ControlMode == ankerControlOff {
writeError(w, http.StatusConflict, "control mode is off; choose Modbus TCP or a CSMS mode to send commands")
return nil, res, binding, false
}
binding = ankerBindingFor(userRaw, sn)
if res.eff.ControlMode == ankerControlModbus {
if strings.TrimSpace(binding.ModbusHost) == "" {
writeError(w, http.StatusNotFound, "no local address for this charger; enable Modbus TCP in the Anker app and save the address it shows")
return nil, res, binding, false
}
return who, res, binding, true
}
// A serial alone can't be used to reach someone else's charger.
if binding.TokenHash == "" {
writeError(w, http.StatusNotFound, "no control token for this charger; generate one first")
return nil, res, binding, false
}
return who, res, binding, true
}
// ankerControlSession applies the gate and returns the charger's live OCPP
// session.
func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*ocpp.Session, bool) {
if _, _, _, ok := s.ankerControlGate(w, r); !ok {
return nil, false
}
sess, ok := s.ocpp.SessionFor(r.PathValue("sn"))
if !ok {
writeError(w, http.StatusConflict, "charger is not connected to the control backend")
return nil, false
}
return sess, true
}
// ---- helpers -----------------------------------------------------------------
// 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 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 {
var doc ankerSettingsDoc
if len(userRaw) > 0 {
_ = json.Unmarshal(userRaw, &doc)
}
return doc.AnkerSolix.ControlChargers[sn]
}
func genControlToken() string {
var b [24]byte
_, _ = rand.Read(b[:])
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 {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return ""
}
return walkForWS(v)
}
func walkForWS(v any) string {
switch t := v.(type) {
case string:
s := strings.TrimSpace(t)
if strings.HasPrefix(s, "ws://") || strings.HasPrefix(s, "wss://") {
return s
}
case map[string]any:
for _, val := range t {
if u := walkForWS(val); u != "" {
return u
}
}
case []any:
for _, val := range t {
if u := walkForWS(val); u != "" {
return u
}
}
}
return ""
}