Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(client/windows): introduce go plugin to call fetchResource as a library #2263

Merged
merged 18 commits into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build_and_test_debug_client.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 16 additions & 5 deletions client/electron/app_paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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'
);
}

Expand All @@ -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(
Expand Down
19 changes: 0 additions & 19 deletions client/electron/go_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const tun2socks = new ChildProcessHelper(pathToEmbeddedTun2socksBinary());
tun2socks.isDebugModeEnabled = debugMode;

console.debug('[tun2socks] - fetching resource ...');
return tun2socks.launch(['-fetchUrl', url]);
}
68 changes: 68 additions & 0 deletions client/electron/go_plugin.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
}
17 changes: 13 additions & 4 deletions client/electron/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -501,10 +501,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<string> => fetchResource(url, debugMode)
'outline-ipc-invoke-go-api',
(_, api: GoApiName, input: string): Promise<string> =>
invokeGoApi(api, input)
);

// Connects to a proxy server specified by a config.
Expand Down
5 changes: 4 additions & 1 deletion client/electron/webpack_electron_main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {fileURLToPath} from 'url';

import webpack from 'webpack';


const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

Expand All @@ -37,6 +36,10 @@ export default ({sentryDsn, appVersion}) => [
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.node$/,
loader: 'node-loader',
},
],
},
resolve: {
Expand Down
35 changes: 24 additions & 11 deletions client/go/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
133 changes: 133 additions & 0 deletions client/go/outline/electron/go_plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// 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 <stdlib.h> // for C.free

// InvokeGoAPIResult is a struct used to pass result from Go to TypeScript boundary.
typedef struct InvokeGoAPIResult_t
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you can define this struct in Go instead and only have the include here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you mean define it as the struct as:

type InvokeGoAPIResult struct {
  Output, ErrorJson *C.char
}

I'm afraid this is not supported by CGo:

Not all Go types can be mapped to C types in a useful way. Go struct types are not supported; use a C struct type. Go array types are not supported; use a C pointer.

{
// 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.IllegalConfig,
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 = "true"
opts.Level = slog.LevelDebug
} else {
dbg = "false"
}

logger := slog.New(slog.NewTextHandler(os.Stderr, &opts))
slog.SetDefault(logger)

slog.Info("Backend module initialized", "debug", dbg)
}
Loading
Loading