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>
271 lines
11 KiB
Markdown
271 lines
11 KiB
Markdown
# 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/tun` for TAP devices)
|
|
- `CAP_NET_ADMIN` or root (TAP device creation, route management)
|
|
- `ip` command (iproute2) on `$PATH`
|
|
- `iptables` on `$PATH` (only if using `-connmark`)
|
|
|
|
## Building
|
|
|
|
Requires Go 1.24 or later.
|
|
|
|
```bash
|
|
go build -o softether-go ./cmd/softether-go/
|
|
```
|
|
|
|
Static binary (for Alpine, scratch containers):
|
|
|
|
```bash
|
|
CGO_ENABLED=0 go build -o softether-go ./cmd/softether-go/
|
|
```
|
|
|
|
Cross-compilation:
|
|
|
|
```bash
|
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o softether-go ./cmd/softether-go/
|
|
```
|
|
|
|
Nix:
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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:
|
|
```bash
|
|
softether-go -host vpn.example.com -user admin -pass secret
|
|
```
|
|
|
|
Full setup:
|
|
```bash
|
|
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):
|
|
```bash
|
|
softether-go -host vpn.example.com -user admin -pass secret -no-dhcp -tap vpn0
|
|
```
|
|
|
|
### Docker
|
|
|
|
```bash
|
|
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
|
|
│ │
|
|
```
|
|
|
|
1. **TLS connect** — standard TLS to the server port (typically 443 or 992)
|
|
2. **Upload signature** — `VPNCONNECT` via HTTP POST to `/vpnsvc/connect.cgi`
|
|
3. **Download Hello** — server sends version info and 20-byte random challenge
|
|
4. **Upload Auth** — credentials, client version, connection options, node info as a Pack
|
|
5. **Download Welcome** — session info on success, error code on failure
|
|
6. **Tunnel mode** — raw TCP block framing: `uint32(numBlocks) + [uint32(size) + data]...`. Keepalive: `uint32(0xFFFFFFFF) + uint32(randSize) + randData`
|
|
|
|
### Session lifecycle
|
|
|
|
1. **Start bridge** — bidirectional frame forwarding begins immediately
|
|
2. **DHCP exchange** — DHCP frames sent through tunnel while bridge runs. DHCP client intercepts server frames via `FeedFrame` callback
|
|
3. **Configure TAP** — IP, routes, DNS, policy routing applied from lease
|
|
4. **Wait** — blocks until bridge errors or signal arrives
|
|
5. **Cleanup** — flush addresses, restore DNS, remove policy routes
|
|
6. **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
|