diff --git a/client/electron/go_vpn_tunnel.ts b/client/electron/go_vpn_tunnel.ts index 49351310147..9a6948f0e5b 100755 --- a/client/electron/go_vpn_tunnel.ts +++ b/client/electron/go_vpn_tunnel.ts @@ -17,18 +17,13 @@ import {platform} from 'os'; import {powerMonitor} from 'electron'; import {pathToEmbeddedTun2socksBinary} from './app_paths'; -import { - ChildProcessHelper, - ProcessTerminatedExitCodeError, - ProcessTerminatedSignalError, -} from './process'; +import {ChildProcessHelper, ProcessTerminatedSignalError} from './process'; import {RoutingDaemon} from './routing_service'; import {VpnTunnel} from './vpn_tunnel'; import { TransportConfigJson, TunnelStatus, } from '../src/www/app/outline_server_repository/vpn'; -import {ErrorCode} from '../src/www/model/errors'; const isLinux = platform() === 'linux'; const isWindows = platform() === 'win32'; @@ -297,7 +292,7 @@ class GoTun2socks { this.monitorStarted().then(() => (restarting = true)); try { lastError = null; - await this.process.launch(args); + await this.process.launch(args, false); console.info('[tun2socks] - exited with no errors'); } catch (e) { console.error('[tun2socks] - terminated due to:', e); @@ -315,9 +310,9 @@ class GoTun2socks { } /** - * Checks connectivity and exits with an error code as defined in `errors.ErrorCode`. - * If exit code is not zero, a `ProcessTerminatedExitCodeError` might be thrown. - * -tun* and -dnsFallback options have no effect on this mode. + * Checks connectivity and exits with the string of stdout. + * + * @throws ProcessTerminatedExitCodeError if tun2socks failed to run successfully. */ checkConnectivity() { console.debug('[tun2socks] - checking connectivity ...'); @@ -333,21 +328,23 @@ class GoTun2socks { } } -// Leverages the outline-go-tun2socks binary to check connectivity to the server specified in -// `config`. Checks whether proxy server is reachable, whether the network and proxy support UDP -// forwarding and validates the proxy credentials. Resolves with a boolean indicating whether UDP -// forwarding is supported. Throws if the checks fail or if the process fails to start. +/** + * Leverages the GoTun2socks binary to check connectivity to the server specified in `config`. + * Checks whether proxy server is reachable, whether the network and proxy support UDP forwarding + * and validates the proxy credentials. + * + * @returns A boolean indicating whether UDP forwarding is supported. + * @throws Error if the server is not reachable or if the process fails to start. + */ async function checkConnectivity(tun2socks: GoTun2socks) { - try { - await tun2socks.checkConnectivity(); - return true; - } catch (e) { - console.error('connectivity check error:', e); - if (e instanceof ProcessTerminatedExitCodeError) { - if (e.exitCode === ErrorCode.UDP_RELAY_NOT_ENABLED) { - return false; - } - } - throw e; + const output = await tun2socks.checkConnectivity(); + // Only parse the first line, because sometimes Windows Crypto API adds warnings to stdout. + const outObj = JSON.parse(output.split('\n')[0]); + if (outObj.tcp) { + throw new Error(outObj.tcp); + } + if (outObj.udp) { + return false; } + return true; } diff --git a/client/electron/process.ts b/client/electron/process.ts index 2ff39998ddc..6e40db6bb15 100755 --- a/client/electron/process.ts +++ b/client/electron/process.ts @@ -20,7 +20,10 @@ import process from 'node:process'; * A child process is terminated abnormally, caused by a non-zero exit code. */ export class ProcessTerminatedExitCodeError extends Error { - constructor(readonly exitCode: number, errJSON: string) { + constructor( + readonly exitCode: number, + errJSON: string + ) { super(errJSON); } } @@ -43,7 +46,7 @@ export class ProcessTerminatedSignalError extends Error { export class ChildProcessHelper { private readonly processName: string; private childProcess?: ChildProcess; - private waitProcessToExit?: Promise; + private waitProcessToExit?: Promise; /** * Whether to enable verbose logging for the process. Must be called before launch(). @@ -60,18 +63,23 @@ export class ChildProcessHelper { * Start the process with the given args and wait for the process to exit. If `isDebugModeEnabled` * is `true`, the process is started in verbose mode if supported. * - * If the process does not exist normally (i.e., exit code !== 0 or received a signal), it will + * If the process does not exit normally (i.e., exit code !== 0 or received a signal), it will * throw either `ProcessTerminatedExitCodeError` or `ProcessTerminatedSignalError`. * + * It the process exits normally, it will return the stdout string. + * * @param args The args for the process */ - async launch(args: string[]): Promise { + async launch(args: string[], returnStdOut: boolean = true): Promise { if (this.childProcess) { - throw new Error(`subprocess ${this.processName} has already been launched`); + throw new Error( + `subprocess ${this.processName} has already been launched` + ); } this.childProcess = spawn(this.path, args); - return (this.waitProcessToExit = new Promise((resolve, reject) => { + return (this.waitProcessToExit = new Promise((resolve, reject) => { let stdErrJSON = ''; + let stdOutStr = ''; const onExit = (code?: number, signal?: string) => { if (this.childProcess) { this.childProcess.removeAllListeners(); @@ -84,7 +92,7 @@ export class ChildProcessHelper { logExit(this.processName, code, signal); if (code === 0) { - resolve(); + resolve(stdOutStr); } else if (code) { reject(new ProcessTerminatedExitCodeError(code, stdErrJSON)); } else { @@ -92,7 +100,12 @@ export class ChildProcessHelper { } }; - this.childProcess?.stdout?.on('data', data => this.stdOutListener?.(data)); + this.childProcess?.stdout?.on('data', data => { + this.stdOutListener?.(data); + if (returnStdOut) { + stdOutStr += data?.toString() ?? ''; + } + }); this.childProcess?.stderr?.on('data', (data?: string | Buffer) => { if (this.isDebugModeEnabled) { // This will be captured by Sentry @@ -121,13 +134,13 @@ export class ChildProcessHelper { * If the process does not exist normally (i.e., exit code !== 0 or received a signal), it will * throw either `ProcessTerminatedExitCodeError` or `ProcessTerminatedSignalError`. */ - async stop(): Promise { + async stop(): Promise { if (!this.childProcess) { // Never started. - return; + return ''; } this.childProcess.kill(); - return await this.waitProcessToExit; + return (await this.waitProcessToExit) ?? ''; } set onStdOut(listener: ((data?: string | Buffer) => void) | null) { diff --git a/client/go/outline/electron/main.go b/client/go/outline/electron/main.go index 42e029c4734..e4df2cdf13c 100644 --- a/client/go/outline/electron/main.go +++ b/client/go/outline/electron/main.go @@ -15,6 +15,7 @@ package main import ( + "encoding/json" "flag" "fmt" "io" @@ -26,7 +27,6 @@ import ( "time" "github.com/Jigsaw-Code/outline-apps/client/go/outline" - "github.com/Jigsaw-Code/outline-apps/client/go/outline/neterrors" "github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors" "github.com/Jigsaw-Code/outline-apps/client/go/outline/tun2socks" _ "github.com/eycorsican/go-tun2socks/common/log/simple" // Register a simple logger. @@ -38,9 +38,8 @@ import ( // tun2socks exit codes. Must be kept in sync with definitions in "go_vpn_tunnel.ts" // TODO: replace exit code with structured JSON output const ( - exitCodeSuccess = 0 - exitCodeFailure = 1 - exitCodeNoUDPConnectivity = 4 + exitCodeSuccess = 0 + exitCodeFailure = 1 ) const ( @@ -51,6 +50,12 @@ const ( var logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +// The result JSON containing two error strings when "--checkConnectivity". +type CheckConnectivityResult struct { + TCPErrorJson string `json:"tcp"` + UDPErrorJson string `json:"udp"` +} + var args struct { tunAddr *string tunGw *string @@ -97,13 +102,24 @@ func main() { if len(*args.transportConfig) == 0 { printErrorAndExit(platerrors.PlatformError{Code: platerrors.IllegalConfig, Message: "transport config missing"}, exitCodeFailure) } - client, err := outline.NewClient(*args.transportConfig) - if err != nil { - printErrorAndExit(err, exitCodeFailure) + clientResult := outline.NewClientAndReturnError(*args.transportConfig) + if clientResult.Error != nil { + printErrorAndExit(clientResult.Error, exitCodeFailure) } + client := clientResult.Client if *args.checkConnectivity { - checkConnectivityAndExit(client) + result := outline.CheckTCPAndUDPConnectivity(client) + output := CheckConnectivityResult{ + TCPErrorJson: marshalErrorToJSON(result.TCPError), + UDPErrorJson: marshalErrorToJSON(result.UDPError), + } + jsonBytes, err := json.Marshal(output) + if err != nil { + printErrorAndExit(err, exitCodeFailure) + } + fmt.Println(string(jsonBytes)) + os.Exit(exitCodeSuccess) } // Open TUN device @@ -169,50 +185,20 @@ func setLogLevel(level string) { logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slvl})) } -func printErrorAndExit(e error, exitCode int) { +func marshalErrorToJSON(e error) string { pe := platerrors.ToPlatformError(e) + if pe == nil { + return "" + } errJson, err := platerrors.MarshalJSONString(pe) if err != nil { // TypeScript's PlatformError can unmarshal a raw string - errJson = string(pe.Code) + return string(pe.Code) } - fmt.Fprintln(os.Stderr, errJson) - os.Exit(exitCode) + return errJson } -// checkConnectivity checks whether the remote Outline server supports TCP or UDP, -// and converts the neterrors to a PlatformError. -// TODO: remove this function once we migrated CheckConnectivity to return a PlatformError. -func checkConnectivityAndExit(c *outline.Client) { - connErrCode, err := outline.CheckConnectivity(c) - if err != nil { - printErrorAndExit(platerrors.PlatformError{ - Code: platerrors.InternalError, - Message: "failed to check connectivity", - Cause: platerrors.ToPlatformError(err), - }, exitCodeFailure) - } - switch connErrCode { - case neterrors.NoError.Number(): - os.Exit(exitCodeSuccess) - case neterrors.AuthenticationFailure.Number(): - printErrorAndExit(platerrors.PlatformError{ - Code: platerrors.Unauthenticated, - Message: "authentication failed", - }, exitCodeFailure) - case neterrors.Unreachable.Number(): - printErrorAndExit(platerrors.PlatformError{ - Code: platerrors.ProxyServerUnreachable, - Message: "cannot connect to Outline server", - }, exitCodeFailure) - case neterrors.UDPConnectivity.Number(): - printErrorAndExit(platerrors.PlatformError{ - Code: platerrors.ProxyServerUDPUnsupported, - Message: "Outline server does not support UDP", - }, exitCodeNoUDPConnectivity) - } - printErrorAndExit(platerrors.PlatformError{ - Code: platerrors.InternalError, - Message: "failed to check connectivity", - }, exitCodeFailure) +func printErrorAndExit(e error, exitCode int) { + fmt.Fprintln(os.Stderr, marshalErrorToJSON(e)) + os.Exit(exitCode) } diff --git a/client/go/outline/neterrors/neterrors.go b/client/go/outline/neterrors/neterrors.go index 2246d66e525..089da1d2005 100644 --- a/client/go/outline/neterrors/neterrors.go +++ b/client/go/outline/neterrors/neterrors.go @@ -24,7 +24,7 @@ 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-apps/blob/master/src/www/model/errors.ts +// Outline error codes. Must be kept in sync with definitions in https://github.com/Jigsaw-Code/outline-apps/blob/master/client/src/www/model/errors.ts const ( NoError Error = 0 Unexpected Error = 1