From 1a397c363f26b516bf32bd9cfed3e42af2a39b4b Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Sat, 11 Jul 2026 11:54:12 -0300 Subject: [PATCH 1/2] tunnel: add -keepalive-timeout-secs for dead connection detection Tracks last server receive time (atomic) and runs a watchdog goroutine that closes the connection if no data arrives within the timeout, triggering reconnection. Also fixes Close() to guard Conn.Close() inside sync.Once so concurrent/repeated calls are safe. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 5 +++- README.md | 5 +++- cmd/softether-go/main.go | 3 +- cmd/softether-go/session.go | 4 +-- pkg/client/tunnel.go | 58 ++++++++++++++++++++++++++++--------- 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c7ba978..d9ce20b 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` (5s) +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), `-keepalive-timeout-secs` (0=disabled) ## SoftEther protocol pitfalls @@ -99,6 +99,9 @@ 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 291dd35..b80f0b3 100644 --- a/README.md +++ b/README.md @@ -82,6 +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` | `5s` | Delay between reconnection attempts | ### Authentication @@ -112,6 +113,8 @@ 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: @@ -238,7 +241,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`. Silently consumed, never forwarded to TAP. +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. ## Project structure diff --git a/cmd/softether-go/main.go b/cmd/softether-go/main.go index 82e667c..1122a06 100644 --- a/cmd/softether-go/main.go +++ b/cmd/softether-go/main.go @@ -37,6 +37,7 @@ func main() { 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() @@ -99,7 +100,7 @@ func main() { } for { - err := runSession(cfg, dev, mac, netOpts, sig) + err := runSession(cfg, dev, mac, netOpts, time.Duration(*keepaliveTimeout)*time.Second, sig) if err == errShutdown { log.Println("shutting down") return diff --git a/cmd/softether-go/session.go b/cmd/softether-go/session.go index bb7a285..ee353db 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, sig chan os.Signal) error { +func runSession(cfg client.Config, dev *tap.Device, mac net.HardwareAddr, opts netcfg.Options, keepaliveTimeout time.Duration, 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) + tunnel := client.NewTunnel(sess, keepaliveTimeout) tunnel.StartKeepalive() defer tunnel.Close() diff --git a/pkg/client/tunnel.go b/pkg/client/tunnel.go index 9d9d2a2..9abe334 100644 --- a/pkg/client/tunnel.go +++ b/pkg/client/tunnel.go @@ -4,6 +4,7 @@ import ( "encoding/binary" "fmt" "io" + "log" "math/rand" "sync" "sync/atomic" @@ -29,21 +30,26 @@ 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 + 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 } // NewTunnel creates a tunnel from an established session. -// Call StartKeepalive() before reading/writing frames. -func NewTunnel(sess *Session) *Tunnel { +// 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 { t := &Tunnel{ - sess: sess, - writeCh: make(chan []byte, writeChanSize), - stopCh: make(chan struct{}), + sess: sess, + writeCh: make(chan []byte, writeChanSize), + stopCh: make(chan struct{}), + keepaliveTimeout: keepaliveTimeout, } + t.lastRecv.Store(time.Now().UnixNano()) go t.writeLoop() return t } @@ -65,10 +71,14 @@ func (t *Tunnel) writeLoop() { } // Close stops the keepalive goroutine and closes the underlying connection. -// The writeCh is not closed here — writeLoop exits when the connection write fails. +// Safe for concurrent/repeated calls — only the first call actually closes. func (t *Tunnel) Close() error { - t.stopped.Do(func() { close(t.stopCh) }) - return t.sess.Conn.Close() + var err error + t.stopped.Do(func() { + close(t.stopCh) + err = t.sess.Conn.Close() + }) + return err } // ReadFrames reads a batch of Ethernet frames from the server. @@ -78,6 +88,7 @@ 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 { @@ -225,4 +236,25 @@ 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 + } + } + } + }() + } } From 9e050b140b9fba25d26491e6e4a8d9f6e01ab202 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Sat, 11 Jul 2026 11:56:37 -0300 Subject: [PATCH 2/2] cli: rename -reconnect-delay to -reconnect-delay-secs All time arguments now use -secs suffix with integer seconds for consistency (-reconnect-delay-secs, -keepalive-timeout-secs). Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- README.md | 2 +- cmd/softether-go/main.go | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d9ce20b..02c0d5d 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` (5s), `-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-secs` (5), `-keepalive-timeout-secs` (0=disabled) ## SoftEther protocol pitfalls diff --git a/README.md b/README.md index b80f0b3..b60d55b 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ softether-go [flags] | `-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` | `5s` | Delay between reconnection attempts | +| `-reconnect-delay-secs` | `5` | Delay between reconnection attempts in seconds | ### Authentication diff --git a/cmd/softether-go/main.go b/cmd/softether-go/main.go index 1122a06..c4c25ce 100644 --- a/cmd/softether-go/main.go +++ b/cmd/softether-go/main.go @@ -31,7 +31,7 @@ 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.Duration("reconnect-delay", 5*time.Second, "Delay between reconnection attempts") + reconnectDelay := flag.Int("reconnect-delay-secs", 5, "Delay between reconnection attempts in seconds") 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") @@ -106,13 +106,14 @@ func main() { return } log.Printf("session ended: %v", err) - log.Printf("reconnecting in %v...", *reconnectDelay) + delay := time.Duration(*reconnectDelay) * time.Second + log.Printf("reconnecting in %v...", delay) select { case <-sig: log.Println("shutting down") return - case <-time.After(*reconnectDelay): + case <-time.After(delay): } } }