softether-go/CLAUDE.md
Git Sagar 42f8333783 rename -dhcp flag to -no-dhcp for cleaner UX
DHCP is on by default; use -no-dhcp to disable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 17:53:01 +05:30

137 lines
6.5 KiB
Markdown

# CLAUDE.md
## What this is
Standalone SoftEther VPN client written in Go. Single binary, Linux only, ~2100 LOC. Connects to SoftEther VPN servers using the native protocol over TLS with built-in DHCP, automatic reconnection, and route management. Zero runtime deps beyond `ip` (iproute2).
**Repo**: `/storage/gitrepos/softether-go/`
**Remote**: `ssh://forgejo@git.sagar.ch:2255/sagar/softether-go.git`
## Build & test
```bash
# Build (no Go on this host, use nix-shell)
nix-shell -p go --run "cd /storage/gitrepos/softether-go && CGO_ENABLED=0 go build -o softether-go ./cmd/softether-go/"
# Nix build
nix build
# Docker test (staging server)
docker run --rm --cap-add NET_ADMIN --device /dev/net/tun \
-v /storage/gitrepos/softether-go/softether-go:/usr/bin/softether-go \
alpine:latest sh -c '
apk add --no-cache iproute2 curl > /dev/null 2>&1
softether-go -host 50-117-55-1.ip.fresh.ipb.cloud -port 992 \
-user b83dsu -pass xrXRYmG9 -plain-password -insecure -tap vpn0 \
-accept-default-gateway -accept-dns 2>&1 &
sleep 12 && curl -s https://ifconfig.me && echo ""
kill %1; wait'
```
## Project structure
```
cmd/softether-go/
main.go Flag parsing, TAP setup, reconnect loop
session.go Session lifecycle (connect → bridge → DHCP → configure), runDHCP
pkg/client/
client.go SoftEther handshake: TLS → signature → hello → auth → welcome
tunnel.go TCP block framing, keepalive, Bridge() for bidirectional frame forwarding
crypto.go SHA-0 (not SHA-1) for password hashing
pkg/protocol/
http.go TLS connection, HTTP POST for Pack exchange
pack.go Pack binary serialization (SoftEther's wire format)
pkg/dhcp/
dhcp.go Raw Ethernet/IP/UDP DHCP client through VPN tunnel
pkg/netcfg/
netcfg.go TAP config, routing (default/static/policy), DNS, server host route
pkg/tap/
tap.go Linux TAP device via /dev/net/tun (IFF_TAP | IFF_NO_PI)
```
## CLI flags
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)
## SoftEther protocol pitfalls
### Pack element name collision
SoftEther's `AddElement` rejects case-insensitive duplicate names, and `PackRead` returns NULL on failure — **aborting the entire Pack parse**. The auth pack has `hubname` (lowercase, auth field). Do NOT also add `HubName` (node info field) — they collide. The `HubName` line is commented out in `client.go`.
### Node info LittleEndian32
`OutRpcNodeInfo` wraps int fields (`ClientProductVer`, `ClientProductBuild`, `ClientPort`, `ServerPort2`, `ProxyPort`) with `LittleEndian32()` before `PackAddInt`. Our Pack writes big-endian ints, so we pre-swap these with `swapLE32()`. Without this, server logs show garbled version numbers.
### BufStr wire format
`WriteBufStr`: writes `uint32(strlen+1)` then `strlen` bytes — NO null terminator on wire. The +1 in the size field accounts for a logical null, but it's not transmitted.
### SHA-0 password hashing
SoftEther uses SHA-0 (withdrawn FIPS-180), NOT SHA-1. Only difference: no left-rotate in the message schedule expansion (`W[t] = W[t-3] ^ W[t-8] ^ W[t-14] ^ W[t-16]`, no `rol(1, ...)`).
### DHCPForce policy
Server drops packets from IPs not in its DhcpAllocated table. On disconnect, server clears ALL MAC/IP entries for the session. Fresh DHCP is required on every reconnect — reusing old IP gets silently dropped.
### pencore padding
HTTP-transported Packs must include a `pencore` element with random data. Without it, server may reject the Pack.
## Architecture decisions
### Bridge starts before DHCP
The frame bridge runs concurrently with DHCP. The DHCP client intercepts server frames via `FeedFrame` callback. Non-DHCP frames go to TAP (OS drops them since TAP has no IP yet). This avoids missing DHCP responses.
### TAP goroutine leak on disconnect
The TAP→Server goroutine in `Bridge()` blocks on `tapRead` (TAP fd doesn't support `SetReadDeadline`). On disconnect, it survives until the next TAP frame arrives, then `WriteFrames` fails on the closed connection and it exits. Holds 1.6 KB buffer. Not worth adding `poll(2)` complexity.
### Single TCP connection
`max_connection` is set to 1. SoftEther supports multiple TCP connections for bandwidth aggregation, but this adds complexity for minimal gain on modern connections.
### Server host route
Before connecting, resolves server hostname and adds `/32` route via current default gateway. Prevents routing loop when `-accept-default-gateway` installs VPN as default route.
### 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.
## Performance
- **RAM**: 4.6 MB RSS idle, flat under 97 Mbit/s load (vs SoftEther C client: ~23 MB across 4 processes)
- **Speed**: 97 Mbit/s down, 95 Mbit/s up, 140ms ping (single TCP, Docker, staging server)
- **Binary**: ~7.5 MB static (CGO_ENABLED=0)
## Test servers
| Server | Host | Port | User | Pass | Auth |
|---|---|---|---|---|---|
| Staging (US) | 50-117-55-1.ip.fresh.ipb.cloud | 992 | b83dsu | xrXRYmG9 | plain (RADIUS) |
| Production (India) | 65-20-68-5.ip.fresh.ipb.cloud | 992 | T0Eq5yf97gXy8Q | (empty) | plain (RADIUS) |
The staging user/pass was split from the original username `b83dsuxrXRYmG9` — the SoftEther server patches in softether-5 concatenate username+password when len==14.
## NixOS integration (pending)
junto host currently uses the SoftEther C client (`modules/apps/softether-client.nix`). Plan is to replace with softether-go. The C client module config for junto:
- Instance name: `india-pub-ip`
- MAC: `5E-3B-6F-63-A8-3E` (deterministic from sha256 of instance name)
- Policy route table: 200
- No default gateway (`nogateway`), no DNS override
- dhcpcd with custom script for policy routing
Equivalent softether-go command:
```
softether-go -host 65-20-68-5.ip.fresh.ipb.cloud -port 992 \
-user T0Eq5yf97gXy8Q -plain-password -insecure \
-tap india-pub-ip -mac 5E:3B:6F:63:A8:3E \
-policy-route-table 200
```
To add as flake input: `softether-go.url = "git+https://git.sagar.ch/sagar/softether-go.git";`
## Dependencies
- Go 1.24+, single dep: `golang.org/x/sys` (vendored)
- Runtime: `ip` (iproute2)
- `vendorHash = null` in flake.nix (deps are vendored)