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

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