Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ func (a *App) Connect(serverAddr, nickname string) (AppStatus, error) {
a.vpn = nil
}
a.mu.Unlock()
newVPN.Stop()
return AppStatus{}, err
}

Expand Down
108 changes: 107 additions & 1 deletion client/vpncore/lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -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{})
Expand All @@ -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
}
17 changes: 17 additions & 0 deletions client/vpncore/vpn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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")
}

Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading