Skip to content

Commit

Permalink
feat: ✨ add OutlineDevice API that uses Outline SDK
Browse files Browse the repository at this point in the history
  • Loading branch information
jyyi1 committed Aug 17, 2023
1 parent 6c3c63a commit 045a025
Show file tree
Hide file tree
Showing 8 changed files with 539 additions and 0 deletions.
23 changes: 23 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
module github.com/Jigsaw-Code/outline-apps

go 1.21.0

require (
github.com/Jigsaw-Code/outline-sdk v0.0.2
github.com/Jigsaw-Code/outline-sdk/x v0.0.0-20230807220427-893de7fdc6b8
github.com/stretchr/testify v1.8.4
golang.org/x/sys v0.11.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/eycorsican/go-tun2socks v1.16.11 // indirect
github.com/miekg/dns v1.1.54 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/tools v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
50 changes: 50 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
github.com/Jigsaw-Code/outline-sdk v0.0.2 h1:uCuyJMaWj57IYEG/Hdml8YMdk9chU60ZkSxJXBhyGHU=
github.com/Jigsaw-Code/outline-sdk v0.0.2/go.mod h1:hhlKz0+r9wSDFT8usvN8Zv/BFToCIFAUn1P2Qk8G2CM=
github.com/Jigsaw-Code/outline-sdk/x v0.0.0-20230807220427-893de7fdc6b8 h1:BxOHmmuppPM8K0DGUsfvajKF4PKfGxv9boNDhmbszFU=
github.com/Jigsaw-Code/outline-sdk/x v0.0.0-20230807220427-893de7fdc6b8/go.mod h1:tBqJXpVm+kym+EAUdwNodcFxy872FfjVErfj8Br+gs0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eycorsican/go-tun2socks v1.16.11 h1:+hJDNgisrYaGEqoSxhdikMgMJ4Ilfwm/IZDrWRrbaH8=
github.com/eycorsican/go-tun2socks v1.16.11/go.mod h1:wgB2BFT8ZaPKyKOQ/5dljMG/YIow+AIXyq4KBwJ5sGQ=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/miekg/dns v1.1.54 h1:5jon9mWcb0sFJGpnI99tOMhCPyJ+RPVz5b63MQG0VWI=
github.com/miekg/dns v1.1.54/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s=
github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28=
github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM=
github.com/songgao/water v0.0.0-20190725173103-fd331bda3f4b/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20191021144547-ec77196f6094/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
98 changes: 98 additions & 0 deletions outline/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright 2023 The Outline Authors
//
// 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
//
// http://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 (
"encoding/json"
"fmt"

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

// An internal configuration data structure to be used by Outline transports.
type TransportConfig struct {
Hostname string
Port int
CryptoKey *shadowsocks.EncryptionKey
Prefix []byte
}

// The configuration interface between the Outline backend and Outline apps.
// Must match the ShadowsocksSessionConfig interface defined in Outline Client.
type configJSON struct {
Host string `json:"host"`
Port uint16 `json:"port"`
Password string `json:"password"`
Method string `json:"method"`
Prefix string `json:"prefix"`
}

// ParseConfigFromJSON parses a transport configuration string in JSON format, and returns a corresponding
// TransportConfig. The JSON string `in` must match the ShadowsocksSessionConfig interface defined in Outline Client.
func ParseConfigFromJSON(in string) (config *TransportConfig, err error) {
var confJson configJSON
if err = json.Unmarshal([]byte(in), &confJson); err != nil {
return nil, err
}
if err = validateConfig(&confJson); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}

config = &TransportConfig{
Hostname: confJson.Host,
Port: int(confJson.Port),
}
if config.CryptoKey, err = shadowsocks.NewEncryptionKey(confJson.Method, confJson.Password); err != nil {
return nil, fmt.Errorf("invalid cipher: %w", err)
}
if len(confJson.Prefix) > 0 {
if config.Prefix, err = parseStringPrefix(confJson.Prefix); err != nil {
return nil, fmt.Errorf("invalid configuration prefix: %w", err)
}
}

return config, nil
}

// validateConfig validates whether an Outline transport configuration is valid (it won't do any connectivity tests).
//
// Returns nil if it is valid; or an error if not.
func validateConfig(config *configJSON) error {
if len(config.Host) == 0 {
return fmt.Errorf("must provide a hostname or IP address")
}
if config.Port <= 0 || config.Port > 65535 {
return fmt.Errorf("port must be within range [1..65535]")
}
if len(config.Method) == 0 {
return fmt.Errorf("must provide an encryption cipher method")
}
if len(config.Password) == 0 {
return fmt.Errorf("must provide an encryption cipher password")
}
return nil
}

func parseStringPrefix(utf8Str string) ([]byte, error) {
runes := []rune(utf8Str)
rawBytes := make([]byte, len(runes))
for i, r := range runes {
if (r & 0xFF) != r {
return nil, fmt.Errorf("character out of range: %d", r)
}
rawBytes[i] = byte(r)
}
return rawBytes, nil
}
141 changes: 141 additions & 0 deletions outline/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright 2023 The Outline Authors
//
// 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
//
// http://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 (
"testing"

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

func Test_ParseConfigFromJSON(t *testing.T) {
tests := []struct {
name string
input string
expectErr bool
expectHost string
expectPort int
expectPrefix []byte
}{
{
name: "normal config",
input: `{"host":"192.0.2.1","port":12345,"method":"chacha20-ietf-poly1305","password":"abcd1234"}`,
expectHost: "192.0.2.1",
expectPort: 12345,
},
{
name: "normal config with prefix",
input: `{"host":"192.0.2.1","port":12345,"method":"aes-128-gcm","password":"abcd1234","prefix":"abc 123"}`,
expectHost: "192.0.2.1",
expectPort: 12345,
expectPrefix: []byte("abc 123"),
},
{
name: "normal config with extra fields",
input: `{"extra_field":"ignored","host":"192.0.2.1","port":12345,"method":"aes-192-gcm","password":"abcd1234"}`,
expectHost: "192.0.2.1",
expectPort: 12345,
},
{
name: "unprintable prefix",
input: `{"host":"192.0.2.1","port":12345,"method":"AES-256-gcm","password":"abcd1234","prefix":"abc 123","prefix":"\u0000\u0080\u00ff"}`,
expectHost: "192.0.2.1",
expectPort: 12345,
expectPrefix: []byte{0x00, 0x80, 0xff},
},
{
name: "multi-byte utf-8 prefix",
input: `{"host":"192.0.2.1","port":12345,"method":"chacha20-ietf-poly1305","password":"abcd1234","prefix":"abc 123","prefix":"` + "\xc2\x80\xc2\x81\xc3\xbd\xc3\xbf" + `"}`,
expectHost: "192.0.2.1",
expectPort: 12345,
expectPrefix: []byte{0x80, 0x81, 0xfd, 0xff},
},
{
name: "missing host",
input: `{"port":12345,"method":"AES-128-GCM","password":"abcd1234"}`,
expectErr: true,
},
{
name: "missing port",
input: `{"host":"192.0.2.1","method":"aes-192-gcm","password":"abcd1234"}`,
expectErr: true,
},
{
name: "missing method",
input: `{"host":"192.0.2.1","port":12345,"password":"abcd1234"}`,
expectHost: "192.0.2.1",
expectErr: true,
},
{
name: "missing password",
input: `{"host":"192.0.2.1","port":12345,"method":"chacha20-ietf-poly1305"}`,
expectErr: true,
},
{
name: "empty host",
input: `{"host":"","port":12345,"method":"chacha20-ietf-poly1305","password":"abcd1234"}`,
expectErr: true,
},
{
name: "zero port",
input: `{"host":"192.0.2.1","port":0,"method":"chacha20-ietf-poly1305","password":"abcd1234"}`,
expectErr: true,
},
{
name: "empty method",
input: `{"host":"192.0.2.1","port":12345,"method":"","password":"abcd1234"}`,
expectErr: true,
},
{
name: "empty password",
input: `{"host":"192.0.2.1","port":12345,"method":"chacha20-ietf-poly1305","password":""}`,
expectErr: true,
},
{
name: "empty prefix",
input: `{"host":"192.0.2.1","port":12345,"method":"some-cipher","password":"abcd1234","prefix":""}`,
expectErr: true,
},
{
name: "port -1",
input: `{"host":"192.0.2.1","port":-1,"method":"aes-128-gcm","password":"abcd1234"}`,
expectErr: true,
},
{
name: "port 65536",
input: `{"host":"192.0.2.1","port":65536,"method":"aes-128-gcm","password":"abcd1234"}`,
expectErr: true,
},
{
name: "prefix out-of-range",
input: `{"host":"192.0.2.1","port":8080,"method":"aes-128-gcm","password":"abcd1234","prefix":"\x1234"}`,
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseConfigFromJSON(tt.input)
if tt.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.expectHost, got.Hostname)
require.Equal(t, tt.expectPort, got.Port)
require.NotNil(t, got.CryptoKey)
require.Equal(t, tt.expectPrefix, got.Prefix)
}
})
}
}
68 changes: 68 additions & 0 deletions outline/device/device.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright 2023 The Outline Authors
//
// 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
//
// http://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 device

import (
"fmt"

"github.com/Jigsaw-Code/outline-apps/outline/config"
"github.com/Jigsaw-Code/outline-sdk/network"
"github.com/Jigsaw-Code/outline-sdk/network/lwip2transport"
"github.com/Jigsaw-Code/outline-sdk/transport"
)

const (
connectivityTestDNSResolver = "1.1.1.1:53"
connectivityTestTargetDomain = "www.google.com"
)

// OutlineDevice delegates the TCP and UDP traffic from local machine to the remote Outline server.
type OutlineDevice struct {
t2s network.IPDevice
pp *outlinePacketProxy
sd transport.StreamDialer
}

// NewOutlineDevice creates a new [OutlineDevice] that can relay traffic to a remote Outline server.
func NewOutlineDevice(configJSON string) (d *OutlineDevice, err error) {
config, err := config.ParseConfigFromJSON(configJSON)
if err != nil {
return nil, err
}

d = &OutlineDevice{}

if d.sd, err = newOutlineStreamDialer(config); err != nil {
return nil, fmt.Errorf("failed to create TCP dialer: %w", err)
}

if d.pp, err = newOutlinePacketProxy(config); err != nil {
return nil, fmt.Errorf("failed to create UDP proxy: %w", err)
}

if d.t2s, err = lwip2transport.ConfigureDevice(d.sd, d.pp); err != nil {
return nil, fmt.Errorf("failed to configure lwIP: %w", err)
}

return
}

func (d *OutlineDevice) Close() error {
return d.t2s.Close()
}

func (d *OutlineDevice) Refresh() error {
return d.pp.testConnectivityAndRefresh(connectivityTestDNSResolver, connectivityTestTargetDomain)
}
Loading

0 comments on commit 045a025

Please sign in to comment.