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>
140 lines
4.7 KiB
Go
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}
|
|
}
|