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

refactor(client/windows): move fetching logic from TypeScript to Go #2220

Merged
merged 15 commits into from
Nov 1, 2024
Merged
85 changes: 55 additions & 30 deletions client/electron/go_vpn_tunnel.ts
Copy link
Collaborator

Choose a reason for hiding this comment

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

This file should be reverted, since there's no change needed for the VPN tunnel.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ export class GoVpnTunnel implements VpnTunnel {
private readonly routing: RoutingDaemon,
readonly transportConfig: TransportConfigJson
) {
this.tun2socks = new GoTun2socks(transportConfig);
this.connectivityChecker = new GoTun2socks(transportConfig);
this.tun2socks = new GoTun2socks();
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
this.connectivityChecker = new GoTun2socks();
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved

// This promise, tied to both helper process' exits, is key to the instance's
// lifecycle:
Expand Down Expand Up @@ -104,13 +104,15 @@ export class GoVpnTunnel implements VpnTunnel {
});

if (checkProxyConnectivity) {
this.isUdpEnabled = await checkConnectivity(this.connectivityChecker);
this.isUdpEnabled = await this.connectivityChecker.checkConnectivity(
this.transportConfig
);
}
console.log(`UDP support: ${this.isUdpEnabled}`);

console.log('starting routing daemon');
await Promise.all([
this.tun2socks.start(this.isUdpEnabled),
this.tun2socks.start(this.transportConfig, this.isUdpEnabled),
this.routing.start(),
]);
}
Expand Down Expand Up @@ -152,15 +154,17 @@ export class GoVpnTunnel implements VpnTunnel {

console.log('restarting tun2socks after resume');
await Promise.all([
this.tun2socks.start(this.isUdpEnabled),
this.tun2socks.start(this.transportConfig, this.isUdpEnabled),
this.updateUdpSupport(), // Check if UDP support has changed; if so, silently restart.
]);
}

private async updateUdpSupport() {
const wasUdpEnabled = this.isUdpEnabled;
try {
this.isUdpEnabled = await checkConnectivity(this.connectivityChecker);
this.isUdpEnabled = await this.connectivityChecker.checkConnectivity(
this.transportConfig
);
} catch (e) {
console.error(`connectivity check failed: ${e}`);
return;
Expand All @@ -173,7 +177,7 @@ export class GoVpnTunnel implements VpnTunnel {

// Restart tun2socks.
await this.tun2socks.stop();
await this.tun2socks.start(this.isUdpEnabled);
await this.tun2socks.start(this.transportConfig, this.isUdpEnabled);
}

// Use #onceDisconnected to be notified when the tunnel terminates.
Expand Down Expand Up @@ -234,7 +238,7 @@ class GoTun2socks {
private stopRequested = false;
private readonly process: ChildProcessHelper;

constructor(private readonly transportConfig: TransportConfigJson) {
constructor() {
this.process = new ChildProcessHelper(pathToEmbeddedTun2socksBinary());
}

Expand All @@ -244,7 +248,10 @@ class GoTun2socks {
* Otherwise, an error containing a JSON-formatted message will be thrown.
* @param isUdpEnabled Indicates whether the remote Outline server supports UDP.
*/
async start(isUdpEnabled: boolean): Promise<void> {
async start(
config: TransportConfigJson,
isUdpEnabled: boolean
): Promise<void> {
// ./tun2socks.exe \
// -tunName outline-tap0 -tunDNS 1.1.1.1,9.9.9.9 \
// -tunAddr 10.0.85.2 -tunGw 10.0.85.1 -tunMask 255.255.255.0 \
Expand All @@ -256,7 +263,7 @@ class GoTun2socks {
args.push('-tunGw', TUN2SOCKS_VIRTUAL_ROUTER_IP);
args.push('-tunMask', TUN2SOCKS_VIRTUAL_ROUTER_NETMASK);
args.push('-tunDNS', DNS_RESOLVERS.join(','));
args.push('-transport', JSON.stringify(this.transportConfig));
args.push('-transport', JSON.stringify(config));
args.push('-logLevel', this.process.isDebugModeEnabled ? 'debug' : 'info');
if (!isUdpEnabled) {
args.push('-dnsFallback');
Expand Down Expand Up @@ -310,17 +317,39 @@ class GoTun2socks {
}

/**
* Checks connectivity and exits with the string of stdout.
* Checks connectivity of 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 ProcessTerminatedExitCodeError if tun2socks failed to run successfully.
*/
checkConnectivity() {
async checkConnectivity(config: TransportConfigJson): Promise<boolean> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you move this to a standalone function?

There's no real dependency on internal state, only this.process, which you can replace with new ChildProcessHelper(pathToEmbeddedTun2socksBinary()).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

console.debug('[tun2socks] - checking connectivity ...');
return this.process.launch([
const output = await this.process.launch([
'-transport',
JSON.stringify(this.transportConfig),
JSON.stringify(config),
'-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;
}

/**
* Fetches a dynamic key from the given URL.
* @param url The URL to be fetched.
* @throws ProcessTerminatedExitCodeError if we failed to fetch the config.
*/
fetchConfig(url: string): Promise<string> {
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
console.debug('[tun2socks] - fetching dynamic key ...');
return this.process.launch(['-fetchConfig', url]);
}

enableDebugMode() {
Expand All @@ -329,22 +358,18 @@ class GoTun2socks {
}

/**
* 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.
* Fetches a dynamic key config using native code (Go).
* @param url The HTTP(s) location of the config.
* @returns A string representing the config.
* @throws ProcessTerminatedExitCodeError if we failed to fetch the config.
*/
async function checkConnectivity(tun2socks: GoTun2socks) {
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;
export function fetchDynamicKeyConfig(
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
url: string,
debugMode: boolean = false
): Promise<string> {
const tun2socks = new GoTun2socks();
if (debugMode) {
tun2socks.enableDebugMode();
}
return true;
return tun2socks.fetchConfig(url);
}
13 changes: 11 additions & 2 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 {GoVpnTunnel} from './go_vpn_tunnel';
import {fetchDynamicKeyConfig, GoVpnTunnel} from './go_vpn_tunnel';
import {installRoutingServices, RoutingDaemon} from './routing_service';
import {TunnelStore} from './tunnel_store';
import {VpnTunnel} from './vpn_tunnel';
Expand Down Expand Up @@ -292,7 +292,9 @@ function interceptShadowsocksLink(argv: string[]) {
if (mainWindow) {
// The system adds a trailing slash to the intercepted URL (before the fragment).
// Remove it before sending to the UI.
url = `${protocol}${url.substring(protocol.length).replace(/\/$/g, '')}`;
url = `${protocol}${url
.substring(protocol.length)
.replace(/\/$/g, '')}`;
// TODO: refactor channel name and namespace to a constant
mainWindow.webContents.send('outline-ipc-add-server', url);
} else {
Expand Down Expand Up @@ -498,6 +500,13 @@ function main() {
mainWindow?.webContents.send('outline-ipc-push-clipboard');
});

// Fetches dynamic key config from a remote URL.
ipcMain.handle(
'outline-ipc-fetch-config',
Copy link
Collaborator

Choose a reason for hiding this comment

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

Perhaps rename this to outline-ipc-fetch-resource to align with the function name and avoid confusion.

You'll need to change in the caller as well.

async (_, url: string): Promise<string> =>
fetchDynamicKeyConfig(url, debugMode)
);

// Connects to a proxy server specified by a config.
//
// If any issues occur, an Error will be thrown, which you can try-catch around
Expand Down
67 changes: 67 additions & 0 deletions client/go/outline/dynamic_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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 outline

import (
"io"
"net/http"

"github.com/Jigsaw-Code/outline-apps/client/go/outline/platerrors"
)

// FetchDynamicKeyResult represents the result of fetching a dynamic key.
//
// We use a struct instead of a tuple to preserve a strongly typed error that gobind recognizes.
type FetchDynamicKeyResult struct {
Key string
Error *platerrors.PlatformError
}

// FetchDynamicKey fetches a dynamic key from the given URL.
//
// The function makes an HTTP GET request to the specified URL and returns the response body as a
// string. If the request fails or the server returns a non-2xx status code, an error is returned.
func FetchDynamicKey(url string) *FetchDynamicKeyResult {
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Collaborator

Choose a reason for hiding this comment

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

Call it FetchUrl?
Is there anything specific to dynamic keys?

Copy link
Contributor Author

@jyyi1 jyyi1 Oct 11, 2024

Choose a reason for hiding this comment

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

Nope. Updated to FetchResource

resp, err := http.Get(url)
if err != nil {
return &FetchDynamicKeyResult{Error: &platerrors.PlatformError{
Code: platerrors.FetchConfigFailed,
Message: "failed to fetch the URL",
Details: platerrors.ErrorDetails{"url": url},
Cause: platerrors.ToPlatformError(err),
}}
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode > 299 {
return &FetchDynamicKeyResult{Error: &platerrors.PlatformError{
Code: platerrors.FetchConfigFailed,
Message: "non-successful HTTP status",
Details: platerrors.ErrorDetails{
"status": resp.Status,
"body": string(body),
},
}}
}
if err != nil {
return &FetchDynamicKeyResult{Error: &platerrors.PlatformError{
Code: platerrors.FetchConfigFailed,
Message: "failed to read the body",
Details: platerrors.ErrorDetails{"url": url},
Cause: platerrors.ToPlatformError(err),
}}
}
return &FetchDynamicKeyResult{Key: string(body)}
}
13 changes: 13 additions & 0 deletions client/go/outline/electron/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ var args struct {
checkConnectivity *bool
dnsFallback *bool
version *bool

fetchConfig *string
}

var version string // Populated at build time through `-X main.version=...`

// This app sets up a local network stack to handle requests from a tun device.
Expand All @@ -88,6 +91,7 @@ func main() {
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.fetchConfig = flag.String("fetchConfig", "", "The HTTPS URL of a dynamic key to fetch")
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
args.version = flag.Bool("version", false, "Print the version and exit.")

flag.Parse()
Expand All @@ -97,6 +101,15 @@ func main() {
os.Exit(exitCodeSuccess)
}

if *args.fetchConfig != "" {
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
result := outline.FetchDynamicKey(*args.fetchConfig)
if result.Error != nil {
printErrorAndExit(result.Error, exitCodeFailure)
}
fmt.Println(result.Key)
os.Exit(exitCodeSuccess)
}

setLogLevel(*args.logLevel)

if len(*args.transportConfig) == 0 {
Expand Down
25 changes: 9 additions & 16 deletions client/src/www/app/outline_server_repository/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ export class OutlineServer implements Server {
async connect() {
let tunnelConfig: TunnelConfigJson;
if (this.type === ServerType.DYNAMIC_CONNECTION) {
tunnelConfig = await fetchTunnelConfig(this.tunnelConfigLocation);
tunnelConfig = await fetchTunnelConfig(
this.vpnApi,
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
this.tunnelConfigLocation
);
this._address = getAddressFromTransportConfig(tunnelConfig.transport);
} else {
tunnelConfig = this.staticTunnelConfig;
Expand Down Expand Up @@ -164,23 +167,13 @@ function parseTunnelConfigJson(responseBody: string): TunnelConfigJson | null {

/** fetchTunnelConfig fetches information from a dynamic access key and attempts to parse it. */
// TODO(daniellacosse): unit tests
export async function fetchTunnelConfig(
async function fetchTunnelConfig(
vpnApi: VpnApi,
configLocation: URL
): Promise<TunnelConfigJson> {
let response;
try {
response = await fetch(configLocation, {
cache: 'no-store',
redirect: 'follow',
});
} catch (cause) {
throw new errors.SessionConfigFetchFailed(
'Failed to fetch VPN information from dynamic access key.',
{cause}
);
}

const responseBody = (await response.text()).trim();
const responseBody = (
await vpnApi.fetchDynamicConfig(configLocation.toString())
).trim();
if (!responseBody) {
throw new errors.ServerAccessKeyInvalid(
'Got empty config from dynamic key.'
Expand Down
17 changes: 17 additions & 0 deletions client/src/www/app/outline_server_repository/vpn.cordova.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,21 @@ export class CordovaVpnApi implements VpnApi {
console.debug('CordovaVpnApi: registering onStatusChange callback');
cordova.exec(callback, onError, OUTLINE_PLUGIN_NAME, 'onStatusChange', []);
}

// TODO: move to Go fetch implementation later
async fetchDynamicConfig(url: string): Promise<string> {
let response: Response;
try {
response = await fetch(url, {
cache: 'no-store',
redirect: 'follow',
});
} catch (cause) {
throw new errors.SessionConfigFetchFailed(
'Failed to fetch VPN information from dynamic access key.',
{cause}
);
}
return (await response.text()).trim();
}
}
8 changes: 8 additions & 0 deletions client/src/www/app/outline_server_repository/vpn.electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,12 @@ export class ElectronVpnApi implements VpnApi {
onStatusChange(listener: (id: string, status: TunnelStatus) => void): void {
this.statusChangeListener = listener;
}

async fetchDynamicConfig(url: string): Promise<string> {
try {
return await window.electron.methodChannel.invoke('fetch-config', url);
} catch (e) {
throw PlatformError.parseFrom(e);
}
}
}
Loading
Loading