Files
the-other-dude/poller/internal/tunnel/tunnel_test.go
Jason Staack 8105b995ff feat(poller): add TCP tunnel with bidirectional proxy and activity tracking
Implements Tunnel type that listens on a local port, accepts WinBox client
connections, dials the remote RouterOS device, and proxies traffic
bidirectionally. Uses activityReader to atomically update LastActive on
each read for idle timeout detection. Per-connection contexts derive from
the tunnel context so Close() terminates all connections cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:26:47 -05:00

162 lines
3.4 KiB
Go

package tunnel
import (
"context"
"io"
"net"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockRouter simulates a RouterOS device accepting TCP connections
func mockRouter(t *testing.T) (string, func()) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
io.Copy(c, c) // echo server
}(conn)
}
}()
return ln.Addr().String(), func() { ln.Close() }
}
func TestTunnel_ProxyBidirectional(t *testing.T) {
routerAddr, cleanup := mockRouter(t)
defer cleanup()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tun := &Tunnel{
ID: "test-1",
RemoteAddr: routerAddr,
LastActive: time.Now().UnixNano(),
cancel: cancel,
ctx: ctx,
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
tun.listener = ln
go tun.accept()
// Connect as a WinBox client
conn, err := net.Dial("tcp", ln.Addr().String())
require.NoError(t, err)
defer conn.Close()
// Write and read back (echo)
msg := []byte("hello winbox")
_, err = conn.Write(msg)
require.NoError(t, err)
buf := make([]byte, len(msg))
_, err = io.ReadFull(conn, buf)
require.NoError(t, err)
assert.Equal(t, msg, buf)
}
func TestTunnel_ActivityTracking(t *testing.T) {
routerAddr, cleanup := mockRouter(t)
defer cleanup()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
before := time.Now().UnixNano()
tun := &Tunnel{
ID: "test-2",
RemoteAddr: routerAddr,
LastActive: before,
cancel: cancel,
ctx: ctx,
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
tun.listener = ln
go tun.accept()
conn, err := net.Dial("tcp", ln.Addr().String())
require.NoError(t, err)
conn.Write([]byte("data"))
buf := make([]byte, 4)
io.ReadFull(conn, buf)
conn.Close()
time.Sleep(50 * time.Millisecond)
after := atomic.LoadInt64(&tun.LastActive)
assert.Greater(t, after, before)
}
func TestTunnel_Close(t *testing.T) {
routerAddr, cleanup := mockRouter(t)
defer cleanup()
ctx, cancel := context.WithCancel(context.Background())
tun := &Tunnel{
ID: "test-3",
RemoteAddr: routerAddr,
LastActive: time.Now().UnixNano(),
cancel: cancel,
ctx: ctx,
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
tun.listener = ln
go tun.accept()
// Open a connection
conn, err := net.Dial("tcp", ln.Addr().String())
require.NoError(t, err)
// Close tunnel — should terminate everything
tun.Close()
// Connection should be dead
conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
_, err = conn.Read(make([]byte, 1))
assert.Error(t, err)
}
func TestTunnel_DialFailure(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tun := &Tunnel{
ID: "test-4",
RemoteAddr: "127.0.0.1:1", // nothing listening
LastActive: time.Now().UnixNano(),
cancel: cancel,
ctx: ctx,
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
tun.listener = ln
go tun.accept()
conn, err := net.Dial("tcp", ln.Addr().String())
require.NoError(t, err)
// Should be closed quickly since dial to router fails
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err = conn.Read(make([]byte, 1))
assert.Error(t, err)
}