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) <noreply@anthropic.com>
This commit is contained in:
Git Sagar 2026-07-11 11:54:12 -03:00
parent 1d919aafc5
commit 1a397c363f
5 changed files with 57 additions and 18 deletions

View file

@ -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 <VPN_IP>` 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)

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -4,6 +4,7 @@ import (
"encoding/binary"
"fmt"
"io"
"log"
"math/rand"
"sync"
"sync/atomic"
@ -34,16 +35,21 @@ type Tunnel struct {
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{}),
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
}
}
}
}()
}
}