Skip to content

Commit

Permalink
feat(x): add Websocket client support (#231)
Browse files Browse the repository at this point in the history
  • Loading branch information
fortuna authored May 9, 2024
1 parent fb4d1cc commit 78384c0
Show file tree
Hide file tree
Showing 5 changed files with 305 additions and 52 deletions.
4 changes: 4 additions & 0 deletions x/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ func NewDefaultConfigToDialer() *ConfigToDialer {
}
return tlsfrag.NewFixedLenStreamDialer(sd, fixedLen)
})

p.RegisterStreamDialerType("ws", wrapStreamDialerWithWebSocket)
p.RegisterPacketDialerType("ws", wrapPacketDialerWithWebSocket)

return p
}

Expand Down
55 changes: 35 additions & 20 deletions x/config/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ The configuration string is composed of parts separated by the `|` symbol, which
For example, `A|B` means dialer `B` takes dialer `A` as its input.
An empty string represents the direct TCP/UDP dialer, and is used as the input to the first cofigured dialer.
Each dialer configuration follows a URL format, where the scheme defines the type of Dialer. Supported formats include:
Each dialer configuration follows a URL format, where the scheme defines the type of Dialer. Supported formats are described below.
# Proxy Protocols
Shadowsocks proxy (compatible with Outline's access keys, package [github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks])
Expand All @@ -44,6 +46,21 @@ SOCKS5 proxy (currently streams only, package [github.com/Jigsaw-Code/outline-sd
USERINFO field is optional and only required if username and password authentication is used. It is in the format of username:password.
# Transports
TLS transport (currently streams only, package [github.com/Jigsaw-Code/outline-sdk/transport/tls])
The sni parameter defines the name to be sent in the TLS SNI. It can be empty.
The certname parameter defines what name to validate against the server certificate.
tls:sni=[SNI]&certname=[CERT_NAME]
WebSockets
ws:tcp_path=[PATH]&udp_path=[PATH]
# DNS Protection
DNS resolution (streams only, package [github.com/Jigsaw-Code/outline-sdk/dns])
It takes a host:port address. If the port is missing, it will use 53. The resulting dialer will use the input dialer with
Expand All @@ -59,18 +76,24 @@ Happy Eyeballs to connect to the destination.
doh:name=[NAME]&address=[ADDRESS]
Stream split transport (streams only, package [github.com/Jigsaw-Code/outline-sdk/transport/split])
Address override.
It takes the length of the prefix. The stream will be split when PREFIX_LENGTH bytes are first written.
This dialer configuration is helpful for testing and development or if you need to fix the domain
resolution.
The host parameter, if not empty, specifies the host to dial instead of the original host.
The port parameter, if not empty, specifies the port to dial instead of the original port.
split:[PREFIX_LENGTH]
override:host=[HOST]&port=[PORT]
TLS transport (currently streams only, package [github.com/Jigsaw-Code/outline-sdk/transport/tls])
# Packet manipulation
The sni parameter defines the name to be sent in the TLS SNI. It can be empty.
The certname parameter defines what name to validate against the server certificate.
These strategies manipulate packets to bypass SNI-based blocking.
tls:sni=[SNI]&certname=[CERT_NAME]
Stream split transport (streams only, package [github.com/Jigsaw-Code/outline-sdk/transport/split])
It takes the length of the prefix. The stream will be split when PREFIX_LENGTH bytes are first written.
split:[PREFIX_LENGTH]
TLS fragmentation (streams only, package [github.com/Jigsaw-Code/outline-sdk/transport/tlsfrag]).
Expand All @@ -80,25 +103,17 @@ For more details, refer to [github.com/Jigsaw-Code/outline-sdk/transport/tlsfrag
tlsfrag:[LENGTH]
Address override.
This dialer configuration is helpful for testing and development or if you need to fix the domain
resolution.
The host parameter, if not empty, specifies the host to dial instead of the original host.
The port parameter, if not empty, specifies the port to dial instead of the original port.
override:host=[HOST]&port=[PORT]
# Examples
Packet splitting - To split outgoing streams on bytes 2 and 123, you can use:
split:2|split:123
Evading DNS and SNI blocking - A blocked site hosted on Cloudflare can potentially be accessed by resolving cloudflare.net instead of the original
domain and using stream split:
Evading DNS and SNI blocking - You can use Cloudflare's DNS-over-HTTPS to protect against DNS disruption.
The DoH resolver cloudflare-dns.com is accessible from any cloudflare.net IP, so you can specify the address to avoid blocking
of the resolver itself. This can be combines with a TCP split or TLS Record Fragmentation to bypass SNI-based blocking:
override:host=cloudflare.net.|split:2
doh:name=cloudflare-dns.com.&address=cloudflare.net.:443|split:2
SOCKS5-over-TLS, with domain-fronting - To tunnel SOCKS5 over TLS, and set the SNI to decoy.example.com, while still validating against your host name, use:
Expand Down
136 changes: 136 additions & 0 deletions x/config/websocket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright 2024 Jigsaw Operations LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package config

import (
"context"
"errors"
"fmt"
"net"
"net/url"
"strings"

"github.com/Jigsaw-Code/outline-sdk/transport"
"golang.org/x/net/websocket"
)

type wsConfig struct {
tcpPath string
udpPath string
}

func parseWSConfig(configURL *url.URL) (*wsConfig, error) {
query := configURL.Opaque
values, err := url.ParseQuery(query)
if err != nil {
return nil, err
}
var cfg wsConfig
for key, values := range values {
switch strings.ToLower(key) {
case "tcp_path":
if len(values) != 1 {
return nil, fmt.Errorf("udp_path option must has one value, found %v", len(values))
}
cfg.tcpPath = values[0]
case "udp_path":
if len(values) != 1 {
return nil, fmt.Errorf("tcp_path option must has one value, found %v", len(values))
}
cfg.udpPath = values[0]
default:
return nil, fmt.Errorf("unsupported option %v", key)
}
}
return &cfg, nil
}

// wsToStreamConn converts a [websocket.Conn] to a [transport.StreamConn].
type wsToStreamConn struct {
*websocket.Conn
}

func (c *wsToStreamConn) CloseRead() error {
// Nothing to do.
return nil
}

func (c *wsToStreamConn) CloseWrite() error {
return c.Close()
}

func wrapStreamDialerWithWebSocket(innerSD func() (transport.StreamDialer, error), _ func() (transport.PacketDialer, error), configURL *url.URL) (transport.StreamDialer, error) {
sd, err := innerSD()
if err != nil {
return nil, err
}
config, err := parseWSConfig(configURL)
if err != nil {
return nil, err
}
if config.tcpPath == "" {
return nil, errors.New("must specify tcp_path")
}
return transport.FuncStreamDialer(func(ctx context.Context, addr string) (transport.StreamConn, error) {
wsURL := url.URL{Scheme: "ws", Host: addr, Path: config.tcpPath}
origin := url.URL{Scheme: "http", Host: addr}
wsCfg, err := websocket.NewConfig(wsURL.String(), origin.String())
if err != nil {
return nil, fmt.Errorf("failed to create websocket config: %w", err)
}
baseConn, err := sd.DialStream(ctx, addr)
if err != nil {
return nil, fmt.Errorf("failed to connect to websocket endpoint: %w", err)
}
wsConn, err := websocket.NewClient(wsCfg, baseConn)
if err != nil {
baseConn.Close()
return nil, fmt.Errorf("failed to create websocket client: %w", err)
}
return &wsToStreamConn{wsConn}, nil
}), nil
}

func wrapPacketDialerWithWebSocket(innerSD func() (transport.StreamDialer, error), _ func() (transport.PacketDialer, error), configURL *url.URL) (transport.PacketDialer, error) {
sd, err := innerSD()
if err != nil {
return nil, err
}
config, err := parseWSConfig(configURL)
if err != nil {
return nil, err
}
if config.udpPath == "" {
return nil, errors.New("must specify udp_path")
}
return transport.FuncPacketDialer(func(ctx context.Context, addr string) (net.Conn, error) {
wsURL := url.URL{Scheme: "ws", Host: addr, Path: config.udpPath}
origin := url.URL{Scheme: "http", Host: addr}
wsCfg, err := websocket.NewConfig(wsURL.String(), origin.String())
if err != nil {
return nil, fmt.Errorf("failed to create websocket config: %w", err)
}
baseConn, err := sd.DialStream(ctx, addr)
if err != nil {
return nil, fmt.Errorf("failed to connect to websocket endpoint: %w", err)
}
wsConn, err := websocket.NewClient(wsCfg, baseConn)
if err != nil {
baseConn.Close()
return nil, fmt.Errorf("failed to create websocket client: %w", err)
}
return wsConn, nil
}), nil
}
56 changes: 54 additions & 2 deletions x/examples/ws2endpoint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
This package contains a command-line tool to expose a WebSocket endpoint that connects to
any endpoint over a transport.


## Connecting to an arbitrary endpoint


```sh
go run ./examples/ws2endpoint --endpoint ipinfo.io:443 --transport tls
go run ./examples/ws2endpoint --backend ipinfo.io:443 --transport tls
```

Then, on a browser console, you can do:
Expand Down Expand Up @@ -42,6 +46,8 @@ Alt-Svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
}
```

## Using Cloudflare

You can expose your WebSockets on Cloudflare with [clourdflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/). For example:

```console
Expand All @@ -57,6 +63,52 @@ You can expose your WebSockets on Cloudflare with [clourdflared](https://develop

In this case, use `wss://recorders-uganda-starring-stopping.trycloudflare.com` as the WebSocket url.


Note that the Cloudflare tunnel does not add any user authentication mechanism. You must implement authentication yourself
if you would like to prevent unauthorized access to your service.

## Shadowsocks over WebSocket

Run the reverse proxy, pointing to your Outline Server:

```sh
go run github.com/Jigsaw-Code/outline-sdk/x/examples/ws2endpoint --backend $HOST:$PORT --listen 127.0.0.1:8080
```

Expose the endpoint on Cloudflare:

```sh
cloudflared tunnel --url http://localhost:8080
```

Connect to the Cloudflare domain. For example:

```sh
go run github.com/Jigsaw-Code/outline-sdk/x/examples/fetch \
-transport "tls|ws:tcp_path=/tcp|ss://${REDACTED}@prefix-marion-covered-operators.trycloudflare.com.trycloudflare.com:443" \
https://ipinfo.io/
```

You can use override to make it easier to insert an Outline key:

```sh
go run github.com/Jigsaw-Code/outline-sdk/x/examples/fetch \
-transport "tls|ws:tcp_path=/tcp|override:host=prefix-marion-covered-operators.trycloudflare.com&port=443|$OUTLINE_KEY" \
https://ipinfo.io/
```

It's possible to bypass DNS-based blocking by resolving `cloudflare.net`, and SNI-based blocking by using TLS Record Fragmentation:

```sh
go run github.com/Jigsaw-Code/outline-sdk/x/examples/fetch \
-transport "override:host=cloudflare.net|tlsfrag:1|tls|ws:tcp_path=/tcp|ss://${REDACTED}@prefix-marion-covered-operators.trycloudflare.com:443" \
https://ipinfo.io/
```

The WebSockets transport supports UDP as well:

```sh
go run github.com/Jigsaw-Code/outline-sdk/x/examples/resolve \
-transport "tls|ws:tcp_path=/tcp&udp_path=/udp|ss://${REDACTED}@prefix-marion-covered-operators.trycloudflare.com:443"
-resolver 8.8.8.8 \
getoutline.org
```
Loading

0 comments on commit 78384c0

Please sign in to comment.