6.5 KiB
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
# 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, -dhcp (true), -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 = nullin flake.nix (deps are vendored)