Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(transport): TLS StreamDialer & Dynamic HTTP CONNECT #117

Merged
merged 30 commits into from
Oct 30, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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] Packet splitting ([reference](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/transport/split)) (v0.0.6)
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
- [x] TLS wrapper and StreamDialer ([reference](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/transport/tls))
fortuna marked this conversation as resolved.
Show resolved Hide resolved

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

- Integration resources
- For Mobile apps
Expand Down
150 changes: 118 additions & 32 deletions x/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,71 @@
// 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.
/*
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
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 [shadowsocks])

ss://[USERINFO]@[HOST]:[PORT]?prefix=[PREFIX]

SOCKS5 proxy (currently streams only, package [socks5])

socks5://[HOST]:[PORT]

Stream split transport (streams only, package [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 [tls])
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved

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

import (
Expand All @@ -28,13 +91,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("failed to parse config part: %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 +133,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)
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
}

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 +152,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 +180,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)
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
}

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 +206,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)
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
}
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)
}
59 changes: 59 additions & 0 deletions x/config/tls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 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

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

"github.com/Jigsaw-Code/outline-sdk/transport"
"github.com/Jigsaw-Code/outline-sdk/x/tls"
)

func parseOptions(configURL *url.URL) ([]tls.ClientOption, error) {
query := configURL.Opaque
values, err := url.ParseQuery(query)
if err != nil {
return nil, err
}
options := []tls.ClientOption{}
for key, values := range values {
switch strings.ToLower(key) {
case "sni":
if len(values) != 1 {
return nil, fmt.Errorf("sni option must has one value, found %v", len(values))
}
options = append(options, tls.WithSNI(values[0]))
case "certname":
if len(values) != 1 {
return nil, fmt.Errorf("certName option must has one value, found %v", len(values))
}
options = append(options, tls.WithCertificateName(values[0]))
default:
return nil, fmt.Errorf("unsupported option %v", key)

}
}
return options, nil
}

func newTlsStreamDialerFromURL(innerDialer transport.StreamDialer, configURL *url.URL) (transport.StreamDialer, error) {
options, err := parseOptions(configURL)
if err != nil {
return nil, err
}
return tls.NewStreamDialer(innerDialer, options...)
}
Loading