Skip to content

Commit

Permalink
introduce extensible YAML config
Browse files Browse the repository at this point in the history
  • Loading branch information
fortuna committed Jan 15, 2025
1 parent 86781d7 commit 1093e52
Show file tree
Hide file tree
Showing 20 changed files with 1,403 additions and 570 deletions.
93 changes: 38 additions & 55 deletions client/go/outline/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,30 @@
package outline

import (
"fmt"
"context"
"errors"
"net"

"github.com/Jigsaw-Code/outline-apps/client/go/outline/config"
"github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors"
"github.com/Jigsaw-Code/outline-sdk/transport"
"github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks"
"github.com/eycorsican/go-tun2socks/common/log"
)

// Client provides a transparent container for [transport.StreamDialer] and [transport.PacketListener]
// that is exportable (as an opaque object) via gobind.
// It's used by the connectivity test and the tun2socks handlers.
// TODO: Rename to Transport. Needs to update per-platform code.
type Client struct {
transport.StreamDialer
transport.PacketListener
sd *config.Dialer[transport.StreamConn]
pl *config.PacketListener
}

func (c *Client) DialStream(ctx context.Context, address string) (transport.StreamConn, error) {
return c.sd.Dial(ctx, address)
}

func (c *Client) ListenPacket(ctx context.Context) (net.PacketConn, error) {
return c.pl.ListenPacket(ctx)
}

// NewClientResult represents the result of [NewClientAndReturnError].
Expand All @@ -42,67 +51,41 @@ type NewClientResult struct {

// NewClient creates a new Outline client from a configuration string.
func NewClient(transportConfig string) *NewClientResult {
client, err := newClientWithBaseDialers(transportConfig, net.Dialer{KeepAlive: -1}, net.Dialer{})
return &NewClientResult{
Client: client,
Error: platerrors.ToPlatformError(err),
}
}

func newClientWithBaseDialers(transportConfig string, tcpDialer, udpDialer net.Dialer) (*Client, error) {
conf, err := parseConfigFromJSON(transportConfig)
if err != nil {
return nil, err
}
prefixBytes, err := ParseConfigPrefixFromString(conf.Prefix)
tcpDialer := transport.TCPDialer{Dialer: net.Dialer{KeepAlive: -1}}
udpDialer := transport.UDPDialer{}
client, err := newClientWithBaseDialers(transportConfig, &tcpDialer, &udpDialer)
if err != nil {
return nil, err
return &NewClientResult{Error: platerrors.ToPlatformError(err)}
}

return newShadowsocksClient(conf.Host, int(conf.Port), conf.Method, conf.Password, prefixBytes, tcpDialer, udpDialer)
return &NewClientResult{Client: client}
}

func newShadowsocksClient(
host string, port int, cipherName, password string, prefix []byte, tcpDialer, udpDialer net.Dialer,
) (*Client, error) {
if err := validateConfig(host, port, cipherName, password); err != nil {
return nil, err
}

// TODO: consider using net.LookupIP to get a list of IPs, and add logic for optimal selection.
proxyAddress := net.JoinHostPort(host, fmt.Sprint(port))

cryptoKey, err := shadowsocks.NewEncryptionKey(cipherName, password)
func newClientWithBaseDialers(transportConfig string, tcpDialer transport.StreamDialer, udpDialer transport.PacketDialer) (*Client, error) {
transportYAML, err := config.ParseConfigYAML(transportConfig)
if err != nil {
return nil, newIllegalConfigErrorWithDetails("cipher&password pair is not valid",
"cipher|password", cipherName+"|"+password, "valid combination", err)
}

// We disable Keep-Alive as per https://datatracker.ietf.org/doc/html/rfc1122#page-101, which states that it should only be
// enabled in server applications. This prevents the device from unnecessarily waking up to send keep alives.
streamDialer, err := shadowsocks.NewStreamDialer(&transport.TCPEndpoint{Address: proxyAddress, Dialer: tcpDialer}, cryptoKey)
if err != nil {
return nil, platerrors.PlatformError{
Code: platerrors.SetupTrafficHandlerFailed,
Message: "failed to create TCP traffic handler",
Details: platerrors.ErrorDetails{"proxy-protocol": "shadowsocks", "handler": "tcp"},
return nil, &platerrors.PlatformError{
Code: platerrors.IllegalConfig,
Message: "config is not valid YAML",
Cause: platerrors.ToPlatformError(err),
}
}
if len(prefix) > 0 {
log.Debugf("Using salt prefix: %s", string(prefix))
streamDialer.SaltGenerator = shadowsocks.NewPrefixSaltGenerator(prefix)
}

packetListener, err := shadowsocks.NewPacketListener(&transport.UDPEndpoint{Address: proxyAddress, Dialer: udpDialer}, cryptoKey)
transportPair, err := config.NewDefaultTransportProvider(tcpDialer, udpDialer).Parse(context.Background(), transportYAML)
if err != nil {
return nil, platerrors.PlatformError{
Code: platerrors.SetupTrafficHandlerFailed,
Message: "failed to create UDP traffic handler",
Details: platerrors.ErrorDetails{"proxy-protocol": "shadowsocks", "handler": "udp"},
Cause: platerrors.ToPlatformError(err),
if errors.Is(err, errors.ErrUnsupported) {
return nil, &platerrors.PlatformError{
Code: platerrors.IllegalConfig,
Message: "unsupported config",
Cause: platerrors.ToPlatformError(err),
}
} else {
return nil, &platerrors.PlatformError{
Code: platerrors.IllegalConfig,
Message: "failed to create transport",
Cause: platerrors.ToPlatformError(err),
}
}
}

return &Client{StreamDialer: streamDialer, PacketListener: packetListener}, nil
return &Client{sd: transportPair.StreamDialer, pl: transportPair.PacketListener}, nil
}
206 changes: 205 additions & 1 deletion client/go/outline/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,211 @@

package outline

import "testing"
import (
"testing"

"github.com/stretchr/testify/require"
)

func Test_NewTransport_SS_URL(t *testing.T) {
config := "ss://[email protected]:4321/"
firstHop := "example.com:4321"

result := NewClient(config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
require.Equal(t, firstHop, result.Client.pl.FirstHop)
}

func Test_NewTransport_Legacy_JSON(t *testing.T) {
config := `{
"server": "example.com",
"server_port": 4321,
"method": "chacha20-ietf-poly1305",
"password": "SECRET"
}`
firstHop := "example.com:4321"

result := NewClient(config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
require.Equal(t, firstHop, result.Client.pl.FirstHop)
}

func Test_NewTransport_Flexible_JSON(t *testing.T) {
config := `{
# Comment
server: example.com,
server_port: 4321,
method: chacha20-ietf-poly1305,
password: SECRET
}`
firstHop := "example.com:4321"

result := NewClient(config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
require.Equal(t, firstHop, result.Client.pl.FirstHop)
}

func Test_NewTransport_YAML(t *testing.T) {
config := `# Comment
server: example.com
server_port: 4321
method: chacha20-ietf-poly1305
password: SECRET`
firstHop := "example.com:4321"

result := NewClient(config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
require.Equal(t, firstHop, result.Client.pl.FirstHop)
}

func Test_NewTransport_Explicit_endpoint(t *testing.T) {
config := `
endpoint:
$parser: dial
address: example.com:4321
cipher: chacha20-ietf-poly1305
secret: SECRET`
firstHop := "example.com:4321"

result := NewClient(config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
require.Equal(t, firstHop, result.Client.pl.FirstHop)
}

func Test_NewTransport_Multihop_URL(t *testing.T) {
config := `
endpoint:
$parser: dial
address: exit.example.com:4321
dialer: ss://[email protected]:4321/
cipher: chacha20-ietf-poly1305
secret: SECRET`
firstHop := "entry.example.com:4321"

result := NewClient(config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
require.Equal(t, firstHop, result.Client.pl.FirstHop)
}

func Test_NewTransport_Multihop_Explicit(t *testing.T) {
config := `
endpoint:
$parser: dial
address: exit.example.com:4321
dialer:
$parser: shadowsocks
endpoint: entry.example.com:4321
cipher: chacha20-ietf-poly1305
secret: ENTRY_SECRET
cipher: chacha20-ietf-poly1305
secret: EXIT_SECRET`
firstHop := "entry.example.com:4321"

result := NewClient(config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
require.Equal(t, firstHop, result.Client.pl.FirstHop)
}

func Test_NewTransport_Explicit_TCPUDP(t *testing.T) {
config := `
$parser: tcpudp
tcp:
$parser: shadowsocks
endpoint: example.com:80
cipher: chacha20-ietf-poly1305
secret: SECRET
prefix: "POST "
udp:
$parser: shadowsocks
endpoint: example.com:53
cipher: chacha20-ietf-poly1305
secret: SECRET`

result := NewClient(config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, "example.com:80", result.Client.sd.FirstHop)
require.Equal(t, "example.com:53", result.Client.pl.FirstHop)
}

func Test_NewTransport_YAML_Reuse(t *testing.T) {
config := `
$parser: tcpudp
udp: &base
$parser: shadowsocks
endpoint: example.com:4321
cipher: chacha20-ietf-poly1305
secret: SECRET
tcp:
<<: *base
prefix: "POST "`
firstHop := "example.com:4321"

result := NewClient(config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
require.Equal(t, firstHop, result.Client.pl.FirstHop)
}

func Test_NewTransport_YAML_Partial_Reuse(t *testing.T) {
config := `
$parser: tcpudp
tcp:
$parser: shadowsocks
endpoint: example.com:80
<<: &cipher
cipher: chacha20-ietf-poly1305
secret: SECRET
prefix: "POST "
udp:
$parser: shadowsocks
endpoint: example.com:53
<<: *cipher`

result := NewClient(config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, "example.com:80", result.Client.sd.FirstHop)
require.Equal(t, "example.com:53", result.Client.pl.FirstHop)
}

func Test_NewTransport_Unsupported(t *testing.T) {
config := `$parser: unsupported`
result := NewClient(config)
require.Error(t, result.Error, "Got %v", result.Error)
require.Equal(t, "unsupported config", result.Error.Message)
}

/*
TODO: Add Websocket support
func Test_NewTransport_Websocket(t *testing.T) {
config := `
$parser: tcpudp
tcp: &base
$parser: shadowsocks
endpoint:
$parser: websocket
url: https://entrypoint.cdn.example.com/tcp
cipher: chacha20-ietf-poly1305
secret: SECRET
udp:
<<: *base
endpoint:
$parser: websocket
url: https://entrypoint.cdn.example.com/udp`
firstHop := "entrypoint.cdn.example.com:443"
result := NewClient(config)
require.Nil(t, result.Error, "Got %v", result.Error)
require.Equal(t, firstHop, result.Client.sd.FirstHop)
require.Equal(t, firstHop, result.Client.pl.FirstHop)
}
*/

func Test_NewClientFromJSON_Errors(t *testing.T) {
tests := []struct {
Expand Down
Loading

0 comments on commit 1093e52

Please sign in to comment.