diff --git a/.github/workflows/build_and_test_debug_client.yml b/.github/workflows/build_and_test_debug_client.yml index 22a85dd527..a43eb87438 100644 --- a/.github/workflows/build_and_test_debug_client.yml +++ b/.github/workflows/build_and_test_debug_client.yml @@ -129,6 +129,11 @@ jobs: with: go-version-file: '${{ github.workspace }}/go.mod' + - name: Install zig + uses: mlugg/setup-zig@v1 + with: + version: 0.13.0 + - name: Build Windows Client run: npm run action client/electron/build windows diff --git a/client/electron/app_paths.ts b/client/electron/app_paths.ts index 8808655647..5b99b20a63 100644 --- a/client/electron/app_paths.ts +++ b/client/electron/app_paths.ts @@ -17,7 +17,7 @@ import * as path from 'path'; import {app} from 'electron'; -const isWindows = os.platform() === 'win32'; +const IS_WINDOWS = os.platform() === 'win32'; /** * Get the unpacked asar folder path. @@ -39,7 +39,7 @@ function unpackedAppPath() { */ export function getAppPath() { const electronAppPath = app.getAppPath(); - if (isWindows && electronAppPath.includes('app.asar')) { + if (IS_WINDOWS && electronAppPath.includes('app.asar')) { return path.dirname(app.getPath('exe')); } return electronAppPath; @@ -51,8 +51,19 @@ export function pathToEmbeddedTun2socksBinary() { 'client', 'output', 'build', - isWindows ? 'windows' : 'linux', - 'tun2socks' + (isWindows ? '.exe' : '') + IS_WINDOWS ? 'windows' : 'linux', + 'tun2socks' + (IS_WINDOWS ? '.exe' : '') + ); +} + +export function pathToBackendLibrary() { + return path.join( + unpackedAppPath(), + 'client', + 'output', + 'build', + IS_WINDOWS ? 'windows' : 'linux', + IS_WINDOWS ? 'backend.dll' : 'libbackend.so' ); } @@ -63,7 +74,7 @@ export function pathToEmbeddedTun2socksBinary() { * @returns A string representing the path of the directory that contains service binaries. */ export function pathToEmbeddedOutlineService() { - if (isWindows) { + if (IS_WINDOWS) { return getAppPath(); } return path.join( diff --git a/client/electron/electron-builder.json b/client/electron/electron-builder.json index af2651b054..b2227c1e00 100644 --- a/client/electron/electron-builder.json +++ b/client/electron/electron-builder.json @@ -30,7 +30,8 @@ "files": [ "client/electron/linux_proxy_controller/dist", "client/electron/icons/png", - "client/output/build/linux" + "client/output/build/linux", + "!client/output/build/linux/*.h" ], "icon": "client/electron/icons/png", "maintainer": "Jigsaw LLC", @@ -51,7 +52,8 @@ }, "win": { "files": [ - "client/output/build/windows" + "client/output/build/windows", + "!client/output/build/windows/*.h" ], "icon": "client/electron/icons/win/icon.ico", "sign": "client/electron/windows/electron_builder_signing_plugin.cjs", diff --git a/client/electron/go_helpers.ts b/client/electron/go_helpers.ts index ec7df45eba..8ee4a16e3a 100644 --- a/client/electron/go_helpers.ts +++ b/client/electron/go_helpers.ts @@ -57,22 +57,3 @@ export async function checkUDPConnectivity( } return true; } - -/** - * Fetches a resource from the given URL. - * - * @param url The URL of the resource to fetch. - * @param debugMode Optional. Whether to forward logs to stdout. Defaults to false. - * @returns A Promise that resolves to the fetched content as a string. - * @throws ProcessTerminatedExitCodeError if tun2socks failed to run. - */ -export function fetchResource( - url: string, - debugMode: boolean = false -): Promise { - const tun2socks = new ChildProcessHelper(pathToEmbeddedTun2socksBinary()); - tun2socks.isDebugModeEnabled = debugMode; - - console.debug('[tun2socks] - fetching resource ...'); - return tun2socks.launch(['-fetchUrl', url]); -} diff --git a/client/electron/go_plugin.ts b/client/electron/go_plugin.ts new file mode 100644 index 0000000000..db80eaf22a --- /dev/null +++ b/client/electron/go_plugin.ts @@ -0,0 +1,68 @@ +// Copyright 2024 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 {promisify} from 'node:util'; + +import koffi from 'koffi'; + +import {pathToBackendLibrary} from './app_paths'; + +let invokeGoAPIFunc: Function | undefined; + +export type GoApiName = 'FetchResource'; + +/** + * Calls a Go function by invoking the `InvokeGoAPI` function in the native backend library. + * + * @param api The name of the Go API to invoke. + * @param input The input string to pass to the API. + * @returns A Promise that resolves to the output string returned by the API. + * @throws An Error containing PlatformError details if the API call fails. + * + * @remarks + * Ensure that the function signature and data structures are consistent with the C definitions + * in `./client/go/outline/electron/go_plugin.go`. + */ +export async function invokeGoApi( + api: GoApiName, + input: string +): Promise { + if (!invokeGoAPIFunc) { + const backendLib = koffi.load(pathToBackendLibrary()); + + // Define C strings and setup auto release + const cgoString = koffi.disposable( + 'CGoAutoReleaseString', + 'str', + backendLib.func('FreeCGoString', 'void', ['str']) + ); + + // Define InvokeGoAPI data structures and function + const invokeGoApiResult = koffi.struct('InvokeGoAPIResult', { + Output: cgoString, + ErrorJson: cgoString, + }); + invokeGoAPIFunc = promisify( + backendLib.func('InvokeGoAPI', invokeGoApiResult, ['str', 'str']).async + ); + } + + console.debug('[Backend] - calling InvokeGoAPI ...'); + const result = await invokeGoAPIFunc(api, input); + console.debug('[Backend] - InvokeGoAPI returned', result); + if (result.ErrorJson) { + throw Error(result.ErrorJson); + } + return result.Output; +} diff --git a/client/electron/index.ts b/client/electron/index.ts index 8282d2bd8c..89a9f1abb9 100644 --- a/client/electron/index.ts +++ b/client/electron/index.ts @@ -34,7 +34,7 @@ import { import {autoUpdater} from 'electron-updater'; import {lookupIp} from './connectivity'; -import {fetchResource} from './go_helpers'; +import {GoApiName, invokeGoApi} from './go_plugin'; import {GoVpnTunnel} from './go_vpn_tunnel'; import {installRoutingServices, RoutingDaemon} from './routing_service'; import {TunnelStore} from './tunnel_store'; @@ -499,10 +499,19 @@ function main() { mainWindow?.webContents.send('outline-ipc-push-clipboard'); }); - // Fetches a resource (usually the dynamic key config) from a remote URL. + // This IPC handler allows the renderer process to call Go API functions exposed by the backend. + // It takes two arguments: + // - api: The name of the Go API function to call. + // - input: A string representing the input data to the Go function. + // + // The handler returns the output string from the Go function if successful. + // Both the input string and output string need to be interpreted by the renderer process according + // to the specific API being called. + // If Go function encounters an error, it throws an Error that can be parsed by the `PlatformError`. ipcMain.handle( - 'outline-ipc-fetch-resource', - async (_, url: string): Promise => fetchResource(url, debugMode) + 'outline-ipc-invoke-go-api', + (_, api: GoApiName, input: string): Promise => + invokeGoApi(api, input) ); // Connects to a proxy server specified by a config. diff --git a/client/electron/webpack_electron_main.mjs b/client/electron/webpack_electron_main.mjs index 5eacdd83c1..4c459c66b4 100755 --- a/client/electron/webpack_electron_main.mjs +++ b/client/electron/webpack_electron_main.mjs @@ -17,7 +17,6 @@ import {fileURLToPath} from 'url'; import webpack from 'webpack'; - const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -37,6 +36,10 @@ export default ({sentryDsn, appVersion}) => [ use: 'ts-loader', exclude: /node_modules/, }, + { + test: /\.node$/, + use: 'node-loader', + }, ], }, resolve: { diff --git a/client/go/Taskfile.yml b/client/go/Taskfile.yml index 5d17c13b0d..d100d225eb 100644 --- a/client/go/Taskfile.yml +++ b/client/go/Taskfile.yml @@ -25,32 +25,45 @@ vars: tasks: electron: - desc: "Build the tun2socks binary for Electron platforms" + desc: "Build the tun2socks binary and library for Electron platforms" internal: true - requires: {vars: [TARGET_OS]} + requires: {vars: [TARGET_OS, TARGET_ARCH]} vars: # TODO(fortuna): remove this, it's not really needed since we don't release it separately anymore. TUN2SOCKS_VERSION: "v1.16.11" OUTPUT: '{{.OUT_DIR}}/{{.TARGET_OS}}/tun2socks{{if eq .TARGET_OS "windows"}}.exe{{end}}' + # Linux=libbackend.so; Windows=backend.dll + OUTPUT_LIB: '{{.OUT_DIR}}/{{.TARGET_OS}}/{{if eq .TARGET_OS "linux"}}libbackend.so{{else}}backend.dll{{end}}' cmds: - rm -rf "{{dir .OUTPUT}}" && mkdir -p "{{dir .OUTPUT}}" + # C cross-compile (zig) targets: + # Linux (x64) = x86_64-linux-gnu (`x86_64-linux` defaults to `x86_64-linux-musl`, prefer `gnu` over `musl`) + # Windows (x86) = x86-windows # Linker flags: https://pkg.go.dev/cmd/link - # -s Omit the symbol table and debug information. - # -w Omit the DWARF symbol table. - # -X Set the value of the string variable. + # -s Omit the symbol table and debug information. + # -w Omit the DWARF symbol table. + # -X Set the value of the string variable. - | - {{if ne OS .TARGET_OS -}} - GOOS={{.TARGET_OS}} GOARCH=amd64 CGO_ENABLED=1 CC='zig cc -target x86_64-{{.TARGET_OS}}' + {{if or (ne OS .TARGET_OS) (ne ARCH .TARGET_ARCH) -}} + GOOS={{.TARGET_OS}} GOARCH={{.TARGET_ARCH}} CGO_ENABLED=1 \ + CC='zig cc -target {{if eq .TARGET_ARCH "386"}}x86{{else}}x86_64{{end}}-{{.TARGET_OS}}{{if eq .TARGET_OS "linux"}}-gnu{{end}}' {{- end}} \ go build -trimpath -ldflags="-s -w -X=main.version={{.TUN2SOCKS_VERSION}}" -o '{{.OUTPUT}}' '{{.TASKFILE_DIR}}/outline/electron' + - | + {{if or (ne OS .TARGET_OS) (ne ARCH .TARGET_ARCH) -}} + GOOS={{.TARGET_OS}} GOARCH={{.TARGET_ARCH}} CGO_ENABLED=1 \ + CC='zig cc -target {{if eq .TARGET_ARCH "386"}}x86{{else}}x86_64{{end}}-{{.TARGET_OS}}{{if eq .TARGET_OS "linux"}}-gnu{{end}}' + {{- end}} \ + go build -trimpath -buildmode=c-shared -ldflags="-s -w -X=main.version={{.TUN2SOCKS_VERSION}}" -o '{{.OUTPUT_LIB}}' '{{.TASKFILE_DIR}}/outline/electron' windows: - desc: "Build the tun2socks binary for Windows" - cmds: [{task: electron, vars: {TARGET_OS: "windows"}}] + desc: "Build the tun2socks binary and library for Windows" + # 32bit Windows 10 still exists until October 14, 2025 + cmds: [{task: electron, vars: {TARGET_OS: "windows", TARGET_ARCH: "386"}}] linux: - desc: "Build the tun2socks binary for Linux" - cmds: [{task: electron, vars: {TARGET_OS: "linux"}}] + desc: "Build the tun2socks binary and library for Linux" + cmds: [{task: electron, vars: {TARGET_OS: "linux", TARGET_ARCH: "amd64"}}] android: desc: "Build the tun2socks.aar library for Android" diff --git a/client/go/outline/electron/go_plugin.go b/client/go/outline/electron/go_plugin.go new file mode 100644 index 0000000000..9fcff7d12e --- /dev/null +++ b/client/go/outline/electron/go_plugin.go @@ -0,0 +1,128 @@ +// Copyright 2024 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 + +/* +#include // for C.free + +// InvokeGoAPIResult is a struct used to pass result from Go to TypeScript boundary. +typedef struct InvokeGoAPIResult_t +{ + // A string representing the result of the Go function call. + // This may be a raw string or a JSON string depending on the API call. + const char *Output; + + // A string containing a JSON representation of any error that occurred during the + // Go function call, or NULL if no error occurred. + // This error can be parsed by the PlatformError in TypeScript. + const char *ErrorJson; +} InvokeGoAPIResult; +*/ +import "C" +import ( + "fmt" + "log/slog" + "os" + "unsafe" + + "github.com/Jigsaw-Code/outline-apps/client/go/outline" + "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" +) + +// API name constants +const ( + // FetchResourceAPI fetches a resource located at a given URL. + // + // - Input: the URL string of the resource to fetch + // - Output: the content in raw string of the fetched resource + FetchResourceAPI = "FetchResource" +) + +// InvokeGoAPI is the unified entry point for TypeScript to invoke various Go functions. +// +// The input and output are all defined as string, but they may represent either a raw string, +// or a JSON string depending on the API call. +// +// Check the API name constants comment for more details about the input and output format. +// +//export InvokeGoAPI +func InvokeGoAPI(api *C.char, input *C.char) C.InvokeGoAPIResult { + apiName := C.GoString(api) + switch apiName { + + case FetchResourceAPI: + res := outline.FetchResource(C.GoString(input)) + return C.InvokeGoAPIResult{ + Output: newCGoString(res.Content), + ErrorJson: marshalCGoErrorJson(platerrors.ToPlatformError(res.Error)), + } + + default: + err := &platerrors.PlatformError{ + Code: platerrors.InternalError, + Message: fmt.Sprintf("unsupported Go API: %s", apiName), + } + return C.InvokeGoAPIResult{ErrorJson: marshalCGoErrorJson(err)} + } +} + +// newCGoString allocates memory for a C string based on the given Go string. +// It should be paired with [FreeCGoString] to avoid memory leaks. +func newCGoString(s string) *C.char { + if s == "" { + return nil + } + res := C.CString(s) + slog.Debug("malloc CGoString", "addr", res) + return res +} + +// FreeCGoString releases the memory allocated by newCGoString. +// It also accepts null. +// +//export FreeCGoString +func FreeCGoString(s *C.char) { + if s != nil { + slog.Debug("free CGoString", "addr", s) + C.free(unsafe.Pointer(s)) + } +} + +// marshalCGoErrorJson marshals a PlatformError to a C style JSON string. +// It always succeeds with a non-empty string if e is not nil. +func marshalCGoErrorJson(e *platerrors.PlatformError) *C.char { + if e == nil { + return nil + } + json, err := platerrors.MarshalJSONString(e) + if err != nil { + return newCGoString(fmt.Sprintf("%s, failed to retrieve details due to: %s", e.Code, err.Error())) + } + return newCGoString(json) +} + +// init initializes the backend module. +// It sets up a default logger based on the OUTLINE_DEBUG environment variable. +func init() { + opts := slog.HandlerOptions{Level: slog.LevelInfo} + + dbg := os.Getenv("OUTLINE_DEBUG") + if dbg != "" && dbg != "false" && dbg != "0" { + opts.Level = slog.LevelDebug + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &opts)) + slog.SetDefault(logger) +} diff --git a/client/go/outline/electron/main.go b/client/go/outline/electron/main.go index fbca728353..79230f4d1d 100644 --- a/client/go/outline/electron/main.go +++ b/client/go/outline/electron/main.go @@ -69,8 +69,6 @@ var args struct { checkConnectivity *bool dnsFallback *bool version *bool - - fetchUrl *string } var version string // Populated at build time through `-X main.version=...` @@ -86,8 +84,6 @@ var version string // Populated at build time through `-X main.version=...` // // - Connectivity Check: If you run the app with `-checkConnectivity`, it will test the proxy's connectivity // and exit with the result printed out to standard output. -// - Fetch Resource: If you run the app with `-fetchUrl`, it will fetch the content from the specified -// URL and exit with the content printed out to standard output. func main() { // VPN routing configs args.tunAddr = flag.String("tunAddr", "10.0.85.2", "TUN interface IP address") @@ -103,9 +99,6 @@ func main() { // Check connectivity of transportConfig and exit args.checkConnectivity = flag.Bool("checkConnectivity", false, "Check the proxy TCP and UDP connectivity and exit.") - // Fetch content of the given URL value and exit - args.fetchUrl = flag.String("fetchUrl", "", "Fetch the content from the given URL and exit.") - // Misc args.logLevel = flag.String("logLevel", "info", "Logging level: debug|info|warn|error|none") args.version = flag.Bool("version", false, "Print the version and exit.") @@ -117,15 +110,6 @@ func main() { os.Exit(exitCodeSuccess) } - if *args.fetchUrl != "" { - result := outline.FetchResource(*args.fetchUrl) - if result.Error != nil { - printErrorAndExit(result.Error, exitCodeFailure) - } - fmt.Println(result.Content) - os.Exit(exitCodeSuccess) - } - setLogLevel(*args.logLevel) if len(*args.transportConfig) == 0 { diff --git a/client/package.json b/client/package.json index c46891ce15..df837687e2 100644 --- a/client/package.json +++ b/client/package.json @@ -49,6 +49,7 @@ "electron-updater": "^5.0.5", "element-internals-polyfill": "^1.3.12", "fs-extra": "^11.2.0", + "koffi": "^2.9.1", "lit": "^2.2.2", "ShadowsocksConfig": "github:Jigsaw-Code/outline-shadowsocksconfig#v0.2.1", "socks": "^1.1.10", @@ -106,6 +107,7 @@ "karma-webpack": "^5.0.0", "minimist": "^1.2.6", "node-fetch": "^3.3.0", + "node-loader": "^2.0.0", "node-gyp": "^10.0.1", "postcss": "^7.0.39", "postcss-rtl": "^1.7.3", diff --git a/client/src/www/app/resource_fetcher.electron.ts b/client/src/www/app/resource_fetcher.electron.ts index aad193ea3d..a62a7b2d8a 100644 --- a/client/src/www/app/resource_fetcher.electron.ts +++ b/client/src/www/app/resource_fetcher.electron.ts @@ -21,7 +21,11 @@ import {PlatformError} from '../model/platform_error'; export class ElectronResourceFetcher implements ResourceFetcher { async fetch(url: string): Promise { try { - return await window.electron.methodChannel.invoke('fetch-resource', url); + return await window.electron.methodChannel.invoke( + 'invoke-go-api', + 'FetchResource', + url + ); } catch (e) { throw PlatformError.parseFrom(e); } diff --git a/package-lock.json b/package-lock.json index 0a4a83b102..90fad9edfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,7 @@ "electron-updater": "^5.0.5", "element-internals-polyfill": "^1.3.12", "fs-extra": "^11.2.0", + "koffi": "^2.9.1", "lit": "^2.2.2", "ShadowsocksConfig": "github:Jigsaw-Code/outline-shadowsocksconfig#v0.2.1", "socks": "^1.1.10", @@ -142,6 +143,7 @@ "minimist": "^1.2.6", "node-fetch": "^3.3.0", "node-gyp": "^10.0.1", + "node-loader": "^2.0.0", "postcss": "^7.0.39", "postcss-rtl": "^1.7.3", "prettier": "^2.8.0", @@ -23737,6 +23739,12 @@ "node": ">= 0.6" } }, + "node_modules/koffi": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.9.1.tgz", + "integrity": "sha512-LXYOzaiUB8XK7EwbG0tgzhajEr3FLS2RB9oHYbTOiWRQQO+Rgft3xSvd5TFlM3wQ6DMMQG41lvUR5gLgdyWIsA==", + "hasInstallScript": true + }, "node_modules/last-run": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/last-run/-/last-run-1.1.1.tgz", @@ -26266,6 +26274,25 @@ "npm": ">=6.0.0" } }, + "node_modules/node-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-loader/-/node-loader-2.0.0.tgz", + "integrity": "sha512-I5VN34NO4/5UYJaUBtkrODPWxbobrE4hgDqPrjB25yPkonFhCmZ146vTH+Zg417E9Iwoh1l/MbRs1apc5J295Q==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", @@ -40828,10 +40855,12 @@ "karma-coverage-istanbul-reporter": "^3.0.3", "karma-jasmine": "^4.0.1", "karma-webpack": "^5.0.0", + "koffi": "^2.9.1", "lit": "^2.2.2", "minimist": "^1.2.6", "node-fetch": "^3.3.0", "node-gyp": "^10.0.1", + "node-loader": "^2.0.0", "postcss": "^7.0.39", "postcss-rtl": "^1.7.3", "prettier": "^2.8.0", @@ -55628,6 +55657,11 @@ } } }, + "koffi": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.9.1.tgz", + "integrity": "sha512-LXYOzaiUB8XK7EwbG0tgzhajEr3FLS2RB9oHYbTOiWRQQO+Rgft3xSvd5TFlM3wQ6DMMQG41lvUR5gLgdyWIsA==" + }, "last-run": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/last-run/-/last-run-1.1.1.tgz", @@ -57563,6 +57597,15 @@ "tempfile": "^3.0.0" } }, + "node-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/node-loader/-/node-loader-2.0.0.tgz", + "integrity": "sha512-I5VN34NO4/5UYJaUBtkrODPWxbobrE4hgDqPrjB25yPkonFhCmZ146vTH+Zg417E9Iwoh1l/MbRs1apc5J295Q==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0" + } + }, "node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",