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>
11 KiB
softether-go
Standalone SoftEther VPN client written in Go. Connects to SoftEther VPN servers using the native protocol over TLS, with built-in DHCP, automatic reconnection, and route management. Single static binary, Linux only, zero runtime dependencies beyond ip (iproute2).
Features
- Native SoftEther protocol (TLS + HTTP handshake + TCP block framing)
- Built-in DHCP client (raw Ethernet frame construction through the VPN tunnel)
- Automatic reconnection with fresh DHCP on each reconnect
- Host route to VPN server via existing default gateway (prevents routing loops)
- Classless static routes (DHCP option 121/249, RFC 3442)
- 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) - Deterministic MAC address support for stable DHCP assignments
- Hashed password (SHA-0) and plaintext password (RADIUS/external) authentication
- ~4.6 MB RAM under load, single process
Requirements
- Linux (uses
/dev/net/tunfor TAP devices) CAP_NET_ADMINor root (TAP device creation, route management)ipcommand (iproute2) on$PATHiptableson$PATH(only if using-connmark)
Building
Requires Go 1.24 or later.
go build -o softether-go ./cmd/softether-go/
Static binary (for Alpine, scratch containers):
CGO_ENABLED=0 go build -o softether-go ./cmd/softether-go/
Cross-compilation:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o softether-go ./cmd/softether-go/
Nix:
nix build # result in ./result/bin/softether-go
nix develop # dev shell with Go tooling
The only Go dependency is golang.org/x/sys (vendored in vendor/).
Usage
softether-go [flags]
Required flags
| Flag | Description |
|---|---|
-host |
SoftEther server hostname or IP |
-user |
Authentication username |
Optional flags
| Flag | Default | Description |
|---|---|---|
-pass |
"" |
Authentication password |
-port |
443 |
Server port |
-hub |
DEFAULT |
Virtual hub name |
-tap |
(auto) | TAP interface name (kernel-assigned if empty) |
-mac |
(auto) | TAP interface MAC address (e.g. 5E:3B:6F:63:A8:3E) |
-plain-password |
false |
Send password as plaintext (AuthType 2, for RADIUS/external auth) |
-insecure |
false |
Skip TLS certificate verification |
-no-dhcp |
false |
Disable built-in DHCP client |
-accept-default-gateway |
false |
Install DHCP-provided gateway as default route |
-accept-static-routes |
false |
Install DHCP classless static routes (option 121/249) |
-accept-dns |
false |
Set /etc/resolv.conf from DHCP-provided DNS servers |
-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 |
Authentication
Hashed password (AuthType 1) — the default. Password is hashed with SHA-0 and combined with the server's random challenge. Used for local user accounts.
softether-go -host vpn.example.com -user admin -pass secret
Plaintext password (AuthType 2) — enabled with -plain-password. Password sent as-is over TLS. Used for RADIUS/external auth.
softether-go -host vpn.example.com -user admin -pass secret -plain-password
Network configuration
-mac — sets a specific MAC for deterministic DHCP assignments across reconnects.
-accept-default-gateway — installs DHCP gateway as default route (metric 50). A /32 host route to the VPN server via the original gateway prevents routing loops.
-accept-static-routes — installs classless static routes from DHCP option 121/249. A 0.0.0.0/0 entry is only installed if -accept-default-gateway is also set.
-accept-dns — overwrites /etc/resolv.conf with DHCP DNS servers. Original is backed up and restored on disconnect.
-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
Minimal:
softether-go -host vpn.example.com -user admin -pass secret
Full setup:
softether-go \
-host vpn.example.com \
-port 992 \
-user admin \
-pass secret \
-plain-password \
-tap vpn0 \
-mac 5E:3B:6F:63:A8:3E \
-insecure \
-accept-default-gateway \
-accept-static-routes \
-accept-dns \
-policy-route-table 200 \
-connmark
No DHCP (manual config):
softether-go -host vpn.example.com -user admin -pass secret -no-dhcp -tap vpn0
Docker
docker run --rm -it \
--cap-add NET_ADMIN \
--device /dev/net/tun \
-v ./softether-go:/usr/bin/softether-go \
alpine \
softether-go -host vpn.example.com -user admin -pass secret \
-plain-password -insecure -tap vpn0 \
-accept-default-gateway -accept-dns
Container needs iproute2 (apk add iproute2 on Alpine).
Signals
- SIGINT / SIGTERM — clean shutdown: closes tunnel, flushes TAP, restores DNS, removes routes
- During reconnect delay, signal triggers immediate shutdown
Architecture
Connection flow
Client Server
│ │
├──── TLS handshake ───────────────────►│ Standard TLS, looks like HTTPS
│ │
├──── POST /vpnsvc/connect.cgi ────────►│ Upload signature ("VPNCONNECT")
│ │
│◄──── HTTP 200 (Pack: hello) ──────────┤ Server version + random challenge
│ │
├──── POST /vpnsvc/vpn.cgi ───────────►│ Upload auth (credentials + metadata)
│ │
│◄──── HTTP 200 (Pack: welcome) ────────┤ Session info (or error code)
│ │
│◄────── TCP block framing ────────────►│ Ethernet frames + keepalive
│ │
- TLS connect — standard TLS to the server port (typically 443 or 992)
- Upload signature —
VPNCONNECTvia HTTP POST to/vpnsvc/connect.cgi - Download Hello — server sends version info and 20-byte random challenge
- Upload Auth — credentials, client version, connection options, node info as a Pack
- Download Welcome — session info on success, error code on failure
- Tunnel mode — raw TCP block framing:
uint32(numBlocks) + [uint32(size) + data].... Keepalive:uint32(0xFFFFFFFF) + uint32(randSize) + randData
Session lifecycle
- Start bridge — bidirectional frame forwarding begins immediately
- DHCP exchange — DHCP frames sent through tunnel while bridge runs. DHCP client intercepts server frames via
FeedFramecallback - Configure TAP — IP, routes, DNS, policy routing applied from lease
- Wait — blocks until bridge errors or signal arrives
- Cleanup — flush addresses, restore DNS, remove policy routes
- Reconnect — wait, then repeat with fresh handshake and DHCP
DHCP through the tunnel
SoftEther operates at Layer 2. The built-in DHCP client constructs complete Ethernet/IP/UDP/DHCP frames sent through the tunnel:
DHCP Client VPN Tunnel DHCP Server (on VPN)
│ │ │
├── DISCOVER ──► WriteFrames ├────────────────────────────►│
│ │ │
│◄── FeedFrame ◄─ ReadFrames ◄──────── OFFER ─────────────┤
│ │ │
├── REQUEST ───► WriteFrames ├────────────────────────────►│
│ │ │
│◄── FeedFrame ◄─ ReadFrames ◄──────── ACK ───────────────┤
│ │ │
DHCP options requested: subnet mask (1), router (3), DNS (6), lease time (51), classless static routes (121/249).
Why fresh DHCP on reconnect: SoftEther's DHCPForce policy drops packets from IPs not in its DhcpAllocated table. On disconnect, the server clears all entries for the session.
Routing
Server host route — /32 route to VPN server via current default gateway, added before first connection. Prevents routing loop when VPN becomes default route.
Default gateway — DHCP gateway installed with metric 50, lower than existing default routes.
Static routes — classless routes from option 121/249. Default route entries (0.0.0.0/0) only installed with -accept-default-gateway.
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
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.
Keepalive
Sent every 3 seconds: uint32(0xFFFFFFFF) + uint32(randSize) + randData. Silently consumed, never forwarded to TAP.
Project structure
cmd/softether-go/
main.go Flag parsing, TAP setup, reconnect loop
session.go Session lifecycle, DHCP orchestration
pkg/client/
client.go SoftEther handshake and session
tunnel.go TCP block framing, keepalive, frame bridging
crypto.go SHA-0 and password hashing
pkg/protocol/
http.go TLS connection, HTTP transport layer
pack.go Pack binary serialization
pkg/dhcp/
dhcp.go DHCP client (raw Ethernet frames)
pkg/netcfg/
netcfg.go TAP configuration, routing, DNS management
pkg/tap/
tap.go Linux TAP device management
License
MIT