package server
import (
"bufio"
"encoding/json"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/hashicorp/yamux"
"github.com/DeepFal/PortRelay/core/protocol"
)
const testToken = "secret"
type peekedConn struct {
net.Conn
r io.Reader
}
func (c *peekedConn) Read(b []byte) (int, error) { return c.r.Read(b) }
// startServer 复刻 cmd/portrelay-server 的分流逻辑
func startServer(t *testing.T, m *Manager) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
br := bufio.NewReader(c)
p, err := br.Peek(3)
if err != nil {
c.Close()
return
}
_ = c.SetReadDeadline(time.Time{})
if s := string(p); s == "GET" || s == "POS" || s == "HEA" {
m.HandleHTTP(&peekedConn{c, br})
return
}
_ = m.HandleSession(&peekedConn{c, br})
}(c)
}
}()
return ln.Addr().String()
}
type testAgent struct {
sess *yamux.Session
w *bufio.Writer
msgs chan []byte
}
// dialAgent 连接服务端、发送鉴权,并对每条数据流回复 "OK"
func dialAgent(t *testing.T, addr, token string) *testAgent {
t.Helper()
c, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
cfg := yamux.DefaultConfig()
cfg.LogOutput = io.Discard
s, err := yamux.Client(c, cfg)
if err != nil {
t.Fatal(err)
}
cs, err := s.OpenStream()
if err != nil {
t.Fatal(err)
}
a := &testAgent{sess: s, w: bufio.NewWriter(cs), msgs: make(chan []byte, 64)}
t.Cleanup(func() { s.Close() })
go func() {
r := bufio.NewReader(cs)
for {
line, err := r.ReadBytes('\n')
if err != nil {
close(a.msgs)
return
}
var base protocol.BaseMessage
if json.Unmarshal(line, &base) == nil && base.Type != protocol.TypeMetrics {
a.msgs <- line
}
}
}()
go func() {
for {
st, err := s.AcceptStream()
if err != nil {
return
}
go func() {
// 回复 OK 后保持连接,直到对端关闭(便于验证服务端主动断开)
r := bufio.NewReader(st)
_, _ = r.ReadBytes('\n')
_, _ = st.Write([]byte("OK"))
_, _ = io.Copy(io.Discard, r)
st.Close()
}()
}
}()
a.send(t, protocol.AuthMessage{Type: protocol.TypeAuth, Token: token})
return a
}
func (a *testAgent) send(t *testing.T, v any) {
t.Helper()
b, _ := json.Marshal(v)
_, _ = a.w.Write(append(b, '\n'))
if err := a.w.Flush(); err != nil {
t.Fatalf("send: %v", err)
}
}
func (a *testAgent) next(t *testing.T) []byte {
t.Helper()
select {
case m, ok := <-a.msgs:
if !ok {
return nil
}
return m
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for control message")
return nil
}
}
func (a *testAgent) expectAuth(t *testing.T) protocol.AuthRespMessage {
t.Helper()
var resp protocol.AuthRespMessage
if line := a.next(t); line != nil {
_ = json.Unmarshal(line, &resp)
}
return resp
}
func (a *testAgent) register(t *testing.T, msgType string, cfg protocol.TunnelConfig) protocol.RegisterRespMessage {
t.Helper()
if msgType == protocol.TypeUpdateTunnel {
a.send(t, protocol.UpdateTunnelMessage{Type: msgType, Tunnel: cfg})
} else {
a.send(t, protocol.RegisterTunnelMessage{Type: msgType, Tunnel: cfg})
}
var resp protocol.RegisterRespMessage
_ = json.Unmarshal(a.next(t), &resp)
return resp
}
// createInvite asks the server for an invite through the control stream
func (a *testAgent) createInvite(t *testing.T, tunnelID string, maxUses int) protocol.Invite {
t.Helper()
return a.createInviteWith(t, tunnelID, maxUses, "")
}
func (a *testAgent) createInviteWith(t *testing.T, tunnelID string, maxUses int, lease string) protocol.Invite {
t.Helper()
return a.createInviteFull(t, tunnelID, maxUses, lease, false)
}
func (a *testAgent) createInviteFull(t *testing.T, tunnelID string, maxUses int, lease string, approval bool) protocol.Invite {
t.Helper()
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "r1", TunnelID: tunnelID, ValidMinutes: 60, MaxUses: maxUses, LeaseDuration: lease, RequireApproval: approval})
var resp protocol.InviteRespMessage
_ = json.Unmarshal(a.next(t), &resp)
if !resp.Success {
t.Fatalf("create invite failed: %s", resp.Error)
}
return resp.Invite
}
var noRedirect = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
func postForm(t *testing.T, addr, code, name string) (int, string, string) {
t.Helper()
resp, err := noRedirect.PostForm("http://"+addr+"/auth", url.Values{"code": {code}, "name": {name}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, resp.Header.Get("Location"), string(b)
}
// freePort picks a port outside the OS ephemeral range (so outgoing connections cannot
// grab it between the check and the tunnel's bind) and verifies it is bindable on 0.0.0.0.
func freePort(t *testing.T) int {
t.Helper()
start := 20000 + rand.Intn(9000)
for i := 0; i < 1000; i++ {
p := 20000 + (start-20000+i)%9000
if ln, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", p)); err == nil {
ln.Close()
return p
}
}
t.Fatal("no free port")
return 0
}
func newTestManager() *Manager {
return NewManager(1024, 65535, testToken)
}
func tunnelCfg(id string, port int) protocol.TunnelConfig {
return protocol.TunnelConfig{ID: id, Name: id, ListenPort: port, Whitelist: []string{"*"}, Enabled: true, AuthCode: "code-" + id}
}
func hit(port int) string {
c, err := net.DialTimeout("tcp", "127.0.0.1:"+strconv.Itoa(port), time.Second)
if err != nil {
return "dial error"
}
defer c.Close()
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
b := make([]byte, 2)
n, _ := io.ReadFull(c, b)
return string(b[:n])
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("condition not met in time")
}
func TestWrongTokenIsRejected(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, "WRONG")
if resp := a.expectAuth(t); resp.Success {
t.Fatal("auth with wrong token succeeded")
}
port := freePort(t)
// 服务端在鉴权失败后会关闭连接,这里的写入允许失败
b, _ := json.Marshal(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: tunnelCfg("evil", port)})
_, _ = a.w.Write(append(b, '\n'))
_ = a.w.Flush()
time.Sleep(200 * time.Millisecond)
if got := hit(port); got == "OK" {
t.Fatal("unauthenticated agent managed to open a tunnel")
}
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.tunnels) != 0 {
t.Fatalf("expected no tunnels, got %d", len(m.tunnels))
}
}
func TestPortProbeDoesNotBreakActiveSession(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
if !a.expectAuth(t).Success {
t.Fatal("auth failed")
}
// 模拟 nc -z、端口扫描、非 HTTP 垃圾数据
c, _ := net.Dial("tcp", addr)
c.Close()
c, _ = net.Dial("tcp", addr)
_, _ = c.Write([]byte("SSH-2.0-scanner\r\n"))
c.Close()
time.Sleep(200 * time.Millisecond)
port := freePort(t)
if resp := a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("b", port)); !resp.Success {
t.Fatalf("register failed: %s", resp.Error)
}
if got := hit(port); got != "OK" {
t.Fatalf("tunnel registered after port probe is broken: got %q", got)
}
}
func TestStaleSessionTeardownKeepsNewSessionTunnels(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
old := dialAgent(t, addr, testToken)
if !old.expectAuth(t).Success {
t.Fatal("auth failed")
}
port := freePort(t)
if resp := old.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port)); !resp.Success {
t.Fatal(resp.Error)
}
// 唤醒后 Agent 立刻重连并重新注册,旧会话随后才断开
fresh := dialAgent(t, addr, testToken)
if !fresh.expectAuth(t).Success {
t.Fatal("auth failed")
}
if resp := fresh.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port)); !resp.Success {
t.Fatalf("re-register on new session failed: %s", resp.Error)
}
old.sess.Close()
time.Sleep(300 * time.Millisecond)
if got := hit(port); got != "OK" {
t.Fatalf("tunnel lost after stale session teardown: got %q", got)
}
}
// grantTestLease stores a running lease in the access state and applies it to the scope's tunnels
func grantTestLease(m *Manager, scope, ip string, exp time.Time) {
m.state.mu.Lock()
m.state.scopeLocked(scope).Leases[ip] = leaseInfo{ExpiresAt: exp}
m.state.mu.Unlock()
m.syncScope(scope)
}
func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
port := freePort(t)
cfg := tunnelCfg("a", port)
a.register(t, protocol.TypeRegisterTunnel, cfg)
m.mu.RLock()
tun := m.tunnels["a"]
m.mu.RUnlock()
grantTestLease(m, "a", "10.1.2.3", time.Now().Add(time.Hour))
cfg.Name = "renamed"
cfg.Whitelist = []string{"127.0.0.1"}
if resp := a.register(t, protocol.TypeUpdateTunnel, cfg); !resp.Success {
t.Fatal(resp.Error)
}
m.mu.RLock()
same := m.tunnels["a"] == tun
m.mu.RUnlock()
if !same {
t.Fatal("metadata update recreated the tunnel (would drop active connections)")
}
if got := tun.GetDynamicWhitelist(); len(got) != 1 || got[0] != "10.1.2.3" {
t.Fatalf("dynamic lease lost after update: %v", got)
}
if hit(port) != "OK" {
t.Fatal("tunnel unusable after update")
}
}
func TestUpdatePortConflictKeepsOldTunnel(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
p1, p2 := freePort(t), freePort(t)
a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", p1))
a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("b", p2))
moved := tunnelCfg("a", p2)
if resp := a.register(t, protocol.TypeUpdateTunnel, moved); resp.Success {
t.Fatal("moving onto an occupied port should fail")
}
if hit(p1) != "OK" {
t.Fatal("failed port change destroyed the original tunnel")
}
p3 := freePort(t)
if resp := a.register(t, protocol.TypeUpdateTunnel, tunnelCfg("a", p3)); !resp.Success {
t.Fatal(resp.Error)
}
if hit(p3) != "OK" {
t.Fatal("tunnel not reachable on new port")
}
if hit(p1) == "OK" {
t.Fatal("old port still served after move")
}
}
func TestUpdateOfUnknownTunnelRegistersIt(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
port := freePort(t)
if resp := a.register(t, protocol.TypeUpdateTunnel, tunnelCfg("x", port)); !resp.Success {
t.Fatal(resp.Error)
}
if hit(port) != "OK" {
t.Fatal("update of unknown tunnel did not register it")
}
}
func TestDisconnectCleansUpTunnels(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
port := freePort(t)
a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port))
a.sess.Close()
waitFor(t, func() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.active == nil && len(m.tunnels) == 0
})
}
func httpGet(t *testing.T, addr, path string, hdr map[string]string) (int, string) {
t.Helper()
req, _ := http.NewRequest("GET", "http://"+addr+path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
func TestInviteFlowIgnoresForwardedHeadersAndEscapesHTML(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := tunnelCfg("a", freePort(t))
cfg.Name = ``
cfg.Whitelist = nil // 仅授权链接
a.register(t, protocol.TypeRegisterTunnel, cfg)
inv := a.createInvite(t, "a", 1)
if hit(cfg.ListenPort) == "OK" {
t.Fatal("link-only tunnel must reject unauthorised clients")
}
status, body := httpGet(t, addr, "/i/"+inv.Code, map[string]string{"X-Forwarded-For": "8.8.8.8"})
if status != 200 || !strings.Contains(body, `name="name"`) {
t.Fatalf("invite form not shown: %d", status)
}
if strings.Contains(body, "