diff --git a/examples/ws_connect.go b/examples/ws_connect.go new file mode 100644 index 000000000..6405457e8 --- /dev/null +++ b/examples/ws_connect.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "github.com/gorilla/websocket" +) + +func main() { + if len(os.Args) < 3 { + fmt.Println("Usage: go run examples/ws_connect.go ") + fmt.Println("Example: go run examples/ws_connect.go ws://localhost:18800/pico/ws my-secret-token") + return + } + + wsURL := os.Args[1] + token := os.Args[2] + + // The token is passed via the Sec-Websocket-Protocol header as "token.". + // This is the standard way to pass an auth token for WebSockets in a browser-compatible way. + header := http.Header{} + header.Set("Sec-Websocket-Protocol", "token."+token) + + fmt.Printf("Connecting to %s with token...\n", wsURL) + dialer := websocket.DefaultDialer + conn, resp, err := dialer.Dial(wsURL, header) + if err != nil { + if resp != nil { + log.Fatalf("Handshake failed with status %d: %v", resp.StatusCode, err) + } + log.Fatalf("Connection failed: %v", err) + } + defer conn.Close() + + // On success, the server echoes the subprotocol back. + fmt.Printf("Connected successfully! Server subprotocol: %s\n", conn.Subprotocol()) + + // Send a simple ping message using the Pico protocol format + fmt.Println("Sending ping message...") + pingMsg := map[string]any{ + "type": "ping", + "id": "msg-1", + } + if err := conn.WriteJSON(pingMsg); err != nil { + log.Fatalf("Failed to send ping: %v", err) + } + + // Read response + fmt.Println("Waiting for pong...") + var pongMsg map[string]any + if err := conn.ReadJSON(&pongMsg); err != nil { + log.Fatalf("Failed to read pong: %v", err) + } + + fmt.Printf("Received response: %+v\n", pongMsg) + fmt.Println("Test completed successfully.") +} diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index e22da1ba1..c346ac094 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -349,7 +349,7 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { // Echo the matched subprotocol back so the browser accepts the upgrade. var responseHeader http.Header if proto := c.matchedSubprotocol(r); proto != "" { - responseHeader = http.Header{"Sec-WebSocket-Protocol": {proto}} + responseHeader = http.Header{"Sec-Websocket-Protocol": {proto}} } conn, err := c.upgrader.Upgrade(w, r, responseHeader) @@ -387,7 +387,7 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { // authenticate checks the request for a valid token: // 1. Authorization: Bearer header -// 2. Sec-WebSocket-Protocol "token." (for browsers that can't set headers) +// 2. Sec-Websocket-Protocol "token." (for browsers that can't set headers) // 3. Query parameter "token" (only when AllowTokenQuery is on) func (c *PicoChannel) authenticate(r *http.Request) bool { token := c.config.Token.String() @@ -403,7 +403,7 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { } } - // Check Sec-WebSocket-Protocol subprotocol ("token.") + // Check Sec-Websocket-Protocol subprotocol ("token.") if c.matchedSubprotocol(r) != "" { return true } diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index e712767ad..d1cc62f07 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "net/http" + "strings" "sync" "testing" @@ -142,3 +144,49 @@ func (c *PicoChannel) addConnForTest(pc *picoConn) { } bySession[pc.id] = pc } + +func TestPicoChannel_AuthenticateSubprotocol(t *testing.T) { + cfg := config.PicoConfig{} + cfg.SetToken("secret-token") + ch, _ := NewPicoChannel(cfg, bus.NewMessageBus()) + + tests := []struct { + name string + subprotocols []string + want bool + }{ + { + name: "Correct subprotocol", + subprotocols: []string{"token.secret-token"}, + want: true, + }, + { + name: "Incorrect subprotocol", + subprotocols: []string{"token.wrong-token"}, + want: false, + }, + { + name: "Multiple subprotocols with correct one", + subprotocols: []string{"other-proto", "token.secret-token"}, + want: true, + }, + { + name: "No subprotocols", + subprotocols: []string{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := make(http.Header) + if len(tt.subprotocols) > 0 { + header.Set("Sec-Websocket-Protocol", strings.Join(tt.subprotocols, ", ")) + } + req := &http.Request{Header: header} + if got := ch.authenticate(req); got != tt.want { + t.Errorf("authenticate() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go index 7e92fca22..f1c90f8da 100644 --- a/web/backend/middleware/launcher_dashboard_auth.go +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -166,6 +166,9 @@ func isPublicLauncherDashboardPath(method, p string) bool { if isPublicLauncherDashboardStatic(method, p) { return true } + if p == "/pico/ws" && method == http.MethodGet { + return true + } switch p { case "/api/auth/login": return method == http.MethodPost