The Anker token outlives the request that fetched it

The manager builds a throwaway plugin instance for every per-user call —
HealthCheckWith, InvokeWith, InvokeBatchWith each construct, Init, probe and
Shutdown. The auth token lived on that instance, so it died with the HTTP request
that fetched it: opening the Anker panel signed in once for the health probe and
again for the charger list, and a page that also asked for OCPP info signed in a
third time. Every refresh, a fresh login.

Anker throttles passport/login per IP per minute and answers code 26161 ("Failed
to request.") once tripped, so this is the shape of the failure the panel has been
reporting; the cloud has also historically kept one token per account, so each of
those logins could evict the one the mobile app was holding.

Tokens and the login backoff now live in a package-level session keyed by the
account signing in, so every instance configured for that account shares one
login. Re-configuring the same credentials keeps the token; a different account,
or the same account on the other regional server, gets its own session. Sessions
unused for a fortnight are pruned, so an edited password does not leave its entry
behind for the life of the process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-08-31 23:11:52 +02:00
co-authored by Claude Opus 5
parent c1aee0fac1
commit 190ae923a6
3 changed files with 223 additions and 72 deletions
@@ -158,19 +158,14 @@ type Plugin struct {
password string
countryId string
mu sync.Mutex // guards tok, the login backoff and the login flow
tok *tokenInfo
mu sync.Mutex // guards the fields above and the client
apiBase string
client *http.Client
// A refused login is remembered rather than repeated. Anker disables an
// account for ten minutes after five failed sign-ins, and a single chargers
// poll asks four cloud views — each of which, holding no token, would try to
// sign in for itself. Without this the poll that discovers a bad login is
// also the one that locks the account.
loginErr error
loginRetryAt time.Time
loginFails int
// sess holds the token and the login backoff, shared by every instance
// configured for the same account (see session.go). It is what stops the
// manager's per-request instances from signing in once each.
sess *session
}
// Descriptor returns the plugin's static metadata for the admin panel.
@@ -241,10 +236,9 @@ func (p *Plugin) Init(_ context.Context, config map[string]string) error {
} else {
p.apiBase = serverEU
}
// Config change invalidates any cached token, and clears the login backoff:
// new credentials deserve an attempt straight away.
p.tok = nil
p.loginErr, p.loginRetryAt, p.loginFails = nil, time.Time{}, 0
// Credentials pick the session, so re-configuring the same account keeps its
// token and its backoff, and changing account or country starts a fresh one.
p.sess = sessionFor(p.apiBase, p.email, p.password)
p.client = &http.Client{Timeout: 30 * time.Second}
return nil
}
@@ -687,56 +681,40 @@ func isLoginFailure(err error) bool {
// ensureToken guarantees a non-expired auth token, logging in as needed.
// Serialized by p.mu so concurrent requests trigger at most one login.
func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) {
p.mu.Lock()
defer p.mu.Unlock()
s := p.sess
if s == nil {
return tokenInfo{}, errors.New("anker-solix: plugin not initialised")
}
s.mu.Lock()
defer s.mu.Unlock()
if p.tok != nil && time.Now().Before(p.tok.expiration) {
return *p.tok, nil
if s.tok != nil && time.Now().Before(s.tok.expiration) {
return *s.tok, nil
}
if err := p.attemptLogin(ctx); err != nil {
return tokenInfo{}, err
}
return *p.tok, nil
return *s.tok, nil
}
// attemptLogin logs in at most once per backoff window, handing every caller in
// between the failure that opened it. Caller holds p.mu.
// between the failure that opened it. Caller holds p.sess.mu.
func (p *Plugin) attemptLogin(ctx context.Context) error {
if p.loginErr != nil && time.Now().Before(p.loginRetryAt) {
return p.loginErr
if p.email == "" || p.password == "" {
// Nothing was sent to Anker, so there is nothing to back off from.
return errors.New("anker-solix: email and password are required")
}
p.noteLogin(p.login(ctx))
return p.loginErr
}
// 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 p.mu.
func (p *Plugin) noteLogin(err error) {
if err == nil {
p.loginErr, p.loginRetryAt, p.loginFails = nil, time.Time{}, 0
return
s := p.sess
if s.loginErr != nil && time.Now().Before(s.loginRetryAt) {
return s.loginErr
}
p.loginFails++
wait := loginRetryBase << min(p.loginFails-1, 8)
if wait > loginRetryMax {
wait = loginRetryMax
}
var rejected *loginRejected
if errors.As(err, &rejected) && rejected.code == codeSignInLocked && wait < loginLockoutWait {
wait = loginLockoutWait
}
p.loginRetryAt = time.Now().Add(wait)
p.loginErr = &loginFailure{err: err, retryAt: p.loginRetryAt}
s.noteLogin(p.login(ctx))
return s.loginErr
}
// login performs the ECDH + AES password exchange against passport/login and
// stores the returned token. Caller holds p.mu.
func (p *Plugin) login(ctx context.Context) error {
if p.email == "" || p.password == "" {
return errors.New("anker-solix: email and password are required")
}
// Fresh ephemeral P-256 key pair and shared secret against Anker's server key.
curve := ecdh.P256()
priv, err := curve.GenerateKey(rand.Reader)
@@ -781,7 +759,8 @@ func (p *Plugin) login(ctx context.Context) error {
return p.storeToken(body)
}
// storeToken parses the login envelope and updates p.tok. Caller holds p.mu.
// storeToken parses the login envelope and updates the session's token. Caller
// holds p.sess.mu.
func (p *Plugin) storeToken(body []byte) error {
var env struct {
Code int `json:"code"`
@@ -811,7 +790,7 @@ func (p *Plugin) storeToken(body []byte) error {
}
exp = exp.Add(-tokenExpiryMargin)
p.tok = &tokenInfo{
p.sess.tok = &tokenInfo{
authToken: env.Data.AuthToken,
gtoken: md5hex(env.Data.UserID),
nickname: env.Data.NickName,
@@ -840,14 +819,14 @@ func (p *Plugin) apiRequest(ctx context.Context, endpoint string, payload map[st
// A rejected token surfaces as 401/403 or an auth error code; log in afresh
// and retry once.
if status == http.StatusUnauthorized || status == http.StatusForbidden || isAuthCode(body) {
p.mu.Lock()
p.tok = nil
p.sess.mu.Lock()
p.sess.tok = nil
lerr := p.attemptLogin(ctx)
var newTok tokenInfo
if lerr == nil {
newTok = *p.tok
newTok = *p.sess.tok
}
p.mu.Unlock()
p.sess.mu.Unlock()
if lerr != nil {
return nil, lerr
}
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -32,7 +33,10 @@ func TestChargerInventoryLoginRefusedOnce(t *testing.T) {
if err := p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "pw"}); err != nil {
t.Fatal(err)
}
// Point the instance at the test server, session key included, so this test
// shares nothing with the others.
p.apiBase = srv.URL
p.sess = sessionFor(srv.URL, "u@example.com", "pw")
_, err := p.chargerInventory(context.Background())
if err == nil {
@@ -63,43 +67,104 @@ func TestChargerInventoryLoginRefusedOnce(t *testing.T) {
// one starts at loginRetryBase and doubles, an account Anker has already
// disabled gets the full penalty it asked for.
func TestLoginBackoffWindows(t *testing.T) {
p := &Plugin{}
s := &session{}
p.noteLogin(errors.New("boom"))
if wait := time.Until(p.loginRetryAt); wait > loginRetryBase || wait < loginRetryBase-time.Second {
s.noteLogin(errors.New("boom"))
if wait := time.Until(s.loginRetryAt); wait > loginRetryBase || wait < loginRetryBase-time.Second {
t.Errorf("first failure waits %s, want ~%s", wait, loginRetryBase)
}
p.noteLogin(errors.New("boom"))
if wait := time.Until(p.loginRetryAt); wait <= loginRetryBase {
s.noteLogin(errors.New("boom"))
if wait := time.Until(s.loginRetryAt); wait <= loginRetryBase {
t.Errorf("second failure waits %s, want more than %s", wait, loginRetryBase)
}
p.noteLogin(&loginRejected{code: codeSignInLocked, msg: "account disabled for 10 minutes"})
if wait := time.Until(p.loginRetryAt); wait < loginLockoutWait-time.Second {
s.noteLogin(&loginRejected{code: codeSignInLocked, msg: "account disabled for 10 minutes"})
if wait := time.Until(s.loginRetryAt); wait < loginLockoutWait-time.Second {
t.Errorf("locked account waits %s, want at least %s", wait, loginLockoutWait)
}
p.noteLogin(nil)
if p.loginErr != nil || p.loginFails != 0 || !p.loginRetryAt.IsZero() {
s.noteLogin(nil)
if s.loginErr != nil || s.loginFails != 0 || !s.loginRetryAt.IsZero() {
t.Error("a successful login must clear the backoff")
}
}
// TestLoginBackoffCappedAndCleared: the wait never exceeds loginRetryMax however
// long the outage lasts, and new credentials get an attempt straight away.
func TestLoginBackoffCappedAndCleared(t *testing.T) {
p := &Plugin{}
// TestLoginBackoffCapped: the wait never exceeds loginRetryMax however long the
// outage lasts.
func TestLoginBackoffCapped(t *testing.T) {
s := &session{}
for i := 0; i < 20; i++ {
p.noteLogin(errors.New("boom"))
s.noteLogin(errors.New("boom"))
}
if wait := time.Until(p.loginRetryAt); wait > loginRetryMax {
if wait := time.Until(s.loginRetryAt); wait > loginRetryMax {
t.Errorf("wait %s exceeds the %s cap", wait, loginRetryMax)
}
}
if err := p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "new"}); err != nil {
// TestSessionSharedAcrossInstances is the second half of the regression. The
// manager builds a throwaway plugin instance per HTTP call, so a token kept in
// the instance meant a fresh login for the health probe, another for the charger
// list, another for OCPP info — enough on its own to trip Anker's login throttle
// (code 26161) and then its five-failure lockout. Instances sharing an account
// must share the token; a different account must not.
func TestSessionSharedAcrossInstances(t *testing.T) {
logins := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, epLogin) {
logins++
_, _ = w.Write([]byte(`{"code":0,"msg":"success!","data":{"auth_token":"t","user_id":"u","token_expires_at":` +
strconv.FormatInt(time.Now().Add(24*time.Hour).Unix(), 10) + `}}`))
return
}
_, _ = w.Write([]byte(`{"code":0,"msg":"success!","data":{}}`))
}))
defer srv.Close()
dial := func(email, password string) *Plugin {
p := &Plugin{}
if err := p.Init(context.Background(), map[string]string{"email": email, "password": password}); err != nil {
t.Fatal(err)
}
p.apiBase = srv.URL
p.sess = sessionFor(srv.URL, email, password)
return p
}
// Three calls the panel makes as three separate instances, one account.
for i := 0; i < 3; i++ {
if _, err := dial("u@example.com", "pw").ensureToken(context.Background()); err != nil {
t.Fatal(err)
}
}
if logins != 1 {
t.Fatalf("login attempts for one account = %d, want 1", logins)
}
// A different account is a different session, and signs in for itself.
if _, err := dial("other@example.com", "pw").ensureToken(context.Background()); err != nil {
t.Fatal(err)
}
if p.loginErr != nil || p.loginFails != 0 {
t.Error("re-configuring must clear the backoff so new credentials are tried")
if logins != 2 {
t.Fatalf("login attempts after a second account = %d, want 2", logins)
}
}
// TestPruneSessionsDropsIdle keeps the package-level cache from growing an entry
// for every credential the server has ever seen.
func TestPruneSessionsDropsIdle(t *testing.T) {
sessionsMu.Lock()
sessions["stale"] = &session{lastUse: time.Now().Add(-2 * sessionIdle)}
sessions["fresh"] = &session{lastUse: time.Now()}
pruneSessionsLocked()
_, stale := sessions["stale"]
_, fresh := sessions["fresh"]
delete(sessions, "fresh")
sessionsMu.Unlock()
if stale {
t.Error("a session idle beyond sessionIdle should have been pruned")
}
if !fresh {
t.Error("a session in use must not be pruned")
}
}
@@ -0,0 +1,107 @@
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, and how long to
// leave its login alone after a refusal.
type session struct {
mu sync.Mutex // guards tok and the backoff below, and serialises the login exchange
tok *tokenInfo
loginErr error
loginRetryAt time.Time
loginFails int
// 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) {
delete(sessions, k)
}
}
}
// 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}
}