# 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)
