netcfg: add -connmark flag for DNAT reply routing
When VPN traffic is DNAT'd to local namespaces/VMs, reply packets have a different source IP (namespace veth) so the policy route's "from <VPN_IP>" rule doesn't match. CONNMARK marks all connections arriving on the VPN interface and restores the mark on reply packets, routing them back through the tunnel via fwmark rule. New flag: -connmark (requires -policy-route-table) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
857733863c
commit
51824b830e
5 changed files with 51 additions and 7 deletions
|
|
@ -57,7 +57,7 @@ pkg/tap/
|
||||||
## CLI flags
|
## CLI flags
|
||||||
|
|
||||||
Required: `-host`, `-user`
|
Required: `-host`, `-user`
|
||||||
Optional: `-pass`, `-port` (443), `-hub` (DEFAULT), `-tap` (auto), `-mac`, `-plain-password`, `-insecure`, `-no-dhcp`, `-accept-default-gateway`, `-accept-static-routes`, `-accept-dns`, `-policy-route-table` (0=disabled), `-reconnect-delay` (5s)
|
Optional: `-pass`, `-port` (443), `-hub` (DEFAULT), `-tap` (auto), `-mac`, `-plain-password`, `-insecure`, `-no-dhcp`, `-accept-default-gateway`, `-accept-static-routes`, `-accept-dns`, `-policy-route-table` (0=disabled), `-connmark`, `-reconnect-delay` (5s)
|
||||||
|
|
||||||
## SoftEther protocol pitfalls
|
## SoftEther protocol pitfalls
|
||||||
|
|
||||||
|
|
@ -96,6 +96,9 @@ Before connecting, resolves server hostname and adds `/32` route via current def
|
||||||
### Policy routing
|
### Policy routing
|
||||||
`-policy-route-table N` adds `ip rule from <VPN_IP> table N` + `ip route replace default via <VPN_GW> dev <TAP> table N`. Needed when VPN server port-forwards to client — ensures reply packets go back through VPN tunnel, not default route.
|
`-policy-route-table N` adds `ip rule from <VPN_IP> table N` + `ip route replace default via <VPN_GW> dev <TAP> table N`. Needed when VPN server port-forwards to client — ensures reply packets go back through VPN tunnel, not default route.
|
||||||
|
|
||||||
|
### CONNMARK for DNAT reply routing
|
||||||
|
`-connmark` (requires `-policy-route-table`) adds iptables CONNMARK rules so DNAT'd connections (port forwards to namespaces/VMs) have replies routed back through the tunnel. Without this, replies from DNAT targets use the default route because their source IP doesn't match the `from <VPN_IP>` policy rule. CONNMARK marks incoming VPN connections and restores the mark on reply packets.
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
- **RAM**: 4.6 MB RSS idle, flat under 97 Mbit/s load (vs SoftEther C client: ~23 MB across 4 processes)
|
- **RAM**: 4.6 MB RSS idle, flat under 97 Mbit/s load (vs SoftEther C client: ~23 MB across 4 processes)
|
||||||
|
|
@ -125,7 +128,7 @@ Equivalent softether-go command:
|
||||||
softether-go -host 65-20-68-5.ip.fresh.ipb.cloud -port 992 \
|
softether-go -host 65-20-68-5.ip.fresh.ipb.cloud -port 992 \
|
||||||
-user T0Eq5yf97gXy8Q -plain-password -insecure \
|
-user T0Eq5yf97gXy8Q -plain-password -insecure \
|
||||||
-tap india-pub-ip -mac 5E:3B:6F:63:A8:3E \
|
-tap india-pub-ip -mac 5E:3B:6F:63:A8:3E \
|
||||||
-policy-route-table 200
|
-policy-route-table 200 -connmark
|
||||||
```
|
```
|
||||||
|
|
||||||
To add as flake input: `softether-go.url = "git+https://git.sagar.ch/sagar/softether-go.git";`
|
To add as flake input: `softether-go.url = "git+https://git.sagar.ch/sagar/softether-go.git";`
|
||||||
|
|
@ -133,5 +136,5 @@ To add as flake input: `softether-go.url = "git+https://git.sagar.ch/sagar/softe
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- Go 1.24+, single dep: `golang.org/x/sys` (vendored)
|
- Go 1.24+, single dep: `golang.org/x/sys` (vendored)
|
||||||
- Runtime: `ip` (iproute2)
|
- Runtime: `ip` (iproute2), `iptables` (only for `-connmark`)
|
||||||
- `vendorHash = null` in flake.nix (deps are vendored)
|
- `vendorHash = null` in flake.nix (deps are vendored)
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -10,6 +10,7 @@ Standalone SoftEther VPN client written in Go. Connects to SoftEther VPN servers
|
||||||
- Host route to VPN server via existing default gateway (prevents routing loops)
|
- Host route to VPN server via existing default gateway (prevents routing loops)
|
||||||
- Classless static routes (DHCP option 121/249, RFC 3442)
|
- Classless static routes (DHCP option 121/249, RFC 3442)
|
||||||
- Policy routing for asymmetric return paths (VPN port forwards)
|
- Policy routing for asymmetric return paths (VPN port forwards)
|
||||||
|
- CONNMARK-based DNAT reply routing (for port forwards to namespaces/VMs)
|
||||||
- DNS configuration from DHCP lease (backup/restore of `/etc/resolv.conf`)
|
- DNS configuration from DHCP lease (backup/restore of `/etc/resolv.conf`)
|
||||||
- Deterministic MAC address support for stable DHCP assignments
|
- Deterministic MAC address support for stable DHCP assignments
|
||||||
- Hashed password (SHA-0) and plaintext password (RADIUS/external) authentication
|
- Hashed password (SHA-0) and plaintext password (RADIUS/external) authentication
|
||||||
|
|
@ -20,6 +21,7 @@ Standalone SoftEther VPN client written in Go. Connects to SoftEther VPN servers
|
||||||
- Linux (uses `/dev/net/tun` for TAP devices)
|
- Linux (uses `/dev/net/tun` for TAP devices)
|
||||||
- `CAP_NET_ADMIN` or root (TAP device creation, route management)
|
- `CAP_NET_ADMIN` or root (TAP device creation, route management)
|
||||||
- `ip` command (iproute2) on `$PATH`
|
- `ip` command (iproute2) on `$PATH`
|
||||||
|
- `iptables` on `$PATH` (only if using `-connmark`)
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
|
|
@ -79,6 +81,7 @@ softether-go [flags]
|
||||||
| `-accept-static-routes` | `false` | Install DHCP classless static routes (option 121/249) |
|
| `-accept-static-routes` | `false` | Install DHCP classless static routes (option 121/249) |
|
||||||
| `-accept-dns` | `false` | Set `/etc/resolv.conf` from DHCP-provided DNS servers |
|
| `-accept-dns` | `false` | Set `/etc/resolv.conf` from DHCP-provided DNS servers |
|
||||||
| `-policy-route-table` | `0` | Policy routing table number (0 = disabled) |
|
| `-policy-route-table` | `0` | Policy routing table number (0 = disabled) |
|
||||||
|
| `-connmark` | `false` | Use CONNMARK to route DNAT reply traffic back through VPN |
|
||||||
| `-reconnect-delay` | `5s` | Delay between reconnection attempts |
|
| `-reconnect-delay` | `5s` | Delay between reconnection attempts |
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
@ -107,6 +110,8 @@ softether-go -host vpn.example.com -user admin -pass secret -plain-password
|
||||||
|
|
||||||
**`-policy-route-table N`** — policy routing for asymmetric return paths. Adds `ip rule from <VPN_IP> table N` and `ip route replace default via <VPN_GW> dev <TAP> table N`. Needed when the VPN server forwards ports to the client.
|
**`-policy-route-table N`** — policy routing for asymmetric return paths. Adds `ip rule from <VPN_IP> table N` and `ip route replace default via <VPN_GW> dev <TAP> table N`. Needed when the VPN server forwards ports to the client.
|
||||||
|
|
||||||
|
**`-connmark`** — requires `-policy-route-table`. Uses iptables CONNMARK to route DNAT reply traffic back through the VPN tunnel. Without this, traffic forwarded to local namespaces/VMs (via DNAT) gets replies routed via the default gateway instead of the tunnel, breaking the connection. Adds `CONNMARK --set-mark` on incoming VPN packets and `CONNMARK --restore-mark` on reply packets from other interfaces.
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
Minimal:
|
Minimal:
|
||||||
|
|
@ -128,7 +133,8 @@ softether-go \
|
||||||
-accept-default-gateway \
|
-accept-default-gateway \
|
||||||
-accept-static-routes \
|
-accept-static-routes \
|
||||||
-accept-dns \
|
-accept-dns \
|
||||||
-policy-route-table 200
|
-policy-route-table 200 \
|
||||||
|
-connmark
|
||||||
```
|
```
|
||||||
|
|
||||||
No DHCP (manual config):
|
No DHCP (manual config):
|
||||||
|
|
@ -224,6 +230,8 @@ DHCP options requested: subnet mask (1), router (3), DNS (6), lease time (51), c
|
||||||
|
|
||||||
**Policy routing** — `ip rule from <VPN_IP> table N` ensures reply packets for VPN port forwards go back through the tunnel, not the default route.
|
**Policy routing** — `ip rule from <VPN_IP> table N` ensures reply packets for VPN port forwards go back through the tunnel, not the default route.
|
||||||
|
|
||||||
|
**CONNMARK (`-connmark`)** — solves a subtler routing problem: when VPN traffic is DNAT'd to a local namespace or VM, the reply packets have a different source IP (the namespace veth) so the `from <VPN_IP>` rule doesn't match. CONNMARK marks all connections arriving on the VPN interface, then restores the mark on reply packets from any interface, routing them back through the tunnel via `fwmark` rule.
|
||||||
|
|
||||||
### Password hashing
|
### Password hashing
|
||||||
|
|
||||||
SoftEther uses **SHA-0** (not SHA-1) — no left-rotate in message schedule. `HashedPassword = SHA0(password)`, `SecurePassword = SHA0(HashedPassword + ServerRandom)`. Plaintext auth (AuthType 2) sends password as-is over TLS.
|
SoftEther uses **SHA-0** (not SHA-1) — no left-rotate in message schedule. `HashedPassword = SHA0(password)`, `SecurePassword = SHA0(HashedPassword + ServerRandom)`. Plaintext auth (AuthType 2) sends password as-is over TLS.
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ func main() {
|
||||||
acceptStaticRoutes := flag.Bool("accept-static-routes", false, "Install DHCP classless static routes (option 121/249)")
|
acceptStaticRoutes := flag.Bool("accept-static-routes", false, "Install DHCP classless static routes (option 121/249)")
|
||||||
acceptDNS := flag.Bool("accept-dns", false, "Set /etc/resolv.conf from DHCP-provided DNS servers")
|
acceptDNS := flag.Bool("accept-dns", false, "Set /etc/resolv.conf from DHCP-provided DNS servers")
|
||||||
policyRouteTable := flag.Int("policy-route-table", 0, "Policy routing table: route replies from VPN IP back through VPN gateway")
|
policyRouteTable := flag.Int("policy-route-table", 0, "Policy routing table: route replies from VPN IP back through VPN gateway")
|
||||||
|
connMark := flag.Bool("connmark", false, "Use CONNMARK to route DNAT reply traffic back through VPN (for port forwards to namespaces/VMs)")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
|
@ -94,6 +95,7 @@ func main() {
|
||||||
AcceptStaticRoutes: *acceptStaticRoutes,
|
AcceptStaticRoutes: *acceptStaticRoutes,
|
||||||
AcceptDNS: *acceptDNS,
|
AcceptDNS: *acceptDNS,
|
||||||
PolicyRouteTable: *policyRouteTable,
|
PolicyRouteTable: *policyRouteTable,
|
||||||
|
ConnMark: *connMark,
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ func runSession(cfg client.Config, dev *tap.Device, mac net.HardwareAddr, opts n
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
if opts.PolicyRouteTable > 0 && lease.Gateway != nil {
|
if opts.PolicyRouteTable > 0 && lease.Gateway != nil {
|
||||||
defer netcfg.ConfigurePolicyRoute(dev.Name, lease, opts.PolicyRouteTable)()
|
defer netcfg.ConfigurePolicyRoute(dev.Name, lease, opts.PolicyRouteTable, opts.ConnMark)()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start DHCP renewal at T/2
|
// Start DHCP renewal at T/2
|
||||||
|
|
@ -111,7 +111,7 @@ func renewLoop(dc *dhcp.Client, tunnel *client.Tunnel, ifname string, lease *dhc
|
||||||
log.Printf("dhcp: IP changed %s → %s, reconfiguring", currentIP, newLease.ClientIP)
|
log.Printf("dhcp: IP changed %s → %s, reconfiguring", currentIP, newLease.ClientIP)
|
||||||
netcfg.ReconfigureTAP(ifname, newLease, opts.AcceptDefaultGW, opts.AcceptStaticRoutes, opts.AcceptDNS)
|
netcfg.ReconfigureTAP(ifname, newLease, opts.AcceptDefaultGW, opts.AcceptStaticRoutes, opts.AcceptDNS)
|
||||||
if opts.PolicyRouteTable > 0 && newLease.Gateway != nil {
|
if opts.PolicyRouteTable > 0 && newLease.Gateway != nil {
|
||||||
netcfg.ConfigurePolicyRoute(ifname, newLease, opts.PolicyRouteTable)
|
netcfg.ConfigurePolicyRoute(ifname, newLease, opts.PolicyRouteTable, opts.ConnMark)
|
||||||
}
|
}
|
||||||
currentIP = newLease.ClientIP
|
currentIP = newLease.ClientIP
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type Options struct {
|
||||||
AcceptStaticRoutes bool
|
AcceptStaticRoutes bool
|
||||||
AcceptDNS bool
|
AcceptDNS bool
|
||||||
PolicyRouteTable int
|
PolicyRouteTable int
|
||||||
|
ConnMark bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigureTAP sets the IP address, routes, and DNS on a TAP interface from a DHCP lease.
|
// ConfigureTAP sets the IP address, routes, and DNS on a TAP interface from a DHCP lease.
|
||||||
|
|
@ -94,11 +95,16 @@ func ReconfigureTAP(ifname string, lease *dhcp.Lease, acceptDefaultGW, acceptSta
|
||||||
// ConfigurePolicyRoute sets up policy routing so packets from the VPN IP are routed
|
// ConfigurePolicyRoute sets up policy routing so packets from the VPN IP are routed
|
||||||
// back through the VPN gateway. Needed when the VPN server forwards ports to the
|
// back through the VPN gateway. Needed when the VPN server forwards ports to the
|
||||||
// client — without it, reply packets use the default route instead of the VPN tunnel.
|
// client — without it, reply packets use the default route instead of the VPN tunnel.
|
||||||
func ConfigurePolicyRoute(ifname string, lease *dhcp.Lease, table int) func() {
|
//
|
||||||
|
// When connmark is true, also sets up CONNMARK rules so that DNAT'd connections
|
||||||
|
// (e.g. port forwards to namespaces/VMs) have their reply traffic routed back
|
||||||
|
// through the tunnel.
|
||||||
|
func ConfigurePolicyRoute(ifname string, lease *dhcp.Lease, table int, connmark bool) func() {
|
||||||
t := fmt.Sprintf("%d", table)
|
t := fmt.Sprintf("%d", table)
|
||||||
clientIP := lease.ClientIP.String()
|
clientIP := lease.ClientIP.String()
|
||||||
gw := lease.Gateway.String()
|
gw := lease.Gateway.String()
|
||||||
|
|
||||||
|
// Policy route: packets from VPN IP use VPN gateway
|
||||||
runQuiet("ip", "rule", "del", "table", t)
|
runQuiet("ip", "rule", "del", "table", t)
|
||||||
run("ip", "route", "replace", "default", "via", gw, "dev", ifname, "table", t)
|
run("ip", "route", "replace", "default", "via", gw, "dev", ifname, "table", t)
|
||||||
if err := run("ip", "rule", "add", "from", clientIP, "table", t); err != nil {
|
if err := run("ip", "rule", "add", "from", clientIP, "table", t); err != nil {
|
||||||
|
|
@ -107,9 +113,34 @@ func ConfigurePolicyRoute(ifname string, lease *dhcp.Lease, table int) func() {
|
||||||
log.Printf("policy route: from %s via %s dev %s table %s", clientIP, gw, ifname, t)
|
log.Printf("policy route: from %s via %s dev %s table %s", clientIP, gw, ifname, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if connmark {
|
||||||
|
mark := t
|
||||||
|
// CONNMARK: mark connections arriving on VPN interface, restore mark on replies.
|
||||||
|
// This ensures DNAT'd traffic (forwarded to namespaces/VMs) returns via the
|
||||||
|
// tunnel instead of the default route. Without this, reply packets from DNAT
|
||||||
|
// targets (e.g. namespace veth) have a different source IP than the VPN IP,
|
||||||
|
// so the "from <VPN_IP>" policy rule doesn't match them.
|
||||||
|
log.Printf("connmark: adding CONNMARK rules on %s (mark %s)", ifname, mark)
|
||||||
|
runQuiet("ip", "rule", "del", "fwmark", mark, "table", t)
|
||||||
|
run("iptables", "-t", "mangle", "-A", "PREROUTING", "-i", ifname, "-j", "CONNMARK", "--set-mark", mark)
|
||||||
|
run("iptables", "-t", "mangle", "-A", "PREROUTING", "!", "-i", ifname, "-m", "connmark", "--mark", mark, "-j", "CONNMARK", "--restore-mark")
|
||||||
|
if err := run("ip", "rule", "add", "fwmark", mark, "table", t); err != nil {
|
||||||
|
log.Printf("warning: fwmark rule: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("connmark: fwmark %s → table %s for DNAT reply routing", mark, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return func() {
|
return func() {
|
||||||
runQuiet("ip", "rule", "del", "table", t)
|
runQuiet("ip", "rule", "del", "table", t)
|
||||||
runQuiet("ip", "route", "del", "default", "table", t)
|
runQuiet("ip", "route", "del", "default", "table", t)
|
||||||
|
if connmark {
|
||||||
|
mark := t
|
||||||
|
log.Printf("connmark: removing CONNMARK rules (mark %s)", mark)
|
||||||
|
runQuiet("ip", "rule", "del", "fwmark", mark, "table", t)
|
||||||
|
runQuiet("iptables", "-t", "mangle", "-D", "PREROUTING", "-i", ifname, "-j", "CONNMARK", "--set-mark", mark)
|
||||||
|
runQuiet("iptables", "-t", "mangle", "-D", "PREROUTING", "!", "-i", ifname, "-m", "connmark", "--mark", mark, "-j", "CONNMARK", "--restore-mark")
|
||||||
|
}
|
||||||
log.Printf("policy route: cleaned up table %s", t)
|
log.Printf("policy route: cleaned up table %s", t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue