initial commit: standalone SoftEther VPN client in Go

Built-in DHCP (raw Ethernet frames through tunnel), automatic reconnection,
host route management, classless static routes (option 121/249), DNS config.
Single static binary, Linux only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Git Sagar 2026-06-06 16:13:51 +05:30
commit 829ca73b1b
340 changed files with 199140 additions and 0 deletions

137
docs/architecture.md Normal file
View file

@ -0,0 +1,137 @@
# Architecture
## Connection flow
The SoftEther protocol layers HTTP over TLS, then switches to raw TCP framing for the data tunnel.
```
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
│ │
```
### Step details
1. **TLS connect** — standard TLS to the server port (typically 443 or 992). The connection looks like normal HTTPS traffic to network observers.
2. **Upload signature** — the client sends `VPNCONNECT` as the body of an HTTP POST to `/vpnsvc/connect.cgi`. The server validates this to confirm the client speaks the SoftEther protocol.
3. **Download Hello** — the server responds with a binary Pack containing its version info and a 20-byte random challenge used for password hashing.
4. **Upload Auth** — the client sends credentials (hashed or plaintext password), client version, connection options, and node info as a Pack via HTTP POST to `/vpnsvc/vpn.cgi`.
5. **Download Welcome** — the server responds with session info (session name, connection name, session key, policies) on success, or an error code on failure.
6. **Tunnel mode** — the connection switches from HTTP to raw TCP block framing. Each message is `uint32(numBlocks) + [uint32(size) + data]...`. Keepalive packets use `0xFFFFFFFF` as the block count, followed by random padding.
## Pack format
SoftEther uses a custom binary serialization called "Pack" for all structured data exchange. A Pack contains named Elements, each holding typed Values (int, string, data, ip4).
Key details:
- Element names are **case-insensitive** (compared with `StrCmpi`)
- `AddElement` rejects duplicate names — the second add silently fails
- String values use BufStr encoding: `uint32(strlen+1)` followed by `strlen` bytes (no null terminator on wire)
- HTTP-transported Packs include a `pencore` dummy element with random padding
## DHCP through the tunnel
SoftEther operates at Layer 2 — the tunnel carries raw Ethernet frames. The built-in DHCP client constructs complete Ethernet/IP/UDP/DHCP frames and sends them through the tunnel's frame transport.
```
DHCP Client VPN Tunnel DHCP Server (on VPN)
│ │ │
├── DISCOVER ──► WriteFrames ├────────────────────────────►│
│ │ │
│◄── FeedFrame ◄─ ReadFrames ◄──────── OFFER ─────────────┤
│ │ │
├── REQUEST ───► WriteFrames ├────────────────────────────►│
│ │ │
│◄── FeedFrame ◄─ ReadFrames ◄──────── ACK ───────────────┤
│ │ │
```
The DHCP client starts reading tunnel frames **before** sending DISCOVER, so responses are not missed. Non-DHCP frames received during the exchange are dropped (the TAP bridge is not yet active).
### Why raw frames?
The VPN tunnel transports Ethernet frames, and the DHCP exchange must happen *inside* the tunnel before the TAP interface has an IP address. Constructing frames directly avoids any external dependency (`dhcpcd`, `udhcpc`) and keeps the client self-contained.
### DHCP options requested
- Option 1: Subnet Mask
- Option 3: Router (gateway)
- Option 6: DNS Servers
- Option 51: Lease Time
- Option 121: Classless Static Routes (RFC 3442)
- Option 249: Microsoft Classless Static Routes
### Why DHCP is required on reconnect
SoftEther servers with `DHCPForce` policy discard any packet whose source IP is not in the server's IP table with `DhcpAllocated=true`. When a session disconnects, the server calls `HubPaFree` which deletes **all** MAC and IP table entries for that session. The new session has no entries, so all traffic is dropped until a fresh DHCP exchange creates a new `DhcpAllocated=true` entry.
## Reconnection
On disconnect (TCP error, server timeout, etc.), the client:
1. Flushes IP addresses from the TAP interface
2. Restores `/etc/resolv.conf` if DNS was modified
3. Waits `-reconnect-delay` (default 5s)
4. Establishes a new TLS connection and repeats the full handshake
5. Runs a fresh DHCP exchange through the new tunnel
6. Reconfigures the TAP interface with the new lease
The TAP device itself persists across reconnections — only its IP configuration is reset. The host route to the VPN server also persists.
## Routing
### Server host route
Before the first connection, the client resolves the server hostname and adds a `/32` host route via the current default gateway:
```
50.117.55.1/32 via 172.17.0.1 dev eth0
```
This ensures the VPN tunnel traffic itself always uses the original path, even after `-accept-default-gateway` installs a new default route via the VPN.
### Default gateway
With `-accept-default-gateway`, the DHCP-provided gateway is installed as a default route with metric 50:
```
default via 10.100.8.1 dev vpn0 metric 50
```
The metric ensures it takes precedence over any existing default route with a higher metric, while the server host route (no metric, most specific) keeps the tunnel traffic on the original path.
### Static routes
With `-accept-static-routes`, classless static routes from DHCP option 121 or 249 are installed. A `0.0.0.0/0` entry in static routes is treated as a default gateway and only installed if `-accept-default-gateway` is also set. Per RFC 3442, option 121 takes precedence over option 3 (Router) when present.
## Keepalive
The client sends keepalive packets every 3 seconds. A keepalive is `uint32(0xFFFFFFFF) + uint32(randSize) + randData`. The server sends keepalives in the same format. Keepalive packets are silently consumed and never forwarded to the TAP device.
## Password hashing
SoftEther uses **SHA-0** (not SHA-1) for password hashing. SHA-0 differs from SHA-1 only in the message schedule — there is no left-rotate of the W[t] values.
For hashed password auth (AuthType 1):
```
HashedPassword = SHA0(password)
SecurePassword = SHA0(HashedPassword + ServerRandom)
```
For plaintext auth (AuthType 2), the password is sent as-is over the TLS connection.

54
docs/building.md Normal file
View file

@ -0,0 +1,54 @@
# Building
## Go
Requires Go 1.24 or later.
```bash
go build -o softether-go ./cmd/softether-go/
```
### Static binary
For deployment in minimal containers (Alpine, scratch) or systems without glibc:
```bash
CGO_ENABLED=0 go build -o softether-go ./cmd/softether-go/
```
### Cross-compilation
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o softether-go ./cmd/softether-go/
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o softether-go ./cmd/softether-go/
```
Only Linux targets are supported (the TAP device interface uses Linux-specific ioctls).
## Nix
Build with the flake:
```bash
nix build
```
The result is in `./result/bin/softether-go`.
Enter a development shell with Go tooling:
```bash
nix develop
```
Build within the dev shell:
```bash
nix develop -c bash -c 'go build -o softether-go ./cmd/softether-go/'
```
## Dependencies
The only Go dependency is `golang.org/x/sys` for Linux syscall constants (TUN/TAP ioctls). Dependencies are vendored in `vendor/`.
At runtime, the only external dependency is `ip` (iproute2) for route and address management.

27
docs/main.md Normal file
View file

@ -0,0 +1,27 @@
# softether-go documentation
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.
## 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)
- DNS configuration from DHCP lease (backup/restore of `/etc/resolv.conf`)
- Hashed password (SHA-0) and plaintext password (RADIUS/external) authentication
- Single static binary, Linux only
## Contents
- [Usage & CLI reference](usage.md) — flags, examples, Docker usage
- [Architecture](architecture.md) — connection flow, DHCP, reconnection, routing
- [Building](building.md) — Go, Nix, static builds
- [Project structure](structure.md) — source layout and package descriptions
## Requirements
- Linux (uses `/dev/net/tun` for TAP devices)
- `CAP_NET_ADMIN` or root (TAP device creation, route management)
- `ip` command (iproute2) on `$PATH`

57
docs/structure.md Normal file
View file

@ -0,0 +1,57 @@
# Project structure
```
softether-go/
├── cmd/softether-go/
│ └── main.go CLI entry point
├── pkg/
│ ├── client/
│ │ ├── client.go SoftEther handshake and session
│ │ ├── tunnel.go TCP block framing and keepalive
│ │ └── crypto.go SHA-0 and password hashing
│ ├── protocol/
│ │ ├── http.go HTTP transport layer
│ │ └── pack.go Pack binary serialization
│ ├── dhcp/
│ │ └── dhcp.go DHCP client (raw Ethernet frames)
│ └── tap/
│ └── tap.go Linux TAP device management
├── docs/ Documentation
├── vendor/ Vendored Go dependencies
├── flake.nix Nix build definition
├── go.mod
└── go.sum
```
## Package details
### `cmd/softether-go`
CLI entry point. Handles flag parsing, signal handling, and the reconnection loop. On each session:
- Connects to the server
- Runs DHCP through the tunnel
- Configures the TAP interface (IP, routes, DNS)
- Bridges Ethernet frames between the TAP device and the VPN tunnel
- Cleans up on disconnect and retries
### `pkg/client`
**`client.go`** — implements the SoftEther handshake: TLS connect, signature upload, hello/auth/welcome pack exchange. Exports `Connect(Config) (*Session, error)` and the `Config`/`Session` types.
**`tunnel.go`** — TCP block framing after the HTTP handshake. `ReadFrames()` reads batches of Ethernet frames from the server. `WriteFrames()` sends batches. `StartKeepalive()` sends periodic keepalive packets (every 3s) to prevent server timeout.
**`crypto.go`** — SHA-0 implementation (differs from SHA-1 only in the message schedule — no left-rotate). `HashPassword()` produces `SHA0(password)`. `SecurePassword()` produces `SHA0(hashed + serverRandom)`.
### `pkg/protocol`
**`http.go`** — HTTP transport layer. `DialTLS()` establishes the TLS connection. `UploadSignature()` sends the protocol signature. `SendPack()` and `RecvPack()` exchange binary Packs as HTTP POST request/response bodies.
**`pack.go`** — SoftEther Pack binary serialization. A Pack is a list of named Elements, each containing typed Values (int, string, data, ip4). Handles the BufStr wire format (`uint32(strlen+1)` then `strlen` bytes) and the `pencore` random padding element.
### `pkg/dhcp`
**`dhcp.go`** — DHCP client that constructs complete Ethernet/IP/UDP/DHCP frames. The full DHCP exchange (DISCOVER → OFFER → REQUEST → ACK) runs through the VPN tunnel's frame transport. Parses lease information including classless static routes (option 121/249, RFC 3442).
### `pkg/tap`
**`tap.go`** — Linux TAP (Layer 2) device management via `/dev/net/tun`. Opens TAP devices with `IFF_TAP | IFF_NO_PI`, reads/writes raw Ethernet frames. Provides `MAC()` to get the hardware address and `SetUp()` to bring the interface up.

118
docs/usage.md Normal file
View file

@ -0,0 +1,118 @@
# 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) |
| `-plain-password` | `false` | Send password as plaintext (AuthType 2, for RADIUS/external auth) |
| `-insecure` | `false` | Skip TLS certificate verification |
| `-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 |
| `-reconnect-delay` | `5s` | Delay between reconnection attempts |
## Authentication
Two authentication modes are supported:
**Hashed password (AuthType 1)** — the default. Password is hashed with SHA-0 and combined with the server's random challenge to produce a `SecurePassword`. Used for local user accounts on the SoftEther server.
```bash
softether-go -host vpn.example.com -user admin -pass secret
```
**Plaintext password (AuthType 2)** — enabled with `-plain-password`. Password is sent as-is over TLS. Used when the server delegates authentication to an external system like RADIUS.
```bash
softether-go -host vpn.example.com -user admin -pass secret -plain-password
```
## Network configuration flags
These flags control what the client does with the DHCP lease it receives from the VPN server.
### `-accept-default-gateway`
Adds a default route via the DHCP-provided gateway on the TAP interface with metric 50. Before doing this, the client adds a `/32` host route to the VPN server via the current default gateway so the tunnel itself is not routed through the VPN.
Without this flag, only the subnet route (implicit from the assigned IP/mask) is added.
### `-accept-static-routes`
Installs classless static routes from DHCP option 121 (RFC 3442) or option 249 (Microsoft variant). These are non-default routes pushed by the DHCP server, such as routes to specific subnets via the VPN gateway.
If a static route entry has destination `0.0.0.0/0` (default route), it is only installed when `-accept-default-gateway` is also set. Per RFC 3442, when option 121 is present it takes precedence over option 3 (Router).
### `-accept-dns`
Overwrites `/etc/resolv.conf` with the DNS servers from the DHCP lease. The original file is backed up in memory and restored when the session ends (disconnect, reconnect, or shutdown).
## Examples
Minimal connection:
```bash
softether-go -host vpn.example.com -user admin -pass secret
```
Full setup with routing and DNS:
```bash
softether-go \
-host vpn.example.com \
-port 992 \
-hub DEFAULT \
-user admin \
-pass secret \
-plain-password \
-tap vpn0 \
-insecure \
-accept-default-gateway \
-accept-static-routes \
-accept-dns
```
Named TAP interface with custom reconnect delay:
```bash
softether-go \
-host vpn.example.com \
-user admin \
-pass secret \
-tap myvpn \
-reconnect-delay 10s
```
## Docker
The client works in containers with `NET_ADMIN` capability and the TUN device:
```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
```
The container needs `iproute2` installed (`apk add iproute2` on Alpine) for the `ip` command.
## Signals
- **SIGINT / SIGTERM** — clean shutdown: closes tunnel, flushes TAP addresses, restores DNS, removes server host route
- During reconnect delay, a signal triggers immediate shutdown instead of waiting