diff --git a/x/config/config.go b/x/config/config.go index c5d9d8ec..aa6d7888 100644 --- a/x/config/config.go +++ b/x/config/config.go @@ -77,6 +77,10 @@ func NewDefaultConfigToDialer() *ConfigToDialer { } return tlsfrag.NewFixedLenStreamDialer(sd, fixedLen) }) + + p.RegisterStreamDialerType("ws", wrapStreamDialerWithWebSocket) + p.RegisterPacketDialerType("ws", wrapPacketDialerWithWebSocket) + return p } diff --git a/x/config/doc.go b/x/config/doc.go index 6152b628..539fcf29 100644 --- a/x/config/doc.go +++ b/x/config/doc.go @@ -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]) @@ -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 @@ -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]). @@ -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: diff --git a/x/config/websocket.go b/x/config/websocket.go new file mode 100644 index 00000000..9d02c95c --- /dev/null +++ b/x/config/websocket.go @@ -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 +} diff --git a/x/examples/ws2endpoint/README.md b/x/examples/ws2endpoint/README.md index 55826221..1b3e662a 100644 --- a/x/examples/ws2endpoint/README.md +++ b/x/examples/ws2endpoint/README.md @@ -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: @@ -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 @@ -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 +``` diff --git a/x/examples/ws2endpoint/main.go b/x/examples/ws2endpoint/main.go index 155af82b..581c4d94 100644 --- a/x/examples/ws2endpoint/main.go +++ b/x/examples/ws2endpoint/main.go @@ -30,49 +30,95 @@ import ( "golang.org/x/net/websocket" ) +type natConn struct { + net.Conn + mappingTimeout time.Duration +} + +// Consider ReadFrom/WriteTo +func (c *natConn) Write(b []byte) (int, error) { + c.Conn.SetDeadline(time.Now().Add(c.mappingTimeout)) + return c.Conn.Write(b) +} + func main() { - addrFlag := flag.String("localAddr", "localhost:8080", "Local proxy address") + listenFlag := flag.String("listen", "localhost:8080", "Local proxy address to listen on") transportFlag := flag.String("transport", "", "Transport config") - endpointFlag := flag.String("endpoint", "", "Address of the target endpoint") - pathPrefix := flag.String("path", "/", "Path where to run the Websocket forwarder") + backendFlag := flag.String("backend", "", "Address of the endpoint to forward traffic to") + tcpPathFlag := flag.String("tcp_path", "/tcp", "Path where to run the WebSocket TCP forwarder") + udpPathFlag := flag.String("udp_path", "/udp", "Path where to run the WebSocket UDP forwarder") flag.Parse() - dialer, err := config.NewDefaultConfigToDialer().NewStreamDialer(*transportFlag) - if err != nil { - log.Fatalf("Could not create dialer: %v", err) + if *backendFlag == "" { + log.Fatal("Must specify flag -backend") } - if *endpointFlag == "" { - log.Fatal("Must specify flag -endpoint") - } - endpoint := transport.StreamDialerEndpoint{Dialer: dialer, Address: *endpointFlag} - listener, err := net.Listen("tcp", *addrFlag) + listener, err := net.Listen("tcp", *listenFlag) if err != nil { - log.Fatalf("Could not listen on address %v: %v", *addrFlag, err) + log.Fatalf("Could not listen on address %v: %v", *listenFlag, err) } defer listener.Close() log.Printf("Proxy listening on %v\n", listener.Addr().String()) - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Printf("Got request: %v\n", r) - handler := func(wsConn *websocket.Conn) { - targetConn, err := endpoint.ConnectStream(r.Context()) - if err != nil { - log.Printf("Failed to upgrade: %v\n", err) - w.WriteHeader(http.StatusBadGateway) - return + config2Dialer := config.NewDefaultConfigToDialer() + mux := http.NewServeMux() + if *tcpPathFlag != "" { + dialer, err := config2Dialer.NewStreamDialer(*transportFlag) + if err != nil { + log.Fatalf("Could not create stream dialer: %v", err) + } + endpoint := transport.StreamDialerEndpoint{Dialer: dialer, Address: *backendFlag} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("Got stream request: %v\n", r) + handler := func(wsConn *websocket.Conn) { + targetConn, err := endpoint.ConnectStream(r.Context()) + if err != nil { + log.Printf("Failed to upgrade: %v\n", err) + w.WriteHeader(http.StatusBadGateway) + return + } + defer targetConn.Close() + go func() { + io.Copy(targetConn, wsConn) + targetConn.CloseWrite() + }() + io.Copy(wsConn, targetConn) + wsConn.Close() } - defer targetConn.Close() - go func() { - io.Copy(targetConn, wsConn) - targetConn.CloseWrite() - }() - io.Copy(wsConn, targetConn) - wsConn.Close() + websocket.Server{Handler: handler}.ServeHTTP(w, r) + }) + mux.Handle(*tcpPathFlag, http.StripPrefix(*tcpPathFlag, handler)) + } + if *udpPathFlag != "" { + dialer, err := config2Dialer.NewPacketDialer(*transportFlag) + if err != nil { + log.Fatalf("Could not create stream dialer: %v", err) } - websocket.Server{Handler: handler}.ServeHTTP(w, r) - }) - server := http.Server{Handler: http.StripPrefix(*pathPrefix, handler)} + endpoint := transport.PacketDialerEndpoint{Dialer: dialer, Address: *backendFlag} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("Got packet request: %v\n", r) + handler := func(wsConn *websocket.Conn) { + targetConn, err := endpoint.ConnectPacket(r.Context()) + if err != nil { + log.Printf("Failed to upgrade: %v\n", err) + w.WriteHeader(http.StatusBadGateway) + return + } + // Expire connetion after 5 minutes of idle time, as per + // https://datatracker.ietf.org/doc/html/rfc4787#section-4.3 + targetConn = &natConn{targetConn, 5 * time.Minute} + go func() { + io.Copy(targetConn, wsConn) + targetConn.Close() + }() + io.Copy(wsConn, targetConn) + wsConn.Close() + } + websocket.Server{Handler: handler}.ServeHTTP(w, r) + }) + mux.Handle(*udpPathFlag, http.StripPrefix(*udpPathFlag, handler)) + } + server := http.Server{Handler: mux} go func() { if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { log.Fatalf("Error running web server: %v", err)