Remote access: show tunnel connect errors, self-recover after offline boot (#32511)
This commit is contained in:
parent
37fa159d47
commit
addbda0c43
6 changed files with 67 additions and 2 deletions
|
|
@ -35,6 +35,11 @@
|
|||
{{ $t("config.remote.disconnected") }}
|
||||
</span>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
v-if="config.enabled"
|
||||
:error="status.error ?? null"
|
||||
class="mt-2 mb-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -963,6 +963,8 @@ export type RemoteStatus = {
|
|||
loginBlocked: boolean;
|
||||
/** Last remote activity per client, keyed by username. RFC3339 timestamps. */
|
||||
lastSeen?: Record<string, string>;
|
||||
/** Last connection error. */
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type RemoteClient = {
|
||||
|
|
|
|||
4
server/mcp/openapi.json
generated
4
server/mcp/openapi.json
generated
|
|
@ -4127,6 +4127,10 @@
|
|||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"description": "Last connection error.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
|
|||
3
server/openapi.state.yaml
generated
3
server/openapi.state.yaml
generated
|
|
@ -1586,6 +1586,9 @@ components:
|
|||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
error:
|
||||
description: Last connection error.
|
||||
type: string
|
||||
required:
|
||||
- connected
|
||||
- loginBlocked
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue