Skip to content

Commit f90d520

Browse files
committed
feat(wireguard): add linux host routing
1 parent 7b51900 commit f90d520

File tree

7 files changed

+171
-1
lines changed

7 files changed

+171
-1
lines changed

Dockerfile.wireguard

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ FROM alpine:latest
1717

1818
LABEL org.opencontainers.image.source="https://github.com/PasarGuard/node"
1919

20-
RUN apk update && apk add --no-cache wireguard-tools
20+
RUN apk update && apk add --no-cache wireguard-tools nftables iproute2
2121

2222
WORKDIR /app
2323
COPY --from=builder /src /app
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//go:build linux
2+
3+
package wireguard
4+
5+
import (
6+
"bytes"
7+
"encoding/json"
8+
"os"
9+
"os/exec"
10+
"strings"
11+
)
12+
13+
// linuxDefaultRouteInterfaceIPv4 returns the egress interface for the IPv4 default route
14+
// (e.g. ens192, eth0, enp0s3). Used when PG_NODE_WG_NAT_OUTPUT_INTERFACE is unset.
15+
func linuxDefaultRouteInterfaceIPv4() (string, bool) {
16+
if out, err := exec.Command("ip", "-4", "-j", "route", "show", "default").Output(); err == nil {
17+
var routes []struct {
18+
Dev string `json:"dev"`
19+
}
20+
if err := json.Unmarshal(bytes.TrimSpace(out), &routes); err == nil && len(routes) > 0 {
21+
if dev := strings.TrimSpace(routes[0].Dev); dev != "" {
22+
return dev, true
23+
}
24+
}
25+
}
26+
if out, err := os.ReadFile("/proc/net/route"); err == nil {
27+
return parseDefaultIfaceFromProcNetRoute(out)
28+
}
29+
return "", false
30+
}
31+
32+
// parseDefaultIfaceFromProcNetRoute parses /proc/net/route (kernel ABI).
33+
// Default route rows use destination 00000000.
34+
func parseDefaultIfaceFromProcNetRoute(data []byte) (string, bool) {
35+
for _, line := range bytes.Split(data, []byte{'\n'}) {
36+
fields := strings.Fields(string(line))
37+
if len(fields) < 2 {
38+
continue
39+
}
40+
iface := fields[0]
41+
if iface == "Iface" {
42+
continue
43+
}
44+
if fields[1] == "00000000" {
45+
return iface, true
46+
}
47+
}
48+
return "", false
49+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build linux
2+
3+
package wireguard
4+
5+
import "testing"
6+
7+
func TestParseDefaultIfaceFromProcNetRoute(t *testing.T) {
8+
const sample = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n" +
9+
"ens33\t00000000\t010AA8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n"
10+
11+
got, ok := parseDefaultIfaceFromProcNetRoute([]byte(sample))
12+
if !ok || got != "ens33" {
13+
t.Fatalf("got %q ok=%v", got, ok)
14+
}
15+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//go:build linux
2+
3+
package wireguard
4+
5+
import (
6+
"fmt"
7+
"log"
8+
"os"
9+
"os/exec"
10+
"strings"
11+
)
12+
13+
const (
14+
envHostRouting = "PG_NODE_WG_HOST_ROUTING"
15+
envNATOutputInterface = "PG_NODE_WG_NAT_OUTPUT_INTERFACE"
16+
)
17+
18+
// applyLinuxHostRouting enables IPv4/IPv6 forwarding and installs an nftables masquerade
19+
// rule for traffic from the WireGuard interface to the IPv4 default-route egress interface.
20+
//
21+
// wgInterfaceName comes from core JSON interface_name (e.g. wg0, wg1); never hardcoded here.
22+
// The NAT egress interface is resolved in order:
23+
// 1. PG_NODE_WG_NAT_OUTPUT_INTERFACE if set
24+
// 2. IPv4 default route interface (ip -4 -j route, else /proc/net/route)
25+
// 3. eth0 as last-resort fallback
26+
//
27+
// Disable all of this with PG_NODE_WG_HOST_ROUTING=0.
28+
func applyLinuxHostRouting(wgInterfaceName string) {
29+
if v := strings.TrimSpace(os.Getenv(envHostRouting)); v == "0" || strings.EqualFold(v, "false") {
30+
return
31+
}
32+
33+
wgIf := strings.TrimSpace(wgInterfaceName)
34+
if wgIf == "" {
35+
wgIf = "wg0"
36+
}
37+
38+
outIf := strings.TrimSpace(os.Getenv(envNATOutputInterface))
39+
if outIf == "" {
40+
var ok bool
41+
outIf, ok = linuxDefaultRouteInterfaceIPv4()
42+
if !ok {
43+
outIf = "eth0"
44+
log.Printf(
45+
"wireguard host routing: could not detect default IPv4 egress interface; using fallback %q (set %s)",
46+
outIf,
47+
envNATOutputInterface,
48+
)
49+
}
50+
}
51+
52+
if err := writeSysctl("net/ipv4/ip_forward", "1"); err != nil {
53+
log.Printf("wireguard host routing: ipv4 forwarding: %v", err)
54+
}
55+
if err := writeSysctl("net/ipv6/conf/all/forwarding", "1"); err != nil {
56+
log.Printf("wireguard host routing: ipv6 forwarding: %v", err)
57+
}
58+
59+
log.Printf("wireguard host routing: wg interface %q, NAT egress %q (masquerade)", wgIf, outIf)
60+
61+
if err := ensureNFTMasquerade(wgIf, outIf); err != nil {
62+
log.Printf("wireguard host routing: nftables masquerade (optional): %v", err)
63+
}
64+
}
65+
66+
func writeSysctl(relPath, value string) error {
67+
path := "/proc/sys/" + relPath
68+
return os.WriteFile(path, []byte(value+"\n"), 0)
69+
}
70+
71+
// ensureNFTMasquerade replaces table ip pasarguard_wg. Traffic is matched from the
72+
// configured WireGuard interface to the detected/configured egress interface only.
73+
func ensureNFTMasquerade(wgIface, outputIface string) error {
74+
del := exec.Command("nft", "delete", "table", "ip", "pasarguard_wg")
75+
_ = del.Run()
76+
77+
cfg := fmt.Sprintf(`table ip pasarguard_wg {
78+
chain postrouting {
79+
type nat hook postrouting priority 100;
80+
policy accept;
81+
iifname %q oifname %q masquerade
82+
}
83+
}
84+
`, wgIface, outputIface)
85+
86+
cmd := exec.Command("nft", "-f", "-")
87+
cmd.Stdin = strings.NewReader(cfg)
88+
out, err := cmd.CombinedOutput()
89+
if err != nil {
90+
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
91+
}
92+
return nil
93+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
//go:build !linux
2+
3+
package wireguard
4+
5+
// applyLinuxHostRouting is a no-op on non-Linux platforms.
6+
func applyLinuxHostRouting(_ string) {}

backend/wireguard/wireguard.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ func newWithManagerFactory(cfg *config.Config, wgConfig *Config, users []*common
170170
psk, _ := wgConfig.GetPreSharedKey()
171171
startupPeerConfigs, appliedKeys := buildTargetPeerConfigs(startupDiff.TargetPeers, psk)
172172

173+
173174
manager, err := wg.newManager(wgConfig.InterfaceName)
174175
if err != nil {
175176
return nil, fmt.Errorf("failed to create manager: %w", err)
@@ -181,6 +182,9 @@ func newWithManagerFactory(cfg *config.Config, wgConfig *Config, users []*common
181182
return nil, fmt.Errorf("failed to initialize interface: %w", err)
182183
}
183184

185+
// After the tunnel exists, apply sysctl + nft so iifname matches the real interface name from core config.
186+
applyLinuxHostRouting(wgConfig.InterfaceName)
187+
184188
wg.manager = manager
185189

186190
// Initialize PeerStore with successfully committed peers.

docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ services:
1313
environment:
1414
SERVICE_PORT: 62050
1515
SERVICE_PROTOCOL: "grpc"
16+
# Linux: IP forwarding + nft masquerade from the WG interface (core interface_name) to the IPv4 default route iface.
17+
# NAT egress is auto-detected (ip route / /proc/net/route); set PG_NODE_WG_NAT_OUTPUT_INTERFACE to override (e.g. eth0, ens192).
18+
PG_NODE_WG_HOST_ROUTING: "1"
1619

1720
SSL_CERT_FILE: "/var/lib/pg-node/certs/ssl_cert.pem"
1821
SSL_KEY_FILE: "/var/lib/pg-node/certs/ssl_key.pem"

0 commit comments

Comments
 (0)