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(x): add x/internal/outline package for some shared app logic #39

Closed
wants to merge 7 commits into from
Closed
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
1 change: 1 addition & 0 deletions x/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions x/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -25,15 +25,15 @@ import (
"github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks"
)

type sessionConfig struct {
type Prefix []byte

type SessionConfig struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't match our dynamic key config JSON. If we want to expose a format, we should use either the access key or the config we support.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, however outline-connectivity is referencing these fields. I think ideally the type should be:

type SessionConfig struct {
  // all private, no public fields
}

func (SessionConfig) String() string {
  // this method can be used by connectivity test to print the structured config object
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would still like to keep this intermediate config private and only provide methods that take an access key and return objects.

That doesn't work for outline-connectivity, so let's revert outline-connectivity. It's ok if it duplicates code for now, we can figure that out later.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to provide some sort of shadowsocks-specific public library to parse Shadowsocks URLs, but let's do that later. It needs more discussion. The config area is a whole can of worms.

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 {
Expand All @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change it to private.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this function is not ready for production. The old function supports parsing the legacy Shadowsocks format (code).

We will need to make sure it's on par with the existing code. A good way to do that is by processing the examples from https://github.com/Jigsaw-Code/outline-shadowsocksconfig/blob/master/src/shadowsocks_config.spec.ts.

You will have to address that before we can use it in the Outline client.

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 <host>:<port> string
config.Hostname, portString, err = net.SplitHostPort(accessKeyURL.Host)
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
84 changes: 84 additions & 0 deletions x/internal/outline/access_key_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
86 changes: 86 additions & 0 deletions x/internal/outline/outline_device.go
Original file line number Diff line number Diff line change
@@ -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)
}
34 changes: 34 additions & 0 deletions x/internal/outline/outline_packet_listener.go
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Argh, I just realized that there's a semantic issue here, uncovered by your change.

Is an access key the config for a VPN service, or just the transport for a tun2socks-based VPN?

I think it makes more sense to treat it as a config for the VPN service, so we can augment it with things like, the dns resolvers, the IP address to use, the routes to include/exclude....

However, we've been treating it as the transport for a tun2socks-based VPN. Kind of, since it also includes the service name.

We can pick one or the other, or say it's a VPN config that just includes the transport, but we can't say it's both.

What would happen if an accesskey encoded a wireguard config, for instance?

I'm not sure how to disentangle that right now. It needs some more thought and design.

Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want something just to use in your cli, perhaps just define the NewOutlineClientDevice(accessKey string) for now?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it helps, but I'll point out that the server is configured in a completely different way. That's transport only, and it uses a different format and there's no prefix or host.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends, because a VPN config would also contain something that is not related to OutlineClientDevice, such as the TUN device name, routing table configuration, etc. But OutlineClientDevice may also need something more than a key, for example, the DNS resolver for connectivity test. I feel we may introduce both an OutlineTransportOptions NewOutlineClientDevice(options OutlineTransportOptions) and an OutlineVPNOptions:

type OutlineTransportOptions struct {
  OutlineServerAccessKey string
  ConnectivityTestDNSResolver string
  ...
}

type OutlineVPNOptions struct {
  Transport *OutlineTransportOptions
  Routing *RoutingTableOptions
  ...
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, let's just rename accessKey to transportConfig, so it's clear it must be a transport config, in case we extend the access key later.

The assumption is that the existing accessKey format is a transport config, but that can change.

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)
}
72 changes: 72 additions & 0 deletions x/internal/outline/outline_packet_proxy.go
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DNS fallback logic is really valuable, and others will need it too. Let's decouple it from the transport.

Can you provide a NewOutlineDevice that takes the StreamDialer and the PacketListener?

That way we can layer functionality. People will be able to provide their own transports and leverage the fallback functionality.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can live in the Outline device instead.

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)
}
}
Loading