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

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