# 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), `-connmark`, `-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 table N` + `ip route replace default via dev 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 ` policy rule. CONNMARK marks incoming VPN connections and restores the mark on reply packets. ## 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 -connmark ``` 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), `iptables` (only for `-connmark`) - `vendorHash = null` in flake.nix (deps are vendored)