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) } } 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() tun.AddDynamicWhitelist("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, "