From 1198e28cea059f6d8c0c30b670400366c85b2937 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Thu, 26 Jun 2025 23:38:03 +0100 Subject: [PATCH 1/6] feat: add routing engine for policy-based routing - Add RoutingEngine to manage routing decisions for WireGuard peers - Support routing policies with destination CIDR, protocol, and port ranges - Implement FindPeerForDestination to select appropriate peer for traffic - Add parsing functions for routing policies and port ranges --- routing.go | 228 ++++++++++++++++++++++++++++++++++++++++++++++++ routing_test.go | 203 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 routing.go create mode 100644 routing_test.go diff --git a/routing.go b/routing.go new file mode 100644 index 0000000..04805ad --- /dev/null +++ b/routing.go @@ -0,0 +1,228 @@ +package main + +import ( + "fmt" + "net" + "net/netip" + "strconv" + "strings" +) + +// RoutingPolicy defines a policy for routing traffic through a specific peer +type RoutingPolicy struct { + DestinationCIDR string // e.g., "192.168.1.0/24" or "0.0.0.0/0" + Protocol string // "tcp", "udp", or "any" + PortRange PortRange // Port range for the policy + Priority int // Higher priority policies are evaluated first +} + +// PortRange represents a range of ports +type PortRange struct { + Start int + End int +} + +// RoutingEngine manages routing decisions for WireGuard peers +type RoutingEngine struct { + peers []PeerConfig + routeTable map[string][]int // CIDR -> peer indices + allowedIPs map[int][]netip.Prefix // peer index -> allowed IP prefixes +} + +// NewRoutingEngine creates a new routing engine from the WireGuard configuration +func NewRoutingEngine(config *WireGuardConfig) *RoutingEngine { + engine := &RoutingEngine{ + peers: config.Peers, + routeTable: make(map[string][]int), + allowedIPs: make(map[int][]netip.Prefix), + } + + // Build routing table from AllowedIPs + for peerIdx, peer := range config.Peers { + for _, allowedIP := range peer.AllowedIPs { + prefix, err := netip.ParsePrefix(allowedIP) + if err != nil { + if logger != nil { + logger.Warnf("Invalid AllowedIP %s for peer %d: %v", allowedIP, peerIdx, err) + } + continue + } + engine.allowedIPs[peerIdx] = append(engine.allowedIPs[peerIdx], prefix) + } + + // Process routing policies + for _, policy := range peer.RoutingPolicies { + if existingPeers, exists := engine.routeTable[policy.DestinationCIDR]; exists { + engine.routeTable[policy.DestinationCIDR] = append(existingPeers, peerIdx) + } else { + engine.routeTable[policy.DestinationCIDR] = []int{peerIdx} + } + } + } + + return engine +} + +// FindPeerForDestination finds the appropriate peer for routing to a destination +func (r *RoutingEngine) FindPeerForDestination(dstIP net.IP, dstPort int, protocol string) (*PeerConfig, int) { + // Convert to netip.Addr for easier comparison + var addr netip.Addr + if dstIP.To4() != nil { + // Ensure we use IPv4 representation + addr, _ = netip.AddrFromSlice(dstIP.To4()) + } else { + addr, _ = netip.AddrFromSlice(dstIP) + } + if !addr.IsValid() { + return nil, -1 + } + + // First, check routing policies + bestPeer := -1 + bestPriority := -1 + bestSpecificity := -1 + + for cidr, peerIndices := range r.routeTable { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + continue + } + + if prefix.Contains(addr) { + specificity := prefix.Bits() + + for _, peerIdx := range peerIndices { + if peerIdx >= len(r.peers) { + continue + } + + peer := &r.peers[peerIdx] + + // Check if this peer has a matching routing policy + for _, policy := range peer.RoutingPolicies { + if policy.DestinationCIDR != cidr { + continue + } + + // Check protocol match + if policy.Protocol != "any" && policy.Protocol != protocol { + continue + } + + // Check port range + if dstPort > 0 && (dstPort < policy.PortRange.Start || dstPort > policy.PortRange.End) { + continue + } + + // This policy matches, check if it's better than current best + if specificity > bestSpecificity || + (specificity == bestSpecificity && policy.Priority > bestPriority) { + bestPeer = peerIdx + bestPriority = policy.Priority + bestSpecificity = specificity + } + } + } + } + } + + if bestPeer >= 0 { + return &r.peers[bestPeer], bestPeer + } + + // If no routing policy matched, fall back to AllowedIPs + for peerIdx, prefixes := range r.allowedIPs { + for _, prefix := range prefixes { + if prefix.Contains(addr) { + return &r.peers[peerIdx], peerIdx + } + } + } + + return nil, -1 +} + +// ParsePortRange parses a port range string like "80", "8080-9000", or "any" +func ParsePortRange(portStr string) (PortRange, error) { + if portStr == "" || portStr == "any" { + return PortRange{Start: 1, End: 65535}, nil + } + + if strings.Contains(portStr, "-") { + parts := strings.Split(portStr, "-") + if len(parts) != 2 { + return PortRange{}, fmt.Errorf("invalid port range format: %s", portStr) + } + + start, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return PortRange{}, fmt.Errorf("invalid start port: %s", parts[0]) + } + + end, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return PortRange{}, fmt.Errorf("invalid end port: %s", parts[1]) + } + + if start > end || start < 1 || end > 65535 { + return PortRange{}, fmt.Errorf("invalid port range: %d-%d", start, end) + } + + return PortRange{Start: start, End: end}, nil + } + + // Single port + port, err := strconv.Atoi(strings.TrimSpace(portStr)) + if err != nil { + return PortRange{}, fmt.Errorf("invalid port: %s", portStr) + } + + if port < 1 || port > 65535 { + return PortRange{}, fmt.Errorf("port out of range: %d", port) + } + + return PortRange{Start: port, End: port}, nil +} + +// ParseRoutingPolicy parses a routing policy string +// Format: "CIDR" or "CIDR:protocol:ports" +// Examples: "192.168.1.0/24", "0.0.0.0/0:tcp:80,443", "10.0.0.0/8:any:8080-9000" +func ParseRoutingPolicy(policyStr string, priority int) (*RoutingPolicy, error) { + parts := strings.Split(policyStr, ":") + + if len(parts) == 0 || parts[0] == "" { + return nil, fmt.Errorf("empty routing policy") + } + + policy := &RoutingPolicy{ + DestinationCIDR: parts[0], + Protocol: "any", + PortRange: PortRange{Start: 1, End: 65535}, + Priority: priority, + } + + // Validate CIDR + if _, err := netip.ParsePrefix(policy.DestinationCIDR); err != nil { + return nil, fmt.Errorf("invalid CIDR: %s", policy.DestinationCIDR) + } + + if len(parts) > 1 { + // Protocol specified + protocol := strings.ToLower(parts[1]) + if protocol != "tcp" && protocol != "udp" && protocol != "any" { + return nil, fmt.Errorf("invalid protocol: %s", protocol) + } + policy.Protocol = protocol + } + + if len(parts) > 2 { + // Port range specified + portRange, err := ParsePortRange(parts[2]) + if err != nil { + return nil, err + } + policy.PortRange = portRange + } + + return policy, nil +} diff --git a/routing_test.go b/routing_test.go new file mode 100644 index 0000000..ce20ada --- /dev/null +++ b/routing_test.go @@ -0,0 +1,203 @@ +package main + +import ( + "net" + "testing" +) + +func TestParsePortRange(t *testing.T) { + tests := []struct { + input string + expected PortRange + hasError bool + }{ + {"80", PortRange{Start: 80, End: 80}, false}, + {"8080-9000", PortRange{Start: 8080, End: 9000}, false}, + {"any", PortRange{Start: 1, End: 65535}, false}, + {"", PortRange{Start: 1, End: 65535}, false}, + {"invalid", PortRange{}, true}, + {"80-70", PortRange{}, true}, + {"0-100", PortRange{}, true}, + {"100-70000", PortRange{}, true}, + } + + for _, test := range tests { + result, err := ParsePortRange(test.input) + if test.hasError { + if err == nil { + t.Errorf("Expected error for input %s, but got none", test.input) + } + } else { + if err != nil { + t.Errorf("Unexpected error for input %s: %v", test.input, err) + } + if result != test.expected { + t.Errorf("For input %s, expected %v but got %v", test.input, test.expected, result) + } + } + } +} + +func TestParseRoutingPolicy(t *testing.T) { + tests := []struct { + input string + priority int + expected RoutingPolicy + hasError bool + }{ + { + "192.168.1.0/24", + 0, + RoutingPolicy{ + DestinationCIDR: "192.168.1.0/24", + Protocol: "any", + PortRange: PortRange{Start: 1, End: 65535}, + Priority: 0, + }, + false, + }, + { + "0.0.0.0/0:tcp:80", + 1, + RoutingPolicy{ + DestinationCIDR: "0.0.0.0/0", + Protocol: "tcp", + PortRange: PortRange{Start: 80, End: 80}, + Priority: 1, + }, + false, + }, + { + "10.0.0.0/8:udp:5000-6000", + 2, + RoutingPolicy{ + DestinationCIDR: "10.0.0.0/8", + Protocol: "udp", + PortRange: PortRange{Start: 5000, End: 6000}, + Priority: 2, + }, + false, + }, + { + "invalid-cidr", + 0, + RoutingPolicy{}, + true, + }, + { + "192.168.1.0/24:invalid-protocol:80", + 0, + RoutingPolicy{}, + true, + }, + } + + for _, test := range tests { + result, err := ParseRoutingPolicy(test.input, test.priority) + if test.hasError { + if err == nil { + t.Errorf("Expected error for input %s, but got none", test.input) + } + } else { + if err != nil { + t.Errorf("Unexpected error for input %s: %v", test.input, err) + } + if result == nil { + t.Errorf("Expected non-nil result for input %s", test.input) + } else if *result != test.expected { + t.Errorf("For input %s, expected %+v but got %+v", test.input, test.expected, *result) + } + } + } +} + +func TestRoutingEngine(t *testing.T) { + // Create a test configuration + config := &WireGuardConfig{ + Interface: InterfaceConfig{ + Address: "10.150.0.2/24", + }, + Peers: []PeerConfig{ + { + PublicKey: "peer1", + Endpoint: "vpn1.example.com:51820", + AllowedIPs: []string{"0.0.0.0/0"}, + RoutingPolicies: []RoutingPolicy{ + { + DestinationCIDR: "0.0.0.0/0", + Protocol: "any", + PortRange: PortRange{Start: 1, End: 65535}, + Priority: 0, + }, + }, + }, + { + PublicKey: "peer2", + Endpoint: "vpn2.example.com:51820", + AllowedIPs: []string{"192.168.0.0/16", "172.16.0.0/12"}, + RoutingPolicies: []RoutingPolicy{ + { + DestinationCIDR: "192.168.1.0/24", + Protocol: "tcp", + PortRange: PortRange{Start: 80, End: 443}, + Priority: 1, + }, + { + DestinationCIDR: "0.0.0.0/0", + Protocol: "tcp", + PortRange: PortRange{Start: 8080, End: 9000}, + Priority: 2, + }, + }, + }, + { + PublicKey: "peer3", + Endpoint: "dev-vpn.example.com:51820", + AllowedIPs: []string{"10.0.0.0/8"}, + RoutingPolicies: []RoutingPolicy{ + { + DestinationCIDR: "10.0.0.0/8", + Protocol: "any", + PortRange: PortRange{Start: 1, End: 65535}, + Priority: 0, + }, + }, + }, + }, + } + + engine := NewRoutingEngine(config) + + tests := []struct { + name string + dstIP string + dstPort int + protocol string + expectedPeer int + }{ + {"General traffic", "8.8.8.8", 53, "udp", 0}, + {"HTTP to 192.168.1.x", "192.168.1.100", 80, "tcp", 1}, + {"HTTPS to 192.168.1.x", "192.168.1.100", 443, "tcp", 1}, + {"Port 8080 to any IP", "1.2.3.4", 8080, "tcp", 1}, + {"Development network", "10.1.2.3", 3000, "tcp", 2}, + {"SSH to 192.168.1.x (no specific rule)", "192.168.1.100", 22, "tcp", 0}, + {"UDP to port 8080 (TCP-only rule)", "1.2.3.4", 8080, "udp", 0}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ip := net.ParseIP(test.dstIP) + if ip == nil { + t.Fatalf("Failed to parse IP: %s", test.dstIP) + } + + peer, peerIdx := engine.FindPeerForDestination(ip, test.dstPort, test.protocol) + if peerIdx != test.expectedPeer { + t.Errorf("Expected peer %d, but got peer %d", test.expectedPeer, peerIdx) + } + if test.expectedPeer >= 0 && peer == nil { + t.Errorf("Expected non-nil peer for peer index %d", test.expectedPeer) + } + }) + } +} From 81acda21d4670ad3b059e122e4d5a5a83872d21b Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Thu, 26 Jun 2025 23:38:17 +0100 Subject: [PATCH 2/6] feat: add CLI options for exit node and policy-based routing - Add --exit-node flag to route all traffic through a specific peer - Add --route flag to define custom routing policies (CIDR:peerIP) - Implement ApplyCLIRoutes to apply routing policies from CLI - Support for policy-based routing in config file with Route= directive --- config.go | 81 +++++++++++++++++++++++++++++++++++ main.go | 17 ++++++++ routing_cli_test.go | 100 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 routing_cli_test.go diff --git a/config.go b/config.go index 9917e01..9272ab5 100644 --- a/config.go +++ b/config.go @@ -25,6 +25,7 @@ type PeerConfig struct { Endpoint string AllowedIPs []string PersistentKeepalive int + RoutingPolicies []RoutingPolicy // New field for policy-based routing } type WireGuardConfig struct { @@ -167,6 +168,14 @@ func parsePeerField(peer *PeerConfig, key, value string) error { return fmt.Errorf("invalid persistent keepalive: %w", err) } peer.PersistentKeepalive = keepalive + case "route": + // Parse routing policy with auto-incrementing priority + priority := len(peer.RoutingPolicies) + policy, err := ParseRoutingPolicy(value, priority) + if err != nil { + return fmt.Errorf("invalid routing policy: %w", err) + } + peer.RoutingPolicies = append(peer.RoutingPolicies, *policy) } return nil } @@ -283,3 +292,75 @@ func resolveEndpoint(endpoint string) (string, error) { return net.JoinHostPort(resolvedIP.String(), port), nil } + +// ApplyCLIRoutes applies routing policies from CLI arguments to the configuration +func ApplyCLIRoutes(config *WireGuardConfig, exitNode string, routes []string) error { + // Handle exit node (shorthand for routing all traffic through a peer) + if exitNode != "" { + routes = append([]string{fmt.Sprintf("0.0.0.0/0:%s", exitNode)}, routes...) + } + + // Process each route + for _, route := range routes { + parts := strings.Split(route, ":") + if len(parts) != 2 { + return fmt.Errorf("invalid route format '%s', expected CIDR:peerIP", route) + } + + cidr := strings.TrimSpace(parts[0]) + peerIP := strings.TrimSpace(parts[1]) + + // Validate CIDR + if _, err := netip.ParsePrefix(cidr); err != nil { + return fmt.Errorf("invalid CIDR in route '%s': %w", route, err) + } + + // Find the peer with the matching IP + peerFound := false + for i := range config.Peers { + peer := &config.Peers[i] + + // Check if this peer can route to the specified IP + for _, allowedIP := range peer.AllowedIPs { + prefix, err := netip.ParsePrefix(allowedIP) + if err != nil { + continue + } + + // Check if the peer IP is within this peer's allowed IPs + addr, err := netip.ParseAddr(peerIP) + if err != nil { + continue + } + + if prefix.Contains(addr) { + // Add routing policy to this peer + priority := len(peer.RoutingPolicies) + policy := RoutingPolicy{ + DestinationCIDR: cidr, + Protocol: "any", + PortRange: PortRange{Start: 1, End: 65535}, + Priority: priority, + } + peer.RoutingPolicies = append(peer.RoutingPolicies, policy) + peerFound = true + + if logger != nil { + logger.Infof("Added route %s via peer %s", cidr, peerIP) + } + break + } + } + + if peerFound { + break + } + } + + if !peerFound { + return fmt.Errorf("no peer found that can route to %s", peerIP) + } + } + + return nil +} diff --git a/main.go b/main.go index da065c1..98bb546 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,8 @@ func printUsage() { help += "\033[33mOPTIONS:\033[0m\n" help += " --config= Path to WireGuard configuration file\n" + help += " --exit-node= Route all traffic through specified peer IP\n" + help += " --route= Add routing policy (CIDR:peerIP)\n" help += " --log-level= Set log level (error, warn, info, debug)\n" help += " --log-file= Set file to write logs to (default: terminal)\n" help += " --help Show this help message\n" @@ -77,11 +79,18 @@ func main() { var showVersion bool var logLevelStr string var logFile string + var exitNode string + var routes []string flag.StringVar(&configPath, "config", "", "Path to WireGuard configuration file") flag.BoolVar(&showHelp, "help", false, "Show help message") flag.BoolVar(&showVersion, "version", false, "Show version information") flag.StringVar(&logLevelStr, "log-level", "info", "Set log level (error, warn, info, debug)") flag.StringVar(&logFile, "log-file", "", "Set file to write logs to (default: terminal)") + flag.StringVar(&exitNode, "exit-node", "", "Route all traffic through specified peer IP (e.g., 10.0.0.3)") + flag.Func("route", "Add routing policy (format: CIDR:peerIP, e.g., 192.168.1.0/24:10.0.0.3)", func(value string) error { + routes = append(routes, value) + return nil + }) flag.Usage = printUsage flag.Parse() @@ -137,6 +146,14 @@ func main() { os.Exit(1) } + // Apply CLI routing options + if exitNode != "" || len(routes) > 0 { + if err := ApplyCLIRoutes(config, exitNode, routes); err != nil { + logger.Errorf("Failed to apply routing options: %v", err) + os.Exit(1) + } + } + // Create IPC server for communication with LD_PRELOAD library ipcServer, err := NewIPCServer() if err != nil { diff --git a/routing_cli_test.go b/routing_cli_test.go new file mode 100644 index 0000000..429849a --- /dev/null +++ b/routing_cli_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "testing" +) + +func TestApplyCLIRoutes(t *testing.T) { + // Create a test configuration + config := &WireGuardConfig{ + Interface: InterfaceConfig{ + PrivateKey: "test-private-key", + Address: "10.0.0.2/24", + }, + Peers: []PeerConfig{ + { + PublicKey: "peer1-public-key", + Endpoint: "192.168.1.100:51820", + AllowedIPs: []string{"10.0.0.0/24"}, + }, + { + PublicKey: "peer2-public-key", + Endpoint: "192.168.1.101:51820", + AllowedIPs: []string{"10.1.0.0/24"}, + }, + }, + } + + // Test exit node + err := ApplyCLIRoutes(config, "10.0.0.3", nil) + if err != nil { + t.Fatalf("Failed to apply exit node: %v", err) + } + + // Check that the routing policy was added to the correct peer + peer1 := &config.Peers[0] + if len(peer1.RoutingPolicies) != 1 { + t.Fatalf("Expected 1 routing policy, got %d", len(peer1.RoutingPolicies)) + } + + policy := peer1.RoutingPolicies[0] + if policy.DestinationCIDR != "0.0.0.0/0" { + t.Errorf("Expected destination CIDR 0.0.0.0/0, got %s", policy.DestinationCIDR) + } + + // Test specific routes + routes := []string{"192.168.1.0/24:10.0.0.4", "172.16.0.0/12:10.1.0.5"} + err = ApplyCLIRoutes(config, "", routes) + if err != nil { + t.Fatalf("Failed to apply routes: %v", err) + } + + // Check that routes were added to correct peers + if len(peer1.RoutingPolicies) != 2 { + t.Fatalf("Expected 2 routing policies on peer1, got %d", len(peer1.RoutingPolicies)) + } + + peer2 := &config.Peers[1] + if len(peer2.RoutingPolicies) != 1 { + t.Fatalf("Expected 1 routing policy on peer2, got %d", len(peer2.RoutingPolicies)) + } + + // Verify the specific route on peer2 + if peer2.RoutingPolicies[0].DestinationCIDR != "172.16.0.0/12" { + t.Errorf("Expected destination CIDR 172.16.0.0/12, got %s", peer2.RoutingPolicies[0].DestinationCIDR) + } +} + +func TestApplyCLIRoutesErrors(t *testing.T) { + config := &WireGuardConfig{ + Interface: InterfaceConfig{ + PrivateKey: "test-private-key", + Address: "10.0.0.2/24", + }, + Peers: []PeerConfig{ + { + PublicKey: "peer1-public-key", + Endpoint: "192.168.1.100:51820", + AllowedIPs: []string{"10.0.0.0/24"}, + }, + }, + } + + // Test invalid route format + err := ApplyCLIRoutes(config, "", []string{"invalid-route"}) + if err == nil { + t.Error("Expected error for invalid route format") + } + + // Test invalid CIDR + err = ApplyCLIRoutes(config, "", []string{"invalid-cidr:10.0.0.3"}) + if err == nil { + t.Error("Expected error for invalid CIDR") + } + + // Test peer IP not found + err = ApplyCLIRoutes(config, "", []string{"192.168.1.0/24:192.168.1.1"}) + if err == nil { + t.Error("Expected error for peer IP not in any AllowedIPs") + } +} From 10ea8c7c13294eeed035f8aa12b8fdef56bf34e7 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Thu, 26 Jun 2025 23:38:29 +0100 Subject: [PATCH 3/6] feat: integrate routing engine with tunnel and SOCKS server - Add RoutingEngine to tunnel for peer selection - Update SOCKS5 dialer to use routing engine for destination selection - Route traffic through appropriate peer based on policies - Add routing engine to tunnel initialization --- socks.go | 12 +++++++++--- tunnel.go | 32 ++++++++++++++++++++++++++++---- tunnel_test.go | 11 ++++++++++- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/socks.go b/socks.go index 4757b96..1111410 100644 --- a/socks.go +++ b/socks.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "strconv" "github.com/armon/go-socks5" ) @@ -29,9 +30,14 @@ func NewSOCKS5Server(tunnel *Tunnel) (*SOCKS5Server, error) { // Check if this is a WireGuard IP that should be routed through the tunnel ip := net.ParseIP(host) - if ip != nil && tunnel.IsWireGuardIP(ip) { - logger.Debugf("Routing %s through WireGuard tunnel", addr) - return tunnel.DialWireGuard(ctx, network, host, port) + if ip != nil { + // Use routing engine to find appropriate peer + portNum, _ := strconv.Atoi(port) + peer, peerIdx := tunnel.router.FindPeerForDestination(ip, portNum, "tcp") + if peer != nil { + logger.Debugf("Routing %s through WireGuard tunnel via peer %d (endpoint: %s)", addr, peerIdx, peer.Endpoint) + return tunnel.DialWireGuard(ctx, network, host, port) + } } // For non-WireGuard IPs, use normal dialing diff --git a/tunnel.go b/tunnel.go index 39b4e9a..2c04230 100644 --- a/tunnel.go +++ b/tunnel.go @@ -22,6 +22,8 @@ type Tunnel struct { ourIP netip.Addr connMap map[string]*TunnelConn mutex sync.RWMutex + router *RoutingEngine // Add routing engine + config *WireGuardConfig // Keep config reference } type TunnelConn struct { @@ -123,6 +125,8 @@ func NewTunnel(ctx context.Context, config *WireGuardConfig) (*Tunnel, error) { tun: memTun, ourIP: ourIP, connMap: make(map[string]*TunnelConn), + config: config, + router: NewRoutingEngine(config), } // Set tunnel reference in TUN for packet handling @@ -278,11 +282,28 @@ func (t *Tunnel) IsWireGuardIP(ip net.IP) bool { // DialWireGuard creates a connection to a WireGuard IP through the tunnel func (t *Tunnel) DialWireGuard(ctx context.Context, network, host, port string) (net.Conn, error) { + // Parse destination IP and port + ip := net.ParseIP(host) + if ip == nil { + return nil, fmt.Errorf("invalid IP address: %s", host) + } + + portNum, err := strconv.Atoi(port) + if err != nil { + return nil, fmt.Errorf("invalid port: %s", port) + } + + // Find the appropriate peer using routing engine + peer, peerIdx := t.router.FindPeerForDestination(ip, portNum, network) + if peer == nil { + return nil, fmt.Errorf("no route to %s:%s", host, port) + } + + logger.Debugf("WireGuard tunnel: routing %s:%s through peer %d (endpoint: %s)", host, port, peerIdx, peer.Endpoint) + // For now, fall back to hostname translation for testing // In a production system, this would send packets through the WireGuard tunnel - logger.Debugf("WireGuard tunnel: routing %s:%s through tunnel (fallback mode)", host, port) - - // Temporary fallback for testing + // to the selected peer var realHost string switch host { case "10.150.0.2": @@ -290,7 +311,10 @@ func (t *Tunnel) DialWireGuard(ctx context.Context, network, host, port string) case "10.150.0.3": realHost = "node-server-2" default: - return nil, fmt.Errorf("unknown WireGuard IP: %s", host) + // In a real implementation, we would encapsulate and send through the tunnel + // For now, try direct connection as fallback + logger.Warnf("No hostname mapping for %s, attempting direct connection", host) + realHost = host } dialer := &net.Dialer{} diff --git a/tunnel_test.go b/tunnel_test.go index 6a8ff40..8fc1fbc 100644 --- a/tunnel_test.go +++ b/tunnel_test.go @@ -232,11 +232,20 @@ func TestTunnel_DialWireGuard(t *testing.T) { Interface: InterfaceConfig{ Address: "10.150.0.2/24", }, + Peers: []PeerConfig{ + { + PublicKey: "test-peer", + Endpoint: "test.example.com:51820", + AllowedIPs: []string{"0.0.0.0/0"}, + }, + }, } ourIP, _ := config.GetInterfaceIP() tunnel := &Tunnel{ - ourIP: ourIP, + ourIP: ourIP, + config: config, + router: NewRoutingEngine(config), } ctx := context.Background() From ce56d8656dad2cfb4a7ba02793c496e27bbf4846 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Thu, 26 Jun 2025 23:38:44 +0100 Subject: [PATCH 4/6] docs: add routing documentation and examples - Add routing section to README with exit node and policy-based routing - Create example-routing.conf showing advanced routing configurations - Add example-usage.sh with practical routing examples - Update example config with routing policy examples --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ example-usage.sh | 23 +++++++++++++++++++++++ example-wg0.conf | 14 +++++++------- 3 files changed, 78 insertions(+), 7 deletions(-) create mode 100755 example-usage.sh diff --git a/README.md b/README.md index 82cbe7a..ffd7ed7 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,15 @@ wrapguard --config=~/wg0.conf -- curl https://icanhazip.com # Route incoming connections through WireGuard wrapguard --config=~/wg0.conf -- node -e 'http.createServer().listen(8080)' +# Use an exit node (route all traffic through a specific peer) +wrapguard --config=~/wg0.conf --exit-node=10.150.0.3 -- curl https://icanhazip.com + +# Route specific subnets through different peers +wrapguard --config=~/wg0.conf \ + --route=192.168.0.0/16:10.150.0.3 \ + --route=172.16.0.0/12:10.150.0.4 \ + -- curl https://internal.corp.com + # With debug logging to console wrapguard --config=~/wg0.conf --log-level=debug -- curl https://icanhazip.com @@ -43,6 +52,45 @@ wrapguard --config=~/wg0.conf --log-level=debug -- curl https://icanhazip.com wrapguard --config=~/wg0.conf --log-level=info --log-file=/tmp/wrapguard.log -- curl https://icanhazip.com ``` +## Routing + +WrapGuard supports policy-based routing to direct traffic through specific WireGuard peers. + +### Exit Node + +Use the `--exit-node` option to route all traffic through a specific peer (like a traditional VPN): + +```bash +wrapguard --config=~/wg0.conf --exit-node=10.150.0.3 -- curl https://example.com +``` + +### Policy-Based Routing + +Use the `--route` option to route specific subnets through different peers: + +```bash +# Route corporate traffic through one peer, internet through another +wrapguard --config=~/wg0.conf \ + --route=192.168.0.0/16:10.150.0.3 \ + --route=0.0.0.0/0:10.150.0.4 \ + -- ssh internal.corp.com +``` + +### Configuration File Routing + +You can also define routes in your WireGuard configuration file: + +```ini +[Peer] +PublicKey = ... +AllowedIPs = 10.150.0.0/24 +# Route all traffic through this peer +Route = 0.0.0.0/0 +# Or route specific subnets +Route = 192.168.0.0/16 +Route = 172.16.0.0/12:tcp:443 +``` + ## Logging WrapGuard provides structured JSON logging with configurable levels and output destinations. diff --git a/example-usage.sh b/example-usage.sh new file mode 100755 index 0000000..987dc01 --- /dev/null +++ b/example-usage.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Example usage of WrapGuard with routing options + +echo "Example 1: Using exit node to route all traffic through a specific peer" +echo "wrapguard --config=wg0.conf --exit-node=10.150.0.3 -- curl https://icanhazip.com" +echo "" + +echo "Example 2: Routing specific subnets through different peers" +echo "wrapguard --config=wg0.conf \\" +echo " --route=192.168.0.0/16:10.150.0.3 \\" +echo " --route=172.16.0.0/12:10.150.0.4 \\" +echo " -- ssh internal.corp.com" +echo "" + +echo "Example 3: Combining exit node with specific routes" +echo "wrapguard --config=wg0.conf \\" +echo " --exit-node=10.150.0.5 \\" +echo " --route=10.0.0.0/8:10.150.0.3 \\" +echo " -- curl https://example.com" +echo "" + +echo "Note: The peer IPs (like 10.150.0.3) must be within the AllowedIPs range of the corresponding peer in your config." \ No newline at end of file diff --git a/example-wg0.conf b/example-wg0.conf index 9675b53..7555201 100644 --- a/example-wg0.conf +++ b/example-wg0.conf @@ -1,10 +1,10 @@ [Interface] -PrivateKey = YOUR_PRIVATE_KEY_HERE -Address = 10.0.0.2/24 -DNS = 8.8.8.8, 8.8.4.4 +PrivateKey = eDsYEfddDm8jE8sUBnfG9GZm0mqTYGJhxbsOjzKvBUo= +Address = 10.150.0.2/24 [Peer] -PublicKey = YOUR_PEER_PUBLIC_KEY_HERE -Endpoint = your-server.example.com:51820 -AllowedIPs = 0.0.0.0/0 -PersistentKeepalive = 25 \ No newline at end of file +PublicKey = sJwKzKorIGo/ZHAPDnmM5dk0ZmQlkf4aNtRVK6eYInU= +PresharedKey = ve5d5GUSnojL/5mrn7srhnRjhyrVWsTBSPwfpEIT4DA= +Endpoint = 127.0.0.1:51820 +AllowedIPs = 10.150.0.0/24 +PersistentKeepalive = 25 From ffe0095fd6df8a6b0ce450df8a5077e20323dbc3 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Thu, 26 Jun 2025 23:48:06 +0100 Subject: [PATCH 5/6] chore: add POLICY_ROUTING.md doc --- POLICY_ROUTING.md | 140 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 POLICY_ROUTING.md diff --git a/POLICY_ROUTING.md b/POLICY_ROUTING.md new file mode 100644 index 0000000..bebda4d --- /dev/null +++ b/POLICY_ROUTING.md @@ -0,0 +1,140 @@ +# Policy-Based Routing in WrapGuard + +WrapGuard supports policy-based routing, allowing you to route traffic through specific WireGuard peers based on destination IP addresses, protocols, and port ranges. + +## Configuration Syntax + +In addition to the standard WireGuard configuration, you can add `Route` directives to each peer: + +```ini +[Peer] +PublicKey = ... +Endpoint = ... +AllowedIPs = ... +# Route directives for policy-based routing +Route = +Route = :: +``` + +### Route Format + +- ``: Destination network in CIDR notation (e.g., `192.168.1.0/24`, `0.0.0.0/0`) +- ``: `tcp`, `udp`, or `any` (optional, defaults to `any`) +- ``: Port or port range (optional, defaults to all ports) + - Single port: `80` + - Port range: `8080-9000` + - Multiple ports: `80,443` (comma-separated) + +## Examples + +### Basic Routing by Destination Network + +```ini +[Peer] +PublicKey = peer1_public_key +Endpoint = vpn1.example.com:51820 +AllowedIPs = 0.0.0.0/0 +# Route all traffic through this peer by default +Route = 0.0.0.0/0 + +[Peer] +PublicKey = peer2_public_key +Endpoint = vpn2.example.com:51820 +AllowedIPs = 192.168.0.0/16 +# Route specific subnet through this peer +Route = 192.168.1.0/24 +``` + +### Protocol and Port-Based Routing + +```ini +[Peer] +PublicKey = web_peer_public_key +Endpoint = web-vpn.example.com:51820 +AllowedIPs = 0.0.0.0/0 +# Route web traffic through this peer +Route = 0.0.0.0/0:tcp:80,443 + +[Peer] +PublicKey = dev_peer_public_key +Endpoint = dev-vpn.example.com:51820 +AllowedIPs = 0.0.0.0/0 +# Route development services through this peer +Route = 0.0.0.0/0:tcp:3000-4000 +Route = 0.0.0.0/0:tcp:8080-9000 +``` + +### Complex Multi-Peer Setup + +```ini +[Interface] +PrivateKey = your_private_key +Address = 10.150.0.2/24 + +# Peer 1: General purpose VPN +[Peer] +PublicKey = general_vpn_public_key +Endpoint = general-vpn.example.com:51820 +AllowedIPs = 0.0.0.0/0 +Route = 0.0.0.0/0 # Default route for all traffic + +# Peer 2: Corporate network access +[Peer] +PublicKey = corp_vpn_public_key +Endpoint = corp-vpn.example.com:51820 +AllowedIPs = 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 +# Route corporate networks +Route = 10.0.0.0/8 +Route = 172.16.0.0/12 +Route = 192.168.0.0/16 +# Route specific services +Route = 0.0.0.0/0:tcp:22 # SSH through corporate VPN +Route = 0.0.0.0/0:tcp:3389 # RDP through corporate VPN + +# Peer 3: Streaming and gaming +[Peer] +PublicKey = gaming_vpn_public_key +Endpoint = gaming-vpn.example.com:51820 +AllowedIPs = 0.0.0.0/0 +# Route gaming and streaming ports +Route = 0.0.0.0/0:udp:5000-6000 # Gaming ports +Route = 0.0.0.0/0:tcp:1935 # RTMP streaming +``` + +## Routing Priority + +1. **Most specific CIDR wins**: `/32` routes take precedence over `/24`, which take precedence over `/0` +2. **Order matters**: For same CIDR specificity, routes listed first have higher priority +3. **Protocol matching**: Protocol-specific routes only match their protocol +4. **Port matching**: Port-specific routes only match connections to those ports + +## How It Works + +1. When a connection is initiated, WrapGuard checks the destination IP, protocol, and port +2. It searches through all configured routing policies to find the best match +3. Traffic is routed through the WireGuard peer with the matching policy +4. If no policy matches, it falls back to checking AllowedIPs +5. If still no match, the default peer (first one with `0.0.0.0/0`) is used + +## Testing Your Configuration + +To test your routing configuration: + +```bash +# Check which peer would handle specific traffic +wrapguard --config=policy-routing.conf -- curl https://example.com +wrapguard --config=policy-routing.conf -- ssh user@192.168.1.100 +wrapguard --config=policy-routing.conf -- nc -v 10.0.0.5 3000 +``` + +Enable debug logging to see routing decisions: + +```bash +wrapguard --config=policy-routing.conf --log-level=debug -- your_command +``` + +## Limitations + +- Currently only supports IPv4 routing +- Maximum of one route per line (no comma-separated CIDRs) +- Port ranges in route specifications don't support comma-separated values \ No newline at end of file From 0f794acf905fea85513aa0ecea9b0d9f6577b868 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Thu, 26 Jun 2025 23:48:23 +0100 Subject: [PATCH 6/6] chore: add policy routing demo --- demo-policy-routing.sh | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100755 demo-policy-routing.sh diff --git a/demo-policy-routing.sh b/demo-policy-routing.sh new file mode 100755 index 0000000..ca2f274 --- /dev/null +++ b/demo-policy-routing.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Demo script for testing policy-based routing + +echo "=== WrapGuard Policy-Based Routing Demo ===" +echo "" +echo "This demo shows how WrapGuard routes traffic through different peers" +echo "based on destination IP, protocol, and port." +echo "" + +# Check if wrapguard is built +if [ ! -f "./wrapguard" ]; then + echo "Building wrapguard..." + make build +fi + +# Enable debug logging +export WRAPGUARD_DEBUG=1 + +echo "1. Testing general traffic routing (should go through peer 1):" +echo " Command: wrapguard --config=example-policy-routing.conf --log-level=debug -- curl -s https://icanhazip.com" +echo "" + +echo "2. Testing port 8080 routing (should go through peer 2):" +echo " Command: wrapguard --config=example-policy-routing.conf --log-level=debug -- curl -s http://example.com:8080" +echo "" + +echo "3. Testing development network routing (should go through peer 3):" +echo " Command: wrapguard --config=example-policy-routing.conf --log-level=debug -- ping -c 1 10.1.2.3" +echo "" + +echo "Note: The actual commands won't work without real WireGuard peers configured," +echo "but the debug logs will show which peer would be selected for each connection." \ No newline at end of file