Control had two transports and neither fitted the ordinary customer. OCPP waits for the charger to dial in, which needs a public endpoint it can reach, a certificate, and a firmware willing to talk to our CSMS. Modbus TCP dials the charger, which needs the server on the charger's own network. Between them they cover a charger we host and a charger we stand next to; the common case is a charger behind someone else's router, and that had nothing. It was never unreachable, though. The charger holds a connection open to Anker's own broker — it is how the mobile app drives it from anywhere, and it is the mqttStatus register the Modbus snapshot has been reporting all along. So a third control mode joins that broker as the account: get_user_mqtt_info issues a client certificate, mTLS to aiot-mqtt-eu.anker.com:8883, and commands go out on the same topics the app publishes on. Nothing on the customer's side has to be forwarded, addressed or certificated. What travels is not an API call. The payload is a JSON envelope around a base64 binary frame the device itself speaks — marker, little-endian length, message type, name/length/type/value fields, XOR checksum — so mqttframe.go is a codec rather than a client, written from the message maps in anker-solix-api and anchored on the one frame that project documents byte for byte. A frame whose fields do not tile exactly up to the checksum is refused rather than half-read: these arrive over a link we do not control, and a truncated frame must not read as a charger reporting zeros. Two of the charger's habits shape the rest. It publishes nothing unless asked, so a status read arms a telemetry trigger and waits for the next frame, and a poll inside that window answers from what has since arrived. And a broker connection costs a fetched certificate and a TLS handshake while the plugin manager builds a throwaway instance per request — so the connection lives on the account's shared session beside the auth token, for exactly the reason the token lives there, and closes itself after five idle minutes. The transport also sees two signals no other one does: the boost flag, and the plug and start countdowns. The package doc has said since the first commit that they are never set and the derived mode must do without them. Here they are set, so a charger that has been told to start and is counting down a delay says so rather than sitting in "preparing", and "skip the delay" is offered only while there is a delay to skip. The clients generalise instead of growing a second layout. Both snapshots name the same quantities the same way, so what was Modbus-only in the readouts is now whichever transport read the charger — ModbusStatus becomes ChargerStatus on the phone, mb becomes dev on the web. What each transport can be *told* still differs, and the buttons branch on that: reset and clear-limit stay with OCPP, the timeout and phase registers with Modbus, skip-delay with the cloud. A command a transport has no equivalent for is refused by name, saying which one has it. The cost is worth saying plainly. This leans on Anker's cloud being up and on an unofficial protocol the app may change under us, where Modbus leans on nothing but the LAN. And it is checked against the reference implementation's own worked example rather than against hardware — there is no charger on this end to point it at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
873 lines
29 KiB
Go
873 lines
29 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,
|
|
}
|
|
|
|
// Over the Anker cloud the charger holds its session with Anker, not with us,
|
|
// so "connected" means it answered the broker just now.
|
|
if res.eff.ControlMode == ankerControlMqtt {
|
|
snap, why, ok := s.ankerMqttSnapshot(r.Context(), res, sn)
|
|
body["connected"] = ok
|
|
if ok {
|
|
body["status"] = snap
|
|
} else if strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "" {
|
|
body["detail"] = "Enter your Anker account email and password in Settings; the cloud connection signs in as your account."
|
|
} else {
|
|
body["detail"] = "The charger did not answer over Anker's cloud. Check that it is powered on and online in the Anker app. (" + shortenDetail(why) + ")"
|
|
}
|
|
writeJSON(w, http.StatusOK, body)
|
|
return
|
|
}
|
|
|
|
// 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 == ankerControlMqtt {
|
|
s.ankerMqttAction(w, r, who, res, sn, action, body)
|
|
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 each transport binds a charger
|
|
// differently: OCPP by the control token the charger authenticates with, Modbus
|
|
// by the local address we dial, the Anker cloud by the account the charger is
|
|
// registered to. Requiring a token in Modbus or cloud mode would demand a
|
|
// credential neither path ever 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 == ankerControlMqtt {
|
|
// The cloud path binds a charger by the account it is registered to, so
|
|
// there is no token to install and no address to save. What it does need is
|
|
// the credentials for that account — and the plugin refuses a serial the
|
|
// account does not own, which is the ownership check the other two get from
|
|
// their own binding.
|
|
if strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "" {
|
|
writeError(w, http.StatusBadRequest, "the Anker cloud connection signs in as your account; enter your Anker email and password in Settings first")
|
|
return nil, res, binding, false
|
|
}
|
|
return who, res, binding, true
|
|
}
|
|
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 ""
|
|
}
|