Skip to content

Commit

Permalink
feat(transport): TLS StreamDialer & Dynamic HTTP CONNECT (#117)
Browse files Browse the repository at this point in the history
  • Loading branch information
fortuna authored Oct 30, 2023
1 parent 01a1746 commit c103980
Show file tree
Hide file tree
Showing 11 changed files with 737 additions and 77 deletions.
49 changes: 20 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,37 +89,28 @@ Steps:

This launch is currently in Beta. Most of the code is not new. It's the same code that is currently being used by the production Outline Client and Server. The SDK repackages code from [outline-ss-server](https://github.com/Jigsaw-Code/outline-ss-server) and [outline-go-tun2socks](https://github.com/Jigsaw-Code/outline-go-tun2socks) in a way that is easier to reuse and extend.

### Beta

The goal of the Beta release is to make the SDK available for broad consumption, with no major expected changes to the APIs and all supporting resources in place (website, documentation, examples, and so on).

Beta features:
### Features

- Network-level libraries
- [x] Add IP Device abstraction (v0.0.2)
- [x] Add IP Device implementation based on go-tun2socks (LWIP) (v0.0.2)
- [x] Add UDP handler to fallback to DNS-over-TCP (v0.0.2)
- [x] Add DelegatePacketProxy for runtime PacketProxy replacement (v0.0.2)

- Network library implementations
- [ ] Use network libraries in the Outline Client (coming soon)
- [ ] Add extensive testing (coming soon)

- Transport-level libraries
- [x] Add generic transport client primitives (`StreamDialer`, `PacketListener` and Endpoints) (v0.0.2)
- [x] Add TCP and UDP client implementations (v0.0.2)
- [x] Add Shadowsocks client implementations (v0.0.2)
- [x] Use transport libraries in the Outline Client (v0.0.2)
- [x] Use transport libraries in the Outline Server (v0.0.2)

- Transport client strategies
- Proxyless strategies
- [ ] Encrypted DNS (coming soon)
- [x] Packet splitting ([reference](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/transport/split)) (v0.0.6)
- Proxy-based strategies
- [ ] HTTP Connect (coming soon)
- [x] SOCKS5 StreamDialer ([reference](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/transport/socks5)) (v0.0.6)
- [ ] SOCKS5 PacketDialer (coming soon)
- [x] IP Device abstraction (v0.0.2)
- [x] IP Device implementation based on go-tun2socks (LWIP) (v0.0.2)
- [x] UDP handler to fallback to DNS-over-TCP (v0.0.2)
- [x] DelegatePacketProxy for runtime PacketProxy replacement (v0.0.2)

- Proxy protocols
- [x] TCP and UDP Dialers (v0.0.2)
- [x] Shadowsocks wrappers and Dialers ([reference](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks)) (v0.0.2)
- [x] SOCKS5 StreamDialer ([reference](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/transport/socks5)) (v0.0.6)
- [ ] SOCKS5 PacketDialer (coming soon)
- [ ] HTTP Connect (coming soon)

- Transport protocols
- [x] Stream (TCP) split ([reference](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/transport/split)) (v0.0.6)
- [x] TLS connection wrapper and StreamDialer ([reference](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/transport/tls))

- Name resolution
- [ ] Resilient DNS (coming soon)
- [ ] Encrypted DNS (coming soon)

- Integration resources
- For Mobile apps
Expand Down
33 changes: 23 additions & 10 deletions transport/socks5/stream_dialer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package socks5
import (
"context"
"errors"
"fmt"
"io"
"net"
"sync"
Expand Down Expand Up @@ -70,15 +71,21 @@ func TestSOCKS5Dialer_DialError(t *testing.T) {
require.NoError(t, err, "Failed to create TCP listener: %v", err)
defer listener.Close()

testExchange(t, listener, "example.com:443", nil, nil, ErrGeneralServerFailure)
testExchange(t, listener, "example.com:443", nil, nil, ErrConnectionNotAllowedByRuleset)
testExchange(t, listener, "example.com:443", nil, nil, ErrNetworkUnreachable)
testExchange(t, listener, "example.com:443", nil, nil, ErrHostUnreachable)
testExchange(t, listener, "example.com:443", nil, nil, ErrConnectionRefused)
testExchange(t, listener, "example.com:443", nil, nil, ErrTTLExpired)
testExchange(t, listener, "example.com:443", nil, nil, ErrCommandNotSupported)
testExchange(t, listener, "example.com:443", nil, nil, ErrAddressTypeNotSupported)
testExchange(t, listener, "example.com:443", nil, nil, ReplyCode(0xff))
for _, replyCode := range []ReplyCode{
ErrGeneralServerFailure,
ErrConnectionNotAllowedByRuleset,
ErrNetworkUnreachable,
ErrHostUnreachable,
ErrConnectionRefused,
ErrTTLExpired,
ErrCommandNotSupported,
ErrAddressTypeNotSupported,
ReplyCode(0xff),
} {
t.Run(fmt.Sprintf("ReplyCode=%v", replyCode), func(t *testing.T) {
testExchange(t, listener, "example.com:443", nil, nil, ErrGeneralServerFailure)
})
}
}

func testExchange(tb testing.TB, listener *net.TCPListener, destAddr string, request []byte, response []byte, replyCode ReplyCode) {
Expand Down Expand Up @@ -145,8 +152,14 @@ func testExchange(tb testing.TB, listener *net.TCPListener, destAddr string, req
require.Equal(tb, len(response), n)
}

// There's a race condition here. If the replyCode is an error, the client may close
// the connection before we have a chance to close the write, resulting in the error
// "shutdown: transport endpoint is not connected". For that reason we don't treat the
// error as fatal.
err = clientConn.CloseWrite()
assert.NoError(tb, err, "CloseWrite failed: %v", err)
if err != nil {
tb.Logf("CloseWrite failed: %v", err)
}
}()

running.Wait()
Expand Down
85 changes: 53 additions & 32 deletions x/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.

// Package config provides convenience functions to create [transport.StreamDialer] and [transport.PacketDialer]
// objects based on a text config. This is experimental and mostly for illustrative purposes at this point.
package config

import (
Expand All @@ -28,13 +26,38 @@ import (
"github.com/Jigsaw-Code/outline-sdk/transport/split"
)

func parseConfigPart(oneDialerConfig string) (*url.URL, error) {
oneDialerConfig = strings.TrimSpace(oneDialerConfig)
if oneDialerConfig == "" {
return nil, errors.New("empty config part")
}
// Make it "<scheme>:" it it's only "<scheme>" to parse as a URL.
if !strings.Contains(oneDialerConfig, ":") {
oneDialerConfig += ":"
}
url, err := url.Parse(oneDialerConfig)
if err != nil {
return nil, fmt.Errorf("part is not a valid URL: %w", err)
}
return url, nil
}

// NewStreamDialer creates a new [transport.StreamDialer] according to the given config.
func NewStreamDialer(transportConfig string) (dialer transport.StreamDialer, err error) {
dialer = &transport.TCPStreamDialer{}
func NewStreamDialer(transportConfig string) (transport.StreamDialer, error) {
return WrapStreamDialer(&transport.TCPStreamDialer{}, transportConfig)
}

// WrapStreamDialer created a [transport.StreamDialer] according to transportConfig, using dialer as the
// base [transport.StreamDialer]. The given dialer must not be nil.
func WrapStreamDialer(dialer transport.StreamDialer, transportConfig string) (transport.StreamDialer, error) {
if dialer == nil {
return nil, errors.New("base dialer must not be nil")
}
transportConfig = strings.TrimSpace(transportConfig)
if transportConfig == "" {
return dialer, nil
}
var err error
for _, part := range strings.Split(transportConfig, "|") {
dialer, err = newStreamDialerFromPart(dialer, part)
if err != nil {
Expand All @@ -45,25 +68,17 @@ func NewStreamDialer(transportConfig string) (dialer transport.StreamDialer, err
}

func newStreamDialerFromPart(innerDialer transport.StreamDialer, oneDialerConfig string) (transport.StreamDialer, error) {
oneDialerConfig = strings.TrimSpace(oneDialerConfig)

if oneDialerConfig == "" {
return nil, errors.New("empty config part")
}

url, err := url.Parse(oneDialerConfig)
url, err := parseConfigPart(oneDialerConfig)
if err != nil {
return nil, fmt.Errorf("failed to parse config part: %w", err)
}

switch url.Scheme {
// Please keep scheme list sorted.
switch strings.ToLower(url.Scheme) {
case "socks5":
endpoint := transport.StreamDialerEndpoint{Dialer: innerDialer, Address: url.Host}
return socks5.NewStreamDialer(&endpoint)

case "ss":
return newShadowsocksStreamDialerFromURL(innerDialer, url)

case "split":
prefixBytesStr := url.Opaque
prefixBytes, err := strconv.Atoi(prefixBytesStr)
Expand All @@ -72,6 +87,12 @@ func newStreamDialerFromPart(innerDialer transport.StreamDialer, oneDialerConfig
}
return split.NewStreamDialer(innerDialer, int64(prefixBytes))

case "ss":
return newShadowsocksStreamDialerFromURL(innerDialer, url)

case "tls":
return newTlsStreamDialerFromURL(innerDialer, url)

default:
return nil, fmt.Errorf("config scheme '%v' is not supported", url.Scheme)
}
Expand All @@ -94,26 +115,24 @@ func NewPacketDialer(transportConfig string) (dialer transport.PacketDialer, err
}

func newPacketDialerFromPart(innerDialer transport.PacketDialer, oneDialerConfig string) (transport.PacketDialer, error) {
oneDialerConfig = strings.TrimSpace(oneDialerConfig)

if oneDialerConfig == "" {
return nil, errors.New("empty config part")
}

url, err := url.Parse(oneDialerConfig)
url, err := parseConfigPart(oneDialerConfig)
if err != nil {
return nil, fmt.Errorf("failed to parse config part: %w", err)
}

switch url.Scheme {
// Please keep scheme list sorted.
switch strings.ToLower(url.Scheme) {
case "socks5":
return nil, errors.New("socks5 is not supported for PacketDialers")

case "split":
return nil, errors.New("split is not supported for PacketDialers")

case "ss":
return newShadowsocksPacketDialerFromURL(innerDialer, url)

case "split":
return nil, errors.New("split is not supported for PacketDialers")
case "tls":
return nil, errors.New("tls is not yet supported for PacketDialers")

default:
return nil, fmt.Errorf("config scheme '%v' is not supported", url.Scheme)
Expand All @@ -122,22 +141,24 @@ func newPacketDialerFromPart(innerDialer transport.PacketDialer, oneDialerConfig

// NewpacketListener creates a new [transport.PacketListener] according to the given config,
// the config must contain only one "ss://" segment.
func NewpacketListener(transportConfig string) (transport.PacketListener, error) {
func NewPacketListener(transportConfig string) (transport.PacketListener, error) {
if transportConfig = strings.TrimSpace(transportConfig); transportConfig == "" {
return nil, errors.New("config is required")
}
if strings.Contains(transportConfig, "|") {
return nil, errors.New("multi-part config is not supported")
}

url, err := url.Parse(transportConfig)
url, err := parseConfigPart(transportConfig)
if err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if url.Scheme != "ss" {
return nil, errors.New("config scheme must be 'ss' for a PacketListener")
// Please keep scheme list sorted.
switch strings.ToLower(url.Scheme) {
case "ss":
// TODO: support nested dialer, the last part must be "ss://"
return newShadowsocksPacketListenerFromURL(url)
default:
return nil, fmt.Errorf("config scheme '%v' is not supported", url.Scheme)
}

// TODO: support nested dialer, the last part must be "ss://"
return newShadowsocksPacketListenerFromURL(url)
}
80 changes: 80 additions & 0 deletions x/config/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright 2023 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 provides convenience functions to create dialer objects based on a text config.
This is experimental and mostly for illustrative purposes at this point.
Configurable transports simplifies the way you create and manage transports.
With the config package, you can use [NewPacketDialer] and [NewStreamDialer] to create dialers using a simple text string.
Key Benefits:
- Ease of Use: Create transports effortlessly by providing a textual configuration, reducing boilerplate code.
- Serialization: Easily share configurations with users or between different parts of your application, including your Go backend.
- Dynamic Configuration: Set your app's transport settings at runtime.
- DPI Evasion: Advanced nesting and configuration options help you evade Deep Packet Inspection (DPI).
# Config Format
The configuration string is composed of parts separated by the `|` symbol, which define nested dialers.
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:
Shadowsocks proxy (compatible with Outline's access keys, package [transport/shadowsocks])
ss://[USERINFO]@[HOST]:[PORT]?prefix=[PREFIX]
SOCKS5 proxy (currently streams only, package [transport/socks5])
socks5://[HOST]:[PORT]
Stream split transport (streams only, package [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 transport (currently streams only, package [x/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]
# Examples
Packet splitting - To split outgoing streams on bytes 2 and 123, you can use:
split:2|split:123
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:
tls:sni=decoy.example.com&certname=[HOST]|socks5:[HOST]:[PORT]
Onion Routing with Shadowsocks - To route your traffic through three Shadowsocks servers, similar to [Onion Routing], use:
ss://[USERINFO1]@[HOST1]:[PORT1]|ss://[USERINFO2]@[HOST2]:[PORT2]|ss://[USERINFO3]@[HOST3]:[PORT3]
In that case, HOST1 will be your entry node, and HOST3 will be your exit node.
DPI Evasion - To add packet splitting to a Shadowsocks server for enhanced DPI evasion, use:
split:2|ss://[USERINFO]@[HOST]:[PORT]
[Onion Routing]: https://en.wikipedia.org/wiki/Onion_routing
*/
package config
Loading

0 comments on commit c103980

Please sign in to comment.