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=