Skip to content

Commit 946132d

Browse files
authored
feat(client): add Websocket support to the Outline Client config (#2351)
1 parent c88c57a commit 946132d

File tree

6 files changed

+173
-16
lines changed

6 files changed

+173
-16
lines changed

client/config.md

+63-2
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,51 @@ transport:
7070
udp: *shared
7171
```
7272

73+
In case of blocking of "look-like-nothing" protocols like Shadowsocks, you can use Shadowsocks over Websockets. See the [server examnple configuration](https://github.com/Jigsaw-Code/outline-ss-server/blob/master/cmd/outline-ss-server/config_example.yml) on how to deploy it. A client configuration will look like:
74+
75+
```yaml
76+
transport:
77+
$type: tcpudp
78+
tcp:
79+
$type: shadowsocks
80+
endpoint:
81+
$type: websocket
82+
url: wss://legendary-faster-packs-und.trycloudflare.com/SECRET_PATH/tcp
83+
cipher: chacha20-ietf-poly1305
84+
secret: SS_SECRET
85+
86+
udp:
87+
$type: shadowsocks
88+
endpoint:
89+
$type: websocket
90+
url: wss://legendary-faster-packs-und.trycloudflare.com/SECRET_PATH/udp
91+
cipher: chacha20-ietf-poly1305
92+
secret: SS_SECRET
93+
```
94+
95+
Note that the Websocket endpoint can, in turn, take an endpoint, which can be leveraged to bypass DNS-based blocking:
96+
```yaml
97+
transport:
98+
$type: tcpudp
99+
tcp:
100+
$type: shadowsocks
101+
endpoint:
102+
$type: websocket
103+
url: wss://legendary-faster-packs-und.trycloudflare.com/SECRET_PATH/tcp
104+
endpoint: cloudflare.net:443
105+
cipher: chacha20-ietf-poly1305
106+
secret: SS_SECRET
107+
108+
udp:
109+
$type: shadowsocks
110+
endpoint:
111+
$type: websocket
112+
url: wss://legendary-faster-packs-und.trycloudflare.com/SECRET_PATH/udp
113+
endpoint: cloudflare.net:443
114+
cipher: chacha20-ietf-poly1305
115+
secret: SS_SECRET
116+
```
117+
73118
## Tunnels
74119

75120
### <a id=TunnelConfig></a>TunnelConfig
@@ -163,8 +208,10 @@ The _string_ Endpoint is the host:port address of the desired endpoint. The conn
163208
Supported Interface types for Stream and Packet Endpoints:
164209

165210
- `dial`: [DialEndpointConfig](#DialEndpointConfig)
166-
<!-- - `shadowsocks`: [ShadowsocksConfig](#ShadowsocksConfig) -->
167-
<!-- - `websocket`: [WebsocketEndpointConfig](#WebsocketEndpointConfig) -->
211+
- `websocket`: [WebsocketEndpointConfig](#WebsocketEndpointConfig)
212+
<!-- TODO(fortuna): Add Shadowsocks endpoint
213+
- `shadowsocks`: [ShadowsocksConfig](#ShadowsocksConfig)
214+
-->
168215

169216
### <a id=DialEndpointConfig></a>DialEndpointConfig
170217

@@ -177,6 +224,20 @@ Establishes connections by dialing a fixed address. It can take a dialer, which
177224
- `address` (_string_): the endpoint address to dial
178225
- `dialer` ([DialerConfig](#dialers)): the dialer to use to dial the address
179226

227+
### <a id=WebsocketEndpointConfig></a>WebsocketEndpointConfig
228+
229+
Tunnels stream and packet connections to an endpoint over Websockets.
230+
231+
For stream connections, each write is turned into a Websocket message. For packet connections, each packet is turned into a Websocket message.
232+
233+
**Format:** _struct_
234+
235+
**Fields:**
236+
237+
- `url` (_string_): the URL for the Websocket endpoint. The schema must be `https` or `wss` for Websocket over TLS, and `http` or `ws` for plaintext Websocket.
238+
- `endpoint` ([EndpointConfig](#EndpointConfig)): the web server endpoint to connect to. If absent, is connects to the address specified in the URL.
239+
240+
180241
## Dialers
181242

182243
Dialers establishes connections given an endpoint address. There are Stream and Packet Dialers.

client/go/outline/client_test.go

-3
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,6 @@ func Test_NewTransport_Unsupported(t *testing.T) {
194194
require.Equal(t, "unsupported config", result.Error.Message)
195195
}
196196

197-
/*
198-
TODO: Add Websocket support
199197
func Test_NewTransport_Websocket(t *testing.T) {
200198
config := `
201199
$type: tcpudp
@@ -218,7 +216,6 @@ udp:
218216
require.Equal(t, firstHop, result.Client.sd.FirstHop)
219217
require.Equal(t, firstHop, result.Client.pl.FirstHop)
220218
}
221-
*/
222219

223220
func Test_NewClientFromJSON_Errors(t *testing.T) {
224221
tests := []struct {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright 2025 The Outline Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package config
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"net"
21+
"net/http"
22+
"net/url"
23+
"runtime"
24+
25+
"github.com/Jigsaw-Code/outline-sdk/transport"
26+
"github.com/Jigsaw-Code/outline-sdk/x/websocket"
27+
)
28+
29+
type WebsocketEndpointConfig struct {
30+
URL string
31+
Endpoint any
32+
}
33+
34+
func parseWebsocketStreamEndpoint(ctx context.Context, configMap map[string]any, parseSE ParseFunc[*Endpoint[transport.StreamConn]]) (*Endpoint[transport.StreamConn], error) {
35+
return parseWebsocketEndpoint[transport.StreamConn](ctx, configMap, parseSE, websocket.NewStreamEndpoint)
36+
}
37+
38+
func parseWebsocketPacketEndpoint(ctx context.Context, configMap map[string]any, parseSE ParseFunc[*Endpoint[transport.StreamConn]]) (*Endpoint[net.Conn], error) {
39+
return parseWebsocketEndpoint[net.Conn](ctx, configMap, parseSE, websocket.NewPacketEndpoint)
40+
}
41+
42+
type newWebsocketEndpoint[ConnType any] func(urlStr string, se transport.StreamEndpoint, opts ...websocket.Option) (func(context.Context) (ConnType, error), error)
43+
44+
func parseWebsocketEndpoint[ConnType any](ctx context.Context, configMap map[string]any, parseSE ParseFunc[*Endpoint[transport.StreamConn]], newWE newWebsocketEndpoint[ConnType]) (*Endpoint[ConnType], error) {
45+
var config WebsocketEndpointConfig
46+
if err := mapToAny(configMap, &config); err != nil {
47+
return nil, fmt.Errorf("invalid config format: %w", err)
48+
}
49+
50+
url, err := url.Parse(config.URL)
51+
if err != nil {
52+
return nil, fmt.Errorf("url is invalid: %w", err)
53+
}
54+
port := url.Port()
55+
if port == "" {
56+
switch url.Scheme {
57+
case "https", "wss":
58+
url.Scheme = "wss"
59+
port = "443"
60+
case "http", "ws":
61+
url.Scheme = "ws"
62+
port = "80"
63+
}
64+
}
65+
66+
if config.Endpoint == nil {
67+
config.Endpoint = net.JoinHostPort(url.Hostname(), port)
68+
}
69+
se, err := parseSE(ctx, config.Endpoint)
70+
if err != nil {
71+
return nil, fmt.Errorf("failed to parse websocket endpoint: %w", err)
72+
}
73+
74+
headers := http.Header(map[string][]string{
75+
"User-Agent": {fmt.Sprintf("Outline (%s; %s; %s)", runtime.GOOS, runtime.GOARCH, runtime.Version())},
76+
})
77+
connect, err := newWE(url.String(), transport.FuncStreamEndpoint(se.Connect), websocket.WithHTTPHeaders(headers))
78+
if err != nil {
79+
return nil, err
80+
}
81+
82+
return &Endpoint[ConnType]{
83+
ConnectionProviderInfo: se.ConnectionProviderInfo,
84+
Connect: connect,
85+
}, nil
86+
}

client/go/outline/config/module.go

+11-8
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"context"
1919
"errors"
2020
"net"
21+
"runtime"
2122

2223
"github.com/Jigsaw-Code/outline-sdk/transport"
2324
)
@@ -111,14 +112,16 @@ func NewDefaultTransportProvider(tcpDialer transport.StreamDialer, udpDialer tra
111112
return parseShadowsocksPacketListener(ctx, input, packetEndpoints.Parse)
112113
})
113114

114-
// TODO: Websocket support.
115-
// httpClient := http.DefaultClient
116-
// streamEndpoints.RegisterSubParser("websocket", func(ctx context.Context, input map[string]any) (*Endpoint[transport.StreamConn], error) {
117-
// return parseWebsocketStreamEndpoint(ctx, input, httpClient)
118-
// })
119-
// packetEndpoints.RegisterSubParser("websocket", func(ctx context.Context, input map[string]any) (*Endpoint[net.Conn], error) {
120-
// return parseWebsocketPacketEndpoint(ctx, input, httpClient)
121-
// })
115+
// Websocket support.
116+
// TODO: make it available on Windows.
117+
if runtime.GOOS != "windows" {
118+
streamEndpoints.RegisterSubParser("websocket", func(ctx context.Context, input map[string]any) (*Endpoint[transport.StreamConn], error) {
119+
return parseWebsocketStreamEndpoint(ctx, input, streamEndpoints.Parse)
120+
})
121+
packetEndpoints.RegisterSubParser("websocket", func(ctx context.Context, input map[string]any) (*Endpoint[net.Conn], error) {
122+
return parseWebsocketPacketEndpoint(ctx, input, streamEndpoints.Parse)
123+
})
124+
}
122125

123126
// Support distinct TCP and UDP configuration.
124127
transports.RegisterSubParser("tcpudp", func(ctx context.Context, config map[string]any) (*TransportPair, error) {

go.mod

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ module github.com/Jigsaw-Code/outline-apps
33
go 1.22.0
44

55
require (
6-
github.com/Jigsaw-Code/outline-sdk v0.0.14-0.20240216220040-f741c57bf854
6+
github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629
7+
github.com/Jigsaw-Code/outline-sdk/x v0.0.0-20250131142109-b32720fa2c3e
78
github.com/Wifx/gonetworkmanager/v2 v2.1.0
89
github.com/eycorsican/go-tun2socks v1.16.11
910
github.com/go-task/task/v3 v3.36.0
@@ -27,6 +28,7 @@ require (
2728
github.com/godbus/dbus/v5 v5.1.0 // indirect
2829
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
2930
github.com/google/licenseclassifier v0.0.0-20210722185704-3043a050f148 // indirect
31+
github.com/gorilla/websocket v1.5.3 // indirect
3032
github.com/inconshreveable/mousetrap v1.0.1 // indirect
3133
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
3234
github.com/joho/godotenv v1.5.1 // indirect

go.sum

+10-2
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,12 @@ cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq
5959
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
6060
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
6161
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
62-
github.com/Jigsaw-Code/outline-sdk v0.0.14-0.20240216220040-f741c57bf854 h1:SXp/tNjb70hpjF/MXAuLDkgCttlRA9qxLR7FCosGydg=
63-
github.com/Jigsaw-Code/outline-sdk v0.0.14-0.20240216220040-f741c57bf854/go.mod h1:9cEaF6sWWMzY8orcUI9pV5D0oFp2FZArTSyJiYtMQQs=
62+
github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629 h1:sHi1X4vwtNNBUDCbxynGXe7cM/inwTbavowHziaxlbk=
63+
github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629/go.mod h1:CFDKyGZA4zatKE4vMLe8TyQpZCyINOeRFbMAmYHxodw=
64+
github.com/Jigsaw-Code/outline-sdk/x v0.0.0-20250130191133-1f7340826841 h1:1tpY2KX6a9/1/uyN0riQVxQHqNthyAvzIhoWWmdYOBA=
65+
github.com/Jigsaw-Code/outline-sdk/x v0.0.0-20250130191133-1f7340826841/go.mod h1:9iEpNbKBsNU3WJs2XzhlI2AOf6DH018bjWxOC38o1Zc=
66+
github.com/Jigsaw-Code/outline-sdk/x v0.0.0-20250131142109-b32720fa2c3e h1:JN3TZFpi98BTw/CZuQWxovFsgqjQ79gGrbvZE1wFIIc=
67+
github.com/Jigsaw-Code/outline-sdk/x v0.0.0-20250131142109-b32720fa2c3e/go.mod h1:aFUEz6Z/eD0NS3c3fEIX+JO2D9aIrXCmWTb1zJFlItw=
6468
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
6569
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
6670
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
@@ -91,6 +95,8 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH
9195
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
9296
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
9397
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
98+
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
99+
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
94100
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
95101
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
96102
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -228,6 +234,8 @@ github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/Oth
228234
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
229235
github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
230236
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
237+
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
238+
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
231239
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
232240
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
233241
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=

0 commit comments

Comments
 (0)