From 70be6eb6a14efe80ac97074154d05a25ebe2af56 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sat, 27 Jun 2026 15:33:20 +0200 Subject: [PATCH 1/4] Remote Access: back off when the proxy rejects a connection --- server/remote/tunnel.go | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/server/remote/tunnel.go b/server/remote/tunnel.go index 303acf92b16..f310d2260d7 100644 --- a/server/remote/tunnel.go +++ b/server/remote/tunnel.go @@ -16,6 +16,13 @@ import ( "github.com/hashicorp/yamux" ) +// bad registration token or sponsor token +var errCredentialsRejected = errors.New("remote access rejected") + +const configRetryInterval = 15 * time.Minute + +const minUptime = 5 * time.Second + // Tunnel manages a WebSocket+yamux tunnel to the cloud proxy. type Tunnel struct { tunnelURL string @@ -63,27 +70,44 @@ func (t *Tunnel) run() { t.log.ERROR.Printf("tunnel: %v", err) } - // reset backoff after successful connection + // reset backoff only after a session that stayed connected if ok { bo.Reset() } + wait := bo.NextBackOff() + // rejected credentials will not self-heal; retry slowly + if errors.Is(err, errCredentialsRejected) { + wait = configRetryInterval + } + select { case <-ctx.Done(): return - case <-time.After(bo.NextBackOff()): + case <-time.After(wait): } } } func (t *Tunnel) connect(ctx context.Context) (bool, error) { - conn, _, err := websocket.Dial(ctx, t.tunnelURL, &websocket.DialOptions{ + conn, resp, err := websocket.Dial(ctx, t.tunnelURL, &websocket.DialOptions{ HTTPHeader: http.Header{ "Authorization": []string{"Bearer " + t.token}, "X-Sponsor-Token": []string{sponsor.Token}, }, }) if err != nil { + // rejected before the websocket upgrade; resp carries the HTTP status + if resp != nil { + switch resp.StatusCode { + case http.StatusConflict: + return false, errors.New("another evcc instance is already connected with these credentials") + case http.StatusUnauthorized: + return false, fmt.Errorf("%w (access revoked or sponsor token missing)", errCredentialsRejected) + case http.StatusForbidden: + return false, fmt.Errorf("%w (sponsor token invalid or expired)", errCredentialsRejected) + } + } return false, fmt.Errorf("websocket dial: %w", err) } @@ -99,6 +123,7 @@ func (t *Tunnel) connect(ctx context.Context) (bool, error) { } t.changeState(session, nil) + start := time.Now() // accept streams from the proxy srv := &http.Server{ @@ -109,7 +134,8 @@ func (t *Tunnel) connect(ctx context.Context) (bool, error) { t.changeState(nil, err) } - return true, nil + // only a sustained connection resets backoff; an instant close keeps backing off + return time.Since(start) >= minUptime, nil } func (t *Tunnel) changeState(session *yamux.Session, err error) { From 026b7877cbdce3b5241a8f620cfdd4e621a3c4ac Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sat, 27 Jun 2026 15:59:40 +0200 Subject: [PATCH 2/4] check for valid sponsoring before connect --- server/remote/remote.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/remote/remote.go b/server/remote/remote.go index 80158dc10f2..b95a272d85a 100644 --- a/server/remote/remote.go +++ b/server/remote/remote.go @@ -93,6 +93,11 @@ func (r *Remote) Enabled() bool { } func (r *Remote) connect() { + if !sponsor.IsAuthorizedForApi() { + r.log.WARN.Println("remote access requires a sponsor token") + return + } + r.mu.Lock() token := r.settings.Token r.mu.Unlock() From 8320295f9bbd8fcc9521c50e45bf197bdc18467a Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sat, 27 Jun 2026 16:02:27 +0200 Subject: [PATCH 3/4] review feedback --- server/remote/tunnel.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/remote/tunnel.go b/server/remote/tunnel.go index f310d2260d7..8e9b8eccac6 100644 --- a/server/remote/tunnel.go +++ b/server/remote/tunnel.go @@ -75,10 +75,12 @@ func (t *Tunnel) run() { bo.Reset() } - wait := bo.NextBackOff() + var wait time.Duration // rejected credentials will not self-heal; retry slowly if errors.Is(err, errCredentialsRejected) { wait = configRetryInterval + } else { + wait = bo.NextBackOff() } select { From ea956c8ffeded228bb307205650be3a4f13ce05c Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 28 Jun 2026 23:36:39 +0200 Subject: [PATCH 4/4] simplify; failed token wont retry --- server/remote/tunnel.go | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/server/remote/tunnel.go b/server/remote/tunnel.go index 8e9b8eccac6..40945cb381f 100644 --- a/server/remote/tunnel.go +++ b/server/remote/tunnel.go @@ -19,8 +19,6 @@ import ( // bad registration token or sponsor token var errCredentialsRejected = errors.New("remote access rejected") -const configRetryInterval = 15 * time.Minute - const minUptime = 5 * time.Second // Tunnel manages a WebSocket+yamux tunnel to the cloud proxy. @@ -70,23 +68,20 @@ func (t *Tunnel) run() { t.log.ERROR.Printf("tunnel: %v", err) } + // rejected credentials will not self-heal; a new token requires a restart + if errors.Is(err, errCredentialsRejected) { + return + } + // reset backoff only after a session that stayed connected if ok { bo.Reset() } - var wait time.Duration - // rejected credentials will not self-heal; retry slowly - if errors.Is(err, errCredentialsRejected) { - wait = configRetryInterval - } else { - wait = bo.NextBackOff() - } - select { case <-ctx.Done(): return - case <-time.After(wait): + case <-time.After(bo.NextBackOff()): } } }