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/linux): revamp the Linux VPN routing logic #2291

Open
wants to merge 32 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
787e605
feat(client/linux): revamp the Linux routing logic
jyyi1 Nov 22, 2024
46b8a25
Implement TUN device configuration
jyyi1 Nov 23, 2024
d5f1b88
add skeleton of the routing logic
jyyi1 Nov 26, 2024
7b7c485
Implement TCP IPv4 Routing
jyyi1 Dec 1, 2024
3bc0c43
refactor to OOP based design
jyyi1 Dec 3, 2024
07c15d2
refactor outlineDevice object
jyyi1 Dec 3, 2024
18682bf
Merge branch 'master' into junyi/modern-linux-routing
jyyi1 Dec 3, 2024
b97cca8
add allowed license "BSD-2-Clause"
jyyi1 Dec 3, 2024
89029fb
initial connectivity check
jyyi1 Dec 4, 2024
d91d388
refine some log messages
jyyi1 Dec 4, 2024
0831f68
Merge branch 'master' into junyi/modern-linux-routing
jyyi1 Dec 4, 2024
407e2a1
resolve conflicts.
jyyi1 Dec 4, 2024
0dede89
Clean up VPN routing to leverage network manager
jyyi1 Dec 6, 2024
47bf530
Merge branch 'master' into junyi/modern-linux-routing
jyyi1 Dec 6, 2024
a9746be
Resolve code review comment round 1
jyyi1 Dec 6, 2024
7586b48
update vpn_others as well
jyyi1 Dec 6, 2024
ed601b4
Wait for device to be available
jyyi1 Dec 6, 2024
b5f43ec
Simplify NM config structure
jyyi1 Dec 6, 2024
e2bde4d
Add retry for create connection
jyyi1 Dec 7, 2024
4aeaa02
Merge branch 'master' into junyi/modern-linux-routing
jyyi1 Dec 9, 2024
bf93a3a
resolve code review comments RD2
jyyi1 Dec 9, 2024
933d693
code review comment RD3
jyyi1 Dec 10, 2024
70f8303
Add NetworkManager documentation link
jyyi1 Dec 10, 2024
a6606f8
refactor vpn package architecture
jyyi1 Dec 16, 2024
134331a
Merge branch 'master' into junyi/modern-linux-routing
jyyi1 Dec 16, 2024
b05140d
Update comments for VPN package
jyyi1 Dec 16, 2024
c77e7a7
extract nm connection code out of the linuxVPNConn
jyyi1 Dec 16, 2024
10c8d4a
move dialer control to outline package
jyyi1 Dec 18, 2024
1edc3b6
resolve code review comments 3
jyyi1 Dec 19, 2024
617d060
move establishVPN to a platform specific file
jyyi1 Dec 19, 2024
61b9c42
remove unused file.
jyyi1 Dec 19, 2024
3f944f2
Replace IPDevice with ReadWriteCloser
jyyi1 Dec 20, 2024
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
2 changes: 1 addition & 1 deletion .github/workflows/license.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ jobs:

- name: Check Go dependency tree licenses
# We allow only "notice" type of licenses.
run: go run github.com/google/go-licenses@latest check --ignore=golang.org/x --allowed_licenses=Apache-2.0,Apache-3,BSD-3-Clause,BSD-4-Clause,CC0-1.0,ISC,MIT ./...
run: go run github.com/google/go-licenses@latest check --ignore=golang.org/x --allowed_licenses=Apache-2.0,Apache-3,BSD-2-Clause,BSD-3-Clause,BSD-4-Clause,CC0-1.0,ISC,MIT ./...
14 changes: 14 additions & 0 deletions client/electron/debian/after_install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,24 @@

# Dependencies:
# - libcap2-bin: setcap
# - patchelf: patchelf

set -eux

# Capabilitites will disable LD_LIBRARY_PATH, and $ORIGIN evaluation in binary's
# rpath. So we need to set the rpath to an absolute path. (for libffmpeg.so)
# This command will also reset capabilitites, so we need to run this before setcap.
/usr/bin/patchelf --add-rpath /opt/Outline /opt/Outline/Outline

# Grant specific capabilities so Outline can run without root permisssion
# - cap_net_admin: configure network interfaces, set up routing tables, etc.
# - cap_dac_override: modify network configuration files owned by root
/usr/sbin/setcap cap_net_admin,cap_dac_override+eip /opt/Outline/Outline

# From electron's hint:
fortuna marked this conversation as resolved.
Show resolved Hide resolved
# > The SUID sandbox helper binary was found, but is not configured correctly.
# > Rather than run without sandboxing I'm aborting now. You need to make sure
# > that /opt/Outline/chrome-sandbox is owned by root and has mode 4755.
#
# https://github.com/electron/electron/issues/42510
/usr/bin/chmod 4755 /opt/Outline/chrome-sandbox
4 changes: 2 additions & 2 deletions client/electron/electron-builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@

"deb": {
"depends": [
"gconf2", "gconf-service", "libnotify4", "libappindicator1", "libxtst6", "libnss3",
"libcap2-bin"
"libnotify4", "libxtst6", "libnss3",
"libcap2-bin", "patchelf"
],
"afterInstall": "client/electron/debian/after_install.sh"
},
Expand Down
4 changes: 2 additions & 2 deletions client/electron/go_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ export async function invokeMethod(
);
}

console.debug('[Backend] - calling InvokeMethod ...');
console.debug(`[Backend] - calling InvokeMethod "${method}" ...`);
const result = await invokeMethodFunc(method, input);
console.debug('[Backend] - InvokeMethod returned', result);
console.debug(`[Backend] - InvokeMethod "${method}" returned`, result);
if (result.ErrorJson) {
throw Error(result.ErrorJson);
}
Expand Down
25 changes: 20 additions & 5 deletions client/electron/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {invokeMethod} from './go_plugin';
import {GoVpnTunnel} from './go_vpn_tunnel';
import {installRoutingServices, RoutingDaemon} from './routing_service';
import {TunnelStore} from './tunnel_store';
import {closeVpn, establishVpn, onVpnStatusChanged} from './vpn_service';
import {VpnTunnel} from './vpn_tunnel';
import * as config from '../src/www/app/outline_server_repository/config';
import {
Expand All @@ -56,7 +57,7 @@ declare const APP_VERSION: string;
// Run-time environment variables:
const debugMode = process.env.OUTLINE_DEBUG === 'true';

const isLinux = os.platform() === 'linux';
const IS_LINUX = os.platform() === 'linux';

// Used for the auto-connect feature. There will be a tunnel in store
// if the user was connected at shutdown.
Expand Down Expand Up @@ -158,7 +159,7 @@ function setupWindow(): void {
//
// The ideal solution would be: either electron-builder supports the app icon; or we add
// dpi-aware features to this app.
if (isLinux) {
if (IS_LINUX) {
mainWindow.setIcon(
path.join(
app.getAppPath(),
Expand Down Expand Up @@ -251,7 +252,7 @@ function updateTray(status: TunnelStatus) {
{type: 'separator'} as MenuItemConstructorOptions,
{label: localizedStrings['quit'], click: quitApp},
];
if (isLinux) {
if (IS_LINUX) {
// Because the click event is never fired on Linux, we need an explicit open option.
menuTemplate = [
{
Expand Down Expand Up @@ -309,7 +310,7 @@ function interceptShadowsocksLink(argv: string[]) {
async function setupAutoLaunch(request: StartRequestJson): Promise<void> {
try {
await tunnelStore.save(request);
if (isLinux) {
if (IS_LINUX) {
if (process.env.APPIMAGE) {
const outlineAutoLauncher = new autoLaunch({
name: 'OutlineClient',
Expand All @@ -327,7 +328,7 @@ async function setupAutoLaunch(request: StartRequestJson): Promise<void> {

async function tearDownAutoLaunch() {
try {
if (isLinux) {
if (IS_LINUX) {
const outlineAutoLauncher = new autoLaunch({
name: 'OutlineClient',
});
Expand Down Expand Up @@ -368,6 +369,15 @@ async function createVpnTunnel(

// Invoked by both the start-proxying event handler and auto-connect.
async function startVpn(request: StartRequestJson, isAutoConnect: boolean) {
if (IS_LINUX && !process.env.APPIMAGE) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is this not needed for APP_IMAGE?

Copy link
Collaborator

Choose a reason for hiding this comment

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

It seems this would also make sense for Windows

Copy link
Contributor Author

@jyyi1 jyyi1 Dec 6, 2024

Choose a reason for hiding this comment

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

APP_IMAGE would still use the old tun2socks binary with the service. Because the capabilities on the APPIMAGE file won't take any effect.

onVpnStatusChanged((id, status) => {
setUiTunnelStatus(status, id);
console.info('VPN Status Changed: ', id, status);
});
await establishVpn(request);
return;
}

if (currentTunnel) {
throw new Error('already connected');
}
Expand Down Expand Up @@ -401,6 +411,11 @@ async function startVpn(request: StartRequestJson, isAutoConnect: boolean) {

// Invoked by both the stop-proxying event and quit handler.
async function stopVpn() {
if (IS_LINUX && !process.env.APPIMAGE) {
await Promise.all([closeVpn(), tearDownAutoLaunch()]);
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
return;
}

if (!currentTunnel) {
return;
}
Expand Down
90 changes: 90 additions & 0 deletions client/electron/vpn_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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 {invokeMethod} from './go_plugin';
import {
StartRequestJson,
TunnelStatus,
} from '../src/www/app/outline_server_repository/vpn';

// TODO: Separate this config into LinuxVpnConfig and WindowsVpnConfig. Some fields may share.
interface VpnConfig {
jyyi1 marked this conversation as resolved.
Show resolved Hide resolved
id: string;
interfaceName: string;
connectionName: string;
ipAddress: string;
dnsServers: string[];
routingTableId: number;
routingPriority: number;
protectionMark: number;
}

interface EstablishVpnRequest {
vpn: VpnConfig;
transport: string;
}

let currentRequestId: string | undefined = undefined;

export async function establishVpn(request: StartRequestJson) {
currentRequestId = request.id;
statusCb?.(currentRequestId, TunnelStatus.RECONNECTING);

const config: EstablishVpnRequest = {
vpn: {
id: currentRequestId,

// TUN device name, being compatible with old code:
// https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L203
interfaceName: 'outline-tun0',

// Network Manager connection name, Use "TUN Connection" instead of "VPN Connection"
// because Network Manager has a dedicated "VPN Connection" concept that we did not implement
connectionName: 'Outline TUN Connection',

// TUN IP, being compatible with old code:
// https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L204
ipAddress: '10.0.85.1',

// DNS server list, being compatible with old code:
// https://github.com/Jigsaw-Code/outline-apps/blob/client/linux/v1.14.0/client/electron/linux_proxy_controller/outline_proxy_controller.h#L207
dnsServers: ['9.9.9.9'],

// Outline magic numbers, 7113 and 0x711E visually resembles "T L I E" in "ouTLInE"
routingTableId: 7113,
routingPriority: 0x711e,
protectionMark: 0x711e,
},

// The actual transport config
transport: JSON.stringify(request.config.transport),
};

await invokeMethod('EstablishVPN', JSON.stringify(config));
statusCb?.(currentRequestId, TunnelStatus.CONNECTED);
}

export async function closeVpn(): Promise<void> {
statusCb?.(currentRequestId!, TunnelStatus.DISCONNECTING);
await invokeMethod('CloseVPN', '');
statusCb?.(currentRequestId!, TunnelStatus.DISCONNECTED);
}

export type VpnStatusCallback = (id: string, status: TunnelStatus) => void;

let statusCb: VpnStatusCallback | undefined = undefined;

export function onVpnStatusChanged(cb: VpnStatusCallback): void {
statusCb = cb;
}
30 changes: 18 additions & 12 deletions client/go/outline/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,23 +42,29 @@ type NewClientResult struct {

// NewClient creates a new Outline client from a configuration string.
func NewClient(transportConfig string) *NewClientResult {
config, err := parseConfigFromJSON(transportConfig)
client, err := newClientWithBaseDialers(transportConfig, newTCPDialer(), newUDPDialer())
return &NewClientResult{
Client: client,
Error: platerrors.ToPlatformError(err),
}
}

func newClientWithBaseDialers(transportConfig string, tcpDialer, udpDialer net.Dialer) (*Client, error) {
conf, err := parseConfigFromJSON(transportConfig)
if err != nil {
return &NewClientResult{Error: platerrors.ToPlatformError(err)}
return nil, err
}
prefixBytes, err := ParseConfigPrefixFromString(config.Prefix)
prefixBytes, err := ParseConfigPrefixFromString(conf.Prefix)
if err != nil {
return &NewClientResult{Error: platerrors.ToPlatformError(err)}
return nil, err
}

client, err := newShadowsocksClient(config.Host, int(config.Port), config.Method, config.Password, prefixBytes)
return &NewClientResult{
Client: client,
Error: platerrors.ToPlatformError(err),
}
return newShadowsocksClient(conf.Host, int(conf.Port), conf.Method, conf.Password, prefixBytes, tcpDialer, udpDialer)
}

func newShadowsocksClient(host string, port int, cipherName, password string, prefix []byte) (*Client, error) {
func newShadowsocksClient(
host string, port int, cipherName, password string, prefix []byte, tcpDialer, udpDialer net.Dialer,
) (*Client, error) {
if err := validateConfig(host, port, cipherName, password); err != nil {
return nil, err
}
Expand All @@ -74,7 +80,7 @@ func newShadowsocksClient(host string, port int, cipherName, password string, pr

// We disable Keep-Alive as per https://datatracker.ietf.org/doc/html/rfc1122#page-101, which states that it should only be
// enabled in server applications. This prevents the device from unnecessarily waking up to send keep alives.
streamDialer, err := shadowsocks.NewStreamDialer(&transport.TCPEndpoint{Address: proxyAddress, Dialer: net.Dialer{KeepAlive: -1}}, cryptoKey)
streamDialer, err := shadowsocks.NewStreamDialer(&transport.TCPEndpoint{Address: proxyAddress, Dialer: tcpDialer}, cryptoKey)
if err != nil {
return nil, platerrors.PlatformError{
Code: platerrors.SetupTrafficHandlerFailed,
Expand All @@ -88,7 +94,7 @@ func newShadowsocksClient(host string, port int, cipherName, password string, pr
streamDialer.SaltGenerator = shadowsocks.NewPrefixSaltGenerator(prefix)
}

packetListener, err := shadowsocks.NewPacketListener(&transport.UDPEndpoint{Address: proxyAddress}, cryptoKey)
packetListener, err := shadowsocks.NewPacketListener(&transport.UDPEndpoint{Address: proxyAddress, Dialer: udpDialer}, cryptoKey)
if err != nil {
return nil, platerrors.PlatformError{
Code: platerrors.SetupTrafficHandlerFailed,
Expand Down
19 changes: 1 addition & 18 deletions client/go/outline/connectivity.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,10 @@
package outline

import (
"net"

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

const (
tcpTestWebsite = "http://example.com"
dnsServerIP = "1.1.1.1"
dnsServerPort = 53
)

// TCPAndUDPConnectivityResult represents the result of TCP and UDP connectivity checks.
//
// We use a struct instead of a tuple to preserve a strongly typed error that gobind recognizes.
Expand All @@ -40,16 +32,7 @@ type TCPAndUDPConnectivityResult struct {
// containing a TCP error and a UDP error.
// If the connectivity check was successful, the corresponding error field will be nil.
func CheckTCPAndUDPConnectivity(client *Client) *TCPAndUDPConnectivityResult {
// Start asynchronous UDP support check.
udpErrChan := make(chan error)
go func() {
resolverAddr := &net.UDPAddr{IP: net.ParseIP(dnsServerIP), Port: dnsServerPort}
udpErrChan <- connectivity.CheckUDPConnectivityWithDNS(client, resolverAddr)
}()

tcpErr := connectivity.CheckTCPConnectivityWithHTTP(client, tcpTestWebsite)
udpErr := <-udpErrChan

tcpErr, udpErr := connectivity.CheckTCPAndUDPConnectivity(client, client)
return &TCPAndUDPConnectivityResult{
TCPError: platerrors.ToPlatformError(tcpErr),
UDPError: platerrors.ToPlatformError(udpErr),
Expand Down
25 changes: 25 additions & 0 deletions client/go/outline/connectivity/connectivity.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,31 @@ const (
bufferLength = 512
)

const (
testTCPWebsite = "http://example.com"
testDNSServerIP = "1.1.1.1"
testDNSServerPort = 53
)

// CheckTCPAndUDPConnectivity checks whether the given `tcp` and `udp` clients can relay traffic.
//
// It parallelizes the execution of TCP and UDP checks, and returns a TCP error and a UDP error.
// A nil error indicates successful connectivity for the corresponding protocol.
func CheckTCPAndUDPConnectivity(
tcp transport.StreamDialer, udp transport.PacketListener,
) (tcpErr error, udpErr error) {
// Start asynchronous UDP support check.
udpErrChan := make(chan error)
go func() {
resolverAddr := &net.UDPAddr{IP: net.ParseIP(testDNSServerIP), Port: testDNSServerPort}
udpErrChan <- CheckUDPConnectivityWithDNS(udp, resolverAddr)
}()

tcpErr = CheckTCPConnectivityWithHTTP(tcp, testTCPWebsite)
udpErr = <-udpErrChan
return
}

// CheckUDPConnectivityWithDNS determines whether the Outline 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.
Expand Down
29 changes: 29 additions & 0 deletions client/go/outline/dialer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// 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 (
"net"
)

// newTCPDialer creates a default base TCP dialer for [Client].
func newTCPDialer() net.Dialer {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's just inline these two functions in the caller.

return net.Dialer{KeepAlive: -1}
}

// newUDPDialer creates a default base UDP dialer for [Client].
func newUDPDialer() net.Dialer {
return net.Dialer{}
}
Loading
Loading