Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/session.go
T
tajniak81andClaude Opus 5 f8b39fa0a3 Anker Solix: the encrypted transport the app uses, ported from upstream v3.8.2
anker-solix-api v3.8.2 (2026-09-13) worked out the mobile app's REST payload
encryption end to end — a second ECDH handshake after login, then every body
AES-sealed and HMAC-signed both ways. Anker's servers still accept the plain
JSON this connector has always sent, and upstream leaves the mode off for that
reason; this ports it so that the day they stop is a config change rather than
an outage.

payloadcrypto.go holds the handshake (openapi/oauth/key/exchange, both public
keys wrapped under the app's presetKey) and the per-request envelope
(AES-128-CBC under secret[:16], x-signature keyed on its ascii hex). The
handshake lives beside the token in the shared session and is dropped with it,
so a fresh login is a fresh handshake before the retry. The switch is a
global-layer field, payloadEncryption, default off: it is how the connector
talks to Anker rather than whose account it talks as, so it does not cascade —
ankerCloudConfig copies it into every per-user config, and the six places that
built that map by hand now go through it.

Verified against a fake cloud that implements the server half from upstream's
description, not against Anker's; the switch should be tried on a test account
before it is relied on. Nothing else in v3.8.2 reaches an EV charger: the
Solarbank expansion sizing, the Gen 2 smart meter and the new site parameter
types are other devices, and the A5191 MQTT block is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 16:40:43 +02:00

140 lines
4.7 KiB
Go

package ankersolix
// A login has to outlive the plugin instance that made it.
//
// The manager builds a fresh, throwaway instance for every per-user health check
// and every capability call (internal/plugins/manager.go: HealthCheckWith,
// InvokeWith, InvokeBatchWith), so a token held in the instance dies with the
// request that fetched it. Against Anker that is expensive: passport/login is
// throttled per IP per minute and answers code 26161 ("Failed to request") once
// tripped, five refusals in a row disable the account for ten minutes, and the
// cloud has historically kept one token per account, so each login can evict the
// one the mobile app is holding. Opening the panel — health, then the charger
// list — was two logins; a page that also asked for OCPP info, three.
//
// Sessions therefore live in the package, keyed by the account being signed in
// as, so every instance configured for that account shares one token and one
// login backoff.
import (
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"sync"
"time"
)
// session is one account's cloud state: the token it holds, how long to leave
// its login alone after a refusal, and the broker connection its chargers are
// commanded over.
type session struct {
mu sync.Mutex // guards tok, enc and the backoff below, and serialises the login and key exchanges
tok *tokenInfo
// enc is the payload-encryption handshake run under tok, when the connector
// is configured to encrypt (payloadcrypto.go). It is tied to the token — the
// server files the secret under the login — so it is dropped whenever tok is
// replaced, and stays nil while encryption is off.
enc *payloadCipher
loginErr error
loginRetryAt time.Time
loginFails int
// The cloud MQTT half (cloudmqtt.go), under its own lock: a broker connection
// takes a TLS handshake and a fetched certificate to open, so it outlives the
// plugin instance for the same reason the token does. mqttMu guards all four
// fields and is never held while mu is.
mqttMu sync.Mutex
mqttCreds *mqttCredentials
mqttCredsAt time.Time
mqttConn *mqttConn
devices map[string]string // charger serial -> product code, from the account's inventory
devicesAt time.Time
// lastUse is touched and read under sessionsMu, never under mu.
lastUse time.Time
}
// sessionIdle is how long an unused session is kept before being pruned — well
// beyond a token's ~7-day life, so pruning never costs a login.
const sessionIdle = 14 * 24 * time.Hour
var (
sessionsMu sync.Mutex
sessions = map[string]*session{}
)
// sessionFor returns the shared session for one set of credentials, creating it
// on first use. Different credentials — or the same account on the other
// regional server — get their own.
func sessionFor(apiBase, email, password string) *session {
// The password decides which account this is, but is not kept in the key.
sum := sha256.Sum256([]byte(password))
key := strings.Join([]string{
apiBase,
strings.ToLower(strings.TrimSpace(email)),
hex.EncodeToString(sum[:]),
}, "\x00")
sessionsMu.Lock()
defer sessionsMu.Unlock()
pruneSessionsLocked()
s := sessions[key]
if s == nil {
s = &session{}
sessions[key] = s
}
s.lastUse = time.Now()
return s
}
// pruneSessionsLocked drops sessions nothing has used for sessionIdle, so a
// long-lived server does not keep an entry for every credential it has ever
// seen — an edited password leaves its old session behind. Caller holds
// sessionsMu.
func pruneSessionsLocked() {
cutoff := time.Now().Add(-sessionIdle)
for k, s := range sessions {
if s.lastUse.Before(cutoff) {
// A pruned session must not leave its broker connection open; nothing
// else holds a reference to close it afterwards.
s.closeMqtt()
delete(sessions, k)
}
}
}
// closeMqtt drops the session's broker connection, if it holds one.
func (s *session) closeMqtt() {
s.mqttMu.Lock()
c := s.mqttConn
s.mqttConn = nil
s.mqttMu.Unlock()
if c != nil {
c.close()
}
}
// noteLogin records the outcome of a login attempt and, on failure, how long to
// leave the account alone: one Anker has already disabled gets the full penalty,
// anything else backs off exponentially up to loginRetryMax. Caller holds s.mu.
func (s *session) noteLogin(err error) {
if err == nil {
s.loginErr, s.loginRetryAt, s.loginFails = nil, time.Time{}, 0
return
}
s.loginFails++
wait := loginRetryBase << min(s.loginFails-1, 8)
if wait > loginRetryMax {
wait = loginRetryMax
}
var rejected *loginRejected
if errors.As(err, &rejected) && rejected.code == codeSignInLocked && wait < loginLockoutWait {
wait = loginLockoutWait
}
s.loginRetryAt = time.Now().Add(wait)
s.loginErr = &loginFailure{err: err, retryAt: s.loginRetryAt}
}