Files
DeepFal 5b355fb759 feat: 通道组并入「添加通道」表单,不再单独新建
- 去掉单独的「新建通道组」入口:添加通道表单的目标可以一次填多个
  (10.0.1.11-13:22、10.0.1.11:8080-8082、逗号分隔),预览每个地址分到的连续端口
- 表单新增「通道组」选项:不分组 / 新建通道组 / 已有组;多个目标自动放进新建的组,
  组名默认与通道名称相同;加入组时名称可不填,按「组名 (主机)」命名
- 闲置关闭、最长时长、限速、访问控制、责任人等设置对每个地址都生效,与单个通道一致
- 编辑通道时可以新建通道组并移入;组卡片上「添加地址」打开同一个表单并选好该组
- 编辑通道组只负责改名、责任人和加入已有通道
- 多个目标的连通性测试并发进行并汇总结果;网页表单补上责任人与限速
- 接口:POST /api/tunnels/batch(dry_run 预览、group_id / new_group),
  PUT /api/tunnels/{id} 支持 new_group;移除 /api/groups/preview 及组接口里的批量建通道
2026-10-13 20:55:57 +08:00

386 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package client
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"reflect"
"strings"
"testing"
"time"
"github.com/DeepFal/PortRelay/core/protocol"
)
func TestExpandTargets(t *testing.T) {
cases := []struct {
hosts, ports string
want []string
}{
{"10.0.1.11-13", "22", []string{"10.0.1.11:22", "10.0.1.12:22", "10.0.1.13:22"}},
{"10.0.1.11, 10.0.1.12", "22, 8080", []string{"10.0.1.11:22", "10.0.1.11:8080", "10.0.1.12:22", "10.0.1.12:8080"}},
{"10.0.1.11-10.0.1.12", "8080-8081", []string{"10.0.1.11:8080", "10.0.1.11:8081", "10.0.1.12:8080", "10.0.1.12:8081"}},
{"db.internal:3306\n10.0.1.5", "22", []string{"db.internal:3306", "10.0.1.5:22"}},
{"10.0.1.1,10.0.1.2、10.0.1.1", "22", []string{"10.0.1.1:22", "10.0.1.2:22"}},
{"fd00::1", "22", []string{"[fd00::1]:22"}},
{"10.0.2.9:5432", "", []string{"10.0.2.9:5432"}},
{"10.0.1.11-13:22", "", []string{"10.0.1.11:22", "10.0.1.12:22", "10.0.1.13:22"}},
{"10.0.1.11:8080-8081, db_1.internal:3306", "", []string{"10.0.1.11:8080", "10.0.1.11:8081", "db_1.internal:3306"}},
}
for _, c := range cases {
got, err := ExpandTargets(c.hosts, c.ports)
if err != nil || !reflect.DeepEqual(got, c.want) {
t.Errorf("ExpandTargets(%q, %q) = %v, %v; want %v", c.hosts, c.ports, got, err, c.want)
}
}
for _, bad := range [][2]string{
{"", "22"},
{"10.0.1.11", ""},
{"bad host!", "22"},
{"10.0.1.1-200", "22"},
{"10.0.1.13-11", "22"},
{"10.0.1.11", "0"},
{"10.0.1.11", "70000"},
{"10.0.1.1-40", "22, 23"},
{"10.0.1.11-13", ""},
{"10.0.1.11-300:22", ""},
{"10.0.1.11:0", ""},
} {
if got, err := ExpandTargets(bad[0], bad[1]); err == nil {
t.Errorf("ExpandTargets(%q, %q) should fail, got %v", bad[0], bad[1], got)
}
}
}
func TestMemberNames(t *testing.T) {
if got := memberNames("订单服务", []string{"10.0.1.11:22", "10.0.1.12:22"}); got[0] != "订单服务 (10.0.1.11)" {
t.Errorf("single port should name by host: %v", got)
}
if got := memberNames("订单服务", []string{"10.0.1.11:22", "10.0.1.11:8080"}); got[1] != "订单服务 (10.0.1.11:8080)" {
t.Errorf("several ports should name by host:port: %v", got)
}
}
func TestAllocatePortsPrefersContiguousBlock(t *testing.T) {
cm := newTestConfig(t, "127.0.0.1:1", testToken)
for _, p := range []int{33660, 33662} {
if _, err := cm.AddTunnel(protocol.TunnelConfig{Name: fmt.Sprint(p), Target: "10.0.0.1:22", ListenPort: p}); err != nil {
t.Fatal(err)
}
}
cm.mu.Lock()
three, err3 := cm.allocatePortsLocked(3)
one, err1 := cm.allocatePortsLocked(1)
cm.mu.Unlock()
if err3 != nil || !reflect.DeepEqual(three, []int{33663, 33664, 33665}) {
t.Fatalf("3 ports: %v %v", three, err3)
}
if err1 != nil || !reflect.DeepEqual(one, []int{33661}) {
t.Fatalf("1 port: %v %v", one, err1)
}
}
type batchResp struct {
Group *TunnelGroup `json:"group"`
Tunnels []protocol.TunnelConfig `json:"tunnels"`
}
// addTunnels submits the add-tunnel form
func (e *env) addTunnels(t *testing.T, body map[string]any) batchResp {
t.Helper()
status, raw := e.call(t, "POST", "/api/tunnels/batch", body)
want := http.StatusCreated
if body["dry_run"] == true {
want = http.StatusOK
}
if status != want {
t.Fatalf("add tunnels %v: %d %s", body, status, raw)
}
var out batchResp
_ = json.Unmarshal(raw, &out)
return out
}
func (e *env) group(t *testing.T, id string) GroupView {
t.Helper()
_, body := e.call(t, "GET", "/api/groups", nil)
var list []GroupView
_ = json.Unmarshal(body, &list)
for _, g := range list {
if g.ID == id {
return g
}
}
t.Fatalf("group %s not found", id)
return GroupView{}
}
func grantViaInvite(t *testing.T, e *env, code, name string) {
t.Helper()
resp, err := http.PostForm(fmt.Sprintf("http://%s/auth", e.addr), url.Values{"code": {code}, "name": {name}})
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("grant: %d", resp.StatusCode)
}
}
func TestGroupEndToEnd(t *testing.T) {
e := startEnv(t, testToken)
waitFor(t, "agent connected", e.agent.IsConnected)
echo1, echo2 := startEcho(t), startEcho(t)
g := e.addTunnels(t, map[string]any{
"name": "订单服务", "owner": "运维组", "target": echo1 + ", " + echo2, "new_group": "订单服务",
"whitelist": []string{}, "idle_close_minutes": 60, "duration_minutes": 480, "max_bandwidth_mb": 5,
})
if g.Group == nil || g.Group.Name != "订单服务" || g.Group.Owner != "运维组" {
t.Fatalf("new group: %+v", g.Group)
}
if len(g.Tunnels) != 2 || g.Tunnels[1].ListenPort != g.Tunnels[0].ListenPort+1 {
t.Fatalf("members should get a contiguous port block: %+v", g.Tunnels)
}
m1, m2 := g.Tunnels[0], g.Tunnels[1]
for _, m := range g.Tunnels {
if m.GroupID != g.Group.ID || m.Owner != "运维组" || len(m.Whitelist) != 0 || !strings.HasPrefix(m.Name, "订单服务 (") {
t.Fatalf("member not set up as a link-only group member: %+v", m)
}
// 表单里的每一项设置都用于每个成员
if *m.IdleCloseMinutes != 60 || m.DurationMinutes != 480 || m.MaxBandwidthMB != 5 || m.ExpiresAt == nil {
t.Fatalf("member lost form settings: %+v", m)
}
}
waitFor(t, "members online", func() bool {
return e.tunnel(t, m1.ID).Metrics.Status == "online" && e.tunnel(t, m2.ID).Metrics.Status == "online"
})
if _, err := echoVia(m1.ListenPort, "x"); err == nil {
t.Fatal("group members are link-only by default")
}
status, body := e.call(t, "POST", "/api/groups/"+g.Group.ID+"/invites", map[string]any{"note": "小王", "lease_duration": "8h"})
if status != http.StatusCreated {
t.Fatalf("group invite: %d %s", status, body)
}
var inv struct {
Invite protocol.Invite `json:"invite"`
ShareText string `json:"share_text"`
}
_ = json.Unmarshal(body, &inv)
for _, want := range []string{"通道组:订单服务(2 个地址)", fmt.Sprintf("127.0.0.1:%d → %s", m1.ListenPort, echo1), fmt.Sprintf("127.0.0.1:%d → %s", m2.ListenPort, echo2), "8 小时"} {
if !strings.Contains(inv.ShareText, want) {
t.Errorf("share text missing %q:\n%s", want, inv.ShareText)
}
}
waitFor(t, "group invite listed", func() bool { return len(e.group(t, g.Group.ID).Metrics.Invites) == 1 })
if status, _ := e.call(t, "GET", "/api/groups/"+g.Group.ID+"/invites/"+inv.Invite.Code+"/share", nil); status != http.StatusOK {
t.Fatalf("share of existing group invite: %d", status)
}
grantViaInvite(t, e, inv.Invite.Code, "小王")
for _, m := range g.Tunnels {
if got, err := echoVia(m.ListenPort, "hi"); err != nil || got != "hi" {
t.Fatalf("member %d not reachable after group grant: %q %v", m.ListenPort, got, err)
}
}
waitFor(t, "group lease started", func() bool {
l := e.group(t, g.Group.ID).Metrics.DynamicLeases
return len(l) == 1 && l[0].Name == "小王" && !l[0].AwaitingConnect
})
if gv := e.group(t, g.Group.ID); !reflect.DeepEqual(gv.TunnelIDs, []string{m1.ID, m2.ID}) || gv.RateHistory == nil {
t.Fatalf("group view: %+v", gv)
}
if status, _ := e.call(t, "POST", "/api/groups/"+g.Group.ID+"/leases/revoke", map[string]string{}); status != http.StatusOK {
t.Fatal("revoke group leases failed")
}
waitFor(t, "group access revoked", func() bool {
_, err1 := echoVia(m1.ListenPort, "x")
_, err2 := echoVia(m2.ListenPort, "x")
return err1 != nil && err2 != nil
})
// 整组关闭 / 开启
e.call(t, "POST", "/api/groups/"+g.Group.ID+"/toggle", map[string]bool{"enabled": false})
if e.tunnel(t, m1.ID).Enabled || e.tunnel(t, m2.ID).Enabled {
t.Fatal("group toggle should close every member")
}
e.call(t, "POST", "/api/groups/"+g.Group.ID+"/toggle", map[string]bool{"enabled": true})
if !e.tunnel(t, m1.ID).Enabled || !e.tunnel(t, m2.ID).Enabled {
t.Fatal("group toggle should open every member")
}
// 解散:成员保留为独立通道
if status, _ := e.call(t, "DELETE", "/api/groups/"+g.Group.ID, nil); status != http.StatusOK {
t.Fatal("dissolve failed")
}
if e.tunnel(t, m1.ID).GroupID != "" || len(e.cm.Get().Groups) != 0 {
t.Fatal("dissolved group should leave standalone tunnels")
}
}
func TestGroupFromExistingTunnelsRenameAndMoveOut(t *testing.T) {
e := startEnv(t, testToken)
waitFor(t, "agent connected", e.agent.IsConnected)
var ids []string
var ports []int
for i := 0; i < 2; i++ {
port := freeRelayPort(t, ports...)
_, body := e.call(t, "POST", "/api/tunnels", map[string]any{"name": fmt.Sprint("t", i), "target": "127.0.0.1:1", "listen_port": port})
var tc protocol.TunnelConfig
_ = json.Unmarshal(body, &tc)
ids, ports = append(ids, tc.ID), append(ports, port)
}
status, body := e.call(t, "POST", "/api/groups", map[string]any{"name": "支付服务", "tunnel_ids": ids})
if status != http.StatusCreated {
t.Fatalf("group from existing tunnels: %d %s", status, body)
}
var g batchResp
_ = json.Unmarshal(body, &g)
if e.tunnel(t, ids[0]).GroupID != g.Group.ID || e.tunnel(t, ids[1]).GroupID != g.Group.ID {
t.Fatal("existing tunnels should join the group")
}
_, body = e.call(t, "POST", "/api/groups/"+g.Group.ID+"/invites", map[string]any{})
var inv struct {
Invite protocol.Invite `json:"invite"`
}
_ = json.Unmarshal(body, &inv)
page := func() string {
resp, err := http.Get(fmt.Sprintf("http://%s/i/%s", e.addr, inv.Invite.Code))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
if p := page(); !strings.Contains(p, "支付服务(2 个地址)") {
t.Fatalf("invite page should list the group: %s", p)
}
// 改名会同步到服务端
if status, body := e.call(t, "PUT", "/api/groups/"+g.Group.ID, map[string]any{"name": "支付服务后端"}); status != http.StatusOK {
t.Fatalf("rename: %d %s", status, body)
}
waitFor(t, "new group name on the invite page", func() bool { return strings.Contains(page(), "支付服务后端(2 个地址)") })
// 移出组;不能加入不存在的组
if status, _ := e.call(t, "PUT", "/api/tunnels/"+ids[0], map[string]any{"group_id": ""}); status != http.StatusOK {
t.Fatal("move out failed")
}
waitFor(t, "invite page lists the remaining member", func() bool { return strings.Contains(page(), "(1 个地址)") })
if status, _ := e.call(t, "PUT", "/api/tunnels/"+ids[0], map[string]any{"group_id": "nope"}); status != http.StatusBadRequest {
t.Fatal("joining an unknown group must fail")
}
// 一个通道只能属于一个组;组名不能重复
if status, _ := e.call(t, "POST", "/api/groups", map[string]any{"name": "其它", "tunnel_ids": []string{ids[1]}}); status != http.StatusBadRequest {
t.Fatal("a tunnel cannot be in two groups")
}
if status, _ := e.call(t, "POST", "/api/groups", map[string]any{"name": "支付服务后端", "tunnel_ids": []string{ids[0]}}); status != http.StatusBadRequest {
t.Fatal("duplicate group names must be rejected")
}
// 解散并删除成员
if status, _ := e.call(t, "DELETE", "/api/groups/"+g.Group.ID+"?delete_members=1", nil); status != http.StatusOK {
t.Fatal("delete group with members failed")
}
if _, ok := e.cm.FindTunnel(ids[1]); ok {
t.Fatal("members should be deleted with the group")
}
if _, ok := e.cm.FindTunnel(ids[0]); !ok {
t.Fatal("the tunnel moved out earlier must remain")
}
}
func TestAddTunnelFormPreviewAndRules(t *testing.T) {
e := startEnv(t, testToken)
// 预览:不保存,展开目标并预分配连续端口
pv := e.addTunnels(t, map[string]any{"target": "10.0.1.11-12:22", "dry_run": true})
if len(pv.Tunnels) != 2 || pv.Tunnels[0].Target != "10.0.1.11:22" || pv.Tunnels[1].ListenPort != pv.Tunnels[0].ListenPort+1 {
t.Fatalf("preview: %+v", pv.Tunnels)
}
if len(e.cm.Get().Tunnels) != 0 {
t.Fatal("preview must not create tunnels")
}
// 多个目标必须放进通道组
if status, _ := e.call(t, "POST", "/api/tunnels/batch", map[string]any{"name": "x", "target": "10.0.1.11-12:22"}); status != http.StatusBadRequest {
t.Fatal("several targets without a group must be rejected")
}
// 单个目标:名称和端口按填写的来,不分组
port := freeRelayPort(t)
one := e.addTunnels(t, map[string]any{"name": "MySQL", "target": "10.0.2.9:3306", "listen_port": port})
if one.Group != nil || len(one.Tunnels) != 1 || one.Tunnels[0].Name != "MySQL" || one.Tunnels[0].ListenPort != port {
t.Fatalf("single tunnel: %+v", one)
}
// 加入已有组且不填名称:按组名命名
grp := e.addTunnels(t, map[string]any{"name": "订单服务", "target": "10.0.1.11:22", "new_group": "订单服务"})
if grp.Tunnels[0].Name != "订单服务" {
t.Fatalf("a named single tunnel keeps its name: %+v", grp.Tunnels[0])
}
more := e.addTunnels(t, map[string]any{"target": "10.0.1.12-13:22", "group_id": grp.Group.ID})
if more.Group.ID != grp.Group.ID || more.Tunnels[0].Name != "订单服务 (10.0.1.12)" || more.Tunnels[0].GroupID != grp.Group.ID {
t.Fatalf("members added to an existing group: %+v", more)
}
if status, _ := e.call(t, "POST", "/api/tunnels/batch", map[string]any{"name": "y", "target": "10.0.3.1:22", "new_group": "订单服务"}); status != http.StatusBadRequest {
t.Fatal("duplicate group names must be rejected")
}
// 编辑通道时新建通道组
status, body := e.call(t, "PUT", "/api/tunnels/"+one.Tunnels[0].ID, map[string]any{"new_group": "数据库"})
var moved protocol.TunnelConfig
_ = json.Unmarshal(body, &moved)
groups := e.cm.Get().Groups
if status != http.StatusOK || len(groups) != 2 || groups[1].Name != "数据库" || moved.GroupID != groups[1].ID {
t.Fatalf("edit into a new group: %d %s %+v", status, body, groups)
}
}
func TestGroupIdleCloseIsLinked(t *testing.T) {
e := startEnvWith(t, testToken, func(a *Agent) { a.idleUnit = 3 * time.Second })
waitFor(t, "agent connected", e.agent.IsConnected)
g := e.addTunnels(t, map[string]any{"name": "g", "target": "127.0.0.1:1, 127.0.0.1:2", "new_group": "g", "idle_close_minutes": 1})
a, b := g.Tunnels[0], g.Tunnels[1]
waitFor(t, "members online", func() bool {
return e.tunnel(t, a.ID).Metrics.Status == "online" && e.tunnel(t, b.ID).Metrics.Status == "online"
})
// 只给成员 a 发邀请:b 也算在用,不会单独闲置关闭
_, ib := e.call(t, "POST", "/api/tunnels/"+a.ID+"/invites", map[string]any{})
var ir struct {
Invite protocol.Invite `json:"invite"`
}
_ = json.Unmarshal(ib, &ir)
time.Sleep(6 * time.Second)
if !e.tunnel(t, b.ID).Enabled {
t.Fatal("a member must stay open while another member is in use")
}
e.call(t, "DELETE", "/api/tunnels/"+a.ID+"/invites/"+ir.Invite.Code, nil)
waitFor(t, "both members closed together", func() bool { return !e.tunnel(t, a.ID).Enabled && !e.tunnel(t, b.ID).Enabled })
// 组邀请同样让整组保持开启
e.call(t, "POST", "/api/groups/"+g.Group.ID+"/toggle", map[string]bool{"enabled": true})
waitFor(t, "members online again", func() bool {
return e.tunnel(t, a.ID).Metrics.Status == "online" && e.tunnel(t, b.ID).Metrics.Status == "online"
})
_, gb := e.call(t, "POST", "/api/groups/"+g.Group.ID+"/invites", map[string]any{})
var gr struct {
Invite protocol.Invite `json:"invite"`
}
_ = json.Unmarshal(gb, &gr)
time.Sleep(6 * time.Second)
if !e.tunnel(t, a.ID).Enabled || !e.tunnel(t, b.ID).Enabled {
t.Fatal("an unused group invite keeps the whole group open")
}
e.call(t, "DELETE", "/api/groups/"+g.Group.ID+"/invites/"+gr.Invite.Code, nil)
waitFor(t, "group closed after its invite was revoked", func() bool { return !e.tunnel(t, a.ID).Enabled && !e.tunnel(t, b.ID).Enabled })
}