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:
Git Sagar 2026-06-07 01:00:43 +05:30
parent 857733863c
commit 51824b830e
5 changed files with 51 additions and 7 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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 {

View file

@ -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
} }

View file

@ -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)
} }
} }