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(client): introduce extensible YAML config #2329

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
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
Loading