From addbda0c4353e5f836981f344c0e4fec51dccb4a Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 4 Aug 2026 12:57:07 +0200 Subject: [PATCH] Remote access: show tunnel connect errors, self-recover after offline boot (#32511) --- .../components/Config/Remote/RemoteModal.vue | 5 +++ assets/js/types/evcc.ts | 2 ++ server/mcp/openapi.json | 4 +++ server/openapi.state.yaml | 3 ++ server/remote/remote.go | 33 +++++++++++++++++-- server/remote/tunnel.go | 22 +++++++++++++ 6 files changed, 67 insertions(+), 2 deletions(-) diff --git a/assets/js/components/Config/Remote/RemoteModal.vue b/assets/js/components/Config/Remote/RemoteModal.vue index c52560217..a25e4e129 100644 --- a/assets/js/components/Config/Remote/RemoteModal.vue +++ b/assets/js/components/Config/Remote/RemoteModal.vue @@ -35,6 +35,11 @@ {{ $t("config.remote.disconnected") }} + diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 5fbd3743c..82b2826fb 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -963,6 +963,8 @@ export type RemoteStatus = { loginBlocked: boolean; /** Last remote activity per client, keyed by username. RFC3339 timestamps. */ lastSeen?: Record; + /** Last connection error. */ + error?: string; }; export type RemoteClient = { diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index f99e838ec..c0312fa0e 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -4127,6 +4127,10 @@ "additionalProperties": { "type": "string" } + }, + "error": { + "description": "Last connection error.", + "type": "string" } }, "required": [ diff --git a/server/openapi.state.yaml b/server/openapi.state.yaml index 500ae5955..fac0068aa 100644 --- a/server/openapi.state.yaml +++ b/server/openapi.state.yaml @@ -1586,6 +1586,9 @@ components: type: object additionalProperties: type: string + error: + description: Last connection error. + type: string required: - connected - loginBlocked diff --git a/server/remote/remote.go b/server/remote/remote.go index b95a272d8..dc0b0c2f8 100644 --- a/server/remote/remote.go +++ b/server/remote/remote.go @@ -1,6 +1,7 @@ package remote import ( + "errors" "fmt" "net/http" "sync" @@ -34,6 +35,7 @@ type Remote struct { publisher chan<- util.Param lastSeen map[string]time.Time // persisted: username → last activity connected map[string]int // in-memory: active connection count per user + lastError error // last connect/registration error, published to UI } // New creates a new Remote manager, loads persisted settings, and connects if enabled. @@ -93,8 +95,10 @@ func (r *Remote) Enabled() bool { } func (r *Remote) connect() { - if !sponsor.IsAuthorizedForApi() { - r.log.WARN.Println("remote access requires a sponsor token") + if sponsor.Token == "" { + msg := "remote access requires a sponsor token" + r.log.WARN.Println(msg) + r.setError(errors.New(msg)) return } @@ -105,10 +109,13 @@ func (r *Remote) connect() { if token == "" { if err := r.register(); err != nil { r.log.ERROR.Printf("registration failed: %v", err) + r.setError(fmt.Errorf("registration failed: %w", err)) return } } + r.setError(nil) + r.log.INFO.Printf("remote access via %s", r.settings.URL) tunnel := NewTunnel(r.settings.TunnelURL, r.settings.Token, r.httpHandler, r.Authenticate, r.TrackActivity, r.log, r.publish) @@ -129,6 +136,16 @@ func (r *Remote) disconnect() { r.tunnel.Close() r.tunnel = nil } + r.lastError = nil +} + +// setError records a connect error and publishes it to the UI. +func (r *Remote) setError(err error) { + r.mu.Lock() + r.lastError = err + r.mu.Unlock() + + r.publish() } type registerRequest struct { @@ -192,6 +209,16 @@ func (r *Remote) ConfigStatus() globalconfig.ConfigStatus { connected := r.tunnel != nil && r.tunnel.IsConnected() loginBlocked := r.tunnel != nil && r.tunnel.LoginBlocked() + lastErr := r.lastError + if lastErr == nil && r.tunnel != nil { + lastErr = r.tunnel.Error() + } + + var errMsg string + if lastErr != nil { + errMsg = lastErr.Error() + } + return globalconfig.ConfigStatus{ Config: struct { Enabled bool `json:"enabled"` @@ -203,11 +230,13 @@ func (r *Remote) ConfigStatus() globalconfig.ConfigStatus { URL string `json:"url,omitempty"` LoginBlocked bool `json:"loginBlocked"` LastSeen map[string]time.Time `json:"lastSeen,omitempty"` + Error string `json:"error,omitempty"` }{ Connected: connected, URL: r.settings.URL, LoginBlocked: loginBlocked, LastSeen: r.lastSeen, + Error: errMsg, }, } } diff --git a/server/remote/tunnel.go b/server/remote/tunnel.go index c452db84e..b9d65cbcd 100644 --- a/server/remote/tunnel.go +++ b/server/remote/tunnel.go @@ -35,6 +35,7 @@ type Tunnel struct { mu sync.Mutex session *yamux.Session + lastErr error } // NewTunnel creates a new tunnel client. @@ -66,6 +67,7 @@ func (t *Tunnel) run() { ok, err := t.connect(ctx) if err != nil && !errors.Is(err, context.Canceled) { t.log.ERROR.Printf("tunnel: %v", err) + t.setError(err) } // rejected credentials will not self-heal; a new token requires a restart @@ -139,6 +141,9 @@ func (t *Tunnel) connect(ctx context.Context) (bool, error) { func (t *Tunnel) changeState(session *yamux.Session, err error) { t.mu.Lock() t.session = session + if session != nil { + t.lastErr = nil + } t.mu.Unlock() if t.onStateChange != nil { @@ -156,6 +161,23 @@ func (t *Tunnel) changeState(session *yamux.Session, err error) { } } +func (t *Tunnel) setError(err error) { + t.mu.Lock() + t.lastErr = err + t.mu.Unlock() + + if t.onStateChange != nil { + t.onStateChange() + } +} + +// Error returns the last connection error, if any. +func (t *Tunnel) Error() error { + t.mu.Lock() + defer t.mu.Unlock() + return t.lastErr +} + // IsConnected returns whether the tunnel is currently connected. func (t *Tunnel) IsConnected() bool { t.mu.Lock()