Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/session.go
T
tajniak81andClaude Opus 5 190ae923a6 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>
2026-08-31 23:11:52 +02:00

108 lines
3.5 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, 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}
}