diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go index 12d08f9..12319c8 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -158,10 +158,19 @@ type Plugin struct { password string countryId string - mu sync.Mutex // guards tok and the login flow + mu sync.Mutex // guards tok, the login backoff and the login flow tok *tokenInfo 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 } // Descriptor returns the plugin's static metadata for the admin panel. @@ -232,8 +241,10 @@ func (p *Plugin) Init(_ context.Context, config map[string]string) error { } else { p.apiBase = serverEU } - // Config change invalidates any cached token. + // 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 p.client = &http.Client{Timeout: 30 * time.Second} return nil } @@ -622,6 +633,57 @@ func chargerModeOptions(mode, statusDesc string) []string { // ---- token management -------------------------------------------------------- +// Login backoff. A refused sign-in is cached and replayed to every caller until +// its window passes, so credentials Anker rejects are offered once, not once per +// request. +const ( + loginRetryBase = 1 * time.Minute + loginRetryMax = 15 * time.Minute + + // codeSignInLocked is what Anker answers once it has already disabled the + // account for repeated failures; it states a ten-minute penalty, so sit the + // penalty out rather than spending attempts against a door that is shut. + codeSignInLocked = 10019 + loginLockoutWait = 10 * time.Minute +) + +// loginRejected is the login endpoint answering with a non-zero API code. The +// code is kept so an already-locked account can be told from an ordinary +// refusal. +type loginRejected struct { + code int + msg string +} + +func (e *loginRejected) Error() string { + return fmt.Sprintf("anker-solix: login rejected (code %d): %s", e.code, e.msg) +} + +// loginFailure wraps whatever made a login attempt fail, together with the time +// before which no further attempt is made. +type loginFailure struct { + err error + retryAt time.Time +} + +func (e *loginFailure) Error() string { + wait := time.Until(e.retryAt).Round(time.Second) + if wait < 0 { + wait = 0 + } + return fmt.Sprintf("%s (not retrying for %s)", e.err, wait) +} + +func (e *loginFailure) Unwrap() error { return e.err } + +// isLoginFailure reports whether err came from the login exchange rather than +// from the endpoint the caller was asking for — the difference between "this +// account cannot sign in" and "this one view said no". +func isLoginFailure(err error) bool { + var lf *loginFailure + return errors.As(err, &lf) +} + // 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) { @@ -631,12 +693,43 @@ func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) { if p.tok != nil && time.Now().Before(p.tok.expiration) { return *p.tok, nil } - if err := p.login(ctx); err != nil { + if err := p.attemptLogin(ctx); err != nil { return tokenInfo{}, err } return *p.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. +func (p *Plugin) attemptLogin(ctx context.Context) error { + if p.loginErr != nil && time.Now().Before(p.loginRetryAt) { + return p.loginErr + } + 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 + } + 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} +} + // 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 { @@ -704,7 +797,7 @@ func (p *Plugin) storeToken(body []byte) error { return fmt.Errorf("anker-solix: decode login response: %w", err) } if env.Code != 0 { - return fmt.Errorf("anker-solix: login rejected (code %d): %s", env.Code, shorten(env.Msg)) + return &loginRejected{code: env.Code, msg: shorten(env.Msg)} } if env.Data.AuthToken == "" || env.Data.UserID == "" { return errors.New("anker-solix: login response missing auth_token or user_id") @@ -749,7 +842,7 @@ func (p *Plugin) apiRequest(ctx context.Context, endpoint string, payload map[st if status == http.StatusUnauthorized || status == http.StatusForbidden || isAuthCode(body) { p.mu.Lock() p.tok = nil - lerr := p.login(ctx) + lerr := p.attemptLogin(ctx) var newTok tokenInfo if lerr == nil { newTok = *p.tok diff --git a/API Server/internal/plugins/builtin/ankersolix/chargers.go b/API Server/internal/plugins/builtin/ankersolix/chargers.go index 7ea45d1..f2b0200 100644 --- a/API Server/internal/plugins/builtin/ankersolix/chargers.go +++ b/API Server/internal/plugins/builtin/ankersolix/chargers.go @@ -216,6 +216,14 @@ func (p *Plugin) accountChargers(ctx context.Context) (json.RawMessage, error) { // described at the top of this file. A view that fails is recorded as a warning // and the others still answer; only losing all of them is an error. func (p *Plugin) chargerInventory(ctx context.Context) (chargersDoc, error) { + // Sign in once, up front. Every view below needs the same token, and a login + // Anker refuses is not one view failing — it is all of them, retried in turn, + // five of which disable the account for ten minutes. Fail on the login + // itself, and say so instead of blaming the views. + if _, err := p.ensureToken(ctx); err != nil { + return chargersDoc{}, err + } + inv := newInventory() var warnings []string views, failed := 0, 0 diff --git a/API Server/internal/plugins/builtin/ankersolix/login_backoff_test.go b/API Server/internal/plugins/builtin/ankersolix/login_backoff_test.go new file mode 100644 index 0000000..bb6c4ab --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/login_backoff_test.go @@ -0,0 +1,105 @@ +package ankersolix + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestChargerInventoryLoginRefusedOnce is the regression: one chargers poll asks +// four cloud views, and each of them holding no token used to try its own login. +// Anker disables the account after five refusals, so the poll that found a bad +// login also locked the account out for ten minutes. The poll must spend exactly +// one sign-in, and the next one must spend none until the backoff expires. +func TestChargerInventoryLoginRefusedOnce(t *testing.T) { + logins := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, epLogin) { + t.Errorf("unexpected request to %s with no token", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + return + } + logins++ + _, _ = w.Write([]byte(`{"code":26161,"msg":"Failed to request."}`)) + })) + defer srv.Close() + + p := &Plugin{} + if err := p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "pw"}); err != nil { + t.Fatal(err) + } + p.apiBase = srv.URL + + _, err := p.chargerInventory(context.Background()) + if err == nil { + t.Fatal("expected the refused login to fail the inventory") + } + if !isLoginFailure(err) { + t.Errorf("error should be recognisable as a login failure, got %v", err) + } + if !strings.Contains(err.Error(), "26161") { + t.Errorf("error should carry Anker's code, got %v", err) + } + if strings.Contains(err.Error(), "every cloud view failed") { + t.Errorf("a refused login is not four failed views, got %v", err) + } + if logins != 1 { + t.Fatalf("login attempts = %d, want 1", logins) + } + + if _, err := p.chargerInventory(context.Background()); err == nil { + t.Fatal("expected the cached failure to be replayed") + } + if logins != 1 { + t.Fatalf("login attempts after a second poll = %d, want 1", logins) + } +} + +// TestLoginBackoffWindows checks how long each kind of refusal buys: an ordinary +// 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{} + + p.noteLogin(errors.New("boom")) + if wait := time.Until(p.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 { + 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 { + 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() { + 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{} + for i := 0; i < 20; i++ { + p.noteLogin(errors.New("boom")) + } + if wait := time.Until(p.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 { + t.Fatal(err) + } + if p.loginErr != nil || p.loginFails != 0 { + t.Error("re-configuring must clear the backoff so new credentials are tried") + } +}