diff --git a/x/go.mod b/x/go.mod index 38cf52eb..42eb6dc6 100644 --- a/x/go.mod +++ b/x/go.mod @@ -11,6 +11,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/eycorsican/go-tun2socks v1.16.11 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect diff --git a/x/go.sum b/x/go.sum index ebfeeb41..b9f4e7d4 100644 --- a/x/go.sum +++ b/x/go.sum @@ -4,6 +4,9 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= @@ -15,6 +18,7 @@ github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr 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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -29,6 +33,7 @@ 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= diff --git a/x/outline-connectivity/access_key.go b/x/internal/outline/access_key.go similarity index 89% rename from x/outline-connectivity/access_key.go rename to x/internal/outline/access_key.go index 248351ab..5c78715e 100644 --- a/x/outline-connectivity/access_key.go +++ b/x/internal/outline/access_key.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package outline import ( "encoding/base64" @@ -25,15 +25,15 @@ import ( "github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks" ) -type sessionConfig struct { +type Prefix []byte + +type SessionConfig struct { Hostname string Port int CryptoKey *shadowsocks.EncryptionKey Prefix Prefix } -type Prefix []byte - func (p Prefix) String() string { runes := make([]rune, len(p)) for i, b := range p { @@ -42,13 +42,14 @@ func (p Prefix) String() string { return string(runes) } -// TODO(fortuna): provide this as a reusable library. Perhaps as x/shadowsocks or x/outline. -func parseAccessKey(accessKey string) (*sessionConfig, error) { - var config sessionConfig +func ParseAccessKey(accessKey string) (*SessionConfig, error) { + var config SessionConfig + accessKeyURL, err := url.Parse(accessKey) if err != nil { return nil, fmt.Errorf("failed to parse access key: %w", err) } + var portString string // Host is a : string config.Hostname, portString, err = net.SplitHostPort(accessKeyURL.Host) @@ -59,9 +60,10 @@ func parseAccessKey(accessKey string) (*sessionConfig, error) { if err != nil { return nil, fmt.Errorf("failed to parse port number: %w", err) } + cipherInfoBytes, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(accessKeyURL.User.String()) if err != nil { - return nil, fmt.Errorf("failed to decode cipher info [%v]: %v", accessKeyURL.User.String(), err) + return nil, fmt.Errorf("failed to decode cipher info [%v]: %w", accessKeyURL.User.String(), err) } cipherName, secret, found := strings.Cut(string(cipherInfoBytes), ":") if !found { @@ -71,6 +73,7 @@ func parseAccessKey(accessKey string) (*sessionConfig, error) { if err != nil { return nil, fmt.Errorf("failed to create cipher: %w", err) } + prefixStr := accessKeyURL.Query().Get("prefix") if len(prefixStr) > 0 { config.Prefix, err = ParseStringPrefix(prefixStr) diff --git a/x/internal/outline/access_key_test.go b/x/internal/outline/access_key_test.go new file mode 100644 index 00000000..1cd39693 --- /dev/null +++ b/x/internal/outline/access_key_test.go @@ -0,0 +1,84 @@ +// 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 outline + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// Make sure ParseKey returns error for an invalid access key +func TestParseKeyInvalidString(t *testing.T) { + inputs := []string{ + "", // empty string + " ", // blank string + "\t\n", // blank string + "what is this?", // random string + "https://example.com", // random https link + } + + for _, in := range inputs { + out, err := ParseAccessKey(in) + require.Error(t, err) + require.Nil(t, out) + } +} + +// Make sure ParseKey works for a normal Outline access key +func TestParseKeyNormalKey(t *testing.T) { + cases := []struct { + input string + host string + port int + prefix []byte + }{ + { + // standard access key (chacha encryption) + input: "ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpteXBhc3M@test.google.com:1234", + host: "test.google.com", + port: 1234, + }, + { + // access key with AES encryption + input: "ss://YWVzLTEyOC1nY206bXlwYXNz@127.0.0.1:4321/?plugin=v2ray-plugin", + host: "127.0.0.1", + port: 4321, + }, + { + // access key with IPv6 and tags + input: "ss://YWVzLTE5Mi1nY206bXlwYXNz@[fe80:0:0:4444:5555:6666:7777:8888]:9999/?outline=1#Test%20Server", + host: "fe80:0:0:4444:5555:6666:7777:8888", + port: 9999, + }, + { + // access key with prefix + input: "ss://QUVTLTI1Ni1nY206bXlwYXNz@xxx.www.outline.yyy.zzz:80/?outline=1&prefix=HTTP%2F1.1%20#random-server", + host: "xxx.www.outline.yyy.zzz", + port: 80, + prefix: []byte("HTTP/1.1 "), + }, + } + + for _, c := range cases { + out, err := ParseAccessKey(c.input) + require.NoError(t, err) + require.NotNil(t, out) + + require.Exactly(t, c.host, out.Hostname) + require.Exactly(t, c.port, out.Port) + require.Equal(t, c.prefix, []byte(out.Prefix)) + } +} diff --git a/x/internal/outline/outline_device.go b/x/internal/outline/outline_device.go new file mode 100644 index 00000000..b497de00 --- /dev/null +++ b/x/internal/outline/outline_device.go @@ -0,0 +1,86 @@ +// 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 outline + +import ( + "errors" + "fmt" + "io" + "sync" + + "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" +) + +type OutlineDevice struct { + t2s network.IPDevice + pp *outlinePacketProxy + sd transport.StreamDialer +} + +func NewOutlineClientDevice(accessKey string) (d *OutlineDevice, err error) { + d = &OutlineDevice{} + + d.sd, err = NewOutlineStreamDialer(accessKey) + if err != nil { + return nil, fmt.Errorf("failed to create TCP dialer: %w", err) + } + + d.pp, err = newOutlinePacketProxy(accessKey) + if err != nil { + return nil, fmt.Errorf("failed to create UDP proxy: %w", err) + } + + d.t2s, err = lwip2transport.ConfigureDevice(d.sd, d.pp) + if 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) +} + +// RelayTraffic copies all traffic between an IPDevice (`netDev`) and the OutlineDevice (`d`) in both directions. +// It will not return until both devices have been closed or any error occur. Therefore, the caller must call this +// function in a goroutine and make sure to close both devices (`netDev` and `d`) asynchronously. +func (d *OutlineDevice) RelayTraffic(netDev io.ReadWriter) error { + var err1, err2 error + + wg := &sync.WaitGroup{} + wg.Add(1) + + go func() { + defer wg.Done() + _, err2 = io.Copy(d.t2s, netDev) + }() + + _, err1 = io.Copy(netDev, d.t2s) + + wg.Wait() + return errors.Join(err1, err2) +} diff --git a/x/internal/outline/outline_packet_listener.go b/x/internal/outline/outline_packet_listener.go new file mode 100644 index 00000000..1017d51d --- /dev/null +++ b/x/internal/outline/outline_packet_listener.go @@ -0,0 +1,34 @@ +// 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 outline + +import ( + "fmt" + "net" + "strconv" + + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks" +) + +func NewOutlinePacketListener(accessKey string) (transport.PacketListener, error) { + config, err := ParseAccessKey(accessKey) + if err != nil { + return nil, fmt.Errorf("access key in invalid: %w", err) + } + + ssAddress := net.JoinHostPort(config.Hostname, strconv.Itoa(config.Port)) + return shadowsocks.NewPacketListener(&transport.UDPEndpoint{Address: ssAddress}, config.CryptoKey) +} diff --git a/x/internal/outline/outline_packet_proxy.go b/x/internal/outline/outline_packet_proxy.go new file mode 100644 index 00000000..4f3451f2 --- /dev/null +++ b/x/internal/outline/outline_packet_proxy.go @@ -0,0 +1,72 @@ +// 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 outline + +import ( + "context" + "fmt" + + "github.com/Jigsaw-Code/outline-sdk/network" + "github.com/Jigsaw-Code/outline-sdk/network/dnstruncate" + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/Jigsaw-Code/outline-sdk/x/connectivity" +) + +type outlinePacketProxy struct { + network.DelegatePacketProxy + + remotePktListener transport.PacketListener // this will be used in connectivity test + remote, fallback network.PacketProxy +} + +func newOutlinePacketProxy(accessKey string) (opp *outlinePacketProxy, err error) { + proxy := outlinePacketProxy{} + + proxy.fallback, err = dnstruncate.NewPacketProxy() + if err != nil { + return nil, fmt.Errorf("failed to create DNS truncate proxy: %w", err) + } + + // Create Shadowsocks UDP PacketProxy + proxy.remotePktListener, err = NewOutlinePacketListener(accessKey) + if err != nil { + return nil, fmt.Errorf("failed to create UDP listener: %w", err) + } + + proxy.remote, err = network.NewPacketProxyFromPacketListener(proxy.remotePktListener) + if err != nil { + return nil, fmt.Errorf("failed to create UDP proxy: %w", err) + } + + // Create DelegatePacketProxy + proxy.DelegatePacketProxy, err = network.NewDelegatePacketProxy(proxy.fallback) + if err != nil { + return nil, fmt.Errorf("failed to create delegate UDP proxy: %w", err) + } + + return &proxy, nil +} + +func (proxy *outlinePacketProxy) testConnectivityAndRefresh(resolver, domain string) error { + dialer := transport.PacketListenerDialer{Listener: proxy.remotePktListener} + dnsResolver := &transport.PacketDialerEndpoint{Dialer: dialer, Address: resolver} + _, err := connectivity.TestResolverPacketConnectivity(context.Background(), dnsResolver, domain) + + if err != nil { + return proxy.SetProxy(proxy.fallback) + } else { + return proxy.SetProxy(proxy.remote) + } +} diff --git a/x/internal/outline/outline_stream_dialer.go b/x/internal/outline/outline_stream_dialer.go new file mode 100644 index 00000000..341f9dc5 --- /dev/null +++ b/x/internal/outline/outline_stream_dialer.go @@ -0,0 +1,42 @@ +// 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 outline + +import ( + "fmt" + "net" + "strconv" + + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks" +) + +func NewOutlineStreamDialer(accessKey string) (transport.StreamDialer, error) { + config, err := ParseAccessKey(accessKey) + if err != nil { + return nil, fmt.Errorf("access key in invalid: %w", err) + } + + ssAddress := net.JoinHostPort(config.Hostname, strconv.Itoa(config.Port)) + dialer, err := shadowsocks.NewStreamDialer(&transport.TCPEndpoint{Address: ssAddress}, config.CryptoKey) + if err != nil { + return nil, err + } + if len(config.Prefix) > 0 { + dialer.SaltGenerator = shadowsocks.NewPrefixSaltGenerator(config.Prefix) + } + + return dialer, nil +} diff --git a/x/outline-connectivity/main.go b/x/outline-connectivity/main.go index 7228d3f7..dd5edcb2 100644 --- a/x/outline-connectivity/main.go +++ b/x/outline-connectivity/main.go @@ -30,6 +30,7 @@ import ( "github.com/Jigsaw-Code/outline-sdk/transport" "github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks" "github.com/Jigsaw-Code/outline-sdk/x/connectivity" + "github.com/Jigsaw-Code/outline-sdk/x/internal/outline" ) var debugLog log.Logger = *log.New(io.Discard, "", 0) @@ -121,7 +122,7 @@ func main() { // - Server IPv4 dial support // - Server IPv6 dial support - config, err := parseAccessKey(*accessKeyFlag) + config, err := outline.ParseAccessKey(*accessKeyFlag) if err != nil { log.Fatal(err.Error()) }