From b38e25274b0331ffb3683ea3e9b0c365b18fdcdb Mon Sep 17 00:00:00 2001 From: "J. Yi" <93548144+jyyi1@users.noreply.github.com> Date: Thu, 26 Oct 2023 18:10:22 -0400 Subject: [PATCH] feat(example): add Outline CLI app for Linux (#15) I added the OutlineVPN command-line application for Linux. The application utilizes the latest SDK features to establish TCP and UDP traffic handlers. Additionally, it leverages best practices for setting up multiple routing tables for VPN services on Linux systems. #### Linux image #### Non-Linux image --- README.md | 15 +- x/config/config.go | 22 +++ x/config/shadowsocks.go | 10 ++ x/examples/outline-cli/README.md | 23 +++ x/examples/outline-cli/app.go | 30 ++++ x/examples/outline-cli/app_linux.go | 81 +++++++++++ x/examples/outline-cli/app_other.go | 23 +++ x/examples/outline-cli/dns_linux.go | 66 +++++++++ x/examples/outline-cli/ipv6_linux.go | 49 +++++++ x/examples/outline-cli/main.go | 55 ++++++++ x/examples/outline-cli/outline_device.go | 102 ++++++++++++++ .../outline-cli/outline_packet_proxy.go | 66 +++++++++ x/examples/outline-cli/routing_linux.go | 132 ++++++++++++++++++ x/examples/outline-cli/tun_device_linux.go | 94 +++++++++++++ x/go.mod | 4 + x/go.sum | 11 ++ 16 files changed, 775 insertions(+), 8 deletions(-) create mode 100644 x/examples/outline-cli/README.md create mode 100644 x/examples/outline-cli/app.go create mode 100644 x/examples/outline-cli/app_linux.go create mode 100644 x/examples/outline-cli/app_other.go create mode 100644 x/examples/outline-cli/dns_linux.go create mode 100644 x/examples/outline-cli/ipv6_linux.go create mode 100644 x/examples/outline-cli/main.go create mode 100644 x/examples/outline-cli/outline_device.go create mode 100644 x/examples/outline-cli/outline_packet_proxy.go create mode 100644 x/examples/outline-cli/routing_linux.go create mode 100644 x/examples/outline-cli/tun_device_linux.go diff --git a/README.md b/README.md index 4c27ea3f..70f1eb1a 100644 --- a/README.md +++ b/README.md @@ -123,13 +123,12 @@ Beta features: - Integration resources - For Mobile apps - - [x] Library to run a local SOCKS5 or HTTP-Connect proxy ([source](./x/mobileproxy/mobileproxy.go), [example Go usage](./x/examples/fetch-proxy/main.go), [example mobile usage](./x/examples/mobileproxy)). (v0.0.6) - - [x] Documentation on how to integrate the SDK into mobile apps (v0.0.6) - - [x] Connectivity Test iOS mobile app using [Capacitor](https://capacitorjs.com/) - - [ ] Connectivity Test Android app using [Capacitor](https://capacitorjs.com/) (coming soon) + - [x] Library to run a local SOCKS5 or HTTP-Connect proxy ([source](./x/mobileproxy/mobileproxy.go), [example Go usage](./x/examples/fetch-proxy/main.go), [example mobile usage](./x/examples/mobileproxy)). + - [x] Documentation on how to integrate the SDK into mobile apps + - [x] Connectivity Test mobile app (iOS and Android) using [Capacitor](https://capacitorjs.com/) - For Go apps - [x] Connectivity Test example [Wails](https://wails.io/) graphical app - - [x] Connectivity Test example command-line app ([source](./x/examples/outline-connectivity/)) (v0.0.6) - - [ ] Outline Client example command-line app (coming soon) - - [x] Page fetch example command-line app ([source](./x/examples/outline-fetch/)) (v0.0.6) - - [x] Local proxy example command-line app ([source](./x/examples/http2transport/)) (v0.0.6) + - [x] Connectivity Test example command-line app ([source](./x/examples/outline-connectivity/)) + - [x] Outline Client example command-line app ([source](./x/examples/outline-cli/)) + - [x] Page fetch example command-line app ([source](./x/examples/outline-fetch/)) + - [x] Local proxy example command-line app ([source](./x/examples/http2transport/)) diff --git a/x/config/config.go b/x/config/config.go index a2dca887..943d9da1 100644 --- a/x/config/config.go +++ b/x/config/config.go @@ -119,3 +119,25 @@ func newPacketDialerFromPart(innerDialer transport.PacketDialer, oneDialerConfig return nil, fmt.Errorf("config scheme '%v' is not supported", url.Scheme) } } + +// NewpacketListener creates a new [transport.PacketListener] according to the given config, +// the config must contain only one "ss://" segment. +func NewpacketListener(transportConfig string) (transport.PacketListener, error) { + if transportConfig = strings.TrimSpace(transportConfig); transportConfig == "" { + return nil, errors.New("config is required") + } + if strings.Contains(transportConfig, "|") { + return nil, errors.New("multi-part config is not supported") + } + + url, err := url.Parse(transportConfig) + if err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + if url.Scheme != "ss" { + return nil, errors.New("config scheme must be 'ss' for a PacketListener") + } + + // TODO: support nested dialer, the last part must be "ss://" + return newShadowsocksPacketListenerFromURL(url) +} diff --git a/x/config/shadowsocks.go b/x/config/shadowsocks.go index 5d56cd68..effdc546 100644 --- a/x/config/shadowsocks.go +++ b/x/config/shadowsocks.go @@ -55,6 +55,16 @@ func newShadowsocksPacketDialerFromURL(innerDialer transport.PacketDialer, confi return dialer, nil } +func newShadowsocksPacketListenerFromURL(configURL *url.URL) (transport.PacketListener, error) { + config, err := parseShadowsocksURL(configURL) + if err != nil { + return nil, err + } + // TODO: accept an inner dialer from the caller and pass it to UDPEndpoint + ep := &transport.UDPEndpoint{Address: config.serverAddress} + return shadowsocks.NewPacketListener(ep, config.cryptoKey) +} + type shadowsocksConfig struct { serverAddress string cryptoKey *shadowsocks.EncryptionKey diff --git a/x/examples/outline-cli/README.md b/x/examples/outline-cli/README.md new file mode 100644 index 00000000..579e3b41 --- /dev/null +++ b/x/examples/outline-cli/README.md @@ -0,0 +1,23 @@ +# Outline VPN Command-Line Client + +A CLI interface of Outline VPN client for Linux. + +### Usage + +``` +go run github.com/Jigsaw-Code/outline-sdk/x/examples/outline-cli@latest -transport "ss://" +``` + +- `-transport` : the Outline server access key from the service provider, it should start with "ss://" + +### Build + +You can use the following command to build the CLI. + + +``` +cd outline-sdk/x/examples/ +go build -o outline-cli -ldflags="-extldflags=-static" ./outline-cli +``` + +> 💡 `cgo` will pull in the C runtime. By default, the C runtime is linked as a dynamic library. Sometimes this can cause problems when running the binary on different versions or distributions of Linux. To avoid this, we have added the `-ldflags="-extldflags=-static"` option. But if you only need to run the binary on the same machine, you can omit this option. diff --git a/x/examples/outline-cli/app.go b/x/examples/outline-cli/app.go new file mode 100644 index 00000000..3147a206 --- /dev/null +++ b/x/examples/outline-cli/app.go @@ -0,0 +1,30 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// 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 +// +// https://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 + +type App struct { + TransportConfig *string + RoutingConfig *RoutingConfig +} + +type RoutingConfig struct { + TunDeviceName string + TunDeviceIP string + TunDeviceMTU int + TunGatewayCIDR string + RoutingTableID int + RoutingTablePriority int + DNSServerIP string +} diff --git a/x/examples/outline-cli/app_linux.go b/x/examples/outline-cli/app_linux.go new file mode 100644 index 00000000..9b35675e --- /dev/null +++ b/x/examples/outline-cli/app_linux.go @@ -0,0 +1,81 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// 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 +// +// https://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 + +import ( + "fmt" + "io" + "os" + "os/signal" + "sync" + + "golang.org/x/sys/unix" +) + +func (app App) Run() error { + // this WaitGroup must Wait() after tun is closed + trafficCopyWg := &sync.WaitGroup{} + defer trafficCopyWg.Wait() + + tun, err := newTunDevice(app.RoutingConfig.TunDeviceName, app.RoutingConfig.TunDeviceIP) + if err != nil { + return fmt.Errorf("failed to create tun device: %w", err) + } + defer tun.Close() + + // disable IPv6 before resolving Shadowsocks server IP + prevIPv6, err := enableIPv6(false) + if err != nil { + return fmt.Errorf("failed to disable IPv6: %w", err) + } + defer enableIPv6(prevIPv6) + + ss, err := NewOutlineDevice(*app.TransportConfig) + if err != nil { + return fmt.Errorf("failed to create OutlineDevice: %w", err) + } + defer ss.Close() + + ss.Refresh() + + // Copy the traffic from tun device to OutlineDevice bidirectionally + trafficCopyWg.Add(2) + go func() { + defer trafficCopyWg.Done() + written, err := io.Copy(ss, tun) + logging.Info.Printf("tun -> OutlineDevice stopped: %v %v\n", written, err) + }() + go func() { + defer trafficCopyWg.Done() + written, err := io.Copy(tun, ss) + logging.Info.Printf("OutlineDevice -> tun stopped: %v %v\n", written, err) + }() + + if err := setSystemDNSServer(app.RoutingConfig.DNSServerIP); err != nil { + return fmt.Errorf("failed to configure system DNS: %w", err) + } + defer restoreSystemDNSServer() + + if err := startRouting(ss.GetServerIP().String(), app.RoutingConfig); err != nil { + return fmt.Errorf("failed to configure routing: %w", err) + } + defer stopRouting(app.RoutingConfig.RoutingTableID) + + sigc := make(chan os.Signal, 1) + signal.Notify(sigc, os.Interrupt, unix.SIGTERM, unix.SIGHUP) + s := <-sigc + logging.Info.Printf("received %v, terminating...\n", s) + return nil +} diff --git a/x/examples/outline-cli/app_other.go b/x/examples/outline-cli/app_other.go new file mode 100644 index 00000000..ca351f30 --- /dev/null +++ b/x/examples/outline-cli/app_other.go @@ -0,0 +1,23 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// 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 +// +// https://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. + +//go:build !linux + +package main + +import "errors" + +func (App) Run() error { + return errors.New("platform not supported") +} diff --git a/x/examples/outline-cli/dns_linux.go b/x/examples/outline-cli/dns_linux.go new file mode 100644 index 00000000..bb34aee8 --- /dev/null +++ b/x/examples/outline-cli/dns_linux.go @@ -0,0 +1,66 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// 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 +// +// https://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 + +import ( + "fmt" + "os" +) + +// todo: find a more portable way of configuring DNS (e.g. resolved) +const ( + resolvConfFile = "/etc/resolv.conf" + resolvConfHeadFile = "/etc/resolv.conf.head" + resolvConfBackupFile = "/etc/resolv.outlinecli.backup" + resolvConfHeadBackupFile = "/etc/resolv.head.outlinecli.backup" +) + +func setSystemDNSServer(serverHost string) error { + setting := []byte(`# Outline CLI DNS Setting +# The original file has been renamed as resolv[.head].outlinecli.backup +nameserver ` + serverHost + "\n") + + if err := backupAndWriteFile(resolvConfFile, resolvConfBackupFile, setting); err != nil { + return err + } + return backupAndWriteFile(resolvConfHeadFile, resolvConfHeadBackupFile, setting) +} + +func restoreSystemDNSServer() { + restoreFileIfExists(resolvConfBackupFile, resolvConfFile) + restoreFileIfExists(resolvConfHeadBackupFile, resolvConfHeadFile) +} + +func backupAndWriteFile(original, backup string, data []byte) error { + if err := os.Rename(original, backup); err != nil { + return fmt.Errorf("failed to backup DNS config file '%s' to '%s': %w", original, backup, err) + } + if err := os.WriteFile(original, data, 0644); err != nil { + return fmt.Errorf("failed to write DNS config file '%s': %w", original, err) + } + return nil +} + +func restoreFileIfExists(backup, original string) { + if _, err := os.Stat(backup); err != nil { + logging.Warn.Printf("no DNS config backup file '%s' presents: %v\n", backup, err) + return + } + if err := os.Rename(backup, original); err != nil { + logging.Err.Printf("failed to restore DNS config from backup '%s' to '%s': %v\n", backup, original, err) + return + } + logging.Info.Printf("DNS config restored from '%s' to '%s'\n", backup, original) +} diff --git a/x/examples/outline-cli/ipv6_linux.go b/x/examples/outline-cli/ipv6_linux.go new file mode 100644 index 00000000..5dd245e9 --- /dev/null +++ b/x/examples/outline-cli/ipv6_linux.go @@ -0,0 +1,49 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// 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 +// +// https://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 + +import ( + "fmt" + "os" +) + +const disableIPv6ProcFile = "/proc/sys/net/ipv6/conf/all/disable_ipv6" + +// enableIPv6 enables or disables the IPv6 support for the Linux system. +// It returns the previous setting value so the caller can restore it. +// Non-nil error means we cannot find the IPv6 setting. +func enableIPv6(enabled bool) (bool, error) { + disabledStr, err := os.ReadFile(disableIPv6ProcFile) + if err != nil { + return false, fmt.Errorf("failed to read IPv6 config: %w", err) + } + if disabledStr[0] != '0' && disabledStr[0] != '1' { + return false, fmt.Errorf("invalid IPv6 config value: %v", disabledStr) + } + + prevEnabled := disabledStr[0] == '0' + + if enabled { + disabledStr[0] = '0' + } else { + disabledStr[0] = '1' + } + if err := os.WriteFile(disableIPv6ProcFile, disabledStr, 0644); err != nil { + return prevEnabled, fmt.Errorf("failed to write IPv6 config: %w", err) + } + + logging.Info.Printf("updated global IPv6 support: %v\n", enabled) + return prevEnabled, nil +} diff --git a/x/examples/outline-cli/main.go b/x/examples/outline-cli/main.go new file mode 100644 index 00000000..1be00964 --- /dev/null +++ b/x/examples/outline-cli/main.go @@ -0,0 +1,55 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// 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 +// +// https://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 + +import ( + "flag" + "fmt" + "io" + "log" + "os" +) + +var logging = &struct { + Debug, Info, Warn, Err *log.Logger +}{ + Debug: log.New(io.Discard, "[DEBUG] ", log.LstdFlags), + Info: log.New(os.Stdout, "[INFO] ", log.LstdFlags), + Warn: log.New(os.Stderr, "[WARN] ", log.LstdFlags), + Err: log.New(os.Stderr, "[ERROR] ", log.LstdFlags), +} + +// ./app -transport "ss://..." +func main() { + fmt.Println("OutlineVPN CLI (experimental)") + + app := App{ + TransportConfig: flag.String("transport", "", "Transport config"), + RoutingConfig: &RoutingConfig{ + TunDeviceName: "outline233", + TunDeviceIP: "10.233.233.1", + TunDeviceMTU: 1500, // todo: read this from netlink + TunGatewayCIDR: "10.233.233.2/32", + RoutingTableID: 233, + RoutingTablePriority: 23333, + DNSServerIP: "9.9.9.9", + }, + } + flag.Parse() + + if err := app.Run(); err != nil { + logging.Err.Printf("%v\n", err) + } +} diff --git a/x/examples/outline-cli/outline_device.go b/x/examples/outline-cli/outline_device.go new file mode 100644 index 00000000..0a17a28b --- /dev/null +++ b/x/examples/outline-cli/outline_device.go @@ -0,0 +1,102 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// 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 +// +// https://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 + +import ( + "errors" + "fmt" + "net" + "net/url" + "strings" + + "github.com/Jigsaw-Code/outline-sdk/network" + "github.com/Jigsaw-Code/outline-sdk/network/lwip2transport" + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/Jigsaw-Code/outline-sdk/x/config" +) + +const ( + connectivityTestDomain = "www.google.com" + connectivityTestResolver = "1.1.1.1:53" +) + +type OutlineDevice struct { + network.IPDevice + sd transport.StreamDialer + pp *outlinePacketProxy + svrIP net.IP +} + +func NewOutlineDevice(transportConfig string) (od *OutlineDevice, err error) { + ip, err := resolveShadowsocksServerIPFromConfig(transportConfig) + if err != nil { + return nil, err + } + od = &OutlineDevice{ + svrIP: ip, + } + + if od.sd, err = config.NewStreamDialer(transportConfig); err != nil { + return nil, fmt.Errorf("failed to create TCP dialer: %w", err) + } + if od.pp, err = newOutlinePacketProxy(transportConfig); err != nil { + return nil, fmt.Errorf("failed to create delegate UDP proxy: %w", err) + } + if od.IPDevice, err = lwip2transport.ConfigureDevice(od.sd, od.pp); err != nil { + return nil, fmt.Errorf("failed to configure lwIP: %w", err) + } + + return +} + +func (d *OutlineDevice) Close() error { + return d.IPDevice.Close() +} + +func (d *OutlineDevice) Refresh() error { + return d.pp.testConnectivityAndRefresh(connectivityTestResolver, connectivityTestDomain) +} + +func (d *OutlineDevice) GetServerIP() net.IP { + return d.svrIP +} + +func resolveShadowsocksServerIPFromConfig(transportConfig string) (net.IP, error) { + if strings.Contains(transportConfig, "|") { + return nil, errors.New("multi-part config is not supported") + } + if transportConfig = strings.TrimSpace(transportConfig); transportConfig == "" { + return nil, errors.New("config is required") + } + url, err := url.Parse(transportConfig) + if err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + if url.Scheme != "ss" { + return nil, errors.New("config must start with 'ss://'") + } + ipList, err := net.LookupIP(url.Hostname()) + if err != nil { + return nil, fmt.Errorf("invalid server hostname: %w", err) + } + + // todo: we only tested IPv4 routing table, need to test IPv6 in the future + for _, ip := range ipList { + if ip = ip.To4(); ip != nil { + return ip, nil + } + } + return nil, errors.New("IPv6 only Shadowsocks server is not supported yet") +} diff --git a/x/examples/outline-cli/outline_packet_proxy.go b/x/examples/outline-cli/outline_packet_proxy.go new file mode 100644 index 00000000..4ed6be1d --- /dev/null +++ b/x/examples/outline-cli/outline_packet_proxy.go @@ -0,0 +1,66 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// 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 +// +// https://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 + +import ( + "context" + "fmt" + + "github.com/Jigsaw-Code/outline-sdk/network" + "github.com/Jigsaw-Code/outline-sdk/network/dnstruncate" + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/Jigsaw-Code/outline-sdk/x/config" + "github.com/Jigsaw-Code/outline-sdk/x/connectivity" +) + +type outlinePacketProxy struct { + network.DelegatePacketProxy + + remote, fallback network.PacketProxy + remotePl transport.PacketListener +} + +func newOutlinePacketProxy(transportConfig string) (opp *outlinePacketProxy, err error) { + opp = &outlinePacketProxy{} + + if opp.remotePl, err = config.NewpacketListener(transportConfig); err != nil { + return nil, fmt.Errorf("failed to create UDP packet listener: %w", err) + } + if opp.remote, err = network.NewPacketProxyFromPacketListener(opp.remotePl); err != nil { + return nil, fmt.Errorf("failed to create UDP packet proxy: %w", err) + } + if opp.fallback, err = dnstruncate.NewPacketProxy(); err != nil { + return nil, fmt.Errorf("failed to create DNS truncate packet proxy: %w", err) + } + if opp.DelegatePacketProxy, err = network.NewDelegatePacketProxy(opp.fallback); err != nil { + return nil, fmt.Errorf("failed to create delegate UDP proxy: %w", err) + } + + return +} + +func (proxy *outlinePacketProxy) testConnectivityAndRefresh(resolver, domain string) error { + dialer := transport.PacketListenerDialer{Listener: proxy.remotePl} + dnsResolver := &transport.PacketDialerEndpoint{Dialer: dialer, Address: resolver} + _, err := connectivity.TestResolverPacketConnectivity(context.Background(), dnsResolver, domain) + + if err != nil { + logging.Info.Println("remote server cannot handle UDP traffic, switch to DNS truncate mode") + return proxy.SetProxy(proxy.fallback) + } else { + logging.Info.Println("remote server supports UDP, we will delegate all UDP packets to it") + return proxy.SetProxy(proxy.remote) + } +} diff --git a/x/examples/outline-cli/routing_linux.go b/x/examples/outline-cli/routing_linux.go new file mode 100644 index 00000000..b8124485 --- /dev/null +++ b/x/examples/outline-cli/routing_linux.go @@ -0,0 +1,132 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// 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 +// +// https://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 + +import ( + "errors" + "fmt" + "net" + + "github.com/vishvananda/netlink" +) + +var ipRule *netlink.Rule = nil + +func startRouting(proxyIP string, config *RoutingConfig) error { + if err := setupRoutingTable(config.RoutingTableID, config.TunDeviceName, config.TunGatewayCIDR, config.TunDeviceIP); err != nil { + return err + } + return setupIpRule(proxyIP+"/32", config.RoutingTableID, config.RoutingTablePriority) +} + +func stopRouting(routingTable int) { + if err := cleanUpRoutingTable(routingTable); err != nil { + logging.Err.Printf("failed to clean up routing table '%v': %v\n", routingTable, err) + } + if err := cleanUpRule(); err != nil { + logging.Err.Printf("failed to clean up IP rule: %v\n", err) + } +} + +func setupRoutingTable(routingTable int, tunName, gwSubnet string, tunIP string) error { + tun, err := netlink.LinkByName(tunName) + if err != nil { + return fmt.Errorf("failed to find tun device '%s': %w", tunName, err) + } + + dst, err := netlink.ParseIPNet(gwSubnet) + if err != nil { + return fmt.Errorf("failed to parse gateway '%s': %w", gwSubnet, err) + } + + r := netlink.Route{ + LinkIndex: tun.Attrs().Index, + Table: routingTable, + Dst: dst, + Src: net.ParseIP(tunIP), + Scope: netlink.SCOPE_LINK, + } + + if err = netlink.RouteAdd(&r); err != nil { + return fmt.Errorf("failed to add routing entry '%v' -> '%v': %w", r.Src, r.Dst, err) + } + logging.Info.Printf("routing traffic from %v to %v through nic %v\n", r.Src, r.Dst, r.LinkIndex) + + r = netlink.Route{ + LinkIndex: tun.Attrs().Index, + Table: routingTable, + Gw: dst.IP, + } + + if err := netlink.RouteAdd(&r); err != nil { + return fmt.Errorf("failed to add gateway routing entry '%v': %w", r.Gw, err) + } + logging.Info.Printf("routing traffic via gw %v through nic %v...\n", r.Gw, r.LinkIndex) + + return nil +} + +func cleanUpRoutingTable(routingTable int) error { + filter := netlink.Route{Table: routingTable} + routes, err := netlink.RouteListFiltered(netlink.FAMILY_V4, &filter, netlink.RT_FILTER_TABLE) + if err != nil { + return fmt.Errorf("failed to list entries in routing table '%v': %w", routingTable, err) + } + + var rtDelErr error = nil + for _, route := range routes { + if err := netlink.RouteDel(&route); err != nil { + rtDelErr = errors.Join(rtDelErr, fmt.Errorf("failed to remove routing entry: %w", err)) + } + } + if rtDelErr == nil { + logging.Info.Printf("routing table '%v' has been cleaned up\n", routingTable) + } + return rtDelErr +} + +func setupIpRule(svrIp string, routingTable, routingPriority int) error { + dst, err := netlink.ParseIPNet(svrIp) + if err != nil { + return fmt.Errorf("failed to parse server IP CIDR '%s': %w", svrIp, err) + } + + // todo: exclude server IP will cause issues when accessing services on the same server, + // use fwmask to protect the shadowsocks socket instead + ipRule = netlink.NewRule() + ipRule.Priority = routingPriority + ipRule.Family = netlink.FAMILY_V4 + ipRule.Table = routingTable + ipRule.Dst = dst + ipRule.Invert = true + + if err := netlink.RuleAdd(ipRule); err != nil { + return fmt.Errorf("failed to add IP rule (table %v, dst %v): %w", ipRule.Table, ipRule.Dst, err) + } + logging.Info.Printf("ip rule 'from all not to %v via table %v' created\n", ipRule.Dst, ipRule.Table) + return nil +} + +func cleanUpRule() error { + if ipRule == nil { + return nil + } + if err := netlink.RuleDel(ipRule); err != nil { + return fmt.Errorf("failed to delete IP rule of routing table '%v': %w", ipRule.Table, err) + } + logging.Info.Printf("ip rule of routing table '%v' deleted\n", ipRule.Table) + ipRule = nil + return nil +} diff --git a/x/examples/outline-cli/tun_device_linux.go b/x/examples/outline-cli/tun_device_linux.go new file mode 100644 index 00000000..bc2da40f --- /dev/null +++ b/x/examples/outline-cli/tun_device_linux.go @@ -0,0 +1,94 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// 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 +// +// https://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 + +import ( + "errors" + "fmt" + + "github.com/Jigsaw-Code/outline-sdk/network" + "github.com/songgao/water" + "github.com/vishvananda/netlink" +) + +type tunDevice struct { + *water.Interface + link netlink.Link +} + +var _ network.IPDevice = (*tunDevice)(nil) + +func newTunDevice(name, ip string) (d network.IPDevice, err error) { + if len(name) == 0 { + return nil, errors.New("name is required for TUN/TAP device") + } + if len(ip) == 0 { + return nil, errors.New("ip is required for TUN/TAP device") + } + + tun, err := water.New(water.Config{ + DeviceType: water.TUN, + PlatformSpecificParams: water.PlatformSpecificParams{ + Name: name, + Persist: false, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create TUN/TAP device: %w", err) + } + + defer func() { + if err != nil { + tun.Close() + } + }() + + tunLink, err := netlink.LinkByName(name) + if err != nil { + return nil, fmt.Errorf("newly created TUN/TAP device '%s' not found: %w", name, err) + } + + tunDev := &tunDevice{tun, tunLink} + if err := tunDev.configureSubnet(ip); err != nil { + return nil, fmt.Errorf("failed to configure TUN/TAP device subnet: %w", err) + } + if err := tunDev.bringUp(); err != nil { + return nil, fmt.Errorf("failed to bring up TUN/TAP device: %w", err) + } + return tunDev, nil +} + +func (d *tunDevice) MTU() int { + return 1500 +} + +func (d *tunDevice) configureSubnet(ip string) error { + subnet := ip + "/32" + addr, err := netlink.ParseAddr(subnet) + if err != nil { + return fmt.Errorf("subnet address '%s' is not valid: %w", subnet, err) + } + if err := netlink.AddrAdd(d.link, addr); err != nil { + return fmt.Errorf("failed to add subnet to TUN/TAP device '%s': %w", d.Interface.Name(), err) + } + return nil +} + +func (d *tunDevice) bringUp() error { + if err := netlink.LinkSetUp(d.link); err != nil { + return fmt.Errorf("failed to bring TUN/TAP device '%s' up: %w", d.Interface.Name(), err) + } + return nil +} diff --git a/x/go.mod b/x/go.mod index a24ce550..cd522ade 100644 --- a/x/go.mod +++ b/x/go.mod @@ -5,16 +5,20 @@ go 1.20 require ( github.com/Jigsaw-Code/outline-sdk v0.0.6 github.com/miekg/dns v1.1.54 + github.com/songgao/water v0.0.0-20190725173103-fd331bda3f4b github.com/stretchr/testify v1.8.2 + github.com/vishvananda/netlink v1.1.0 golang.org/x/mobile v0.0.0-20230905140555-fbe1c053b6a9 golang.org/x/sys v0.13.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/eycorsican/go-tun2socks v1.16.11 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect + github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df // indirect golang.org/x/crypto v0.14.0 // indirect golang.org/x/mod v0.12.0 // indirect golang.org/x/net v0.17.0 // indirect diff --git a/x/go.sum b/x/go.sum index ca863120..ec8a53cc 100644 --- a/x/go.sum +++ b/x/go.sum @@ -4,6 +4,9 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eycorsican/go-tun2socks v1.16.11 h1:+hJDNgisrYaGEqoSxhdikMgMJ4Ilfwm/IZDrWRrbaH8= +github.com/eycorsican/go-tun2socks v1.16.11/go.mod h1:wgB2BFT8ZaPKyKOQ/5dljMG/YIow+AIXyq4KBwJ5sGQ= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= @@ -15,6 +18,8 @@ github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s= github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28= github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM= +github.com/songgao/water v0.0.0-20190725173103-fd331bda3f4b h1:+y4hCMc/WKsDbAPsOQZgBSaSZ26uh2afyaWeVg/3s/c= +github.com/songgao/water v0.0.0-20190725173103-fd331bda3f4b/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -22,6 +27,10 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0= +github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= +github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df h1:OviZH7qLw/7ZovXvuNyL3XQl8UFofeikI1NW1Gypu7k= +github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= @@ -31,11 +40,13 @@ golang.org/x/mobile v0.0.0-20230905140555-fbe1c053b6a9/go.mod h1:2jxcxt/JNJik+N+ golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20191021144547-ec77196f6094/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=