diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go index 12319c8..181c809 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -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 } diff --git a/API Server/internal/plugins/builtin/ankersolix/login_backoff_test.go b/API Server/internal/plugins/builtin/ankersolix/login_backoff_test.go index bb6c4ab..37de5d7 100644 --- a/API Server/internal/plugins/builtin/ankersolix/login_backoff_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/login_backoff_test.go @@ -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") } } diff --git a/API Server/internal/plugins/builtin/ankersolix/session.go b/API Server/internal/plugins/builtin/ankersolix/session.go new file mode 100644 index 0000000..a89578d --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/session.go @@ -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} +}