From 23d53b7c01dfa2a5c1bf749067d7bd8336c8990d Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Thu, 30 Mar 2023 22:49:07 +0000 Subject: [PATCH 1/4] Initial SDK code --- .github/workflows/test.yml | 36 ++ README.md | 23 +- go.mod | 19 + go.sum | 38 ++ internal/slicepool/slicepool.go | 88 ++++ internal/slicepool/slicepool_test.go | 38 ++ transport/packet.go | 48 ++ transport/shadowsocks/cipher.go | 160 +++++++ transport/shadowsocks/cipher_test.go | 60 +++ transport/shadowsocks/cipher_testing.go | 40 ++ .../shadowsocks/client/client_testing.go | 51 ++ .../shadowsocks/client/packet_listener.go | 130 +++++ .../client/packet_listener_test.go | 139 ++++++ transport/shadowsocks/client/salt.go | 50 ++ transport/shadowsocks/client/salt_test.go | 75 +++ transport/shadowsocks/client/stream_dialer.go | 109 +++++ .../shadowsocks/client/stream_dialer_test.go | 226 +++++++++ transport/shadowsocks/compatibility_test.go | 65 +++ transport/shadowsocks/packet.go | 66 +++ transport/shadowsocks/packet_test.go | 42 ++ transport/shadowsocks/salt.go | 37 ++ transport/shadowsocks/salt_test.go | 44 ++ transport/shadowsocks/stream.go | 428 +++++++++++++++++ transport/shadowsocks/stream_test.go | 446 ++++++++++++++++++ transport/stream.go | 98 ++++ transport/stream_test.go | 75 +++ 26 files changed, 2629 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/slicepool/slicepool.go create mode 100644 internal/slicepool/slicepool_test.go create mode 100644 transport/packet.go create mode 100644 transport/shadowsocks/cipher.go create mode 100644 transport/shadowsocks/cipher_test.go create mode 100644 transport/shadowsocks/cipher_testing.go create mode 100644 transport/shadowsocks/client/client_testing.go create mode 100644 transport/shadowsocks/client/packet_listener.go create mode 100644 transport/shadowsocks/client/packet_listener_test.go create mode 100644 transport/shadowsocks/client/salt.go create mode 100644 transport/shadowsocks/client/salt_test.go create mode 100644 transport/shadowsocks/client/stream_dialer.go create mode 100644 transport/shadowsocks/client/stream_dialer_test.go create mode 100644 transport/shadowsocks/compatibility_test.go create mode 100644 transport/shadowsocks/packet.go create mode 100644 transport/shadowsocks/packet_test.go create mode 100644 transport/shadowsocks/salt.go create mode 100644 transport/shadowsocks/salt_test.go create mode 100644 transport/shadowsocks/stream.go create mode 100644 transport/shadowsocks/stream_test.go create mode 100644 transport/stream.go create mode 100644 transport/stream_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..2f4cc9b0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Build and Test + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +permissions: # added using https://github.com/step-security/secure-workflows + contents: read + +jobs: + + build: + name: Build + runs-on: ubuntu-latest + steps: + + - name: Set up Go 1.20 + uses: actions/setup-go@v2 + with: + go-version: ^1.20 + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Get dependencies + run: | + go get -v -t -d ./... + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v -race -bench=. ./... -benchtime=100ms diff --git a/README.md b/README.md index 610823f4..32bfb6a2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,21 @@ -# outline-internal-sdk -SDK to build network tools based on Outline components. +# Outline SDK (Internal, Under Development) + +This is the repository to hold the future Outline SDK as we develop it. the goal is to clean up and move reusable code from [outline-ss-server](https://github.com/Jigsaw-Code/outline-ss-server) and [outline-go-tun2socks](https://github.com/Jigsaw-Code/outline-go-tun2socks). + +Tentative roadmap: + +- Transport libraries + - [x] Generic transport client primitives (`StreamDialer`, `PacketListener` and Endpoints) + - [x] TCP and UDP client implementations + - [x] Shadowsocks client implementations + - [ ] Generic transport server primitives (TBD) + - [ ] TCP and UDP server implementations + - [ ] Shadowsocks server implementations + - [ ] Utility implementations (`ReplaceablePacketListener`, `TruncateDNSPacketListener`) + +- Network libraries + - [ ] Generic network primitives (TBD, something like a generic TUN device) + - [ ] Implementation based on go-tun2socks + +- VPN API + - [ ] VPN API for desktop (Linux, Windows, macOS) diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..63b12a3f --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/Jigsaw-Code/outline-internal-sdk + +go 1.20 + +require ( + github.com/shadowsocks/go-shadowsocks2 v0.1.5 + github.com/stretchr/testify v1.8.2 + golang.org/x/crypto v0.7.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect + golang.org/x/sys v0.6.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..379fc65b --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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/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= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +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.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.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= +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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/slicepool/slicepool.go b/internal/slicepool/slicepool.go new file mode 100644 index 00000000..9a600121 --- /dev/null +++ b/internal/slicepool/slicepool.go @@ -0,0 +1,88 @@ +// Copyright 2020 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 slicepool + +import ( + "sync" +) + +// Pool wraps a sync.Pool of *[]byte. To encourage correct usage, +// all public methods are on slicepool.LazySlice. +// +// All copies of a Pool refer to the same underlying pool. +// +// "*[]byte" is used to avoid a heap allocation when passing a +// []byte to sync.Pool.Put, which leaks its argument to the heap. +type Pool struct { + pool *sync.Pool + len int +} + +// MakePool returns a Pool of slices with the specified length. +func MakePool(sliceLen int) Pool { + return Pool{ + pool: &sync.Pool{ + New: func() interface{} { + slice := make([]byte, sliceLen) + // Return a *[]byte instead of []byte ensures that + // the []byte is not copied, which would cause a heap + // allocation on every call to sync.pool.Put + return &slice + }, + }, + len: sliceLen, + } +} + +func (p *Pool) get() *[]byte { + return p.pool.Get().(*[]byte) +} + +func (p *Pool) put(b *[]byte) { + if len(*b) != p.len || cap(*b) != p.len { + panic("Buffer length mismatch") + } + p.pool.Put(b) +} + +// LazySlice returns an empty LazySlice tied to this Pool. +func (p *Pool) LazySlice() LazySlice { + return LazySlice{pool: p} +} + +// LazySlice holds 0 or 1 slices from a particular Pool. +type LazySlice struct { + slice *[]byte + pool *Pool +} + +// Acquire this slice from the pool and return it. +// This slice must not already be acquired. +func (b *LazySlice) Acquire() []byte { + if b.slice != nil { + panic("buffer already acquired") + } + b.slice = b.pool.get() + return *b.slice +} + +// Release the buffer back to the pool, unless the box is empty. +// The caller must discard any references to the buffer. +func (b *LazySlice) Release() { + if b.slice != nil { + b.pool.put(b.slice) + b.slice = nil + } +} diff --git a/internal/slicepool/slicepool_test.go b/internal/slicepool/slicepool_test.go new file mode 100644 index 00000000..49662c95 --- /dev/null +++ b/internal/slicepool/slicepool_test.go @@ -0,0 +1,38 @@ +// Copyright 2020 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 slicepool + +import ( + "testing" +) + +func TestPool(t *testing.T) { + pool := MakePool(10) + slice := pool.LazySlice() + buf := slice.Acquire() + if len(buf) != 10 { + t.Errorf("Wrong slice length: %d", len(buf)) + } + slice.Release() +} + +func BenchmarkPool(b *testing.B) { + pool := MakePool(10) + for i := 0; i < b.N; i++ { + slice := pool.LazySlice() + slice.Acquire() + slice.Release() + } +} diff --git a/transport/packet.go b/transport/packet.go new file mode 100644 index 00000000..080ec9fc --- /dev/null +++ b/transport/packet.go @@ -0,0 +1,48 @@ +// Copyright 2019 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 transport + +import ( + "context" + "net" +) + +// PacketEndpoint represents an endpoint that can be used to established packet connections (like UDP) +type PacketEndpoint interface { + // Connect creates a connection bound to an endpoint, returning the connection. + Connect(ctx context.Context) (net.Conn, error) +} + +// PacketListener provides a way to create a local unbound packet connection to send packets to different destinations. +type PacketListener interface { + // ListenPacket creates a PacketConn that can be used to relay packets (such as UDP) through some proxy. + ListenPacket(ctx context.Context) (net.PacketConn, error) +} + +// UDPEndpoint is a PacketEndpoint that connects to the given address via UDP +type UDPEndpoint struct { + // The Dialer used to create the net.Conn on Connect(). + Dialer net.Dialer + // The remote address to pass to Dial. + RemoteAddr net.UDPAddr +} + +func (e UDPEndpoint) Connect(ctx context.Context) (net.Conn, error) { + conn, err := e.Dialer.DialContext(ctx, "udp", e.RemoteAddr.String()) + if err != nil { + return nil, err + } + return conn, nil +} diff --git a/transport/shadowsocks/cipher.go b/transport/shadowsocks/cipher.go new file mode 100644 index 00000000..70c6ac44 --- /dev/null +++ b/transport/shadowsocks/cipher.go @@ -0,0 +1,160 @@ +// Copyright 2020 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 shadowsocks + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "crypto/sha1" + "fmt" + "io" + "strings" + + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" +) + +// SupportedCipherNames lists the names of the AEAD ciphers that are supported. +func SupportedCipherNames() []string { + names := make([]string, len(supportedAEADs)) + for i, spec := range supportedAEADs { + names[i] = spec.name + } + return names +} + +type aeadSpec struct { + name string + newInstance func(key []byte) (cipher.AEAD, error) + keySize int + saltSize int + tagSize int +} + +// List of supported AEAD ciphers, as specified at https://shadowsocks.org/en/spec/AEAD-Ciphers.html +var supportedAEADs = [...]aeadSpec{ + newAEADSpec("chacha20-ietf-poly1305", chacha20poly1305.New, chacha20poly1305.KeySize, 32), + newAEADSpec("aes-256-gcm", newAesGCM, 32, 32), + newAEADSpec("aes-192-gcm", newAesGCM, 24, 24), + newAEADSpec("aes-128-gcm", newAesGCM, 16, 16), +} + +func newAEADSpec(name string, newInstance func(key []byte) (cipher.AEAD, error), keySize, saltSize int) aeadSpec { + dummyAead, err := newInstance(make([]byte, keySize)) + if err != nil { + panic(fmt.Sprintf("Failed to initialize AEAD %v", name)) + } + return aeadSpec{name, newInstance, keySize, saltSize, dummyAead.Overhead()} +} + +func getAEADSpec(name string) (*aeadSpec, error) { + name = strings.ToLower(name) + for _, aeadSpec := range supportedAEADs { + if aeadSpec.name == name { + return &aeadSpec, nil + } + } + return nil, fmt.Errorf("unknown cipher %v", name) +} + +func newAesGCM(key []byte) (cipher.AEAD, error) { + blk, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(blk) +} + +func maxTagSize() int { + max := 0 + for _, spec := range supportedAEADs { + if spec.tagSize > max { + max = spec.tagSize + } + } + return max +} + +// Cipher encapsulates a Shadowsocks AEAD spec and a secret +type Cipher struct { + aead aeadSpec + secret []byte +} + +// SaltSize is the size of the salt for this Cipher +func (c *Cipher) SaltSize() int { + return c.aead.saltSize +} + +// TagSize is the size of the AEAD tag for this Cipher +func (c *Cipher) TagSize() int { + return c.aead.tagSize +} + +var subkeyInfo = []byte("ss-subkey") + +// NewAEAD creates the AEAD for this cipher +func (c *Cipher) NewAEAD(salt []byte) (cipher.AEAD, error) { + sessionKey := make([]byte, c.aead.keySize) + r := hkdf.New(sha1.New, c.secret, salt, subkeyInfo) + if _, err := io.ReadFull(r, sessionKey); err != nil { + return nil, err + } + return c.aead.newInstance(sessionKey) +} + +// Function definition at https://www.openssl.org/docs/manmaster/man3/EVP_BytesToKey.html +func simpleEVPBytesToKey(data []byte, keyLen int) []byte { + var derived, di []byte + h := md5.New() + for len(derived) < keyLen { + h.Write(di) + h.Write(data) + derived = h.Sum(derived) + di = derived[len(derived)-h.Size():] + h.Reset() + } + return derived[:keyLen] +} + +// NewCipher creates a Cipher given a cipher name and a secret +func NewCipher(cipherName string, secretText string) (*Cipher, error) { + aeadSpec, err := getAEADSpec(cipherName) + if err != nil { + return nil, err + } + // Key derivation as per https://shadowsocks.org/en/spec/AEAD-Ciphers.html + secret := simpleEVPBytesToKey([]byte(secretText), aeadSpec.keySize) + return &Cipher{*aeadSpec, secret}, nil +} + +// Assumes all ciphers have NonceSize() <= 12. +var zeroNonce [12]byte + +// DecryptOnce will decrypt the cipherText using the cipher and salt, appending the output to plainText. +func DecryptOnce(cipher *Cipher, salt []byte, plainText, cipherText []byte) ([]byte, error) { + aead, err := cipher.NewAEAD(salt) + if err != nil { + return nil, err + } + if len(cipherText) < aead.Overhead() { + return nil, io.ErrUnexpectedEOF + } + if cap(plainText)-len(plainText) < len(cipherText)-aead.Overhead() { + return nil, io.ErrShortBuffer + } + return aead.Open(plainText, zeroNonce[:aead.NonceSize()], cipherText, nil) +} diff --git a/transport/shadowsocks/cipher_test.go b/transport/shadowsocks/cipher_test.go new file mode 100644 index 00000000..8460863e --- /dev/null +++ b/transport/shadowsocks/cipher_test.go @@ -0,0 +1,60 @@ +// Copyright 2020 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 shadowsocks + +import ( + "testing" +) + +func assertCipher(t *testing.T, name string, saltSize, tagSize int) { + cipher, err := NewCipher(name, "") + if err != nil { + t.Fatal(err) + } + if cipher.SaltSize() != saltSize || cipher.TagSize() != tagSize { + t.Fatalf("Bad spec for %v", name) + } +} + +func TestSizes(t *testing.T) { + // Values from https://shadowsocks.org/en/spec/AEAD-Ciphers.html + assertCipher(t, "chacha20-ietf-poly1305", 32, 16) + assertCipher(t, "aes-256-gcm", 32, 16) + assertCipher(t, "aes-192-gcm", 24, 16) + assertCipher(t, "aes-128-gcm", 16, 16) +} + +func TestUnsupportedCipher(t *testing.T) { + _, err := NewCipher("aes-256-cfb", "") + if err == nil { + t.Errorf("Should get an error for unsupported cipher") + } +} + +func TestMaxNonceSize(t *testing.T) { + for _, aeadName := range SupportedCipherNames() { + cipher, err := NewCipher(aeadName, "") + if err != nil { + t.Errorf("Failed to create Cipher %v: %v", aeadName, err) + } + aead, err := cipher.NewAEAD(make([]byte, cipher.SaltSize())) + if err != nil { + t.Errorf("Failed to create AEAD %v: %v", aeadName, err) + } + if aead.NonceSize() > len(zeroNonce) { + t.Errorf("Cipher %v has nonce size %v > zeroNonce (%v)", aeadName, aead.NonceSize(), len(zeroNonce)) + } + } +} diff --git a/transport/shadowsocks/cipher_testing.go b/transport/shadowsocks/cipher_testing.go new file mode 100644 index 00000000..b4073123 --- /dev/null +++ b/transport/shadowsocks/cipher_testing.go @@ -0,0 +1,40 @@ +// Copyright 2018 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 shadowsocks + +import ( + "fmt" +) + +// TestCipher is a preferred cipher to use in testing. +const TestCipher = "chacha20-ietf-poly1305" + +// MakeTestSecrets returns a slice of `n` test passwords. Not secure! +func MakeTestSecrets(n int) []string { + secrets := make([]string, n) + for i := 0; i < n; i++ { + secrets[i] = fmt.Sprintf("secret-%v", i) + } + return secrets +} + +// MakeTestPayload returns a slice of `size` arbitrary bytes. +func MakeTestPayload(size int) []byte { + payload := make([]byte, size) + for i := 0; i < size; i++ { + payload[i] = byte(i) + } + return payload +} diff --git a/transport/shadowsocks/client/client_testing.go b/transport/shadowsocks/client/client_testing.go new file mode 100644 index 00000000..adcc3766 --- /dev/null +++ b/transport/shadowsocks/client/client_testing.go @@ -0,0 +1,51 @@ +// 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 client + +import ( + "bytes" + "io" + "testing" + + "github.com/Jigsaw-Code/outline-internal-sdk/transport/shadowsocks" +) + +const ( + testTargetAddr = "test.local:1111" +) + +// Writes `payload` to `conn` and reads it into `buf`, which we take as a parameter to avoid +// reallocations in benchmarks and memory profiles. Fails the test if the read payload does not match. +func expectEchoPayload(conn io.ReadWriter, payload, buf []byte, t testing.TB) { + _, err := conn.Write(payload) + if err != nil { + t.Fatalf("Failed to write payload: %v", err) + } + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("Failed to read payload: %v", err) + } + if !bytes.Equal(payload, buf[:n]) { + t.Fatalf("Expected output '%v'. Got '%v'", payload, buf[:n]) + } +} + +func makeTestCipher(tb testing.TB) *shadowsocks.Cipher { + cipher, err := shadowsocks.NewCipher(shadowsocks.TestCipher, "testPassword") + if err != nil { + tb.Fatalf("Failed to create cipher: %v", err) + } + return cipher +} diff --git a/transport/shadowsocks/client/packet_listener.go b/transport/shadowsocks/client/packet_listener.go new file mode 100644 index 00000000..51042fb1 --- /dev/null +++ b/transport/shadowsocks/client/packet_listener.go @@ -0,0 +1,130 @@ +// 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 client + +import ( + "context" + "errors" + "fmt" + "io" + "net" + + "github.com/Jigsaw-Code/outline-internal-sdk/internal/slicepool" + "github.com/Jigsaw-Code/outline-internal-sdk/transport" + "github.com/Jigsaw-Code/outline-internal-sdk/transport/shadowsocks" + "github.com/shadowsocks/go-shadowsocks2/socks" +) + +// clientUDPBufferSize is the maximum supported UDP packet size in bytes. +const clientUDPBufferSize = 16 * 1024 + +// udpPool stores the byte slices used for storing encrypted packets. +var udpPool = slicepool.MakePool(clientUDPBufferSize) + +type packetListener struct { + endpoint transport.PacketEndpoint + cipher *shadowsocks.Cipher +} + +func NewShadowsocksPacketListener(endpoint transport.PacketEndpoint, cipher *shadowsocks.Cipher) (transport.PacketListener, error) { + if endpoint == nil { + return nil, errors.New("argument endpoint must not be nil") + } + if cipher == nil { + return nil, errors.New("argument cipher must not be nil") + } + return &packetListener{endpoint: endpoint, cipher: cipher}, nil +} + +func (c *packetListener) ListenPacket(ctx context.Context) (net.PacketConn, error) { + proxyConn, err := c.endpoint.Connect(ctx) + if err != nil { + return nil, fmt.Errorf("could not connect to endpoint: %x", err) + } + conn := packetConn{Conn: proxyConn, cipher: c.cipher} + return &conn, nil +} + +type packetConn struct { + net.Conn + cipher *shadowsocks.Cipher +} + +// WriteTo encrypts `b` and writes to `addr` through the proxy. +func (c *packetConn) WriteTo(b []byte, addr net.Addr) (int, error) { + socksTargetAddr := socks.ParseAddr(addr.String()) + if socksTargetAddr == nil { + return 0, errors.New("failed to parse target address") + } + lazySlice := udpPool.LazySlice() + cipherBuf := lazySlice.Acquire() + defer lazySlice.Release() + saltSize := c.cipher.SaltSize() + // Copy the SOCKS target address and payload, reserving space for the generated salt to avoid + // partially overlapping the plaintext and cipher slices since `Pack` skips the salt when calling + // `AEAD.Seal` (see https://golang.org/pkg/crypto/cipher/#AEAD). + plaintextBuf := append(append(cipherBuf[saltSize:saltSize], socksTargetAddr...), b...) + buf, err := shadowsocks.Pack(cipherBuf, plaintextBuf, c.cipher) + if err != nil { + return 0, err + } + _, err = c.Conn.Write(buf) + return len(b), err +} + +// ReadFrom reads from the embedded PacketConn and decrypts into `b`. +func (c *packetConn) ReadFrom(b []byte) (int, net.Addr, error) { + lazySlice := udpPool.LazySlice() + cipherBuf := lazySlice.Acquire() + defer lazySlice.Release() + n, err := c.Conn.Read(cipherBuf) + if err != nil { + return 0, nil, err + } + // Decrypt in-place. + buf, err := shadowsocks.Unpack(nil, cipherBuf[:n], c.cipher) + if err != nil { + return 0, nil, err + } + socksSrcAddr := socks.SplitAddr(buf) + if socksSrcAddr == nil { + return 0, nil, errors.New("failed to read source address") + } + srcAddr := newAddr(socksSrcAddr.String(), "udp") + n = copy(b, buf[len(socksSrcAddr):]) // Strip the SOCKS source address + if len(b) < len(buf)-len(socksSrcAddr) { + return n, srcAddr, io.ErrShortBuffer + } + return n, srcAddr, nil +} + +type addr struct { + address string + network string +} + +func (a *addr) String() string { + return a.address +} + +func (a *addr) Network() string { + return a.network +} + +// newAddr returns a net.Addr that holds an address of the form `host:port` with a domain name or IP as host. +// Used for SOCKS addressing. +func newAddr(address, network string) net.Addr { + return &addr{address: address, network: network} +} diff --git a/transport/shadowsocks/client/packet_listener_test.go b/transport/shadowsocks/client/packet_listener_test.go new file mode 100644 index 00000000..65f54c91 --- /dev/null +++ b/transport/shadowsocks/client/packet_listener_test.go @@ -0,0 +1,139 @@ +// 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 client + +import ( + "context" + "io" + "net" + "sync" + "testing" + "time" + + "github.com/Jigsaw-Code/outline-internal-sdk/transport" + "github.com/Jigsaw-Code/outline-internal-sdk/transport/shadowsocks" + "github.com/shadowsocks/go-shadowsocks2/socks" +) + +func TestShadowsocksPacketListener_ListenPacket(t *testing.T) { + cipher := makeTestCipher(t) + proxy, running := startShadowsocksUDPEchoServer(cipher, testTargetAddr, t) + proxyEndpoint := transport.UDPEndpoint{RemoteAddr: *proxy.LocalAddr().(*net.UDPAddr)} + d, err := NewShadowsocksPacketListener(proxyEndpoint, cipher) + if err != nil { + t.Fatalf("Failed to create PacketListener: %v", err) + } + conn, err := d.ListenPacket(context.Background()) + if err != nil { + t.Fatalf("PacketListener.ListenPacket failed: %v", err) + } + defer conn.Close() + conn.SetReadDeadline(time.Now().Add(time.Second * 5)) + pcrw := &packetConnReadWriter{PacketConn: conn, targetAddr: newAddr(testTargetAddr, "udp")} + expectEchoPayload(pcrw, shadowsocks.MakeTestPayload(1024), make([]byte, 1024), t) + + proxy.Close() + running.Wait() +} + +func BenchmarkShadowsocksPacketListener_ListenPacket(b *testing.B) { + b.StopTimer() + b.ResetTimer() + + cipher := makeTestCipher(b) + proxy, running := startShadowsocksUDPEchoServer(cipher, testTargetAddr, b) + proxyEndpoint := transport.UDPEndpoint{RemoteAddr: *proxy.LocalAddr().(*net.UDPAddr)} + d, err := NewShadowsocksPacketListener(proxyEndpoint, cipher) + if err != nil { + b.Fatalf("Failed to create PacketListener: %v", err) + } + conn, err := d.ListenPacket(context.Background()) + if err != nil { + b.Fatalf("PacketListener.ListenPacket failed: %v", err) + } + defer conn.Close() + conn.SetReadDeadline(time.Now().Add(time.Second * 5)) + buf := make([]byte, clientUDPBufferSize) + for n := 0; n < b.N; n++ { + payload := shadowsocks.MakeTestPayload(1024) + pcrw := &packetConnReadWriter{PacketConn: conn, targetAddr: newAddr(testTargetAddr, "udp")} + b.StartTimer() + expectEchoPayload(pcrw, payload, buf, b) + b.StopTimer() + } + + proxy.Close() + running.Wait() +} + +func startShadowsocksUDPEchoServer(cipher *shadowsocks.Cipher, expectedTgtAddr string, t testing.TB) (net.Conn, *sync.WaitGroup) { + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + if err != nil { + t.Fatalf("Proxy ListenUDP failed: %v", err) + } + t.Logf("Starting SS UDP echo proxy at %v\n", conn.LocalAddr()) + cipherBuf := make([]byte, clientUDPBufferSize) + clientBuf := make([]byte, clientUDPBufferSize) + var running sync.WaitGroup + running.Add(1) + go func() { + defer running.Done() + defer conn.Close() + for { + n, clientAddr, err := conn.ReadFromUDP(cipherBuf) + if err != nil { + t.Logf("Failed to read from UDP conn: %v", err) + return + } + buf, err := shadowsocks.Unpack(clientBuf, cipherBuf[:n], cipher) + if err != nil { + t.Fatalf("Failed to decrypt: %v", err) + } + tgtAddr := socks.SplitAddr(buf) + if tgtAddr == nil { + t.Fatalf("Failed to read target address: %v", err) + } + if tgtAddr.String() != expectedTgtAddr { + t.Fatalf("Expected target address '%v'. Got '%v'", expectedTgtAddr, tgtAddr) + } + // Echo both the payload and SOCKS address. + buf, err = shadowsocks.Pack(cipherBuf, buf, cipher) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + conn.WriteTo(buf, clientAddr) + if err != nil { + t.Fatalf("Failed to write: %v", err) + } + } + }() + return conn, &running +} + +// io.ReadWriter adapter for net.PacketConn. Used to share code between UDP and TCP tests. +type packetConnReadWriter struct { + net.PacketConn + io.ReadWriter + targetAddr net.Addr +} + +func (pc *packetConnReadWriter) Read(b []byte) (n int, err error) { + n, _, err = pc.PacketConn.ReadFrom(b) + return +} + +func (pc *packetConnReadWriter) Write(b []byte) (int, error) { + return pc.PacketConn.WriteTo(b, pc.targetAddr) +} diff --git a/transport/shadowsocks/client/salt.go b/transport/shadowsocks/client/salt.go new file mode 100644 index 00000000..9a3b22f4 --- /dev/null +++ b/transport/shadowsocks/client/salt.go @@ -0,0 +1,50 @@ +// Copyright 2022 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 client + +import ( + "crypto/rand" + "errors" + + "github.com/Jigsaw-Code/outline-internal-sdk/transport/shadowsocks" +) + +type prefixSaltGenerator struct { + prefix []byte +} + +func (g prefixSaltGenerator) GetSalt(salt []byte) error { + n := copy(salt, g.prefix) + if n != len(g.prefix) { + return errors.New("prefix is too long") + } + _, err := rand.Read(salt[n:]) + return err +} + +// NewPrefixSaltGenerator returns a SaltGenerator whose output consists of +// the provided prefix, followed by random bytes. This is useful to change +// how shadowsocks traffic is classified by middleboxes. +// +// Note: Prefixes steal entropy from the initialization vector. This weakens +// security by increasing the likelihood that the same IV is used in two +// different connections (which becomes likely once 2^(N/2) connections are +// made, due to the birthday attack). If an IV is reused, the attacker can +// not only decrypt the ciphertext of those two connections; they can also +// easily recover the shadowsocks key and decrypt all other connections to +// this server. Use with care! +func NewPrefixSaltGenerator(prefix []byte) shadowsocks.SaltGenerator { + return prefixSaltGenerator{prefix} +} diff --git a/transport/shadowsocks/client/salt_test.go b/transport/shadowsocks/client/salt_test.go new file mode 100644 index 00000000..32a80e3b --- /dev/null +++ b/transport/shadowsocks/client/salt_test.go @@ -0,0 +1,75 @@ +// Copyright 2022 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 client + +import ( + "testing" + + "github.com/Jigsaw-Code/outline-internal-sdk/transport/shadowsocks" +) + +// setRandomBitsToOne replaces any random bits in the output with 1. +func setRandomBitsToOne(salter shadowsocks.SaltGenerator, output []byte) error { + salt := make([]byte, len(output)) + // OR together 128 salts. The probability that any random bit is + // 0 for all 128 random salts is 2^-128, which is close enough to zero. + for i := 0; i < 128; i++ { + if err := salter.GetSalt(salt); err != nil { + return err + } + for i := range salt { + output[i] |= salt[i] + } + } + return nil +} + +// Test that the prefix bytes are respected, and the remainder are random. +func TestTypicalPrefix(t *testing.T) { + prefix := []byte("twelve bytes") + salter := NewPrefixSaltGenerator(prefix) + + output := make([]byte, 32) + if err := setRandomBitsToOne(salter, output); err != nil { + t.Error(err) + } + + for i := 0; i < 12; i++ { + if output[i] != prefix[i] { + t.Error("prefix mismatch") + } + } + + for _, b := range output[12:] { + if b != 0xFF { + t.Error("unexpected zero bit") + } + } +} + +// Test that all bytes are random when the prefix is nil +func TestNilPrefix(t *testing.T) { + salter := NewPrefixSaltGenerator(nil) + + output := make([]byte, 64) + if err := setRandomBitsToOne(salter, output); err != nil { + t.Error(err) + } + for _, b := range output { + if b != 0xFF { + t.Error("unexpected zero bit") + } + } +} diff --git a/transport/shadowsocks/client/stream_dialer.go b/transport/shadowsocks/client/stream_dialer.go new file mode 100644 index 00000000..86fd1434 --- /dev/null +++ b/transport/shadowsocks/client/stream_dialer.go @@ -0,0 +1,109 @@ +// 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 client + +import ( + "context" + "errors" + "time" + + "github.com/Jigsaw-Code/outline-internal-sdk/transport" + "github.com/Jigsaw-Code/outline-internal-sdk/transport/shadowsocks" + "github.com/shadowsocks/go-shadowsocks2/socks" +) + +type StreamDialer interface { + transport.StreamDialer + + // SetTCPSaltGenerator controls the SaltGenerator used for TCP upstream. + // `salter` may be `nil`. + // This method is not thread-safe. + SetTCPSaltGenerator(shadowsocks.SaltGenerator) +} + +// NewShadowsocksStreamDialer creates a client that routes connections to a Shadowsocks proxy listening at +// the given StreamEndpoint, with `cipher` as the Shadowsocks crypto. +func NewShadowsocksStreamDialer(endpoint transport.StreamEndpoint, cipher *shadowsocks.Cipher) (StreamDialer, error) { + if endpoint == nil { + return nil, errors.New("argument endpoint must not be nil") + } + if cipher == nil { + return nil, errors.New("argument cipher must not be nil") + } + d := streamDialer{endpoint: endpoint, cipher: cipher} + return &d, nil +} + +type streamDialer struct { + endpoint transport.StreamEndpoint + cipher *shadowsocks.Cipher + salter shadowsocks.SaltGenerator +} + +func (c *streamDialer) SetTCPSaltGenerator(salter shadowsocks.SaltGenerator) { + c.salter = salter +} + +// This code contains an optimization to send the initial client payload along with +// the Shadowsocks handshake. This saves one packet during connection, and also +// reduces the distinctiveness of the connection pattern. +// +// Normally, the initial payload will be sent as soon as the socket is connected, +// except for delays due to inter-process communication. However, some protocols +// expect the server to send data first, in which case there is no client payload. +// We therefore use a short delay, longer than any reasonable IPC but shorter than +// typical network latency. (In an Android emulator, the 90th percentile delay +// was ~1 ms.) If no client payload is received by this time, we connect without it. +const helloWait = 10 * time.Millisecond + +// Dial implements StreamDialer.Dial via a Shadowsocks server. +// +// The Shadowsocks StreamDialer returns a connection after the connection to the proxy is established, +// but before the connection to the target is established. That means we cannot signal "connection refused" +// or "connection timeout" errors from the target to the application. +// +// This behavior breaks IPv6 Happy Eyeballs because the application IPv6 socket will connect successfully, +// even if the proxy fails to connect to the IPv6 destination. The broken Happy Eyeballs behavior makes +// IPv6 unusable if the proxy cannot use IPv6. +// +// We can't easily fix that issue because Shadowsocks, unlike SOCKS, does not have a way to indicate +// whether the target connection is successful. Even if that was possible, we want to wait until we have +// initial data from the application in order to send the Shadowsocks salt, SOCKS address and initial data +// all in one packet. This makes the size of the initial packet hard to predict, avoiding packet size +// fingerprinting. We can only get the application initial data if we return a connection first. +func (c *streamDialer) Dial(ctx context.Context, remoteAddr string) (transport.StreamConn, error) { + socksTargetAddr := socks.ParseAddr(remoteAddr) + if socksTargetAddr == nil { + return nil, errors.New("failed to parse target address") + } + proxyConn, err := c.endpoint.Connect(ctx) + if err != nil { + return nil, err + } + ssw := shadowsocks.NewShadowsocksWriter(proxyConn, c.cipher) + if c.salter != nil { + ssw.SetSaltGenerator(c.salter) + } + _, err = ssw.LazyWrite(socksTargetAddr) + if err != nil { + proxyConn.Close() + return nil, errors.New("failed to write target address") + } + time.AfterFunc(helloWait, func() { + ssw.Flush() + }) + ssr := shadowsocks.NewShadowsocksReader(proxyConn, c.cipher) + return transport.WrapConn(proxyConn, ssr, ssw), nil +} diff --git a/transport/shadowsocks/client/stream_dialer_test.go b/transport/shadowsocks/client/stream_dialer_test.go new file mode 100644 index 00000000..8f147e25 --- /dev/null +++ b/transport/shadowsocks/client/stream_dialer_test.go @@ -0,0 +1,226 @@ +// 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 client + +import ( + "context" + "io" + "net" + "sync" + "testing" + "time" + + "github.com/Jigsaw-Code/outline-internal-sdk/transport" + "github.com/Jigsaw-Code/outline-internal-sdk/transport/shadowsocks" + "github.com/shadowsocks/go-shadowsocks2/socks" +) + +func TestShadowsocksStreamDialer_Dial(t *testing.T) { + cipher := makeTestCipher(t) + proxy, running := startShadowsocksTCPEchoProxy(cipher, testTargetAddr, t) + proxyEndpoint := transport.TCPEndpoint{RemoteAddr: *proxy.Addr().(*net.TCPAddr)} + d, err := NewShadowsocksStreamDialer(proxyEndpoint, cipher) + if err != nil { + t.Fatalf("Failed to create StreamDialer: %v", err) + } + conn, err := d.Dial(context.Background(), testTargetAddr) + if err != nil { + t.Fatalf("StreamDialer.Dial failed: %v", err) + } + conn.SetReadDeadline(time.Now().Add(time.Second * 5)) + expectEchoPayload(conn, shadowsocks.MakeTestPayload(1024), make([]byte, 1024), t) + conn.Close() + + proxy.Close() + running.Wait() +} + +func TestShadowsocksStreamDialer_DialNoPayload(t *testing.T) { + cipher := makeTestCipher(t) + proxy, running := startShadowsocksTCPEchoProxy(cipher, testTargetAddr, t) + proxyEndpoint := transport.TCPEndpoint{RemoteAddr: *proxy.Addr().(*net.TCPAddr)} + d, err := NewShadowsocksStreamDialer(proxyEndpoint, cipher) + if err != nil { + t.Fatalf("Failed to create StreamDialer: %v", err) + } + conn, err := d.Dial(context.Background(), testTargetAddr) + if err != nil { + t.Fatalf("StreamDialer.Dial failed: %v", err) + } + + // Wait for more than 10 milliseconds to ensure that the target + // address is sent. + time.Sleep(20 * time.Millisecond) + // Force the echo server to verify the target address. + conn.Close() + + proxy.Close() + running.Wait() +} + +func TestShadowsocksStreamDialer_DialFastClose(t *testing.T) { + // Set up a listener that verifies no data is sent. + listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + if err != nil { + t.Fatalf("ListenTCP failed: %v", err) + } + + done := make(chan struct{}) + go func() { + conn, err := listener.Accept() + if err != nil { + t.Error(err) + } + buf := make([]byte, 64) + n, err := conn.Read(buf) + if n > 0 || err != io.EOF { + t.Errorf("Expected EOF, got %v, %v", buf[:n], err) + } + listener.Close() + close(done) + }() + + cipher := makeTestCipher(t) + proxyEndpoint := transport.TCPEndpoint{RemoteAddr: *listener.Addr().(*net.TCPAddr)} + d, err := NewShadowsocksStreamDialer(proxyEndpoint, cipher) + if err != nil { + t.Fatalf("Failed to create StreamDialer: %v", err) + } + conn, err := d.Dial(context.Background(), testTargetAddr) + if err != nil { + t.Fatalf("StreamDialer.Dial failed: %v", err) + } + + // Wait for less than 10 milliseconds to ensure that the target + // address is not sent. + time.Sleep(1 * time.Millisecond) + // Close the connection before the target address is sent. + conn.Close() + // Wait for the listener to verify the close. + <-done +} + +func TestShadowsocksStreamDialer_TCPPrefix(t *testing.T) { + prefix := []byte("test prefix") + + listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + if err != nil { + t.Fatalf("ListenTCP failed: %v", err) + } + var running sync.WaitGroup + running.Add(1) + go func() { + defer running.Done() + defer listener.Close() + clientConn, err := listener.AcceptTCP() + if err != nil { + t.Logf("AcceptTCP failed: %v", err) + return + } + defer clientConn.Close() + prefixReceived := make([]byte, len(prefix)) + if _, err := io.ReadFull(clientConn, prefixReceived); err != nil { + t.Error(err) + } + for i := range prefix { + if prefixReceived[i] != prefix[i] { + t.Error("prefix contents mismatch") + } + } + }() + + cipher := makeTestCipher(t) + proxyEndpoint := transport.TCPEndpoint{RemoteAddr: *listener.Addr().(*net.TCPAddr)} + d, err := NewShadowsocksStreamDialer(proxyEndpoint, cipher) + if err != nil { + t.Fatalf("Failed to create StreamDialer: %v", err) + } + d.SetTCPSaltGenerator(NewPrefixSaltGenerator(prefix)) + conn, err := d.Dial(context.Background(), testTargetAddr) + if err != nil { + t.Fatalf("StreamDialer.Dial failed: %v", err) + } + conn.Write(nil) + conn.Close() + running.Wait() +} + +func BenchmarkShadowsocksStreamDialer_Dial(b *testing.B) { + b.StopTimer() + b.ResetTimer() + + cipher := makeTestCipher(b) + proxy, running := startShadowsocksTCPEchoProxy(cipher, testTargetAddr, b) + proxyEndpoint := transport.TCPEndpoint{RemoteAddr: *proxy.Addr().(*net.TCPAddr)} + d, err := NewShadowsocksStreamDialer(proxyEndpoint, cipher) + if err != nil { + b.Fatalf("Failed to create StreamDialer: %v", err) + } + conn, err := d.Dial(context.Background(), testTargetAddr) + if err != nil { + b.Fatalf("StreamDialer.Dial failed: %v", err) + } + conn.SetReadDeadline(time.Now().Add(time.Second * 5)) + buf := make([]byte, 1024) + for n := 0; n < b.N; n++ { + payload := shadowsocks.MakeTestPayload(1024) + b.StartTimer() + expectEchoPayload(conn, payload, buf, b) + b.StopTimer() + } + + conn.Close() + proxy.Close() + running.Wait() +} + +func startShadowsocksTCPEchoProxy(cipher *shadowsocks.Cipher, expectedTgtAddr string, t testing.TB) (net.Listener, *sync.WaitGroup) { + listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + if err != nil { + t.Fatalf("ListenTCP failed: %v", err) + } + t.Logf("Starting SS TCP echo proxy at %v\n", listener.Addr()) + var running sync.WaitGroup + running.Add(1) + go func() { + defer running.Done() + defer listener.Close() + for { + clientConn, err := listener.AcceptTCP() + if err != nil { + t.Logf("AcceptTCP failed: %v", err) + return + } + running.Add(1) + go func() { + defer running.Done() + defer clientConn.Close() + ssr := shadowsocks.NewShadowsocksReader(clientConn, cipher) + ssw := shadowsocks.NewShadowsocksWriter(clientConn, cipher) + ssClientConn := transport.WrapConn(clientConn, ssr, ssw) + + tgtAddr, err := socks.ReadAddr(ssClientConn) + if err != nil { + t.Fatalf("Failed to read target address: %v", err) + } + if tgtAddr.String() != expectedTgtAddr { + t.Fatalf("Expected target address '%v'. Got '%v'", expectedTgtAddr, tgtAddr) + } + io.Copy(ssw, ssr) + }() + } + }() + return listener, &running +} diff --git a/transport/shadowsocks/compatibility_test.go b/transport/shadowsocks/compatibility_test.go new file mode 100644 index 00000000..df38a0c2 --- /dev/null +++ b/transport/shadowsocks/compatibility_test.go @@ -0,0 +1,65 @@ +// Copyright 2020 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 shadowsocks + +import ( + "io" + "net" + "sync" + "testing" + + "github.com/shadowsocks/go-shadowsocks2/core" + "github.com/shadowsocks/go-shadowsocks2/shadowaead" + "github.com/stretchr/testify/require" +) + +func TestCompatibility(t *testing.T) { + cipherName := "chacha20-ietf-poly1305" + secret := "secret" + toRight := "payload1" + fromRight := "payload2" + left, right := net.Pipe() + + var wait sync.WaitGroup + wait.Add(1) + go func() { + cipher, err := NewCipher(cipherName, secret) + require.Nil(t, err, "NewCipher failed: %v", err) + ssWriter := NewShadowsocksWriter(left, cipher) + ssWriter.Write([]byte(toRight)) + + ssReader := NewShadowsocksReader(left, cipher) + output := make([]byte, len(fromRight)) + _, err = ssReader.Read(output) + require.Nil(t, err, "Read failed: %v", err) + require.Equal(t, fromRight, string(output)) + left.Close() + wait.Done() + }() + + otherCipher, err := core.PickCipher(cipherName, []byte{}, secret) + require.Nil(t, err) + conn := shadowaead.NewConn(right, otherCipher.(shadowaead.Cipher)) + output := make([]byte, len(toRight)) + _, err = io.ReadFull(conn, output) + require.Nil(t, err) + require.Equal(t, toRight, string(output)) + + _, err = conn.Write([]byte(fromRight)) + require.Nil(t, err, "Write failed: %v", err) + + conn.Close() + wait.Wait() +} diff --git a/transport/shadowsocks/packet.go b/transport/shadowsocks/packet.go new file mode 100644 index 00000000..edde01f9 --- /dev/null +++ b/transport/shadowsocks/packet.go @@ -0,0 +1,66 @@ +// Copyright 2020 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 shadowsocks + +import ( + "errors" + "io" +) + +// ErrShortPacket is identical to shadowaead.ErrShortPacket +var ErrShortPacket = errors.New("short packet") + +// Pack encrypts a Shadowsocks-UDP packet and returns a slice containing the encrypted packet. +// dst must be big enough to hold the encrypted packet. +// If plaintext and dst overlap but are not aligned for in-place encryption, this +// function will panic. +func Pack(dst, plaintext []byte, cipher *Cipher) ([]byte, error) { + saltSize := cipher.SaltSize() + if len(dst) < saltSize { + return nil, io.ErrShortBuffer + } + salt := dst[:saltSize] + if err := RandomSaltGenerator.GetSalt(salt); err != nil { + return nil, err + } + + aead, err := cipher.NewAEAD(salt) + if err != nil { + return nil, err + } + + if len(dst) < saltSize+len(plaintext)+aead.Overhead() { + return nil, io.ErrShortBuffer + } + return aead.Seal(salt, zeroNonce[:aead.NonceSize()], plaintext, nil), nil +} + +// Unpack decrypts a Shadowsocks-UDP packet and returns a slice containing the decrypted payload or an error. +// If dst is present, it is used to store the plaintext, and must have enough capacity. +// If dst is nil, decryption proceeds in-place. +// This function is needed because shadowaead.Unpack() embeds its own replay detection, +// which we do not always want, especially on memory-constrained clients. +func Unpack(dst, pkt []byte, cipher *Cipher) ([]byte, error) { + saltSize := cipher.SaltSize() + if len(pkt) < saltSize { + return nil, ErrShortPacket + } + salt := pkt[:saltSize] + msg := pkt[saltSize:] + if dst == nil { + dst = msg + } + return DecryptOnce(cipher, salt, dst[:0], msg) +} diff --git a/transport/shadowsocks/packet_test.go b/transport/shadowsocks/packet_test.go new file mode 100644 index 00000000..51ab5e79 --- /dev/null +++ b/transport/shadowsocks/packet_test.go @@ -0,0 +1,42 @@ +// Copyright 2022 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 shadowsocks + +import ( + "testing" + "time" +) + +// Microbenchmark for the performance of Shadowsocks UDP encryption. +func BenchmarkPack(b *testing.B) { + b.StopTimer() + b.ResetTimer() + + cipher := newTestCipher(b) + MTU := 1500 + pkt := make([]byte, MTU) + plaintextBuf := pkt[cipher.SaltSize() : len(pkt)-cipher.TagSize()] + + start := time.Now() + b.StartTimer() + for i := 0; i < b.N; i++ { + Pack(pkt, plaintextBuf, cipher) + } + b.StopTimer() + elapsed := time.Now().Sub(start) + + megabits := float64(8*len(plaintextBuf)*b.N) * 1e-6 + b.ReportMetric(megabits/(elapsed.Seconds()), "mbps") +} diff --git a/transport/shadowsocks/salt.go b/transport/shadowsocks/salt.go new file mode 100644 index 00000000..64c60cb4 --- /dev/null +++ b/transport/shadowsocks/salt.go @@ -0,0 +1,37 @@ +// Copyright 2020 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 shadowsocks + +import ( + "crypto/rand" +) + +// SaltGenerator generates unique salts to use in Shadowsocks connections. +type SaltGenerator interface { + // Returns a new salt + GetSalt(salt []byte) error +} + +// randomSaltGenerator generates a new random salt. +type randomSaltGenerator struct{} + +// GetSalt outputs a random salt. +func (randomSaltGenerator) GetSalt(salt []byte) error { + _, err := rand.Read(salt) + return err +} + +// RandomSaltGenerator is a basic SaltGenerator. +var RandomSaltGenerator SaltGenerator = randomSaltGenerator{} diff --git a/transport/shadowsocks/salt_test.go b/transport/shadowsocks/salt_test.go new file mode 100644 index 00000000..647e8b7b --- /dev/null +++ b/transport/shadowsocks/salt_test.go @@ -0,0 +1,44 @@ +// Copyright 2020 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 shadowsocks + +import ( + "bytes" + "testing" +) + +func TestRandomSaltGenerator(t *testing.T) { + if err := RandomSaltGenerator.GetSalt(nil); err != nil { + t.Error(err) + } + salt := make([]byte, 16) + if err := RandomSaltGenerator.GetSalt(salt); err != nil { + t.Error(err) + } + if bytes.Equal(salt, make([]byte, 16)) { + t.Error("Salt is all zeros") + } +} + +func BenchmarkRandomSaltGenerator(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + salt := make([]byte, 32) + for pb.Next() { + if err := RandomSaltGenerator.GetSalt(salt); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/transport/shadowsocks/stream.go b/transport/shadowsocks/stream.go new file mode 100644 index 00000000..7e9ab299 --- /dev/null +++ b/transport/shadowsocks/stream.go @@ -0,0 +1,428 @@ +// Copyright 2018 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 shadowsocks + +import ( + "bytes" + "crypto/cipher" + "encoding/binary" + "fmt" + "io" + "sync" + + "github.com/Jigsaw-Code/outline-internal-sdk/internal/slicepool" +) + +// payloadSizeMask is the maximum size of payload in bytes. +const payloadSizeMask = 0x3FFF // 16*1024 - 1 + +// Buffer pool used for decrypting Shadowsocks streams. +// The largest buffer we could need is for decrypting a max-length payload. +var readBufPool = slicepool.MakePool(payloadSizeMask + maxTagSize()) + +// Writer is an io.Writer that also implements io.ReaderFrom to +// allow for piping the data without extra allocations and copies. +// The LazyWrite and Flush methods allow a header to be +// added but delayed until the first write, for concatenation. +// All methods except Flush must be called from a single thread. +type Writer struct { + // This type is single-threaded except when needFlush is true. + // mu protects needFlush, and also protects everything + // else while needFlush could be true. + mu sync.Mutex + // Indicates that a concurrent flush is currently allowed. + needFlush bool + writer io.Writer + ssCipher *Cipher + saltGenerator SaltGenerator + // Wrapper for input that arrives as a slice. + byteWrapper bytes.Reader + // Number of plaintext bytes that are currently buffered. + pending int + // These are populated by init(): + buf []byte + aead cipher.AEAD + // Index of the next encrypted chunk to write. + counter []byte +} + +// NewShadowsocksWriter creates a Writer that encrypts the given Writer using +// the shadowsocks protocol with the given shadowsocks cipher. +func NewShadowsocksWriter(writer io.Writer, ssCipher *Cipher) *Writer { + return &Writer{writer: writer, ssCipher: ssCipher, saltGenerator: RandomSaltGenerator} +} + +// SetSaltGenerator sets the salt generator to be used. Must be called before the first write. +func (sw *Writer) SetSaltGenerator(saltGenerator SaltGenerator) { + sw.saltGenerator = saltGenerator +} + +// init generates a random salt, sets up the AEAD object and writes +// the salt to the inner Writer. +func (sw *Writer) init() (err error) { + if sw.aead == nil { + salt := make([]byte, sw.ssCipher.SaltSize()) + if err := sw.saltGenerator.GetSalt(salt); err != nil { + return fmt.Errorf("failed to generate salt: %v", err) + } + sw.aead, err = sw.ssCipher.NewAEAD(salt) + if err != nil { + return fmt.Errorf("failed to create AEAD: %v", err) + } + sw.saltGenerator = nil // No longer needed, so release reference. + sw.counter = make([]byte, sw.aead.NonceSize()) + // The maximum length message is the salt (first message only), length, length tag, + // payload, and payload tag. + sizeBufSize := 2 + sw.aead.Overhead() + maxPayloadBufSize := payloadSizeMask + sw.aead.Overhead() + sw.buf = make([]byte, len(salt)+sizeBufSize+maxPayloadBufSize) + // Store the salt at the start of sw.buf. + copy(sw.buf, salt) + } + return nil +} + +// encryptBlock encrypts `plaintext` in-place. The slice must have enough capacity +// for the tag. Returns the total ciphertext length. +func (sw *Writer) encryptBlock(plaintext []byte) int { + out := sw.aead.Seal(plaintext[:0], sw.counter, plaintext, nil) + increment(sw.counter) + return len(out) +} + +func (sw *Writer) Write(p []byte) (int, error) { + sw.byteWrapper.Reset(p) + n, err := sw.ReadFrom(&sw.byteWrapper) + return int(n), err +} + +// LazyWrite queues p to be written, but doesn't send it until Flush() is +// called, a non-lazy write is made, or the buffer is filled. +func (sw *Writer) LazyWrite(p []byte) (int, error) { + if err := sw.init(); err != nil { + return 0, err + } + + // Locking is needed due to potential concurrency with the Flush() + // for a previous call to LazyWrite(). + sw.mu.Lock() + defer sw.mu.Unlock() + + queued := 0 + for { + n := sw.enqueue(p) + queued += n + p = p[n:] + if len(p) == 0 { + sw.needFlush = true + return queued, nil + } + // p didn't fit in the buffer. Flush the buffer and try + // again. + if err := sw.flush(); err != nil { + return queued, err + } + } +} + +// Flush sends the pending data, if any. This method is thread-safe. +func (sw *Writer) Flush() error { + sw.mu.Lock() + defer sw.mu.Unlock() + if !sw.needFlush { + return nil + } + return sw.flush() +} + +func isZero(b []byte) bool { + for _, v := range b { + if v != 0 { + return false + } + } + return true +} + +// Returns the slices of sw.buf in which to place plaintext for encryption. +func (sw *Writer) buffers() (sizeBuf, payloadBuf []byte) { + // sw.buf starts with the salt. + saltSize := sw.ssCipher.SaltSize() + + // Each Shadowsocks-TCP message consists of a fixed-length size block, + // followed by a variable-length payload block. + sizeBuf = sw.buf[saltSize : saltSize+2] + payloadStart := saltSize + 2 + sw.aead.Overhead() + payloadBuf = sw.buf[payloadStart : payloadStart+payloadSizeMask] + return +} + +// ReadFrom implements the io.ReaderFrom interface. +func (sw *Writer) ReadFrom(r io.Reader) (int64, error) { + if err := sw.init(); err != nil { + return 0, err + } + var written int64 + var err error + _, payloadBuf := sw.buffers() + + // Special case: one thread-safe read, if necessary + sw.mu.Lock() + if sw.needFlush { + pending := sw.pending + + sw.mu.Unlock() + saltsize := sw.ssCipher.SaltSize() + overhead := sw.aead.Overhead() + // The first pending+overhead bytes of payloadBuf are potentially + // in use, and may be modified on the flush thread. Data after + // that is safe to use on this thread. + readBuf := sw.buf[saltsize+2+overhead+pending+overhead:] + var plaintextSize int + plaintextSize, err = r.Read(readBuf) + written = int64(plaintextSize) + sw.mu.Lock() + + sw.enqueue(readBuf[:plaintextSize]) + if flushErr := sw.flush(); flushErr != nil { + err = flushErr + } + sw.needFlush = false + } + sw.mu.Unlock() + + // Main transfer loop + for err == nil { + sw.pending, err = r.Read(payloadBuf) + written += int64(sw.pending) + if flushErr := sw.flush(); flushErr != nil { + err = flushErr + } + } + + if err == io.EOF { // ignore EOF as per io.ReaderFrom contract + return written, nil + } + return written, fmt.Errorf("failed to read payload: %w", err) +} + +// Adds as much of `plaintext` into the buffer as will fit, and increases +// sw.pending accordingly. Returns the number of bytes consumed. +func (sw *Writer) enqueue(plaintext []byte) int { + _, payloadBuf := sw.buffers() + n := copy(payloadBuf[sw.pending:], plaintext) + sw.pending += n + return n +} + +// Encrypts all pending data and writes it to the output. +func (sw *Writer) flush() error { + if sw.pending == 0 { + return nil + } + // sw.buf starts with the salt. + saltSize := sw.ssCipher.SaltSize() + // Normally we ignore the salt at the beginning of sw.buf. + start := saltSize + if isZero(sw.counter) { + // For the first message, include the salt. Compared to writing the salt + // separately, this saves one packet during TCP slow-start and potentially + // avoids having a distinctive size for the first packet. + start = 0 + } + + sizeBuf, payloadBuf := sw.buffers() + binary.BigEndian.PutUint16(sizeBuf, uint16(sw.pending)) + sizeBlockSize := sw.encryptBlock(sizeBuf) + payloadSize := sw.encryptBlock(payloadBuf[:sw.pending]) + _, err := sw.writer.Write(sw.buf[start : saltSize+sizeBlockSize+payloadSize]) + sw.pending = 0 + return err +} + +// genericChunkReader is similar to io.Reader, except that it controls its own +// buffer granularity. +type genericChunkReader interface { + // ReadChunk reads the next chunk and returns its payload. The caller must + // complete its use of the returned buffer before the next call. + // The buffer is nil iff there is an error. io.EOF indicates a close. + ReadChunk() ([]byte, error) +} + +type chunkReader struct { + reader io.Reader + ssCipher *Cipher + // These are lazily initialized: + aead cipher.AEAD + // Index of the next encrypted chunk to read. + counter []byte + // Buffer for the uint16 size and its AEAD tag. Made in init(). + payloadSizeBuf []byte + // Holds a buffer for the payload and its AEAD tag, when needed. + payload slicepool.LazySlice +} + +// Reader is an io.Reader that also implements io.WriterTo to +// allow for piping the data without extra allocations and copies. +type Reader interface { + io.Reader + io.WriterTo +} + +// NewShadowsocksReader creates a Reader that decrypts the given Reader using +// the shadowsocks protocol with the given shadowsocks cipher. +func NewShadowsocksReader(reader io.Reader, ssCipher *Cipher) Reader { + return &readConverter{ + cr: &chunkReader{ + reader: reader, + ssCipher: ssCipher, + payload: readBufPool.LazySlice(), + }, + } +} + +// init reads the salt from the inner Reader and sets up the AEAD object +func (cr *chunkReader) init() (err error) { + if cr.aead == nil { + // For chacha20-poly1305, SaltSize is 32, NonceSize is 12 and Overhead is 16. + salt := make([]byte, cr.ssCipher.SaltSize()) + if _, err := io.ReadFull(cr.reader, salt); err != nil { + if err != io.EOF && err != io.ErrUnexpectedEOF { + err = fmt.Errorf("failed to read salt: %w", err) + } + return err + } + cr.aead, err = cr.ssCipher.NewAEAD(salt) + if err != nil { + return fmt.Errorf("failed to create AEAD: %v", err) + } + cr.counter = make([]byte, cr.aead.NonceSize()) + cr.payloadSizeBuf = make([]byte, 2+cr.aead.Overhead()) + } + return nil +} + +// readMessage reads, decrypts, and verifies a single AEAD ciphertext. +// The ciphertext and tag (i.e. "overhead") must exactly fill `buf`, +// and the decrypted message will be placed in buf[:len(buf)-overhead]. +// Returns an error only if the block could not be read. +func (cr *chunkReader) readMessage(buf []byte) error { + _, err := io.ReadFull(cr.reader, buf) + if err != nil { + return err + } + _, err = cr.aead.Open(buf[:0], cr.counter, buf, nil) + increment(cr.counter) + if err != nil { + return fmt.Errorf("failed to decrypt: %v", err) + } + return nil +} + +// ReadChunk returns the next chunk from the stream. Callers must fully +// consume and discard the previous chunk before calling ReadChunk again. +func (cr *chunkReader) ReadChunk() ([]byte, error) { + if err := cr.init(); err != nil { + return nil, err + } + + // Release the previous payload buffer. + cr.payload.Release() + + // In Shadowsocks-AEAD, each chunk consists of two + // encrypted messages. The first message contains the payload length, + // and the second message is the payload. Idle read threads will + // block here until the next chunk. + if err := cr.readMessage(cr.payloadSizeBuf); err != nil { + if err != io.EOF && err != io.ErrUnexpectedEOF { + err = fmt.Errorf("failed to read payload size: %w", err) + } + return nil, err + } + size := int(binary.BigEndian.Uint16(cr.payloadSizeBuf) & payloadSizeMask) + sizeWithTag := size + cr.aead.Overhead() + payloadBuf := cr.payload.Acquire() + if cap(payloadBuf) < sizeWithTag { + // This code is unreachable if the constants are set correctly. + return nil, io.ErrShortBuffer + } + if err := cr.readMessage(payloadBuf[:sizeWithTag]); err != nil { + if err == io.EOF { // EOF is not expected mid-chunk. + err = io.ErrUnexpectedEOF + } + cr.payload.Release() + return nil, err + } + return payloadBuf[:size], nil +} + +// readConverter adapts from ChunkReader, with source-controlled +// chunk sizes, to Go-style IO. +type readConverter struct { + cr genericChunkReader + leftover []byte +} + +func (c *readConverter) Read(b []byte) (int, error) { + if err := c.ensureLeftover(); err != nil { + return 0, err + } + n := copy(b, c.leftover) + c.leftover = c.leftover[n:] + return n, nil +} + +func (c *readConverter) WriteTo(w io.Writer) (written int64, err error) { + for { + if err = c.ensureLeftover(); err != nil { + if err == io.EOF { + err = nil + } + return written, err + } + n, err := w.Write(c.leftover) + written += int64(n) + c.leftover = c.leftover[n:] + if err != nil { + return written, err + } + } +} + +// Ensures that c.leftover is nonempty. If leftover is empty, this method +// waits for incoming data and decrypts it. +// Returns an error only if c.leftover could not be populated. +func (c *readConverter) ensureLeftover() error { + if len(c.leftover) > 0 { + return nil + } + c.leftover = nil + payload, err := c.cr.ReadChunk() + if err != nil { + return err + } + c.leftover = payload + return nil +} + +// increment little-endian encoded unsigned integer b. Wrap around on overflow. +func increment(b []byte) { + for i := range b { + b[i]++ + if b[i] != 0 { + return + } + } +} diff --git a/transport/shadowsocks/stream_test.go b/transport/shadowsocks/stream_test.go new file mode 100644 index 00000000..fb9641dc --- /dev/null +++ b/transport/shadowsocks/stream_test.go @@ -0,0 +1,446 @@ +package shadowsocks + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "strings" + "sync" + "testing" + "time" + + "golang.org/x/crypto/chacha20poly1305" +) + +func newTestCipher(t testing.TB) *Cipher { + cipher, err := NewCipher("chacha20-ietf-poly1305", "test secret") + if err != nil { + t.Fatal(err) + } + return cipher +} + +// Overhead for cipher chacha20poly1305 +const testCipherOverhead = 16 + +func TestCipherReaderAuthenticationFailure(t *testing.T) { + cipher := newTestCipher(t) + + clientReader := strings.NewReader("Fails Authentication") + reader := NewShadowsocksReader(clientReader, cipher) + _, err := reader.Read(make([]byte, 1)) + if err == nil { + t.Fatalf("Expected authentication failure, got %v", err) + } +} + +func TestCipherReaderUnexpectedEOF(t *testing.T) { + cipher := newTestCipher(t) + + clientReader := strings.NewReader("short") + server := NewShadowsocksReader(clientReader, cipher) + _, err := server.Read(make([]byte, 10)) + if err != io.ErrUnexpectedEOF { + t.Fatalf("Expected ErrUnexpectedEOF, got %v", err) + } +} + +func TestCipherReaderEOF(t *testing.T) { + cipher := newTestCipher(t) + + clientReader := strings.NewReader("") + server := NewShadowsocksReader(clientReader, cipher) + _, err := server.Read(make([]byte, 10)) + if err != io.EOF { + t.Fatalf("Expected EOF, got %v", err) + } + _, err = server.Read([]byte{}) + if err != io.EOF { + t.Fatalf("Expected EOF, got %v", err) + } +} + +func encryptBlocks(cipher *Cipher, salt []byte, blocks [][]byte) (io.Reader, error) { + var ssText bytes.Buffer + aead, err := cipher.NewAEAD(salt) + if err != nil { + return nil, fmt.Errorf("Failed to create AEAD: %v", err) + } + ssText.Write(salt) + // buf must fit the larges block ciphertext + buf := make([]byte, 2+100+testCipherOverhead) + var expectedCipherSize int + nonce := make([]byte, chacha20poly1305.NonceSize) + for _, block := range blocks { + ssText.Write(aead.Seal(buf[:0], nonce, []byte{0, byte(len(block))}, nil)) + nonce[0]++ + expectedCipherSize += 2 + testCipherOverhead + ssText.Write(aead.Seal(buf[:0], nonce, block, nil)) + nonce[0]++ + expectedCipherSize += len(block) + testCipherOverhead + } + if ssText.Len() != cipher.SaltSize()+expectedCipherSize { + return nil, fmt.Errorf("cipherText has size %v. Expected %v", ssText.Len(), cipher.SaltSize()+expectedCipherSize) + } + return &ssText, nil +} + +func TestCipherReaderGoodReads(t *testing.T) { + cipher := newTestCipher(t) + + salt := []byte("12345678901234567890123456789012") + if len(salt) != cipher.SaltSize() { + t.Fatalf("Salt has size %v. Expected %v", len(salt), cipher.SaltSize()) + } + ssText, err := encryptBlocks(cipher, salt, [][]byte{ + []byte("[First Block]"), + []byte(""), // Corner case: empty block + []byte("[Third Block]")}) + if err != nil { + t.Fatal(err) + } + + reader := NewShadowsocksReader(ssText, cipher) + plainText := make([]byte, len("[First Block]")+len("[Third Block]")) + n, err := io.ReadFull(reader, plainText) + if err != nil { + t.Fatalf("Failed to fully read plain text. Got %v bytes: %v", n, err) + } + _, err = reader.Read([]byte{}) + if err != io.EOF { + t.Fatalf("Expected EOF, got %v", err) + } + _, err = reader.Read(make([]byte, 1)) + if err != io.EOF { + t.Fatalf("Expected EOF, got %v", err) + } +} + +func TestCipherReaderClose(t *testing.T) { + cipher := newTestCipher(t) + + pipeReader, pipeWriter := io.Pipe() + server := NewShadowsocksReader(pipeReader, cipher) + result := make(chan error) + go func() { + _, err := server.Read(make([]byte, 10)) + result <- err + }() + pipeWriter.Close() + err := <-result + if err != io.EOF { + t.Fatalf("Expected ErrUnexpectedEOF, got %v", err) + } +} + +func TestCipherReaderCloseError(t *testing.T) { + cipher := newTestCipher(t) + + pipeReader, pipeWriter := io.Pipe() + server := NewShadowsocksReader(pipeReader, cipher) + result := make(chan error) + go func() { + _, err := server.Read(make([]byte, 10)) + result <- err + }() + pipeWriter.CloseWithError(fmt.Errorf("xx!!ERROR!!xx")) + err := <-result + if err == nil || !strings.Contains(err.Error(), "xx!!ERROR!!xx") { + t.Fatalf("Unexpected error: %v", err) + } +} + +func TestEndToEnd(t *testing.T) { + cipher := newTestCipher(t) + + connReader, connWriter := io.Pipe() + writer := NewShadowsocksWriter(connWriter, cipher) + reader := NewShadowsocksReader(connReader, cipher) + expected := "Test" + wg := sync.WaitGroup{} + var writeErr error + go func() { + defer connWriter.Close() + wg.Add(1) + defer wg.Done() + _, writeErr = writer.Write([]byte(expected)) + }() + var output bytes.Buffer + _, readErr := reader.WriteTo(&output) + wg.Wait() + if writeErr != nil { + t.Fatalf("Failed Write: %v", writeErr) + } + if readErr != nil { + t.Fatalf("Failed WriteTo: %v", readErr) + } + if output.String() != expected { + t.Fatalf("Expected output '%v'. Got '%v'", expected, output.String()) + } +} + +func TestLazyWriteFlush(t *testing.T) { + cipher := newTestCipher(t) + buf := new(bytes.Buffer) + writer := NewShadowsocksWriter(buf, cipher) + header := []byte{1, 2, 3, 4} + n, err := writer.LazyWrite(header) + if n != len(header) { + t.Errorf("Wrong write size: %d", n) + } + if err != nil { + t.Errorf("LazyWrite failed: %v", err) + } + if buf.Len() != 0 { + t.Errorf("LazyWrite isn't lazy: %v", buf.Bytes()) + } + if err = writer.Flush(); err != nil { + t.Errorf("Flush failed: %v", err) + } + len1 := buf.Len() + if len1 <= len(header) { + t.Errorf("Not enough bytes flushed: %d", len1) + } + + // Check that normal writes now work + body := []byte{5, 6, 7} + n, err = writer.Write(body) + if n != len(body) { + t.Errorf("Wrong write size: %d", n) + } + if err != nil { + t.Errorf("Write failed: %v", err) + } + if buf.Len() == len1 { + t.Errorf("No write observed") + } + + // Verify content arrives in two blocks + reader := NewShadowsocksReader(buf, cipher) + decrypted := make([]byte, len(header)+len(body)) + n, err = reader.Read(decrypted) + if n != len(header) { + t.Errorf("Wrong number of bytes out: %d", n) + } + if err != nil { + t.Errorf("Read failed: %v", err) + } + if !bytes.Equal(decrypted[:n], header) { + t.Errorf("Wrong final content: %v", decrypted) + } + n, err = reader.Read(decrypted[n:]) + if n != len(body) { + t.Errorf("Wrong number of bytes out: %d", n) + } + if err != nil { + t.Errorf("Read failed: %v", err) + } + if !bytes.Equal(decrypted[len(header):], body) { + t.Errorf("Wrong final content: %v", decrypted) + } +} + +func TestLazyWriteConcat(t *testing.T) { + cipher := newTestCipher(t) + buf := new(bytes.Buffer) + writer := NewShadowsocksWriter(buf, cipher) + header := []byte{1, 2, 3, 4} + n, err := writer.LazyWrite(header) + if n != len(header) { + t.Errorf("Wrong write size: %d", n) + } + if err != nil { + t.Errorf("LazyWrite failed: %v", err) + } + if buf.Len() != 0 { + t.Errorf("LazyWrite isn't lazy: %v", buf.Bytes()) + } + + // Write additional data and flush the header. + body := []byte{5, 6, 7} + n, err = writer.Write(body) + if n != len(body) { + t.Errorf("Wrong write size: %d", n) + } + if err != nil { + t.Errorf("Write failed: %v", err) + } + len1 := buf.Len() + if len1 <= len(body)+len(header) { + t.Errorf("Not enough bytes flushed: %d", len1) + } + + // Flush after write should have no effect + if err = writer.Flush(); err != nil { + t.Errorf("Flush failed: %v", err) + } + if buf.Len() != len1 { + t.Errorf("Flush should have no effect") + } + + // Verify content arrives in one block + reader := NewShadowsocksReader(buf, cipher) + decrypted := make([]byte, len(body)+len(header)) + n, err = reader.Read(decrypted) + if n != len(decrypted) { + t.Errorf("Wrong number of bytes out: %d", n) + } + if err != nil { + t.Errorf("Read failed: %v", err) + } + if !bytes.Equal(decrypted[:len(header)], header) || + !bytes.Equal(decrypted[len(header):], body) { + t.Errorf("Wrong final content: %v", decrypted) + } +} + +func TestLazyWriteOversize(t *testing.T) { + cipher := newTestCipher(t) + buf := new(bytes.Buffer) + writer := NewShadowsocksWriter(buf, cipher) + N := 25000 // More than one block, less than two. + data := make([]byte, N) + for i := range data { + data[i] = byte(i) + } + n, err := writer.LazyWrite(data) + if n != len(data) { + t.Errorf("Wrong write size: %d", n) + } + if err != nil { + t.Errorf("LazyWrite failed: %v", err) + } + if buf.Len() >= N { + t.Errorf("Too much data in first block: %d", buf.Len()) + } + if err = writer.Flush(); err != nil { + t.Errorf("Flush failed: %v", err) + } + if buf.Len() <= N { + t.Errorf("Not enough data written after flush: %d", buf.Len()) + } + + // Verify content + reader := NewShadowsocksReader(buf, cipher) + decrypted, err := ioutil.ReadAll(reader) + if len(decrypted) != N { + t.Errorf("Wrong number of bytes out: %d", len(decrypted)) + } + if err != nil { + t.Errorf("Read failed: %v", err) + } + if !bytes.Equal(decrypted, data) { + t.Errorf("Wrong final content: %v", decrypted) + } +} + +func TestLazyWriteConcurrentFlush(t *testing.T) { + cipher := newTestCipher(t) + buf := new(bytes.Buffer) + writer := NewShadowsocksWriter(buf, cipher) + header := []byte{1, 2, 3, 4} + n, err := writer.LazyWrite(header) + if n != len(header) { + t.Errorf("Wrong write size: %d", n) + } + if err != nil { + t.Errorf("LazyWrite failed: %v", err) + } + if buf.Len() != 0 { + t.Errorf("LazyWrite isn't lazy: %v", buf.Bytes()) + } + + body := []byte{5, 6, 7} + r, w := io.Pipe() + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + n, err := writer.ReadFrom(r) + if n != int64(len(body)) { + t.Errorf("ReadFrom: Wrong read size %d", n) + } + if err != nil { + t.Errorf("ReadFrom: %v", err) + } + wg.Done() + }() + + // Wait for ReadFrom to start and get blocked. + time.Sleep(20 * time.Millisecond) + + // Flush while ReadFrom is blocked. + if err := writer.Flush(); err != nil { + t.Errorf("Flush error: %v", err) + } + len1 := buf.Len() + if len1 == 0 { + t.Errorf("No bytes flushed") + } + + // Check that normal writes now work + n, err = w.Write(body) + if n != len(body) { + t.Errorf("Wrong write size: %d", n) + } + if err != nil { + t.Errorf("Write failed: %v", err) + } + w.Close() + wg.Wait() + if buf.Len() == len1 { + t.Errorf("No write observed") + } + + // Verify content arrives in two blocks + reader := NewShadowsocksReader(buf, cipher) + decrypted := make([]byte, len(header)+len(body)) + n, err = reader.Read(decrypted) + if n != len(header) { + t.Errorf("Wrong number of bytes out: %d", n) + } + if err != nil { + t.Errorf("Read failed: %v", err) + } + if !bytes.Equal(decrypted[:len(header)], header) { + t.Errorf("Wrong final content: %v", decrypted) + } + n, err = reader.Read(decrypted[len(header):]) + if n != len(body) { + t.Errorf("Wrong number of bytes out: %d", n) + } + if err != nil { + t.Errorf("Read failed: %v", err) + } + if !bytes.Equal(decrypted[len(header):], body) { + t.Errorf("Wrong final content: %v", decrypted) + } +} + +type nullIO struct{} + +func (n *nullIO) Write(b []byte) (int, error) { + return len(b), nil +} + +func (r *nullIO) Read(b []byte) (int, error) { + return len(b), nil +} + +// Microbenchmark for the performance of Shadowsocks TCP encryption. +func BenchmarkWriter(b *testing.B) { + b.StopTimer() + b.ResetTimer() + + cipher := newTestCipher(b) + writer := NewShadowsocksWriter(new(nullIO), cipher) + + start := time.Now() + b.StartTimer() + io.CopyN(writer, new(nullIO), int64(b.N)) + b.StopTimer() + elapsed := time.Now().Sub(start) + + megabits := 8 * float64(b.N) * 1e-6 + b.ReportMetric(megabits/(elapsed.Seconds()), "mbps") +} diff --git a/transport/stream.go b/transport/stream.go new file mode 100644 index 00000000..48dc2755 --- /dev/null +++ b/transport/stream.go @@ -0,0 +1,98 @@ +// Copyright 2019 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 transport + +import ( + "context" + "io" + "net" +) + +// StreamConn is a net.Conn that allows for closing only the reader or writer end of +// it, supporting half-open state. +type StreamConn interface { + net.Conn + // Closes the Read end of the connection, allowing for the release of resources. + // No more reads should happen. + CloseRead() error + // Closes the Write end of the connection. An EOF or FIN signal may be + // sent to the connection target. + CloseWrite() error +} + +// StreamEndpoint represents an endpoint that can be used to established stream connections (like TCP) +type StreamEndpoint interface { + // Connect establishes a connection with the endpoint, returning the connection. + Connect(ctx context.Context) (StreamConn, error) +} + +// StreamDialer provides a way to establish stream connections to a destination. +type StreamDialer interface { + // Dial connects to `raddr`. + // `raddr` has the form `host:port`, where `host` can be a domain name or IP address. + Dial(ctx context.Context, raddr string) (StreamConn, error) +} + +// TCPEndpoint is a StreamEndpoint that connects to the given address via TCP +type TCPEndpoint struct { + // The Dialer used to create the connection on Connect(). + Dialer net.Dialer + // The remote address to pass to DialTCP. + RemoteAddr net.TCPAddr +} + +func (e TCPEndpoint) Connect(ctx context.Context) (StreamConn, error) { + conn, err := e.Dialer.DialContext(ctx, "tcp", e.RemoteAddr.String()) + if err != nil { + return nil, err + } + return conn.(*net.TCPConn), nil +} + +type duplexConnAdaptor struct { + StreamConn + r io.Reader + w io.Writer +} + +func (dc *duplexConnAdaptor) Read(b []byte) (int, error) { + return dc.r.Read(b) +} +func (dc *duplexConnAdaptor) WriteTo(w io.Writer) (int64, error) { + return io.Copy(w, dc.r) +} +func (dc *duplexConnAdaptor) CloseRead() error { + return dc.StreamConn.CloseRead() +} +func (dc *duplexConnAdaptor) Write(b []byte) (int, error) { + return dc.w.Write(b) +} +func (dc *duplexConnAdaptor) ReadFrom(r io.Reader) (int64, error) { + return io.Copy(dc.w, r) +} +func (dc *duplexConnAdaptor) CloseWrite() error { + return dc.StreamConn.CloseWrite() +} + +// WrapDuplexConn wraps an existing DuplexConn with new Reader and Writer, but +// preserving the original CloseRead() and CloseWrite(). +func WrapConn(c StreamConn, r io.Reader, w io.Writer) StreamConn { + conn := c + // We special-case duplexConnAdaptor to avoid multiple levels of nesting. + if a, ok := c.(*duplexConnAdaptor); ok { + conn = a.StreamConn + } + return &duplexConnAdaptor{StreamConn: conn, r: r, w: w} +} diff --git a/transport/stream_test.go b/transport/stream_test.go new file mode 100644 index 00000000..8adf6cb8 --- /dev/null +++ b/transport/stream_test.go @@ -0,0 +1,75 @@ +// 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 transport + +import ( + "context" + "net" + "sync" + "testing" + "testing/iotest" +) + +func TestNewTCPEndpointIPv4(t *testing.T) { + requestText := []byte("Request") + responseText := []byte("Response") + + listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("Failed to create TCP listener: %v", err) + } + var running sync.WaitGroup + running.Add(1) + go func() { + defer running.Done() + defer listener.Close() + clientConn, err := listener.AcceptTCP() + if err != nil { + t.Errorf("AcceptTCP failed: %v", err) + return + } + defer clientConn.Close() + if err = iotest.TestReader(clientConn, requestText); err != nil { + t.Errorf("Request read failed: %v", err) + return + } + if err = clientConn.CloseRead(); err != nil { + t.Errorf("CloseRead failed: %v", err) + return + } + if _, err = clientConn.Write(responseText); err != nil { + t.Errorf("Write failed: %v", err) + return + } + if err = clientConn.CloseWrite(); err != nil { + t.Errorf("CloseWrite failed: %v", err) + return + } + }() + + e := TCPEndpoint{RemoteAddr: *listener.Addr().(*net.TCPAddr)} + serverConn, err := e.Connect(context.Background()) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer serverConn.Close() + serverConn.Write(requestText) + serverConn.CloseWrite() + if err = iotest.TestReader(serverConn, responseText); err != nil { + t.Fatalf("Response read failed: %v", err) + } + serverConn.CloseRead() + running.Wait() +} From 9bcce5db28acd99454316e90e3cdfd6955f5c023 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Thu, 30 Mar 2023 23:01:49 +0000 Subject: [PATCH 2/4] Add badges --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 32bfb6a2..3841bfed 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # Outline SDK (Internal, Under Development) +![Build Status](https://github.com/Jigsaw-Code/outline-internal-sdk/actions/workflows/test.yml/badge.svg) +[![Go Report Card](https://goreportcard.com/badge/github.com/Jigsaw-Code/outline-internal-sdk)](https://goreportcard.com/report/github.com/Jigsaw-Code/outline-internal-sdk) +[![Go Reference](https://pkg.go.dev/badge/github.com/Jigsaw-Code/outline-internal-sdk.svg)](https://pkg.go.dev/github.com/Jigsaw-Code/outline-internal-sdk) + +[![Mattermost](https://badgen.net/badge/Mattermost/Outline%20Community/blue)](https://community.internetfreedomfestival.org/community/channels/outline-community) +[![Reddit](https://badgen.net/badge/Reddit/r%2Foutlinevpn/orange)](https://www.reddit.com/r/outlinevpn/) + This is the repository to hold the future Outline SDK as we develop it. the goal is to clean up and move reusable code from [outline-ss-server](https://github.com/Jigsaw-Code/outline-ss-server) and [outline-go-tun2socks](https://github.com/Jigsaw-Code/outline-go-tun2socks). Tentative roadmap: From 540cd1d1c9087043ce73efbe6841fdc08480f0a0 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Thu, 30 Mar 2023 23:08:27 +0000 Subject: [PATCH 3/4] Fix workflow --- .github/workflows/test.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f4cc9b0..21e243b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Build and Test on: push: - branches: [ master ] + branches: [ "main" ] pull_request: - branches: [ master ] + branches: [ "main" ] permissions: # added using https://github.com/step-security/secure-workflows contents: read @@ -17,17 +17,12 @@ jobs: steps: - name: Set up Go 1.20 - uses: actions/setup-go@v2 + uses: actions/setup-go@v3 with: go-version: ^1.20 - id: go - name: Check out code into the Go module directory - uses: actions/checkout@v2 - - - name: Get dependencies - run: | - go get -v -t -d ./... + uses: actions/checkout@v3 - name: Build run: go build -v ./... From b22ae06f17149baaa5cb18ff60782ff2cb378f1f Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Thu, 30 Mar 2023 23:21:36 +0000 Subject: [PATCH 4/4] Add warning --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3841bfed..800a9925 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Outline SDK (Internal, Under Development) +# Outline SDK (Under Development, DO NOT USE) ![Build Status](https://github.com/Jigsaw-Code/outline-internal-sdk/actions/workflows/test.yml/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/Jigsaw-Code/outline-internal-sdk)](https://goreportcard.com/report/github.com/Jigsaw-Code/outline-internal-sdk) @@ -7,7 +7,9 @@ [![Mattermost](https://badgen.net/badge/Mattermost/Outline%20Community/blue)](https://community.internetfreedomfestival.org/community/channels/outline-community) [![Reddit](https://badgen.net/badge/Reddit/r%2Foutlinevpn/orange)](https://www.reddit.com/r/outlinevpn/) -This is the repository to hold the future Outline SDK as we develop it. the goal is to clean up and move reusable code from [outline-ss-server](https://github.com/Jigsaw-Code/outline-ss-server) and [outline-go-tun2socks](https://github.com/Jigsaw-Code/outline-go-tun2socks). +This is the repository to hold the future Outline SDK as we develop it. The goal is to clean up and move reusable code from [outline-ss-server](https://github.com/Jigsaw-Code/outline-ss-server) and [outline-go-tun2socks](https://github.com/Jigsaw-Code/outline-go-tun2socks). + +**WARNING: This code is not ready to be used by the public. There's no guarantee of stability.** Tentative roadmap: