diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3d93e70737..917fc8e934 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,6 @@ * @Jigsaw-Code/outline-dev +/src/tun2socks/ @Jigsaw-Code/outline-networking-owners /src/cordova/plugin/ @Jigsaw-Code/outline-networking-owners /third_party/ @Jigsaw-Code/outline-networking-owners /tools/ @Jigsaw-Code/outline-networking-owners diff --git a/.github/workflows/build_and_test_debug.yml b/.github/workflows/build_and_test_debug.yml index 144cf98b5e..f2c98cc8c0 100644 --- a/.github/workflows/build_and_test_debug.yml +++ b/.github/workflows/build_and_test_debug.yml @@ -13,6 +13,7 @@ on: branches: - master +# TODO: run go tests jobs: web_test: name: Web Test @@ -63,6 +64,11 @@ jobs: - name: Install NPM Dependencies run: npm ci + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version-file: '${{ github.workspace }}/go.mod' + - name: Build Linux Client run: npm run action electron/build linux @@ -87,6 +93,11 @@ jobs: - name: Install NPM Dependencies run: npm ci + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version-file: '${{ github.workspace }}/go.mod' + - name: Build Windows Client run: npm run action electron/build windows @@ -111,6 +122,14 @@ jobs: - name: Install NPM Dependencies run: npm ci + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version-file: '${{ github.workspace }}/go.mod' + + - name: Build Tun2Socks (required for Test OutlineAppleLib) + run: npm run action tun2socks/build macos + - name: Test OutlineAppleLib run: npm run action cordova/test macos @@ -144,6 +163,14 @@ jobs: - name: Install NPM Dependencies run: npm ci + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version-file: '${{ github.workspace }}/go.mod' + + - name: Build Tun2Socks (required for Test OutlineAppleLib) + run: npm run action tun2socks/build ios + - name: Test OutlineAppleLib run: npm run action cordova/test ios @@ -177,6 +204,14 @@ jobs: - name: Install NPM Dependencies run: npm ci + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version-file: '${{ github.workspace }}/go.mod' + + - name: Build Tun2Socks (required for Test OutlineAppleLib) + run: npm run action tun2socks/build maccatalyst + - name: Test OutlineAppleLib run: npm run action cordova/test maccatalyst @@ -207,6 +242,11 @@ jobs: - name: Install NPM Dependencies run: npm ci + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version-file: '${{ github.workspace }}/go.mod' + - name: Install Java uses: actions/setup-java@v1.4.3 with: diff --git a/.gitignore b/.gitignore index 5416836a03..db795c141d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,28 @@ .idea +.vs/ .vscode *.DS_Store -/output -/build -info.txt +*.env +*.pdb +*.sw? /node_modules +/build +/output /platforms /plugins -/www /third_party/Potatso/Pods -xcuserdata/ -.vs/ +/www +coverage +info.txt +keystore.p12 obj/ +Outline.apk +Outline.apks packages/ -*.pdb +toc.pb tools/OutlineService/OutlineService/bin/* -!tools/OutlineService/OutlineService/bin/*.exe -*.sw? tools/smartdnsblock/bin/* +!tools/OutlineService/OutlineService/bin/*.exe !tools/smartdnsblock/bin/*.exe -keystore.p12 -Outline.apk -Outline.apks universal.apk -toc.pb -coverage -*.env \ No newline at end of file +xcuserdata/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..9a9bf3a245 --- /dev/null +++ b/Makefile @@ -0,0 +1,76 @@ +BUILDDIR=$(CURDIR)/output/build +GOBIN=$(CURDIR)/output/bin + +GOMOBILE=$(GOBIN)/gomobile +# Add GOBIN to $PATH so `gomobile` can find `gobind`. +GOBIND=env PATH="$(GOBIN):$(PATH)" "$(GOMOBILE)" bind +IMPORT_HOST=github.com +IMPORT_PATH=$(IMPORT_HOST)/Jigsaw-Code/outline-client + +.PHONY: android apple linux windows + +all: android apple linux windows + +ROOT_PKG=src/tun2socks +# Don't strip Android debug symbols so we can upload them to crash reporting tools. +ANDROID_BUILD_CMD=$(GOBIND) -a -ldflags '-w' -target=android -androidapi 19 -tags android -work + +android: $(BUILDDIR)/android/tun2socks.aar + +$(BUILDDIR)/android/tun2socks.aar: $(GOMOBILE) + mkdir -p "$(BUILDDIR)/android" + $(ANDROID_BUILD_CMD) -o "$@" $(IMPORT_PATH)/$(ROOT_PKG)/outline/tun2socks $(IMPORT_PATH)/$(ROOT_PKG)/outline/shadowsocks + +# TODO(fortuna): -s strips symbols and is obsolete. Why are we using it? +$(BUILDDIR)/ios/Tun2socks.xcframework: $(GOMOBILE) + # -iosversion should match what outline-client supports. + $(GOBIND) -iosversion=11.0 -target=ios,iossimulator -o $@ -ldflags '-s -w' -bundleid org.outline.tun2socks $(IMPORT_PATH)/$(ROOT_PKG)/outline/tun2socks $(IMPORT_PATH)/$(ROOT_PKG)/outline/shadowsocks + +$(BUILDDIR)/macos/Tun2socks.xcframework: $(GOMOBILE) + # MACOSX_DEPLOYMENT_TARGET and -iosversion should match what outline-client supports. + export MACOSX_DEPLOYMENT_TARGET=10.14; $(GOBIND) -iosversion=13.1 -target=macos,maccatalyst -o $@ -ldflags '-s -w' -bundleid org.outline.tun2socks $(IMPORT_PATH)/$(ROOT_PKG)/outline/tun2socks $(IMPORT_PATH)/$(ROOT_PKG)/outline/shadowsocks + +apple: $(BUILDDIR)/apple/Tun2socks.xcframework + +$(BUILDDIR)/apple/Tun2socks.xcframework: $(BUILDDIR)/ios/Tun2socks.xcframework $(BUILDDIR)/macos/Tun2socks.xcframework + find $^ -name "Tun2socks.framework" -type d | xargs -I {} echo " -framework {} " | \ + xargs xcrun xcodebuild -create-xcframework -output "$@" + +XGO=$(GOBIN)/xgo +TUN2SOCKS_VERSION=v1.16.11 +XGO_LDFLAGS='-s -w -X main.version=$(TUN2SOCKS_VERSION)' +ELECTRON_PKG=$(ROOT_PKG)/outline/electron + +# TODO: build directly when on linux +LINUX_BUILDDIR=$(BUILDDIR)/linux + +linux: $(LINUX_BUILDDIR)/tun2socks + +$(LINUX_BUILDDIR)/tun2socks: $(XGO) + mkdir -p "$(LINUX_BUILDDIR)/$(IMPORT_PATH)" + $(XGO) -ldflags $(XGO_LDFLAGS) --targets=linux/amd64 -dest "$(LINUX_BUILDDIR)" -pkg $(ELECTRON_PKG) . + mv "$(LINUX_BUILDDIR)/$(IMPORT_PATH)-linux-amd64" "$@" + rm -r "$(LINUX_BUILDDIR)/$(IMPORT_HOST)" + +# TODO: build directly when on windows +WINDOWS_BUILDDIR=$(BUILDDIR)/windows + +windows: $(WINDOWS_BUILDDIR)/tun2socks.exe + +$(WINDOWS_BUILDDIR)/tun2socks.exe: $(XGO) + mkdir -p "$(WINDOWS_BUILDDIR)/$(IMPORT_PATH)" + $(XGO) -ldflags $(XGO_LDFLAGS) --targets=windows/386 -dest "$(WINDOWS_BUILDDIR)" -pkg $(ELECTRON_PKG) . + mv "$(WINDOWS_BUILDDIR)/$(IMPORT_PATH)-windows-386.exe" "$@" + rm -r "$(WINDOWS_BUILDDIR)/$(IMPORT_HOST)" + + +$(GOMOBILE): go.mod + env GOBIN="$(GOBIN)" go install golang.org/x/mobile/cmd/gomobile + env GOBIN="$(GOBIN)" $(GOMOBILE) init + +$(XGO): go.mod + env GOBIN="$(GOBIN)" go install github.com/crazy-max/xgo + +go.mod: tools.go + go mod tidy + touch go.mod diff --git a/README.md b/README.md index 398dd5d194..92ab8b7146 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ To join our Outline Community, [sign up for the IFF Mattermost](https://internet ## Requirements for all builds -All builds require [Node](https://nodejs.org/) 18 (lts/hydrogen), in addition to other per-platform requirements. +All builds require [Node](https://nodejs.org/) 18 (lts/hydrogen), and [Go](https://golang.org/) 1.20 installed in addition to other per-platform requirements. > 💡 NOTE: if you have `nvm` installed, run `nvm use` to switch to the correct node version! diff --git a/go.mod b/go.mod index 4e74235ce5..efbd0781fc 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,27 @@ -module github.com/Jigsaw-Code/outline-apps +module github.com/Jigsaw-Code/outline-client go 1.20 require ( - github.com/Jigsaw-Code/outline-sdk v0.0.2 + github.com/Jigsaw-Code/outline-sdk v0.0.9 github.com/Jigsaw-Code/outline-sdk/x v0.0.0-20230807220427-893de7fdc6b8 + github.com/crazy-max/xgo v0.30.0 + github.com/eycorsican/go-tun2socks v1.16.11 github.com/stretchr/testify v1.8.4 - golang.org/x/sys v0.11.0 + golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a + golang.org/x/sys v0.15.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/eycorsican/go-tun2socks v1.16.11 // indirect github.com/miekg/dns v1.1.54 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect - golang.org/x/crypto v0.9.0 // indirect - golang.org/x/mod v0.10.0 // indirect - golang.org/x/net v0.10.0 // indirect - golang.org/x/tools v0.9.1 // indirect + github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sync v0.5.0 // indirect + golang.org/x/tools v0.16.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 56672c45c6..1ba35cb8e4 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,9 @@ -github.com/Jigsaw-Code/outline-sdk v0.0.2 h1:uCuyJMaWj57IYEG/Hdml8YMdk9chU60ZkSxJXBhyGHU= -github.com/Jigsaw-Code/outline-sdk v0.0.2/go.mod h1:hhlKz0+r9wSDFT8usvN8Zv/BFToCIFAUn1P2Qk8G2CM= +github.com/Jigsaw-Code/outline-sdk v0.0.9 h1:FuyrqJ5OBh5y8mpXkSomdGJreGi8bAOWRXRNB2B+Hdc= +github.com/Jigsaw-Code/outline-sdk v0.0.9/go.mod h1:hhlKz0+r9wSDFT8usvN8Zv/BFToCIFAUn1P2Qk8G2CM= github.com/Jigsaw-Code/outline-sdk/x v0.0.0-20230807220427-893de7fdc6b8 h1:BxOHmmuppPM8K0DGUsfvajKF4PKfGxv9boNDhmbszFU= github.com/Jigsaw-Code/outline-sdk/x v0.0.0-20230807220427-893de7fdc6b8/go.mod h1:tBqJXpVm+kym+EAUdwNodcFxy872FfjVErfj8Br+gs0= +github.com/crazy-max/xgo v0.30.0 h1:2uunjwLBrVu5LKIS1dIDXz9U5OIX4H5LEsC3P6wFTto= +github.com/crazy-max/xgo v0.30.0/go.mod h1:m/aqfKaN/cYzfw+Pzk7Mk0tkmShg3/rCS4Zdhdugi4o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eycorsican/go-tun2socks v1.16.11 h1:+hJDNgisrYaGEqoSxhdikMgMJ4Ilfwm/IZDrWRrbaH8= @@ -18,27 +20,32 @@ github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstv github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28= github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM= github.com/songgao/water v0.0.0-20190725173103-fd331bda3f4b/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= +github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= -golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= -golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= -golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20191021144547-ec77196f6094/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= -golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= +golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= 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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/package.json b/package.json index 422791fa78..1a05d103ea 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "action:help": "npm run action list", "action:list": "npm run action list", "action": "node ./src/build/run_action.mjs", - "clean": "rimraf output node_modules www platforms plugins third_party/jsign/*.jar", + "clean": "rimraf build output node_modules www platforms plugins third_party/jsign/*.jar", "format:all": "prettier --write \"**/*.{cjs,mjs,html,js,json,md,ts}\"", "format": "pretty-quick --staged --pattern \"**/*.{cjs,mjs,html,js,json,md,ts}\"", "lint:ts": "eslint --ext ts,mjs src", diff --git a/src/build/run_action.mjs b/src/build/run_action.mjs index c775cf43c5..8189833c28 100644 --- a/src/build/run_action.mjs +++ b/src/build/run_action.mjs @@ -141,5 +141,5 @@ async function main() { } if (import.meta.url === url.pathToFileURL(process.argv[1]).href) { - await main(); + await main(...process.argv.slice(2)); } diff --git a/src/build/spawn_stream.mjs b/src/build/spawn_stream.mjs index 199583daf0..26c68cd72c 100644 --- a/src/build/spawn_stream.mjs +++ b/src/build/spawn_stream.mjs @@ -23,7 +23,7 @@ export const spawnStream = (command, ...parameters) => const stdout = []; const stderr = []; - console.debug(`Running [${[command, ...parameters.map(e => `'${e}'`)].join(' ')}]`); + console.debug(chalk.gray(`Running [${[command, ...parameters.map(e => `'${e}'`)].join(' ')}]...`)); const childProcess = spawn(command, parameters, {env: process.env}); const forEachMessageLine = (buffer, callback) => { @@ -47,14 +47,22 @@ export const spawnStream = (command, ...parameters) => if (code === 0) { return resolve(stdout.join('')); } + console.error( chalk.red( `ERROR(spawn_stream): ${chalk.underline( [command, ...parameters].join(' ') - )} failed with exit code ${chalk.bold(code)}. Printing stderr:` + )} failed with exit code ${chalk.bold(code)}.}` ) ); - stderr.forEach(error => console.error(chalk.rgb(128, 64, 64)(error))); - return reject(code); + + if (!(stderr.length && stderr.every(line => line))) { + console.error(chalk.bgRedBright('No error output was given... Please fix this so it gives an error output :(')); + } else { + console.error(chalk.bgRedBright('Printing stderr:')); + stderr.forEach(error => console.error(chalk.rgb(128, 64, 64)(error))); + } + + return reject(stderr.join('')); }); }); diff --git a/src/cordova/apple/OutlineAppleLib/Package.swift b/src/cordova/apple/OutlineAppleLib/Package.swift index 41743129bb..2643cd1112 100644 --- a/src/cordova/apple/OutlineAppleLib/Package.swift +++ b/src/cordova/apple/OutlineAppleLib/Package.swift @@ -76,8 +76,7 @@ let package = Package( ), .binaryTarget( name: "Tun2socks", - url: "https://github.com/Jigsaw-Code/outline-go-tun2socks/releases/download/v3.4.0/apple.zip", - checksum: "6c6880fa7d419a5fddc10588edffa0b23b5a44f0f840cf6865372127285bcc42" + path: "../../../../output/build/apple/Tun2socks.xcframework" ), .testTarget( name: "OutlineTunnelTest", diff --git a/src/cordova/build.action.mjs b/src/cordova/build.action.mjs index 2847258f26..bab23166ba 100644 --- a/src/cordova/build.action.mjs +++ b/src/cordova/build.action.mjs @@ -34,6 +34,7 @@ export async function main(...parameters) { const {platform, buildMode, verbose} = getBuildParameters(parameters); await runAction('www/build', ...parameters); + await runAction('tun2socks/build', ...parameters); await runAction('cordova/setup', ...parameters); if (verbose) { diff --git a/src/cordova/plugin/android/scripts/copy_third_party.js b/src/cordova/plugin/android/scripts/copy_third_party.js index b56e487314..9f8e6388eb 100644 --- a/src/cordova/plugin/android/scripts/copy_third_party.js +++ b/src/cordova/plugin/android/scripts/copy_third_party.js @@ -20,7 +20,7 @@ const path = require('node:path'); const ANDROID_LIBS_FOLDER_PATH = path.join('plugins', 'cordova-plugin-outline', 'android', 'libs'); const TUN2SOCKS_ANDROID_FOLDER_PATH = path.join('third_party', 'outline-go-tun2socks', 'android'); -module.exports = async function(context) { +module.exports = async function (context) { console.log('Copying Android third party libraries...'); await fs.mkdir(ANDROID_LIBS_FOLDER_PATH, {recursive: true}); await fs.copyFile( diff --git a/src/cordova/setup.action.mjs b/src/cordova/setup.action.mjs index e4c9ca4400..b12713fbe2 100644 --- a/src/cordova/setup.action.mjs +++ b/src/cordova/setup.action.mjs @@ -40,6 +40,7 @@ export async function main(...parameters) { const {platform, buildMode, verbose, buildNumber, versionName} = getBuildParameters(parameters); await runAction('www/build', ...parameters); + await runAction('tun2socks/build', ...parameters); await rmfr(path.resolve(getRootDir(), 'platforms')); await rmfr(path.resolve(getRootDir(), 'plugins')); diff --git a/src/electron/README.md b/src/electron/README.md index 9c59290747..331658da54 100644 --- a/src/electron/README.md +++ b/src/electron/README.md @@ -2,6 +2,20 @@ Unlike the Android and Apple clients, the Windows and Linux clients use the Electron framework, rather than Cordova. +You will need [Docker](https://www.docker.com/) installed to build the Electron clients. + +> If you can't use Docker, you can use [podman](https://podman.io) as substitute by running the following (for macOS): + +```sh +brew install podman +podman machine init +sudo ln -s $(which podman) /usr/local/bin/docker +sudo /opt/homebrew/Cellar/podman//bin/podman-mac-helper install +podman machine start +``` + +> You may run into the error: `/var/folders//xgo-cache: no such file or directory`. If so, simply create that directory with `mkdir -p /var/folders//xgo-cache` and try again. + To build the Electron clients, run (it will also package an installer executable into `build/dist`): ```sh diff --git a/src/electron/build.action.mjs b/src/electron/build.action.mjs index c231c87bed..5afa64b7ab 100644 --- a/src/electron/build.action.mjs +++ b/src/electron/build.action.mjs @@ -49,6 +49,7 @@ export async function main(...parameters) { } await runAction('www/build', ...parameters); + await runAction('tun2socks/build', ...parameters); await runAction('electron/build_main', ...parameters); await copydir.sync( diff --git a/src/electron/electron-builder.json b/src/electron/electron-builder.json index 2a6b014ff8..1fb45d7ae0 100644 --- a/src/electron/electron-builder.json +++ b/src/electron/electron-builder.json @@ -3,14 +3,14 @@ "asarUnpack": ["third_party", "tools"], "artifactName": "Outline-Client.${ext}", "directories": { - "output": "build/dist" + "output": "output/build/dist" }, "linux": { "target": { "target": "AppImage", "arch": ["x64"] }, - "files": ["build/icons/png", "third_party/outline-go-tun2socks/linux", "tools/outline_proxy_controller/dist"], + "files": ["build/icons/png", "output/build/linux", "tools/outline_proxy_controller/dist"], "icon": "build/icons/png", "category": "Network" }, @@ -21,7 +21,7 @@ "arch": "ia32" } ], - "files": ["third_party/outline-go-tun2socks/win32"], + "files": ["output/build/windows"], "icon": "build/icons/win/icon.ico", "sign": "src/electron/windows/electron_builder_signing_plugin.cjs", "signingHashAlgorithms": ["sha256"] diff --git a/src/tun2socks/build.action.mjs b/src/tun2socks/build.action.mjs new file mode 100644 index 0000000000..6a1f2694d9 --- /dev/null +++ b/src/tun2socks/build.action.mjs @@ -0,0 +1,32 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import url from 'url'; +import {spawnStream} from '../build/spawn_stream.mjs'; +import {getBuildParameters} from '../build/get_build_parameters.mjs'; + +/** + * @description Builds the tun2socks library for the specified platform. + * + * @param {string[]} parameters + */ +export async function main(...parameters) { + const {platform} = getBuildParameters(parameters); + + await spawnStream('make', ['ios', 'macos', 'maccatalyst'].includes(platform) ? 'apple' : platform); +} + +if (import.meta.url === url.pathToFileURL(process.argv[1]).href) { + await main(...process.argv.slice(2)); +} diff --git a/src/tun2socks/outline/client.go b/src/tun2socks/outline/client.go new file mode 100644 index 0000000000..1754f48c7b --- /dev/null +++ b/src/tun2socks/outline/client.go @@ -0,0 +1,27 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package outline + +import ( + "github.com/Jigsaw-Code/outline-sdk/transport" +) + +// Client provides a transparent container for [transport.StreamDialer] and [transport.PacketListener] +// that is exportable (as an opaque object) via gobind. +// It's used by the connectivity test and the tun2socks handlers. +type Client struct { + transport.StreamDialer + transport.PacketListener +} diff --git a/src/tun2socks/outline/connectivity/connectivity.go b/src/tun2socks/outline/connectivity/connectivity.go new file mode 100644 index 0000000000..34c5ded2eb --- /dev/null +++ b/src/tun2socks/outline/connectivity/connectivity.go @@ -0,0 +1,157 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connectivity + +import ( + "context" + "errors" + "net" + "net/http" + "time" + + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline" + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline/neterrors" + "github.com/Jigsaw-Code/outline-sdk/transport" +) + +// TODO: make these values configurable by exposing a struct with the connectivity methods. +const ( + tcpTimeout = 10 * time.Second + udpTimeout = 1 * time.Second + udpMaxRetryAttempts = 5 + bufferLength = 512 +) + +// authenticationError is used to signal failed authentication to the Shadowsocks proxy. +type authenticationError struct { + error +} + +// reachabilityError is used to signal an unreachable proxy. +type reachabilityError struct { + error +} + +// CheckConnectivity determines whether the Shadowsocks proxy can relay TCP and UDP traffic under +// the current network. Parallelizes the execution of TCP and UDP checks, selects the appropriate +// error code to return accounting for transient network failures. +// Returns an error if an unexpected error ocurrs. +func CheckConnectivity(client *outline.Client) (neterrors.Error, error) { + // Start asynchronous UDP support check. + udpChan := make(chan error) + go func() { + resolverAddr := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53} + udpChan <- CheckUDPConnectivityWithDNS(client, resolverAddr) + }() + // Check whether the proxy is reachable and that the client is able to authenticate to the proxy + tcpErr := CheckTCPConnectivityWithHTTP(client, "http://example.com") + if tcpErr == nil { + udpErr := <-udpChan + if udpErr == nil { + return neterrors.NoError, nil + } + return neterrors.UDPConnectivity, nil + } + var authErr *authenticationError + var reachabilityErr *reachabilityError + if errors.As(tcpErr, &authErr) { + return neterrors.AuthenticationFailure, nil + } else if errors.As(tcpErr, &reachabilityErr) { + return neterrors.Unreachable, nil + } + // The error is not related to the connectivity checks. + return neterrors.Unexpected, tcpErr +} + +// CheckUDPConnectivityWithDNS determines whether the Shadowsocks proxy represented by `client` and +// the network support UDP traffic by issuing a DNS query though a resolver at `resolverAddr`. +// Returns nil on success or an error on failure. +func CheckUDPConnectivityWithDNS(client transport.PacketListener, resolverAddr net.Addr) error { + conn, err := client.ListenPacket(context.Background()) + if err != nil { + return err + } + defer conn.Close() + buf := make([]byte, bufferLength) + for attempt := 0; attempt < udpMaxRetryAttempts; attempt++ { + conn.SetDeadline(time.Now().Add(udpTimeout)) + _, err := conn.WriteTo(getDNSRequest(), resolverAddr) + if err != nil { + continue + } + n, addr, err := conn.ReadFrom(buf) + if n == 0 && err != nil { + continue + } + if addr.String() != resolverAddr.String() { + continue // Ensure we got a response from the resolver. + } + return nil + } + return errors.New("UDP connectivity check timed out") +} + +// CheckTCPConnectivityWithHTTP determines whether the proxy is reachable over TCP and validates the +// client's authentication credentials by performing an HTTP HEAD request to `targetURL`, which must +// be of the form: http://[host](:[port])(/[path]). Returns nil on success, error if `targetURL` is +// invalid, AuthenticationError or ReachabilityError on connectivity failure. +func CheckTCPConnectivityWithHTTP(dialer transport.StreamDialer, targetURL string) error { + deadline := time.Now().Add(tcpTimeout) + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + req, err := http.NewRequest("HEAD", targetURL, nil) + if err != nil { + return err + } + targetAddr := req.Host + if !hasPort(targetAddr) { + targetAddr = net.JoinHostPort(targetAddr, "80") + } + conn, err := dialer.Dial(ctx, targetAddr) + if err != nil { + return &reachabilityError{err} + } + defer conn.Close() + conn.SetDeadline(deadline) + err = req.Write(conn) + if err != nil { + return &authenticationError{err} + } + n, err := conn.Read(make([]byte, bufferLength)) + if n == 0 && err != nil { + return &authenticationError{err} + } + return nil +} + +func getDNSRequest() []byte { + return []byte{ + 0, 0, // [0-1] query ID + 1, 0, // [2-3] flags; byte[2] = 1 for recursion desired (RD). + 0, 1, // [4-5] QDCOUNT (number of queries) + 0, 0, // [6-7] ANCOUNT (number of answers) + 0, 0, // [8-9] NSCOUNT (number of name server records) + 0, 0, // [10-11] ARCOUNT (number of additional records) + 3, 'c', 'o', 'm', + 0, // null terminator of FQDN (root TLD) + 0, 1, // QTYPE, set to A + 0, 1, // QCLASS, set to 1 = IN (Internet) + } +} + +func hasPort(hostPort string) bool { + _, _, err := net.SplitHostPort(hostPort) + return err == nil +} diff --git a/src/tun2socks/outline/connectivity/connectivity_test.go b/src/tun2socks/outline/connectivity/connectivity_test.go new file mode 100644 index 0000000000..5f4078c4c8 --- /dev/null +++ b/src/tun2socks/outline/connectivity/connectivity_test.go @@ -0,0 +1,150 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connectivity + +import ( + "context" + "errors" + "net" + "reflect" + "testing" + "time" + + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks" +) + +func TestCheckUDPConnectivityWithDNS_Success(t *testing.T) { + client := &fakeSSClient{} + err := CheckUDPConnectivityWithDNS(client, &net.UDPAddr{}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } +} + +func TestCheckUDPConnectivityWithDNS_Fail(t *testing.T) { + client := &fakeSSClient{failUDP: true} + err := CheckUDPConnectivityWithDNS(client, &net.UDPAddr{}) + if err == nil { + t.Fail() + } +} + +func TestCheckTCPConnectivityWithHTTP_Success(t *testing.T) { + client := &fakeSSClient{} + err := CheckTCPConnectivityWithHTTP(client, "") + if err != nil { + t.Fail() + } +} + +func TestCheckTCPConnectivityWithHTTP_FailReachability(t *testing.T) { + client := &fakeSSClient{failReachability: true} + err := CheckTCPConnectivityWithHTTP(client, "") + if err == nil { + t.Fail() + } + if _, ok := err.(*reachabilityError); !ok { + t.Fatalf("Expected reachability error, got: %v", reflect.TypeOf(err)) + } +} + +func TestCheckTCPConnectivityWithHTTP_FailAuthentication(t *testing.T) { + client := &fakeSSClient{failAuthentication: true} + err := CheckTCPConnectivityWithHTTP(client, "") + if err == nil { + t.Fail() + } + if _, ok := err.(*authenticationError); !ok { + t.Fatalf("Expected authentication error, got: %v", reflect.TypeOf(err)) + } +} + +// Fake shadowsocks.Client that can be configured to return failing UDP and TCP connections. +type fakeSSClient struct { + failReachability bool + failAuthentication bool + failUDP bool +} + +func (c *fakeSSClient) Dial(_ context.Context, raddr string) (transport.StreamConn, error) { + if c.failReachability { + return nil, &net.OpError{} + } + return &fakeDuplexConn{failRead: c.failAuthentication}, nil +} +func (c *fakeSSClient) ListenPacket(_ context.Context) (net.PacketConn, error) { + conn, err := net.ListenPacket("udp", "") + if err != nil { + return nil, err + } + // The UDP check should fail if any of the failure conditions are true since it is a superset of the others. + failRead := c.failAuthentication || c.failUDP || c.failReachability + return &fakePacketConn{PacketConn: conn, failRead: failRead}, nil +} +func (c *fakeSSClient) SetTCPSaltGenerator(salter shadowsocks.SaltGenerator) { +} + +// Fake PacketConn that fails `ReadFrom` calls when `failRead` is true. +type fakePacketConn struct { + net.PacketConn + addr net.Addr + failRead bool +} + +func (c *fakePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) { + c.addr = addr + return len(b), nil // Write always succeeds +} + +func (c *fakePacketConn) ReadFrom(b []byte) (int, net.Addr, error) { + if c.failRead { + return 0, c.addr, errors.New("Fake read error") + } + return len(b), c.addr, nil +} + +// Fake DuplexConn that fails `Read` calls when `failRead` is true. +type fakeDuplexConn struct { + transport.StreamConn + failRead bool +} + +func (c *fakeDuplexConn) Read(b []byte) (int, error) { + if c.failRead { + return 0, errors.New("Fake read error") + } + return len(b), nil +} + +func (c *fakeDuplexConn) Write(b []byte) (int, error) { + return len(b), nil // Write always succeeds +} + +func (c *fakeDuplexConn) Close() error { return nil } + +func (c *fakeDuplexConn) LocalAddr() net.Addr { return nil } + +func (c *fakeDuplexConn) RemoteAddr() net.Addr { return nil } + +func (c *fakeDuplexConn) SetDeadline(t time.Time) error { return nil } + +func (c *fakeDuplexConn) SetReadDeadline(t time.Time) error { return nil } + +func (c *fakeDuplexConn) SetWriteDeadline(t time.Time) error { return nil } + +func (c *fakeDuplexConn) CloseRead() error { return nil } + +func (c *fakeDuplexConn) CloseWrite() error { return nil } diff --git a/src/tun2socks/outline/electron/main.go b/src/tun2socks/outline/electron/main.go new file mode 100644 index 0000000000..42bdae3d90 --- /dev/null +++ b/src/tun2socks/outline/electron/main.go @@ -0,0 +1,186 @@ +// Copyright 2019 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "flag" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline/internal/utf8" + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline/neterrors" + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline/shadowsocks" + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline/tun2socks" + "github.com/eycorsican/go-tun2socks/common/log" + _ "github.com/eycorsican/go-tun2socks/common/log/simple" // Register a simple logger. + "github.com/eycorsican/go-tun2socks/core" + "github.com/eycorsican/go-tun2socks/proxy/dnsfallback" + "github.com/eycorsican/go-tun2socks/tun" +) + +const ( + mtu = 1500 + udpTimeout = 30 * time.Second + persistTun = true // Linux: persist the TUN interface after the last open file descriptor is closed. +) + +var args struct { + tunAddr *string + tunGw *string + tunMask *string + tunName *string + tunDNS *string + + // Deprecated: Use proxyConfig instead. + proxyHost *string + proxyPort *int + proxyPassword *string + proxyCipher *string + proxyPrefix *string + + proxyConfig *string + + logLevel *string + checkConnectivity *bool + dnsFallback *bool + version *bool +} +var version string // Populated at build time through `-X main.version=...` +var lwipWriter io.Writer + +func main() { + args.tunAddr = flag.String("tunAddr", "10.0.85.2", "TUN interface IP address") + args.tunGw = flag.String("tunGw", "10.0.85.1", "TUN interface gateway") + args.tunMask = flag.String("tunMask", "255.255.255.0", "TUN interface network mask; prefixlen for IPv6") + args.tunDNS = flag.String("tunDNS", "1.1.1.1,9.9.9.9,208.67.222.222", "Comma-separated list of DNS resolvers for the TUN interface (Windows only)") + args.tunName = flag.String("tunName", "tun0", "TUN interface name") + args.proxyHost = flag.String("proxyHost", "", "Shadowsocks proxy hostname or IP address") + args.proxyPort = flag.Int("proxyPort", 0, "Shadowsocks proxy port number") + args.proxyPassword = flag.String("proxyPassword", "", "Shadowsocks proxy password") + args.proxyCipher = flag.String("proxyCipher", "chacha20-ietf-poly1305", "Shadowsocks proxy encryption cipher") + args.proxyPrefix = flag.String("proxyPrefix", "", "Shadowsocks connection prefix, UTF8-encoded (unsafe)") + args.proxyConfig = flag.String("proxyConfig", "", "A JSON object containing the proxy config, UTF8-encoded") + args.logLevel = flag.String("logLevel", "info", "Logging level: debug|info|warn|error|none") + args.dnsFallback = flag.Bool("dnsFallback", false, "Enable DNS fallback over TCP (overrides the UDP handler).") + args.checkConnectivity = flag.Bool("checkConnectivity", false, "Check the proxy TCP and UDP connectivity and exit.") + args.version = flag.Bool("version", false, "Print the version and exit.") + + flag.Parse() + + if *args.version { + fmt.Println(version) + os.Exit(0) + } + + setLogLevel(*args.logLevel) + + client, err := newShadowsocksClientFromArgs() + if err != nil { + log.Errorf("Failed to create Shadowsocks client: %v", err) + os.Exit(neterrors.IllegalConfiguration.Number()) + } + + if *args.checkConnectivity { + connErrCode, err := shadowsocks.CheckConnectivity(client) + log.Debugf("Connectivity checks error code: %v", connErrCode) + if err != nil { + log.Errorf("Failed to perform connectivity checks: %v", err) + } + os.Exit(connErrCode) + } + + // Open TUN device + dnsResolvers := strings.Split(*args.tunDNS, ",") + tunDevice, err := tun.OpenTunDevice(*args.tunName, *args.tunAddr, *args.tunGw, *args.tunMask, dnsResolvers, persistTun) + if err != nil { + log.Errorf("Failed to open TUN device: %v", err) + os.Exit(neterrors.SystemMisconfigured.Number()) + } + // Output packets to TUN device + core.RegisterOutputFn(tunDevice.Write) + + // Register TCP and UDP connection handlers + core.RegisterTCPConnHandler(tun2socks.NewTCPHandler(client)) + if *args.dnsFallback { + // UDP connectivity not supported, fall back to DNS over TCP. + log.Debugf("Registering DNS fallback UDP handler") + core.RegisterUDPConnHandler(dnsfallback.NewUDPHandler()) + } else { + core.RegisterUDPConnHandler(tun2socks.NewUDPHandler(client, udpTimeout)) + } + + // Configure LWIP stack to receive input data from the TUN device + lwipWriter := core.NewLWIPStack() + go func() { + _, err := io.CopyBuffer(lwipWriter, tunDevice, make([]byte, mtu)) + if err != nil { + log.Errorf("Failed to write data to network stack: %v", err) + os.Exit(neterrors.Unexpected.Number()) + } + }() + + log.Infof("tun2socks running...") + + osSignals := make(chan os.Signal, 1) + signal.Notify(osSignals, os.Interrupt, os.Kill, syscall.SIGTERM, syscall.SIGHUP) + sig := <-osSignals + log.Debugf("Received signal: %v", sig) +} + +func setLogLevel(level string) { + switch strings.ToLower(level) { + case "debug": + log.SetLevel(log.DEBUG) + case "info": + log.SetLevel(log.INFO) + case "warn": + log.SetLevel(log.WARN) + case "error": + log.SetLevel(log.ERROR) + case "none": + log.SetLevel(log.NONE) + default: + log.SetLevel(log.INFO) + } +} + +// newShadowsocksClientFromArgs creates a new shadowsocks.Client instance +// from the global CLI argument object args. +func newShadowsocksClientFromArgs() (*shadowsocks.Client, error) { + if jsonConfig := *args.proxyConfig; len(jsonConfig) > 0 { + return shadowsocks.NewClientFromJSON(jsonConfig) + } else { + // legacy raw flags + config := shadowsocks.Config{ + Host: *args.proxyHost, + Port: *args.proxyPort, + CipherName: *args.proxyCipher, + Password: *args.proxyPassword, + } + if prefixStr := *args.proxyPrefix; len(prefixStr) > 0 { + if p, err := utf8.DecodeUTF8CodepointsToRawBytes(prefixStr); err != nil { + return nil, fmt.Errorf("Failed to parse prefix string: %w", err) + } else { + config.Prefix = p + } + } + return shadowsocks.NewClient(&config) + } +} diff --git a/src/tun2socks/outline/internal/utf8/utf8.go b/src/tun2socks/outline/internal/utf8/utf8.go new file mode 100644 index 0000000000..70058a3a6f --- /dev/null +++ b/src/tun2socks/outline/internal/utf8/utf8.go @@ -0,0 +1,35 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This packages provides helper functions to encode or decode UTF-8 strings +package utf8 + +import "fmt" + +// DecodeUTF8CodepointsToRawBytes parses a UTF-8 string as a raw byte array. +// That is to say, each codepoint in the Unicode string will be treated as a +// single byte (must be in range 0x00 ~ 0xff). +// +// If a codepoint falls out of the range, an error will be returned. +func DecodeUTF8CodepointsToRawBytes(utf8Str string) ([]byte, error) { + runes := []rune(utf8Str) + rawBytes := make([]byte, len(runes)) + for i, r := range runes { + if (r & 0xFF) != r { + return nil, fmt.Errorf("character out of range: %d", r) + } + rawBytes[i] = byte(r) + } + return rawBytes, nil +} diff --git a/src/tun2socks/outline/internal/utf8/utf8_test.go b/src/tun2socks/outline/internal/utf8/utf8_test.go new file mode 100644 index 0000000000..2e3d46c481 --- /dev/null +++ b/src/tun2socks/outline/internal/utf8/utf8_test.go @@ -0,0 +1,85 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package utf8 + +import ( + "bytes" + "testing" +) + +func Test_DecodeUTF8CodepointsToRawBytes(t *testing.T) { + tests := []struct { + name string + input string + want []byte + wantErr bool + }{ + { + name: "basic", + input: "abc 123", + want: []byte{97, 98, 99, 32, 49, 50, 51}, + }, { + name: "empty", + input: "", + want: []byte{}, + }, { + name: "edge cases (explicit)", + input: "\x00\x01\x02 \x7e\x7f \xc2\x80\xc2\x81 \xc3\xbd\xc3\xbf", + // 0xc2+0x80/0x81 will be decoded to 0x80/0x81 (two-byte sequence) + // 0xc3+0xbd/0xbf will be decoded to 0xfd/0xff (two-byte sequence) + want: []byte{0x00, 0x01, 0x02, 32, 0x7e, 0x7f, 32, 0x80, 0x81, 32, 0xfd, 0xff}, + }, { + name: "unicode escapes", + input: "\u0000\u0080\u00ff", + want: []byte{0x00, 0x80, 0xff}, + }, { + name: "edge cases (roundtrip)", + input: string([]rune{0, 1, 2, 126, 127, 128, 129, 254, 255}), + want: []byte{0, 1, 2, 126, 127, 128, 129, 254, 255}, + }, { + name: "out of range 256", + input: string([]rune{256}), + wantErr: true, + }, { + name: "out of range 257", + input: string([]rune{257}), + wantErr: true, + }, { + name: "out of range 65537", + input: string([]rune{65537}), + wantErr: true, + }, { + name: "invalid UTF-8", + input: "\xc3\x28", + wantErr: true, + }, { + name: "invalid Unicode", + input: "\xf8\xa1\xa1\xa1\xa1", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DecodeUTF8CodepointsToRawBytes(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("DecodeCodepointsToBytes() returns error %v, want error %v", err, tt.wantErr) + return + } + if !bytes.Equal(got, tt.want) { + t.Errorf("DecodeCodepointsToBytes() returns %v, want %v", got, tt.want) + } + }) + } +} diff --git a/src/tun2socks/outline/neterrors/neterrors.go b/src/tun2socks/outline/neterrors/neterrors.go new file mode 100644 index 0000000000..a95c9ffbfa --- /dev/null +++ b/src/tun2socks/outline/neterrors/neterrors.go @@ -0,0 +1,42 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package errors contains a model for errors shared with the Outline Client application. +// +// TODO(fortuna): Revamp error handling. This is an inverted dependency. The Go code should +// provide its own standalone API, leaving translations to the consumer. +package neterrors + +type Error int + +func (e Error) Number() int { + return int(e) +} + +// Outline error codes. Must be kept in sync with definitions in https://github.com/Jigsaw-Code/outline-client/blob/master/src/www/model/errors.ts +const ( + NoError Error = 0 + Unexpected Error = 1 + NoVPNPermissions Error = 2 // Unused + AuthenticationFailure Error = 3 + UDPConnectivity Error = 4 + Unreachable Error = 5 + VpnStartFailure Error = 6 // Unused + IllegalConfiguration Error = 7 // Electron only + ShadowsocksStartFailure Error = 8 // Unused + ConfigureSystemProxyFailure Error = 9 // Unused + NoAdminPermissions Error = 10 // Unused + UnsupportedRoutingTable Error = 11 // Unused + SystemMisconfigured Error = 12 // Electron only +) diff --git a/src/tun2socks/outline/shadowsocks/client.go b/src/tun2socks/outline/shadowsocks/client.go new file mode 100644 index 0000000000..a96b004b71 --- /dev/null +++ b/src/tun2socks/outline/shadowsocks/client.go @@ -0,0 +1,138 @@ +// Copyright 2022 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This package provides support of Shadowsocks client and the configuration +// that can be used by Outline Client. +// +// All data structures and functions will also be exposed as libraries that +// non-golang callers can use (for example, C/Java/Objective-C). +package shadowsocks + +import ( + "fmt" + "net" + "strconv" + "time" + + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline" + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline/connectivity" + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline/internal/utf8" + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/Jigsaw-Code/outline-sdk/transport/shadowsocks" + "github.com/eycorsican/go-tun2socks/common/log" +) + +// A client object that can be used to connect to a remote Shadowsocks proxy. +type Client outline.Client + +// NewClient creates a new Shadowsocks client from a non-nil configuration. +// +// Deprecated: Please use NewClientFromJSON. +func NewClient(config *Config) (*Client, error) { + if config == nil { + return nil, fmt.Errorf("shadowsocks configuration is required") + } + return newShadowsocksClient(config.Host, config.Port, config.CipherName, config.Password, config.Prefix) +} + +// NewClientFromJSON creates a new Shadowsocks client from a JSON formatted +// configuration. +func NewClientFromJSON(configJSON string) (*Client, error) { + config, err := parseConfigFromJSON(configJSON) + if err != nil { + return nil, fmt.Errorf("failed to parse Shadowsocks configuration JSON: %w", err) + } + var prefixBytes []byte = nil + if len(config.Prefix) > 0 { + if p, err := utf8.DecodeUTF8CodepointsToRawBytes(config.Prefix); err != nil { + return nil, fmt.Errorf("failed to parse prefix string: %w", err) + } else { + prefixBytes = p + } + } + return newShadowsocksClient(config.Host, int(config.Port), config.Method, config.Password, prefixBytes) +} + +func newShadowsocksClient(host string, port int, cipherName, password string, prefix []byte) (*Client, error) { + if err := validateConfig(host, port, cipherName, password); err != nil { + return nil, fmt.Errorf("invalid Shadowsocks configuration: %w", err) + } + + // TODO: consider using net.LookupIP to get a list of IPs, and add logic for optimal selection. + proxyIP, err := net.ResolveIPAddr("ip", host) + if err != nil { + return nil, fmt.Errorf("failed to resolve proxy address: %w", err) + } + proxyAddress := net.JoinHostPort(proxyIP.String(), fmt.Sprint(port)) + + cryptoKey, err := shadowsocks.NewEncryptionKey(cipherName, password) + if err != nil { + return nil, fmt.Errorf("failed to create Shadowsocks cipher: %w", err) + } + + streamDialer, err := shadowsocks.NewStreamDialer(&transport.TCPEndpoint{Address: proxyAddress}, cryptoKey) + if err != nil { + return nil, fmt.Errorf("failed to create StreamDialer: %w", err) + } + if len(prefix) > 0 { + log.Debugf("Using salt prefix: %s", string(prefix)) + streamDialer.SaltGenerator = shadowsocks.NewPrefixSaltGenerator(prefix) + } + + packetListener, err := shadowsocks.NewPacketListener(&transport.UDPEndpoint{Address: proxyAddress}, cryptoKey) + if err != nil { + return nil, fmt.Errorf("failed to create PacketListener: %w", err) + } + + return &Client{StreamDialer: streamDialer, PacketListener: packetListener}, nil +} + +// Error number constants exported through gomobile +const ( + NoError = 0 + Unexpected = 1 + NoVPNPermissions = 2 // Unused + AuthenticationFailure = 3 + UDPConnectivity = 4 + Unreachable = 5 + VpnStartFailure = 6 // Unused + IllegalConfiguration = 7 // Electron only + ShadowsocksStartFailure = 8 // Unused + ConfigureSystemProxyFailure = 9 // Unused + NoAdminPermissions = 10 // Unused + UnsupportedRoutingTable = 11 // Unused + SystemMisconfigured = 12 // Electron only +) + +const reachabilityTimeout = 10 * time.Second + +// CheckConnectivity determines whether the Shadowsocks proxy can relay TCP and UDP traffic under +// the current network. Parallelizes the execution of TCP and UDP checks, selects the appropriate +// error code to return accounting for transient network failures. +// Returns an error if an unexpected error ocurrs. +func CheckConnectivity(client *Client) (int, error) { + errCode, err := connectivity.CheckConnectivity((*outline.Client)(client)) + return errCode.Number(), err +} + +// CheckServerReachable determines whether the server at `host:port` is reachable over TCP. +// Returns an error if the server is unreachable. +func CheckServerReachable(host string, port int) error { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), reachabilityTimeout) + if err != nil { + return err + } + conn.Close() + return nil +} diff --git a/src/tun2socks/outline/shadowsocks/client_test.go b/src/tun2socks/outline/shadowsocks/client_test.go new file mode 100644 index 0000000000..b9800c3d7a --- /dev/null +++ b/src/tun2socks/outline/shadowsocks/client_test.go @@ -0,0 +1,78 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package shadowsocks + +import "testing" + +func Test_NewClientFromJSON_Errors(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "missing host", + input: `{"port":12345,"method":"some-cipher","password":"abcd1234"}`, + }, + { + name: "missing port", + input: `{"host":"192.0.2.1","method":"some-cipher","password":"abcd1234"}`, + }, + { + name: "missing method", + input: `{"host":"192.0.2.1","port":12345,"password":"abcd1234"}`, + }, + { + name: "missing password", + input: `{"host":"192.0.2.1","port":12345,"method":"some-cipher"}`, + }, + { + name: "empty host", + input: `{"host":"","port":12345,"method":"some-cipher","password":"abcd1234"}`, + }, + { + name: "zero port", + input: `{"host":"192.0.2.1","port":0,"method":"some-cipher","password":"abcd1234"}`, + }, + { + name: "empty method", + input: `{"host":"192.0.2.1","port":12345,"method":"","password":"abcd1234"}`, + }, + { + name: "empty password", + input: `{"host":"192.0.2.1","port":12345,"method":"some-cipher","password":""}`, + }, + { + name: "port -1", + input: `{"host":"192.0.2.1","port":-1,"method":"some-cipher","password":"abcd1234"}`, + }, + { + name: "port 65536", + input: `{"host":"192.0.2.1","port":65536,"method":"some-cipher","password":"abcd1234"}`, + }, + { + name: "prefix out-of-range", + input: `{"host":"192.0.2.1","port":8080,"method":"some-cipher","password":"abcd1234","prefix":"\x1234"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewClientFromJSON(tt.input) + if err == nil || got != nil { + t.Errorf("NewClientFromJSON() expects an error, got = %v", got) + return + } + }) + } +} diff --git a/src/tun2socks/outline/shadowsocks/config.go b/src/tun2socks/outline/shadowsocks/config.go new file mode 100644 index 0000000000..4b71248bb9 --- /dev/null +++ b/src/tun2socks/outline/shadowsocks/config.go @@ -0,0 +1,74 @@ +// Copyright 2022 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package shadowsocks + +import ( + "encoding/json" + "fmt" +) + +// Config represents a (legacy) shadowsocks server configuration. You can use +// NewClientFromJSON(string) instead. +// +// Deprecated: this object will be removed once we migrated from the old +// Outline Client logic. +type Config struct { + Host string + Port int + Password string + CipherName string + Prefix []byte +} + +// An internal data structure to be used by JSON deserialization. +// Must match the ShadowsocksSessionConfig interface defined in Outline Client. +type configJSON struct { + Host string `json:"host"` + Port uint16 `json:"port"` + Password string `json:"password"` + Method string `json:"method"` + Prefix string `json:"prefix"` +} + +// ParseConfigFromJSON parses a JSON string `in` as a configJSON object. +// The JSON string `in` must match the ShadowsocksSessionConfig interface +// defined in Outline Client. +func parseConfigFromJSON(in string) (*configJSON, error) { + var conf configJSON + if err := json.Unmarshal([]byte(in), &conf); err != nil { + return nil, err + } + return &conf, nil +} + +// validateConfig validates whether a Shadowsocks server configuration is valid +// (it won't do any connectivity tests) +// +// Returns nil if it is valid; or an error message. +func validateConfig(host string, port int, cipher, password string) error { + if len(host) == 0 { + return fmt.Errorf("must provide a host name or IP address") + } + if port <= 0 || port > 65535 { + return fmt.Errorf("port must be within range [1..65535]") + } + if len(cipher) == 0 { + return fmt.Errorf("must provide an encryption cipher method") + } + if len(password) == 0 { + return fmt.Errorf("must provide a password") + } + return nil +} diff --git a/src/tun2socks/outline/shadowsocks/config_test.go b/src/tun2socks/outline/shadowsocks/config_test.go new file mode 100644 index 0000000000..55e86e1578 --- /dev/null +++ b/src/tun2socks/outline/shadowsocks/config_test.go @@ -0,0 +1,217 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package shadowsocks + +import ( + "testing" +) + +func Test_parseConfigFromJSON(t *testing.T) { + tests := []struct { + name string + input string + want *configJSON + wantErr bool + }{ + { + name: "normal config", + input: `{"host":"192.0.2.1","port":12345,"method":"some-cipher","password":"abcd1234"}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 12345, + Method: "some-cipher", + Password: "abcd1234", + Prefix: "", + }, + }, + { + name: "normal config with prefix", + input: `{"host":"192.0.2.1","port":12345,"method":"some-cipher","password":"abcd1234","prefix":"abc 123"}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 12345, + Method: "some-cipher", + Password: "abcd1234", + Prefix: "abc 123", + }, + }, + { + name: "normal config with extra fields", + input: `{"extra_field":"ignored","host":"192.0.2.1","port":12345,"method":"some-cipher","password":"abcd1234"}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 12345, + Method: "some-cipher", + Password: "abcd1234", + Prefix: "", + }, + }, + { + name: "unprintable prefix", + input: `{"host":"192.0.2.1","port":12345,"method":"some-cipher","password":"abcd1234","prefix":"abc 123","prefix":"\u0000\u0080\u00ff"}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 12345, + Method: "some-cipher", + Password: "abcd1234", + Prefix: "\u0000\u0080\u00ff", + }, + }, + { + name: "multi-byte utf-8 prefix", + input: `{"host":"192.0.2.1","port":12345,"method":"some-cipher","password":"abcd1234","prefix":"abc 123","prefix":"` + "\xc2\x80\xc2\x81\xc3\xbd\xc3\xbf" + `"}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 12345, + Method: "some-cipher", + Password: "abcd1234", + Prefix: "\u0080\u0081\u00fd\u00ff", + }, + }, + { + name: "missing host", + input: `{"port":12345,"method":"some-cipher","password":"abcd1234"}`, + want: &configJSON{ + Host: "", + Port: 12345, + Method: "some-cipher", + Password: "abcd1234", + Prefix: "", + }, + }, + { + name: "missing port", + input: `{"host":"192.0.2.1","method":"some-cipher","password":"abcd1234"}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 0, + Method: "some-cipher", + Password: "abcd1234", + Prefix: "", + }, + }, + { + name: "missing method", + input: `{"host":"192.0.2.1","port":12345,"password":"abcd1234"}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 12345, + Method: "", + Password: "abcd1234", + Prefix: "", + }, + }, + { + name: "missing password", + input: `{"host":"192.0.2.1","port":12345,"method":"some-cipher"}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 12345, + Method: "some-cipher", + Password: "", + Prefix: "", + }, + }, + { + name: "empty host", + input: `{"host":"","port":12345,"method":"some-cipher","password":"abcd1234"}`, + want: &configJSON{ + Host: "", + Port: 12345, + Method: "some-cipher", + Password: "abcd1234", + Prefix: "", + }, + }, + { + name: "zero port", + input: `{"host":"192.0.2.1","port":0,"method":"some-cipher","password":"abcd1234"}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 0, + Method: "some-cipher", + Password: "abcd1234", + Prefix: "", + }, + }, + { + name: "empty method", + input: `{"host":"192.0.2.1","port":12345,"method":"","password":"abcd1234"}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 12345, + Method: "", + Password: "abcd1234", + Prefix: "", + }, + }, + { + name: "empty password", + input: `{"host":"192.0.2.1","port":12345,"method":"some-cipher","password":""}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 12345, + Method: "some-cipher", + Password: "", + Prefix: "", + }, + }, + { + name: "empty prefix", + input: `{"host":"192.0.2.1","port":12345,"method":"some-cipher","password":"abcd1234","prefix":""}`, + want: &configJSON{ + Host: "192.0.2.1", + Port: 12345, + Method: "some-cipher", + Password: "abcd1234", + Prefix: "", + }, + }, + { + name: "port -1", + input: `{"host":"192.0.2.1","port":-1,"method":"some-cipher","password":"abcd1234"}`, + wantErr: true, + }, + { + name: "port 65536", + input: `{"host":"192.0.2.1","port":65536,"method":"some-cipher","password":"abcd1234"}`, + wantErr: true, + }, + { + name: "prefix out-of-range", + input: `{"host":"192.0.2.1","port":8080,"method":"some-cipher","password":"abcd1234","prefix":"\x1234"}`, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseConfigFromJSON(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseConfigFromJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if got.Host != tt.want.Host || + got.Port != tt.want.Port || + got.Method != tt.want.Method || + got.Password != tt.want.Password || + got.Prefix != tt.want.Prefix { + t.Errorf("ParseConfigFromJSON() = %v (prefix %+q), want %v (prefix %+q)", got, got.Prefix, tt.want, tt.want.Prefix) + } + }) + } +} diff --git a/src/tun2socks/outline/tun2socks/tcp.go b/src/tun2socks/outline/tun2socks/tcp.go new file mode 100644 index 0000000000..1bb6bd2c88 --- /dev/null +++ b/src/tun2socks/outline/tun2socks/tcp.go @@ -0,0 +1,77 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tun2socks + +import ( + "context" + "io" + "net" + + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/eycorsican/go-tun2socks/core" +) + +type tcpHandler struct { + dialer transport.StreamDialer +} + +// NewTCPHandler returns a Shadowsocks TCP connection handler. +func NewTCPHandler(client transport.StreamDialer) core.TCPConnHandler { + return &tcpHandler{client} +} + +func (h *tcpHandler) Handle(conn net.Conn, target *net.TCPAddr) error { + proxyConn, err := h.dialer.Dial(context.Background(), target.String()) + if err != nil { + return err + } + // TODO: Request upstream to make `conn` a `core.TCPConn` so we can avoid this type assertion. + go relay(conn.(core.TCPConn), proxyConn) + return nil +} + +func copyOneWay(leftConn, rightConn transport.StreamConn) (int64, error) { + n, err := io.Copy(leftConn, rightConn) + // Send FIN to indicate EOF + leftConn.CloseWrite() + // Release reader resources + rightConn.CloseRead() + return n, err +} + +// relay copies between left and right bidirectionally. Returns number of +// bytes copied from right to left, from left to right, and any error occurred. +// Relay allows for half-closed connections: if one side is done writing, it can +// still read all remaining data from its peer. +func relay(leftConn, rightConn transport.StreamConn) (int64, int64, error) { + type res struct { + N int64 + Err error + } + ch := make(chan res) + + go func() { + n, err := copyOneWay(rightConn, leftConn) + ch <- res{n, err} + }() + + n, err := copyOneWay(leftConn, rightConn) + rs := <-ch + + if err == nil { + err = rs.Err + } + return n, rs.N, err +} diff --git a/src/tun2socks/outline/tun2socks/tunnel.go b/src/tun2socks/outline/tun2socks/tunnel.go new file mode 100644 index 0000000000..5944373ab8 --- /dev/null +++ b/src/tun2socks/outline/tun2socks/tunnel.go @@ -0,0 +1,97 @@ +// Copyright 2019 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tun2socks + +import ( + "errors" + "io" + "net" + "time" + + "github.com/eycorsican/go-tun2socks/core" + "github.com/eycorsican/go-tun2socks/proxy/dnsfallback" + + "github.com/Jigsaw-Code/outline-sdk/transport" + + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline/connectivity" + "github.com/Jigsaw-Code/outline-client/src/tun2socks/tunnel" +) + +// Tunnel represents a tunnel from a TUN device to a server. +type Tunnel interface { + tunnel.Tunnel + + // UpdateUDPSupport determines if UDP is supported following a network connectivity change. + // Sets the tunnel's UDP connection handler accordingly, falling back to DNS over TCP if UDP is not supported. + // Returns whether UDP proxying is supported in the new network. + UpdateUDPSupport() bool +} + +// Deprecated: use Tunnel directly. +type OutlineTunnel = Tunnel + +type outlinetunnel struct { + tunnel.Tunnel + lwipStack core.LWIPStack + streamDialer transport.StreamDialer + packetDialer transport.PacketListener + isUDPEnabled bool // Whether the tunnel supports proxying UDP. +} + +// newTunnel connects a tunnel to a Shadowsocks proxy server and returns an `outline.Tunnel`. +// +// `host` is the IP or domain of the Shadowsocks proxy. +// `port` is the port of the Shadowsocks proxy. +// `password` is the password of the Shadowsocks proxy. +// `cipher` is the encryption cipher used by the Shadowsocks proxy. +// `isUDPEnabled` indicates if the Shadowsocks proxy and the network support proxying UDP traffic. +// `tunWriter` is used to output packets back to the TUN device. OutlineTunnel.Disconnect() will close `tunWriter`. +func newTunnel(streamDialer transport.StreamDialer, packetDialer transport.PacketListener, isUDPEnabled bool, tunWriter io.WriteCloser) (Tunnel, error) { + if tunWriter == nil { + return nil, errors.New("Must provide a TUN writer") + } + core.RegisterOutputFn(func(data []byte) (int, error) { + return tunWriter.Write(data) + }) + lwipStack := core.NewLWIPStack() + base := tunnel.NewTunnel(tunWriter, lwipStack) + t := &outlinetunnel{base, lwipStack, streamDialer, packetDialer, isUDPEnabled} + t.registerConnectionHandlers() + return t, nil +} + +func (t *outlinetunnel) UpdateUDPSupport() bool { + resolverAddr := &net.UDPAddr{IP: net.ParseIP("1.1.1.1"), Port: 53} + isUDPEnabled := connectivity.CheckUDPConnectivityWithDNS(t.packetDialer, resolverAddr) == nil + if t.isUDPEnabled != isUDPEnabled { + t.isUDPEnabled = isUDPEnabled + t.lwipStack.Close() // Close existing connections to avoid using the previous handlers. + t.registerConnectionHandlers() + } + return isUDPEnabled +} + +// Registers UDP and TCP Shadowsocks connection handlers to the tunnel's host and port. +// Registers a DNS/TCP fallback UDP handler when UDP is disabled. +func (t *outlinetunnel) registerConnectionHandlers() { + var udpHandler core.UDPConnHandler + if t.isUDPEnabled { + udpHandler = NewUDPHandler(t.packetDialer, 30*time.Second) + } else { + udpHandler = dnsfallback.NewUDPHandler() + } + core.RegisterTCPConnHandler(NewTCPHandler(t.streamDialer)) + core.RegisterUDPConnHandler(udpHandler) +} diff --git a/src/tun2socks/outline/tun2socks/tunnel_android.go b/src/tun2socks/outline/tun2socks/tunnel_android.go new file mode 100644 index 0000000000..c9792fc858 --- /dev/null +++ b/src/tun2socks/outline/tun2socks/tunnel_android.go @@ -0,0 +1,54 @@ +// Copyright 2019 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tun2socks + +import ( + "runtime/debug" + + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline/shadowsocks" + "github.com/Jigsaw-Code/outline-client/src/tun2socks/tunnel" + "github.com/eycorsican/go-tun2socks/common/log" +) + +func init() { + // Conserve memory by increasing garbage collection frequency. + debug.SetGCPercent(10) + log.SetLevel(log.WARN) +} + +// ConnectShadowsocksTunnel reads packets from a TUN device and routes it to a Shadowsocks proxy server. +// Returns an OutlineTunnel instance and does *not* take ownership of the TUN file descriptor; the +// caller is responsible for closing after OutlineTunnel disconnects. +// +// - `fd` is the TUN device. The OutlineTunnel acquires an additional reference to it, which +// is released by OutlineTunnel.Disconnect(), so the caller must close `fd` _and_ call +// Disconnect() in order to close the TUN device. +// - `client` is the Shadowsocks client (created by [shadowsocks.NewClient]). +// - `isUDPEnabled` indicates whether the tunnel and/or network enable UDP proxying. +// +// Returns an error if the TUN file descriptor cannot be opened, or if the tunnel fails to +// connect. +func ConnectShadowsocksTunnel(fd int, client *shadowsocks.Client, isUDPEnabled bool) (Tunnel, error) { + tun, err := tunnel.MakeTunFile(fd) + if err != nil { + return nil, err + } + t, err := newTunnel(client, client, isUDPEnabled, tun) + if err != nil { + return nil, err + } + go tunnel.ProcessInputPackets(t, tun) + return t, nil +} diff --git a/src/tun2socks/outline/tun2socks/tunnel_darwin.go b/src/tun2socks/outline/tun2socks/tunnel_darwin.go new file mode 100644 index 0000000000..2caf3d06a8 --- /dev/null +++ b/src/tun2socks/outline/tun2socks/tunnel_darwin.go @@ -0,0 +1,60 @@ +// Copyright 2019 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tun2socks + +import ( + "errors" + "io" + "runtime/debug" + "time" + + "github.com/Jigsaw-Code/outline-client/src/tun2socks/outline/shadowsocks" +) + +// TunWriter is an interface that allows for outputting packets to the TUN (VPN). +type TunWriter interface { + io.WriteCloser +} + +func init() { + // Apple VPN extensions have a memory limit of 15MB. Conserve memory by increasing garbage + // collection frequency and returning memory to the OS every minute. + debug.SetGCPercent(10) + // TODO: Check if this is still needed in go 1.13, which returns memory to the OS + // automatically. + ticker := time.NewTicker(time.Minute * 1) + go func() { + for range ticker.C { + debug.FreeOSMemory() + } + }() +} + +// ConnectShadowsocksTunnel reads packets from a TUN device and routes it to a Shadowsocks proxy server. +// Returns an OutlineTunnel instance that should be used to input packets to the tunnel. +// +// `tunWriter` is used to output packets to the TUN (VPN). +// `client` is the Shadowsocks client (created by [shadowsocks.NewClient]). +// `isUDPEnabled` indicates whether the tunnel and/or network enable UDP proxying. +// +// Sets an error if the tunnel fails to connect. +func ConnectShadowsocksTunnel(tunWriter TunWriter, client *shadowsocks.Client, isUDPEnabled bool) (Tunnel, error) { + if tunWriter == nil { + return nil, errors.New("must provide a TunWriter") + } else if client == nil { + return nil, errors.New("must provide a client") + } + return newTunnel(client, client, isUDPEnabled, tunWriter) +} diff --git a/src/tun2socks/outline/tun2socks/udp.go b/src/tun2socks/outline/tun2socks/udp.go new file mode 100644 index 0000000000..cd48d7d691 --- /dev/null +++ b/src/tun2socks/outline/tun2socks/udp.go @@ -0,0 +1,112 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tun2socks + +import ( + "context" + "fmt" + "net" + "sync" + "time" + + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/eycorsican/go-tun2socks/core" +) + +type udpHandler struct { + // Protects the connections map + sync.Mutex + + // Used to establish connections to the proxy + listener transport.PacketListener + + // How long to wait for a packet from the proxy. Longer than this and the connection + // is closed. + timeout time.Duration + + // Maps connections from TUN to connections to the proxy. + conns map[core.UDPConn]net.PacketConn +} + +// NewUDPHandler returns a Shadowsocks UDP connection handler. +// +// `client` provides the Shadowsocks functionality. +// `timeout` is the UDP read and write timeout. +func NewUDPHandler(dialer transport.PacketListener, timeout time.Duration) core.UDPConnHandler { + return &udpHandler{ + listener: dialer, + timeout: timeout, + conns: make(map[core.UDPConn]net.PacketConn, 8), + } +} + +func (h *udpHandler) Connect(tunConn core.UDPConn, target *net.UDPAddr) error { + proxyConn, err := h.listener.ListenPacket(context.Background()) + if err != nil { + return err + } + h.Lock() + h.conns[tunConn] = proxyConn + h.Unlock() + go h.relayPacketsFromProxy(tunConn, proxyConn) + return nil +} + +// relayPacketsFromProxy relays packets from the proxy to the TUN device. +func (h *udpHandler) relayPacketsFromProxy(tunConn core.UDPConn, proxyConn net.PacketConn) { + buf := core.NewBytes(core.BufSize) + defer func() { + h.close(tunConn) + core.FreeBytes(buf) + }() + for { + proxyConn.SetDeadline(time.Now().Add(h.timeout)) + n, sourceAddr, err := proxyConn.ReadFrom(buf) + if err != nil { + return + } + // No resolution will take place, the address sent by the proxy is a resolved IP. + sourceUDPAddr, err := net.ResolveUDPAddr("udp", sourceAddr.String()) + if err != nil { + return + } + _, err = tunConn.WriteFrom(buf[:n], sourceUDPAddr) + if err != nil { + return + } + } +} + +// ReceiveTo relays packets from the TUN device to the proxy. It's called by tun2socks. +func (h *udpHandler) ReceiveTo(tunConn core.UDPConn, data []byte, destAddr *net.UDPAddr) error { + h.Lock() + proxyConn, ok := h.conns[tunConn] + h.Unlock() + if !ok { + return fmt.Errorf("connection %v->%v does not exist", tunConn.LocalAddr(), destAddr) + } + proxyConn.SetDeadline(time.Now().Add(h.timeout)) + _, err := proxyConn.WriteTo(data, destAddr) + return err +} + +func (h *udpHandler) close(tunConn core.UDPConn) { + tunConn.Close() + h.Lock() + defer h.Unlock() + if proxyConn, ok := h.conns[tunConn]; ok { + proxyConn.Close() + } +} diff --git a/src/tun2socks/tunnel/tun.go b/src/tun2socks/tunnel/tun.go new file mode 100644 index 0000000000..3532c7bb91 --- /dev/null +++ b/src/tun2socks/tunnel/tun.go @@ -0,0 +1,41 @@ +// Copyright 2019 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tunnel + +import ( + "os" + + "github.com/eycorsican/go-tun2socks/common/log" + _ "github.com/eycorsican/go-tun2socks/common/log/simple" // Import simple log for the side effect of making logs printable. +) + +const vpnMtu = 1500 + +// ProcessInputPackets reads packets from a TUN device `tun` and writes them to `tunnel`. +func ProcessInputPackets(tunnel Tunnel, tun *os.File) { + buffer := make([]byte, vpnMtu) + for tunnel.IsConnected() { + len, err := tun.Read(buffer) + if err != nil { + log.Warnf("Failed to read packet from TUN: %v", err) + continue + } + if len == 0 { + log.Infof("Read EOF from TUN") + continue + } + tunnel.Write(buffer) + } +} diff --git a/src/tun2socks/tunnel/tun_android.go b/src/tun2socks/tunnel/tun_android.go new file mode 100644 index 0000000000..9cc0beae02 --- /dev/null +++ b/src/tun2socks/tunnel/tun_android.go @@ -0,0 +1,45 @@ +// Copyright 2019 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build android + +package tunnel + +import ( + "errors" + "os" + + _ "github.com/eycorsican/go-tun2socks/common/log/simple" // Import simple log for the side effect of making logs printable. + "golang.org/x/sys/unix" +) + +// MakeTunFile returns an os.File object from a TUN file descriptor `fd`. +// The returned os.File holds a separate reference to the underlying file, +// so the file will not be closed until both `fd` and the os.File are +// separately closed. (UNIX only.) +func MakeTunFile(fd int) (*os.File, error) { + if fd < 0 { + return nil, errors.New("Must provide a valid TUN file descriptor") + } + // Make a copy of `fd` so that os.File's finalizer doesn't close `fd`. + newfd, err := unix.Dup(fd) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(newfd), "") + if file == nil { + return nil, errors.New("Failed to open TUN file descriptor") + } + return file, nil +} diff --git a/src/tun2socks/tunnel/tunnel.go b/src/tun2socks/tunnel/tunnel.go new file mode 100644 index 0000000000..28c2b4c0d9 --- /dev/null +++ b/src/tun2socks/tunnel/tunnel.go @@ -0,0 +1,62 @@ +// Copyright 2019 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tunnel + +import ( + "errors" + "io" + + "github.com/eycorsican/go-tun2socks/core" +) + +// Tunnel represents a session on a TUN device. +type Tunnel interface { + // IsConnected is true if Disconnect has not been called. + IsConnected() bool + // Disconnect closes the underlying resources. Subsequent Write calls will fail. + Disconnect() + // Write writes input data to the TUN interface. + Write(data []byte) (int, error) +} + +type tunnel struct { + tunWriter io.WriteCloser + lwipStack core.LWIPStack + isConnected bool +} + +func (t *tunnel) IsConnected() bool { + return t.isConnected +} + +func (t *tunnel) Disconnect() { + if !t.isConnected { + return + } + t.isConnected = false + t.lwipStack.Close() + t.tunWriter.Close() +} + +func (t *tunnel) Write(data []byte) (int, error) { + if !t.isConnected { + return 0, errors.New("Failed to write, network stack closed") + } + return t.lwipStack.Write(data) +} + +func NewTunnel(tunWriter io.WriteCloser, lwipStack core.LWIPStack) Tunnel { + return &tunnel{tunWriter, lwipStack, true} +} diff --git a/third_party/outline-go-tun2socks/METADATA b/third_party/outline-go-tun2socks/METADATA index 31737f780a..50a9d77a15 100644 --- a/third_party/outline-go-tun2socks/METADATA +++ b/third_party/outline-go-tun2socks/METADATA @@ -8,4 +8,4 @@ third_party { } version: "2.2.1" last_upgrade_date { year: 2022 month: 4 day: 20 } -} +} \ No newline at end of file diff --git a/third_party/outline-go-tun2socks/linux/tun2socks b/third_party/outline-go-tun2socks/linux/tun2socks deleted file mode 100755 index 0e07ba3fd1..0000000000 Binary files a/third_party/outline-go-tun2socks/linux/tun2socks and /dev/null differ diff --git a/third_party/outline-go-tun2socks/win32/tun2socks.exe b/third_party/outline-go-tun2socks/win32/tun2socks.exe deleted file mode 100755 index 76a6f0cf63..0000000000 Binary files a/third_party/outline-go-tun2socks/win32/tun2socks.exe and /dev/null differ diff --git a/tools.go b/tools.go new file mode 100644 index 0000000000..39aa39188f --- /dev/null +++ b/tools.go @@ -0,0 +1,27 @@ +// Copyright 2019 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build tools +// +build tools + +// See https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module +// and https://github.com/go-modules-by-example/index/blob/master/010_tools/README.md + +package tools + +import ( + _ "github.com/crazy-max/xgo" + _ "golang.org/x/mobile/cmd/gomobile" + _ "golang.org/x/mobile/cmd/gobind" +)