feat: 通道组——一次开放多台主机 / 多个端口,共用邀请与授权

- 批量创建:主机支持 IP、主机名、末段范围(10.0.1.11-13)和 主机:端口,
  与端口列表展开为 主机 × 端口,自动分配连续的访问端口,创建前可预览
- 授权以组为单位:一条邀请链接、一次审批即可访问组内全部地址;
  计时从首次连上任一地址开始,撤销一次全组断开;后加入组的通道自动获得组授权
- 网关授权状态按作用范围(通道 / 通道组)存储,通道的动态白名单统一由状态库重新计算,
  通道授权与组授权互不干扰;组内最后一个通道关闭或组被解散时,组的邀请与授权失效
- 闲置联动:组内任一地址在用时整组都不算闲置;支持整组开关
- 已有通道可并入组或移出组;组可改名、追加目标、解散(保留或删除成员)
- App 与网页面板:组卡片、新建 / 编辑通道组、组邀请与“连接与授权”,授权页列出全部地址
- 测试:网关 9 个、Agent 5 个通道组用例
This commit is contained in:
DeepFal committed 2026-10-13 20:16:43 +08:00
1 parent 86e159b3f0
commit 3797bef88a
22 files changed
+3894 -471

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
package server
import (
"encoding/json"
"fmt"
"io"
"net"
"strings"
"testing"
"time"
"github.com/DeepFal/PortRelay/core/protocol"
)
// groupMember is a link-only tunnel that belongs to group "g"
func groupMember(id string, port int, target string) protocol.TunnelConfig {
cfg := tunnelCfg(id, port)
cfg.Whitelist = nil
cfg.Target = target
cfg.GroupID = "g"
cfg.GroupName = "订单服务"
return cfg
}
func (a *testAgent) createGroupInvite(t *testing.T, groupID string, lease string, approval bool) protocol.Invite {
t.Helper()
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "g1", GroupID: groupID, ValidMinutes: 60, MaxUses: 1, LeaseDuration: lease, RequireApproval: approval})
var resp protocol.InviteRespMessage
_ = json.Unmarshal(a.next(t), &resp)
if !resp.Success {
t.Fatalf("create group invite failed: %s", resp.Error)
}
return resp.Invite
}
// setupGroup registers two members of group "g" plus one ungrouped link-only tunnel
func setupGroup(t *testing.T) (*Manager, *testAgent, string, [3]int) {
t.Helper()
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
ports := [3]int{freePort(t), freePort(t), freePort(t)}
a.register(t, protocol.TypeRegisterTunnel, groupMember("m1", ports[0], "10.0.1.11:22"))
a.register(t, protocol.TypeRegisterTunnel, groupMember("m2", ports[1], "10.0.1.12:22"))
other := tunnelCfg("other", ports[2])
other.Whitelist = nil
a.register(t, protocol.TypeRegisterTunnel, other)
return m, a, addr, ports
}
// holdConn opens a client connection that stays up until the server closes it
func holdConn(t *testing.T, port int) net.Conn {
t.Helper()
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
buf := make([]byte, 2)
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := io.ReadFull(c, buf); err != nil {
t.Fatalf("connection to %d should be allowed: %v", port, err)
}
return c
}
func expectClosed(t *testing.T, c net.Conn) {
t.Helper()
_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
if _, err := c.Read(make([]byte, 1)); err == nil {
t.Fatal("connection should have been closed")
} else if ne, ok := err.(net.Error); ok && ne.Timeout() {
t.Fatal("connection still open after access was revoked")
}
}
func TestGroupInviteGrantsEveryMember(t *testing.T) {
m, a, addr, p := setupGroup(t)
if hit(p[0]) == "OK" {
t.Fatal("link-only member must be closed before any grant")
}
inv := a.createGroupInvite(t, "g", "", false)
status, body := httpGet(t, addr, "/i/"+inv.Code, nil)
if status != 200 {
t.Fatalf("invite page: %d %s", status, body)
}
for _, want := range []string{"目标通道组", "订单服务(2 个地址)", fmt.Sprintf("127.0.0.1:%d", p[0]), fmt.Sprintf("127.0.0.1:%d", p[1]), "10.0.1.11:22", "10.0.1.12:22"} {
if !strings.Contains(body, want) {
t.Errorf("invite page lacks %q", want)
}
}
if status, _, body := postForm(t, addr, inv.Code, "张三"); status != 200 || !strings.Contains(body, "任意一个访问地址") {
t.Fatalf("grant failed: %d %s", status, body)
}
if hit(p[0]) != "OK" || hit(p[1]) != "OK" {
t.Fatal("a group grant must open every member")
}
if hit(p[2]) == "OK" {
t.Fatal("a group grant must not open tunnels outside the group")
}
if l := m.state.leaseList(groupScope("g")); len(l) != 1 || l[0].Name != "张三" {
t.Fatalf("group lease not recorded: %+v", l)
}
if l := m.state.leaseList("m1"); len(l) != 0 {
t.Fatalf("group grant must not create per-tunnel leases: %+v", l)
}
}
func TestGroupLeaseStartsOnFirstConnectionToAnyMember(t *testing.T) {
m, a, addr, p := setupGroup(t)
inv := a.createGroupInvite(t, "g", protocol.LeaseDuration2h, false)
postForm(t, addr, inv.Code, "张三")
if l := m.state.leaseList(groupScope("g")); len(l) != 1 || !l[0].AwaitingConnect {
t.Fatalf("lease should wait for the first connection: %+v", l)
}
if hit(p[1]) != "OK" {
t.Fatal("member 2 should be reachable")
}
waitFor(t, func() bool {
l := m.state.leaseList(groupScope("g"))
return len(l) == 1 && !l[0].AwaitingConnect
})
exp := m.state.leaseList(groupScope("g"))[0].ExpiresAt
if d := time.Until(exp); d < 119*time.Minute || d > 121*time.Minute {
t.Fatalf("countdown should be 2h from first connection, got %v", d)
}
// 其它成员的放通随之更新为同一到期时间
if got := m.tunnel("m1").dynamicLeases()["127.0.0.1"]; !got.Equal(exp) {
t.Fatalf("member 1 expiry %v, want %v", got, exp)
}
}
func TestGroupRevokeDisconnectsAllMembers(t *testing.T) {
_, a, addr, p := setupGroup(t)
inv := a.createGroupInvite(t, "g", "", false)
postForm(t, addr, inv.Code, "张三")
c1, c2 := holdConn(t, p[0]), holdConn(t, p[1])
a.send(t, protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, GroupID: "g"})
expectClosed(t, c1)
expectClosed(t, c2)
if hit(p[0]) == "OK" || hit(p[1]) == "OK" {
t.Fatal("members still open after revoking the group grant")
}
}
func TestGroupApprovalFlow(t *testing.T) {
m, a, addr, p := setupGroup(t)
inv := a.createGroupInvite(t, "g", "", true)
status, loc, _ := postForm(t, addr, inv.Code, "李四")
if status != 303 || !strings.HasPrefix(loc, "/auth/status?id=") {
t.Fatalf("approval invite should redirect to the status page: %d %q", status, loc)
}
pending := m.state.pendingList(groupScope("g"))
if len(pending) != 1 || pending[0].GroupID != "g" || pending[0].TunnelID != "" {
t.Fatalf("pending request should belong to the group: %+v", pending)
}
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "等待管理员批准") || !strings.Contains(body, "10.0.1.12:22") {
t.Fatalf("pending page: %s", body)
}
if hit(p[0]) == "OK" {
t.Fatal("must stay closed until approved")
}
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, GroupID: "g", RequestID: pending[0].ID, Approve: true})
waitFor(t, func() bool { return hit(p[0]) == "OK" && hit(p[1]) == "OK" })
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "申请已批准") {
t.Fatalf("status page after approval: %s", body)
}
}
func TestGroupStateDroppedWhenLastMemberCloses(t *testing.T) {
_, a, addr, _ := setupGroup(t)
inv := a.createGroupInvite(t, "g", "", false)
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "m1"})
a.next(t)
if status, body := httpGet(t, addr, "/i/"+inv.Code, nil); status != 200 || !strings.Contains(body, "(1 个地址)") {
t.Fatalf("invite should still work with one open member: %d", status)
}
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "m2"})
a.next(t)
if status, _ := httpGet(t, addr, "/i/"+inv.Code, nil); status != 404 {
t.Fatalf("invite must be gone once every member is closed, got %d", status)
}
}
func TestGroupMembershipChangesFollowAccess(t *testing.T) {
_, a, addr, p := setupGroup(t)
inv := a.createGroupInvite(t, "g", "", false)
postForm(t, addr, inv.Code, "张三")
c2 := holdConn(t, p[1])
// 移出组:组授权不再适用,现有连接断开
out := groupMember("m2", p[1], "10.0.1.12:22")
out.GroupID, out.GroupName = "", ""
if resp := a.register(t, protocol.TypeUpdateTunnel, out); !resp.Success {
t.Fatal(resp.Error)
}
expectClosed(t, c2)
if hit(p[1]) == "OK" || hit(p[0]) != "OK" {
t.Fatal("only remaining members keep the group grant")
}
// 后加入组的通道自动获得组授权
in := groupMember("other", p[2], "10.0.1.13:22")
if resp := a.register(t, protocol.TypeUpdateTunnel, in); !resp.Success {
t.Fatal(resp.Error)
}
if hit(p[2]) != "OK" {
t.Fatal("a tunnel joining the group should get the group grant")
}
}
func TestTunnelAndGroupLeasesAreIndependent(t *testing.T) {
m, _, _, p := setupGroup(t)
exp := time.Now().Add(time.Hour)
grantTestLease(m, "m1", "127.0.0.1", exp)
grantTestLease(m, groupScope("g"), "127.0.0.1", exp)
m.revokeLease(groupScope("g"), "127.0.0.1")
if hit(p[0]) != "OK" {
t.Fatal("the per-tunnel grant must survive revoking the group grant")
}
if hit(p[1]) == "OK" {
t.Fatal("member without its own grant must close")
}
}
func TestDeleteGroupDropsSharedAccess(t *testing.T) {
m, a, addr, p := setupGroup(t)
inv := a.createGroupInvite(t, "g", "", false)
postForm(t, addr, inv.Code, "张三")
a.send(t, protocol.DeleteGroupMessage{Type: protocol.TypeDeleteGroup, GroupID: "g"})
waitFor(t, func() bool { return hit(p[0]) != "OK" })
if _, _, ok := m.state.findInvite(inv.Code); ok {
t.Fatal("invites of a dissolved group must be dropped")
}
}
func TestGroupInviteNeedsAnOpenMember(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "x", GroupID: "empty", ValidMinutes: 60, MaxUses: 1})
var resp protocol.InviteRespMessage
_ = json.Unmarshal(a.next(t), &resp)
if resp.Success || !strings.Contains(resp.Error, "通道组") {
t.Fatalf("invite for a group without open tunnels should fail: %+v", resp)
}
}