diff --git a/CLAUDE.md b/CLAUDE.md index 02c0d5d..c7ba978 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ pkg/tap/ ## 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-secs` (5), `-keepalive-timeout-secs` (0=disabled) +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 @@ -99,9 +99,6 @@ Before connecting, resolves server hostname and adds `/32` route via current def ### 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. -### Keepalive timeout watchdog -`-keepalive-timeout-secs N` enables a receive-side watchdog. The server sends keepalives every ~3s. The watchdog checks at `N/3` intervals whether any data (frames or keepalives) was received within the last N seconds. If not, it closes the connection, triggering the reconnect loop. Without this, a half-open TCP connection can go undetected for minutes (depends on kernel `tcp_retries2`). `Close()` is guarded by `sync.Once` so the watchdog, Bridge error path, and `defer tunnel.Close()` can all call it safely. - ## Performance - **RAM**: 4.6 MB RSS idle, flat under 97 Mbit/s load (vs SoftEther C client: ~23 MB across 4 processes) diff --git a/README.md b/README.md index b60d55b..291dd35 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,7 @@ softether-go [flags] | `-accept-dns` | `false` | Set `/etc/resolv.conf` from DHCP-provided DNS servers | | `-policy-route-table` | `0` | Policy routing table number (0 = disabled) | | `-connmark` | `false` | Use CONNMARK to route DNAT reply traffic back through VPN | -| `-keepalive-timeout-secs` | `0` | Close connection if no server data within N seconds (0 = disabled) | -| `-reconnect-delay-secs` | `5` | Delay between reconnection attempts in seconds | +| `-reconnect-delay` | `5s` | Delay between reconnection attempts | ### Authentication @@ -113,8 +112,6 @@ softether-go -host vpn.example.com -user admin -pass secret -plain-password **`-connmark`** — requires `-policy-route-table`. Uses iptables CONNMARK to route DNAT reply traffic back through the VPN tunnel. Without this, traffic forwarded to local namespaces/VMs (via DNAT) gets replies routed via the default gateway instead of the tunnel, breaking the connection. Adds `CONNMARK --set-mark` on incoming VPN packets and `CONNMARK --restore-mark` on reply packets from other interfaces. -**`-keepalive-timeout-secs N`** — enables a receive-side keepalive watchdog. The server sends keepalives every ~3 seconds. If no data (frames or keepalives) is received within N seconds, the connection is closed and the reconnect loop kicks in. Detects half-open TCP connections that would otherwise stall for minutes. Recommended value: `15`. - ### Examples Minimal: @@ -241,7 +238,7 @@ SoftEther uses **SHA-0** (not SHA-1) — no left-rotate in message schedule. `Ha ### Keepalive -Sent every 3 seconds: `uint32(0xFFFFFFFF) + uint32(randSize) + randData`. Incoming keepalives update a receive timestamp. With `-keepalive-timeout-secs N`, a watchdog checks this timestamp at `N/3` intervals and closes the connection if no data arrived within N seconds, triggering reconnection. +Sent every 3 seconds: `uint32(0xFFFFFFFF) + uint32(randSize) + randData`. Silently consumed, never forwarded to TAP. ## Project structure diff --git a/cmd/softether-go/main.go b/cmd/softether-go/main.go index c4c25ce..82e667c 100644 --- a/cmd/softether-go/main.go +++ b/cmd/softether-go/main.go @@ -31,13 +31,12 @@ func main() { macAddr := flag.String("mac", "", "TAP interface MAC address (e.g. 5E:3B:6F:63:A8:3E)") insecure := flag.Bool("insecure", false, "Skip TLS certificate verification") noDHCP := flag.Bool("no-dhcp", false, "Disable built-in DHCP client") - reconnectDelay := flag.Int("reconnect-delay-secs", 5, "Delay between reconnection attempts in seconds") + reconnectDelay := flag.Duration("reconnect-delay", 5*time.Second, "Delay between reconnection attempts") acceptDefaultGW := flag.Bool("accept-default-gateway", false, "Install DHCP-provided gateway as default route") acceptStaticRoutes := flag.Bool("accept-static-routes", false, "Install DHCP classless static routes (option 121/249)") acceptDNS := flag.Bool("accept-dns", false, "Set /etc/resolv.conf from DHCP-provided DNS servers") policyRouteTable := flag.Int("policy-route-table", 0, "Policy routing table: route replies from VPN IP back through VPN gateway") connMark := flag.Bool("connmark", false, "Use CONNMARK to route DNAT reply traffic back through VPN (for port forwards to namespaces/VMs)") - keepaliveTimeout := flag.Int("keepalive-timeout-secs", 0, "Close connection if no data from server within N seconds (0 = disabled)") flag.Parse() @@ -100,20 +99,19 @@ func main() { } for { - err := runSession(cfg, dev, mac, netOpts, time.Duration(*keepaliveTimeout)*time.Second, sig) + err := runSession(cfg, dev, mac, netOpts, sig) if err == errShutdown { log.Println("shutting down") return } log.Printf("session ended: %v", err) - delay := time.Duration(*reconnectDelay) * time.Second - log.Printf("reconnecting in %v...", delay) + log.Printf("reconnecting in %v...", *reconnectDelay) select { case <-sig: log.Println("shutting down") return - case <-time.After(delay): + case <-time.After(*reconnectDelay): } } } diff --git a/cmd/softether-go/session.go b/cmd/softether-go/session.go index ee353db..bb7a285 100644 --- a/cmd/softether-go/session.go +++ b/cmd/softether-go/session.go @@ -15,7 +15,7 @@ import ( var errShutdown = fmt.Errorf("shutdown requested") -func runSession(cfg client.Config, dev *tap.Device, mac net.HardwareAddr, opts netcfg.Options, keepaliveTimeout time.Duration, sig chan os.Signal) error { +func runSession(cfg client.Config, dev *tap.Device, mac net.HardwareAddr, opts netcfg.Options, sig chan os.Signal) error { log.Printf("connecting to %s:%d hub=%s user=%s", cfg.Host, cfg.Port, cfg.Hub, cfg.Username) sess, err := client.Connect(cfg) @@ -24,7 +24,7 @@ func runSession(cfg client.Config, dev *tap.Device, mac net.HardwareAddr, opts n } log.Printf("connected: session=%s connection=%s", sess.SessionName, sess.ConnectionName) - tunnel := client.NewTunnel(sess, keepaliveTimeout) + tunnel := client.NewTunnel(sess) tunnel.StartKeepalive() defer tunnel.Close() diff --git a/pkg/client/tunnel.go b/pkg/client/tunnel.go index 9abe334..9d9d2a2 100644 --- a/pkg/client/tunnel.go +++ b/pkg/client/tunnel.go @@ -4,7 +4,6 @@ import ( "encoding/binary" "fmt" "io" - "log" "math/rand" "sync" "sync/atomic" @@ -30,26 +29,21 @@ const ( // Tunnel handles bidirectional TCP block framing for Ethernet frames over a // SoftEther VPN session. Each "block" is one Ethernet frame. type Tunnel struct { - sess *Session - writeCh chan []byte // serialized messages queued for the single writer - writeErr atomic.Value // stores error from writeLoop - stopCh chan struct{} - stopped sync.Once - keepaliveTimeout time.Duration // 0 = disabled - lastRecv atomic.Int64 // unix nano of last data received from server + sess *Session + writeCh chan []byte // serialized messages queued for the single writer + writeErr atomic.Value // stores error from writeLoop + stopCh chan struct{} + stopped sync.Once } // NewTunnel creates a tunnel from an established session. -// keepaliveTimeout controls how long to wait for server data before closing the -// connection (0 = disabled). Call StartKeepalive() before reading/writing frames. -func NewTunnel(sess *Session, keepaliveTimeout time.Duration) *Tunnel { +// Call StartKeepalive() before reading/writing frames. +func NewTunnel(sess *Session) *Tunnel { t := &Tunnel{ - sess: sess, - writeCh: make(chan []byte, writeChanSize), - stopCh: make(chan struct{}), - keepaliveTimeout: keepaliveTimeout, + sess: sess, + writeCh: make(chan []byte, writeChanSize), + stopCh: make(chan struct{}), } - t.lastRecv.Store(time.Now().UnixNano()) go t.writeLoop() return t } @@ -71,14 +65,10 @@ func (t *Tunnel) writeLoop() { } // Close stops the keepalive goroutine and closes the underlying connection. -// Safe for concurrent/repeated calls — only the first call actually closes. +// The writeCh is not closed here — writeLoop exits when the connection write fails. func (t *Tunnel) Close() error { - var err error - t.stopped.Do(func() { - close(t.stopCh) - err = t.sess.Conn.Close() - }) - return err + t.stopped.Do(func() { close(t.stopCh) }) + return t.sess.Conn.Close() } // ReadFrames reads a batch of Ethernet frames from the server. @@ -88,7 +78,6 @@ func (t *Tunnel) ReadFrames() ([][]byte, error) { if err := binary.Read(t.sess.Conn, binary.BigEndian, &numBlocks); err != nil { return nil, fmt.Errorf("read num blocks: %w", err) } - t.lastRecv.Store(time.Now().UnixNano()) // Keepalive: server sends 0xFFFFFFFF + uint32(size) + random data if numBlocks == keepAliveMagic { @@ -236,25 +225,4 @@ func (t *Tunnel) StartKeepalive() { } } }() - - // Watchdog: close connection if no data received within timeout - if t.keepaliveTimeout > 0 { - go func() { - ticker := time.NewTicker(t.keepaliveTimeout / 3) - defer ticker.Stop() - for { - select { - case <-t.stopCh: - return - case <-ticker.C: - last := time.Unix(0, t.lastRecv.Load()) - if time.Since(last) > t.keepaliveTimeout { - log.Printf("keepalive timeout: no data from server for %v, closing connection", t.keepaliveTimeout) - t.Close() - return - } - } - } - }() - } }