-
Notifications
You must be signed in to change notification settings - Fork 51
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
Changes from all commits
3b48d44
616204b
1955bf3
3c7475f
20094ff
84cf12c
30ae0fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Change it to private. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
@@ -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) | ||
|
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)) | ||
} | ||
} |
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) | ||
} |
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For now, let's just rename 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) | ||
} |
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
} | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.