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
}