From c3b032c3b8da5d9b46a7626a0d48af0cf10f9fc2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 10:13:36 +0000 Subject: [PATCH] fix: stop VPN core when signaling auth fails 1.3.0 waited on signaling auth in Start() after opening the WebSocket, but auth failure or timeout returned without closing it. Connect then dropped the core without Stop(), so zombie sessions survived retries. Close that attempt on Start and reconnectLoop, and Stop the core when Connect fails. Co-authored-by: Warexpor --- client/app.go | 1 + client/vpncore/lifecycle_test.go | 108 ++++++++++++++++++++++++++++++- client/vpncore/vpn.go | 17 +++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/client/app.go b/client/app.go index 482d925..a3ee2ea 100644 --- a/client/app.go +++ b/client/app.go @@ -473,6 +473,7 @@ func (a *App) Connect(serverAddr, nickname string) (AppStatus, error) { a.vpn = nil } a.mu.Unlock() + newVPN.Stop() return AppStatus{}, err } diff --git a/client/vpncore/lifecycle_test.go b/client/vpncore/lifecycle_test.go index 2865366..b1743a4 100644 --- a/client/vpncore/lifecycle_test.go +++ b/client/vpncore/lifecycle_test.go @@ -1,6 +1,16 @@ package vpncore -import "testing" +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/gorilla/websocket" +) func TestRoomOperationsRequireConnection(t *testing.T) { v := NewVPNCore(&VPNConfig{}) @@ -22,3 +32,99 @@ func TestTUNAdapterCanClearDNS(t *testing.T) { t.Fatalf("expected DNS to be cleared, got %q", tun.dnsServer) } } + +func TestStartAuthFailureClosesSignaling(t *testing.T) { + var live atomic.Int32 + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ws" { + http.NotFound(w, r) + return + } + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + live.Add(1) + defer live.Add(-1) + defer c.Close() + + _, _, _ = c.ReadMessage() + payload, _ := json.Marshal(map[string]string{"message": "unauthorized"}) + _ = c.WriteJSON(Message{Type: "error", Payload: payload}) + for { + if _, _, err := c.ReadMessage(); err != nil { + return + } + } + })) + defer func() { + closed := make(chan struct{}) + go func() { + srv.Close() + close(closed) + }() + select { + case <-closed: + case <-time.After(2 * time.Second): + t.Fatal("httptest server still has a live WebSocket after Start failure") + } + }() + + addr := strings.TrimPrefix(srv.URL, "http://") + v := NewVPNCore(&VPNConfig{ServerAddr: addr, Nickname: "tester"}) + err := v.Start() + if err == nil { + t.Fatal("Start should fail when signaling rejects auth") + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if !signalingConnLive(v) && live.Load() == 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + if signalingConnLive(v) { + t.Fatal("Start auth failure left a usable live signaling client") + } + if live.Load() != 0 { + t.Fatalf("server still has %d live WebSocket connection(s)", live.Load()) + } + if err := v.CreateRoom("demo", ""); err == nil { + t.Fatal("CreateRoom should fail after Start auth failure") + } + if err := v.JoinRoom("demo", ""); err == nil { + t.Fatal("JoinRoom should fail after Start auth failure") + } +} + +func TestStopAfterFailedDialIsSafe(t *testing.T) { + v := NewVPNCore(&VPNConfig{ServerAddr: "127.0.0.1:1", Nickname: "tester"}) + if err := v.Start(); err == nil { + t.Fatal("Start should fail when the server is unreachable") + } + v.Stop() + v.Stop() + if signalingConnLive(v) { + t.Fatal("Stop after failed Start left a live signaling client") + } + if err := v.CreateRoom("demo", ""); err == nil { + t.Fatal("CreateRoom should fail when not connected") + } + if err := v.JoinRoom("demo", ""); err == nil { + t.Fatal("JoinRoom should fail when not connected") + } +} + +func signalingConnLive(v *VPNCore) bool { + v.mu.Lock() + sig := v.signaling + v.mu.Unlock() + if sig == nil { + return false + } + sig.mu.Lock() + defer sig.mu.Unlock() + return sig.conn != nil +} diff --git a/client/vpncore/vpn.go b/client/vpncore/vpn.go index 6f47896..373f1c1 100644 --- a/client/vpncore/vpn.go +++ b/client/vpncore/vpn.go @@ -167,6 +167,7 @@ func (v *VPNCore) Start() error { v.status.Phase = "error" v.mu.Unlock() v.updateStatus() + v.Stop() return err case <-time.After(15 * time.Second): v.mu.Lock() @@ -176,6 +177,7 @@ func (v *VPNCore) Start() error { v.status.Phase = "error" v.mu.Unlock() v.updateStatus() + v.Stop() return fmt.Errorf("authentication timed out") } @@ -547,12 +549,14 @@ func (v *VPNCore) reconnectLoop(gen int, room, pass string) { v.mu.Lock() v.authErr = nil v.mu.Unlock() + v.closeSignalingAttempt() continue case <-time.After(15 * time.Second): v.log("Reconnect auth timeout") v.mu.Lock() v.authErr = nil v.mu.Unlock() + v.closeSignalingAttempt() continue } } @@ -629,6 +633,18 @@ func (v *VPNCore) cleanupAfterDisconnect() { v.updatePeers() } +// closeSignalingAttempt closes the WebSocket from a failed Start/reconnect +// try without marking the core as stopping (reconnectLoop must keep retrying). +func (v *VPNCore) closeSignalingAttempt() { + v.mu.Lock() + sig := v.signaling + v.signaling = nil + v.mu.Unlock() + if sig != nil { + sig.Close() + } +} + func (v *VPNCore) Stop() { v.mu.Lock() v.stopping = true @@ -637,6 +653,7 @@ func (v *VPNCore) Stop() { v.relay.Stop() } sig := v.signaling + v.signaling = nil if v.listenerConn != nil { v.listenerConn.Close() v.listenerConn = nil