137 lines
6.5 KiB
Markdown
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`, `-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 = null` in flake.nix (deps are vendored)
|