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 及组接口里的批量建通道
This commit is contained in:
1 parent
e02c4c1184
commit
5b355fb759
13 files changed
+1078
-920
No files matched your search
@@ -55,7 +55,7 @@ PortRelay 把这件事变成三步:
|
|||||||
- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
|
- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
|
||||||
- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
|
- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
|
||||||
- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
|
- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
|
||||||
- **通道组**:一次开放多台主机、多个端口(例如 `10.0.1.11-13` × `22, 8080`),自动分配一段连续端口。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。
|
- **通道组**:添加通道时目标可以一次填多个(例如 `10.0.1.11-13:22, 10.0.1.11:8080`),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。
|
||||||
|
|
||||||
**通道生命周期**
|
**通道生命周期**
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
|
|||||||
2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
|
2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
|
||||||
3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
|
3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
|
||||||
|
|
||||||
需要一次开放多台主机时(比如一组服务器),点 **新建通道组**,主机填 `10.0.1.11-13`、端口填 `22`,预览确认端口映射后创建,再点 **邀请整组**。已有的通道也可以在新建组时勾选加入。
|
需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,目标填 `10.0.1.11-13:22`,表单会预览端口映射并自动选上「新建通道组」,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。
|
||||||
|
|
||||||
## 配置参考
|
## 配置参考
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -58,7 +58,7 @@ PortRelay turns that into three steps:
|
|||||||
- When approval is required, the app shows a system notification and you approve or deny with one click. The requester's page refreshes on its own.
|
- When approval is required, the app shows a system notification and you approve or deny with one click. The requester's page refreshes on its own.
|
||||||
- **The access clock starts at the first connection**, so a teammate can accept an invite now and use it later.
|
- **The access clock starts at the first connection**, so a teammate can accept an invite now and use it later.
|
||||||
- When access expires or is revoked, **existing connections are dropped too**. Unauthorized connections are reset (TCP RST) and logged.
|
- When access expires or is revoked, **existing connections are dropped too**. Unauthorized connections are reset (TCP RST) and logged.
|
||||||
- **Tunnel groups** open several hosts and ports at once (for example `10.0.1.11-13` × `22, 8080`) on a contiguous block of gateway ports. A group shares one invite link and one set of grants: a teammate is authorized once for the whole group, and one revoke disconnects them everywhere.
|
- **Tunnel groups**: the add-tunnel form accepts several targets at once (for example `10.0.1.11-13:22, 10.0.1.11:8080`), creates one tunnel per address on a contiguous block of gateway ports and puts them in one group. A group shares one invite link and one set of grants: a teammate is authorized once for the whole group, and one revoke disconnects them everywhere.
|
||||||
|
|
||||||
**Tunnel lifecycle**
|
**Tunnel lifecycle**
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ Open ⚙️ Settings and fill in:
|
|||||||
2. Keep the default access mode, **仅授权链接** (invite-only). Click **邀请** (Invite), then choose how long access lasts, how many devices may use the link, and whether approval is required.
|
2. Keep the default access mode, **仅授权链接** (invite-only). Click **邀请** (Invite), then choose how long access lasts, how many devices may use the link, and whether approval is required.
|
||||||
3. Send the generated message or QR code. Once the teammate has entered their name, they can connect their database client or SSH to `192.168.1.10:33660`.
|
3. Send the generated message or QR code. Once the teammate has entered their name, they can connect their database client or SSH to `192.168.1.10:33660`.
|
||||||
|
|
||||||
To open several hosts at once (a set of servers, say), click **新建通道组** (New tunnel group), enter hosts `10.0.1.11-13` and ports `22`, check the previewed port mapping and create it, then click **邀请整组** (Invite group). Existing tunnels can be ticked to join the group as well.
|
To open several hosts at once (a set of servers, say), use the same **添加通道** (Add tunnel) form and enter `10.0.1.11-13:22` as the target. The form previews the port mapping and selects 新建通道组 (New tunnel group); idle close, bandwidth and access settings apply to every address. Then click **邀请整组** (Invite group). To add addresses later, click **添加地址** (Add address) on the group card; an existing tunnel can join a group from its edit form.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,10 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
tunnelID := parts[2]
|
tunnelID := parts[2]
|
||||||
|
|
||||||
|
if tunnelID == "batch" && len(parts) == 3 && r.Method == http.MethodPost {
|
||||||
|
s.handleTunnelBatch(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost {
|
if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost {
|
||||||
s.handleToggle(w, r, tunnelID)
|
s.handleToggle(w, r, tunnelID)
|
||||||
return
|
return
|
||||||
|
|||||||
+37
-84
@@ -46,42 +46,16 @@ func (s *APIServer) groupsView(metrics protocol.MetricsMessage) []GroupView {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// groupRequest creates a group or adds members to one
|
// groupRequest renames a group or moves existing tunnels into it
|
||||||
type groupRequest struct {
|
type groupRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
// Hosts / Ports 展开为新建成员,见 ExpandTargets
|
|
||||||
Hosts string `json:"hosts"`
|
|
||||||
Ports string `json:"ports"`
|
|
||||||
// TunnelIDs 加入该组的已有通道
|
// TunnelIDs 加入该组的已有通道
|
||||||
TunnelIDs []string `json:"tunnel_ids"`
|
TunnelIDs []string `json:"tunnel_ids"`
|
||||||
// 新建成员的通道设置
|
|
||||||
Whitelist []string `json:"whitelist"`
|
|
||||||
IdleCloseMinutes *int `json:"idle_close_minutes"`
|
|
||||||
DurationMinutes int `json:"duration_minutes"`
|
|
||||||
MaxBandwidthMB float64 `json:"max_bandwidth_mb"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (req groupRequest) spec() (GroupSpec, error) {
|
func (req groupRequest) spec() GroupSpec {
|
||||||
spec := GroupSpec{
|
return GroupSpec{Name: req.Name, Owner: req.Owner, MoveIDs: req.TunnelIDs}
|
||||||
Name: req.Name,
|
|
||||||
Owner: req.Owner,
|
|
||||||
MoveIDs: req.TunnelIDs,
|
|
||||||
Template: protocol.TunnelConfig{
|
|
||||||
Whitelist: req.Whitelist,
|
|
||||||
IdleCloseMinutes: req.IdleCloseMinutes,
|
|
||||||
DurationMinutes: req.DurationMinutes,
|
|
||||||
MaxBandwidthMB: req.MaxBandwidthMB,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.Hosts) != "" {
|
|
||||||
targets, err := ExpandTargets(req.Hosts, req.Ports)
|
|
||||||
if err != nil {
|
|
||||||
return spec, err
|
|
||||||
}
|
|
||||||
spec.Targets = targets
|
|
||||||
}
|
|
||||||
return spec, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeGroupRequest(r *http.Request) (groupRequest, error) {
|
func decodeGroupRequest(r *http.Request) (groupRequest, error) {
|
||||||
@@ -93,11 +67,8 @@ func decodeGroupRequest(r *http.Request) (groupRequest, error) {
|
|||||||
return req, err
|
return req, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncGroupChanges pushes newly created members and changed existing ones to the relay server
|
// syncChanged pushes members whose group changed to the relay server
|
||||||
func (s *APIServer) syncGroupChanges(created, changed []protocol.TunnelConfig) {
|
func (s *APIServer) syncChanged(changed []protocol.TunnelConfig) {
|
||||||
for _, t := range created {
|
|
||||||
s.agent.SyncTunnel(t, false)
|
|
||||||
}
|
|
||||||
for _, t := range changed {
|
for _, t := range changed {
|
||||||
if t.Enabled {
|
if t.Enabled {
|
||||||
s.agent.SyncTunnel(t, true)
|
s.agent.SyncTunnel(t, true)
|
||||||
@@ -105,7 +76,7 @@ func (s *APIServer) syncGroupChanges(created, changed []protocol.TunnelConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGroups: GET 列表;POST 新建通道组(批量新建目标 + / 或加入已有通道)
|
// handleGroups: GET 列表;POST 用已有通道组成通道组(新通道在添加通道表单里创建,见 handleTunnelBatch)
|
||||||
func (s *APIServer) handleGroups(w http.ResponseWriter, r *http.Request) {
|
func (s *APIServer) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
@@ -116,18 +87,13 @@ func (s *APIServer) handleGroups(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
spec, err := req.spec()
|
g, changed, err := s.cm.SaveGroup("", req.spec())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusBadRequest, err.Error())
|
jsonError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g, created, changed, err := s.cm.SaveGroup("", spec)
|
s.syncChanged(changed)
|
||||||
if err != nil {
|
jsonResponse(w, http.StatusCreated, map[string]any{"group": g})
|
||||||
jsonError(w, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.syncGroupChanges(created, changed)
|
|
||||||
jsonResponse(w, http.StatusCreated, map[string]any{"group": g, "created": nonNil(created)})
|
|
||||||
default:
|
default:
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
@@ -135,8 +101,7 @@ func (s *APIServer) handleGroups(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// handleGroupItem:
|
// handleGroupItem:
|
||||||
//
|
//
|
||||||
// POST /api/groups/preview 预览批量展开结果(不保存)
|
// PUT /api/groups/{id} 改名 / 责任人,并可把已有通道加入该组
|
||||||
// PUT /api/groups/{id} 改名 / 责任人,并可追加成员
|
|
||||||
// DELETE /api/groups/{id}?delete_members=1 解散(默认保留成员为独立通道)
|
// DELETE /api/groups/{id}?delete_members=1 解散(默认保留成员为独立通道)
|
||||||
// POST /api/groups/{id}/toggle {"enabled": bool} 整组开关
|
// POST /api/groups/{id}/toggle {"enabled": bool} 整组开关
|
||||||
// ... /api/groups/{id}/leases|invites|requests 与单个通道相同的授权接口
|
// ... /api/groups/{id}/leases|invites|requests 与单个通道相同的授权接口
|
||||||
@@ -146,10 +111,6 @@ func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonError(w, http.StatusBadRequest, "Missing group ID")
|
jsonError(w, http.StatusBadRequest, "Missing group ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if parts[2] == "preview" && len(parts) == 3 && r.Method == http.MethodPost {
|
|
||||||
s.handleGroupPreview(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := parts[2]
|
id := parts[2]
|
||||||
g, ok := s.cm.FindGroup(id)
|
g, ok := s.cm.FindGroup(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -203,18 +164,13 @@ func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
if strings.TrimSpace(req.Name) == "" {
|
if strings.TrimSpace(req.Name) == "" {
|
||||||
req.Name = g.Name
|
req.Name = g.Name
|
||||||
}
|
}
|
||||||
spec, err := req.spec()
|
updated, changed, err := s.cm.SaveGroup(id, req.spec())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusBadRequest, err.Error())
|
jsonError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updated, created, changed, err := s.cm.SaveGroup(id, spec)
|
s.syncChanged(changed)
|
||||||
if err != nil {
|
jsonResponse(w, http.StatusOK, map[string]any{"group": updated})
|
||||||
jsonError(w, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.syncGroupChanges(created, changed)
|
|
||||||
jsonResponse(w, http.StatusOK, map[string]any{"group": updated, "created": nonNil(created)})
|
|
||||||
|
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
deleteMembers := r.URL.Query().Get("delete_members") == "1"
|
deleteMembers := r.URL.Query().Get("delete_members") == "1"
|
||||||
@@ -240,38 +196,35 @@ func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGroupPreview expands hosts × ports and assigns ports without saving
|
// handleTunnelBatch: POST /api/tunnels/batch —— 添加通道表单的提交,可以一次创建单个或多个通道。
|
||||||
func (s *APIServer) handleGroupPreview(w http.ResponseWriter, r *http.Request) {
|
// 请求体是通道设置,另加:
|
||||||
req, err := decodeGroupRequest(r)
|
//
|
||||||
if err != nil {
|
// target 可以写多个目标,如 "10.0.1.11-13:22, 10.0.2.5:3306"(见 ExpandTargets)
|
||||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
// group_id 加入已有通道组;new_group 新建通道组。多个目标必须放进通道组
|
||||||
|
// dry_run 只预览将创建的通道(名称、目标、分配的端口),不保存
|
||||||
|
//
|
||||||
|
// 返回 {"group": 加入的通道组或 null, "tunnels": [...]}
|
||||||
|
func (s *APIServer) handleTunnelBatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
protocol.TunnelConfig
|
||||||
|
NewGroup string `json:"new_group"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "Invalid JSON payload")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
spec, err := req.spec()
|
group, created, err := s.cm.CreateTunnels(NewTunnels{Template: req.TunnelConfig, NewGroup: req.NewGroup}, req.DryRun)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusBadRequest, err.Error())
|
jsonError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
members, err := s.cm.PreviewGroupMembers(spec)
|
status := http.StatusOK
|
||||||
if err != nil {
|
if !req.DryRun {
|
||||||
jsonError(w, http.StatusBadRequest, err.Error())
|
status = http.StatusCreated
|
||||||
return
|
for _, t := range created {
|
||||||
|
s.agent.SyncTunnel(t, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
type item struct {
|
jsonResponse(w, status, map[string]any{"group": group, "tunnels": created})
|
||||||
Name string `json:"name"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
ListenPort int `json:"listen_port"`
|
|
||||||
}
|
|
||||||
out := make([]item, 0, len(members))
|
|
||||||
for _, t := range members {
|
|
||||||
out = append(out, item{t.Name, t.Target, t.ListenPort})
|
|
||||||
}
|
|
||||||
jsonResponse(w, http.StatusOK, map[string]any{"members": out})
|
|
||||||
}
|
|
||||||
|
|
||||||
func nonNil(ts []protocol.TunnelConfig) []protocol.TunnelConfig {
|
|
||||||
if ts == nil {
|
|
||||||
return []protocol.TunnelConfig{}
|
|
||||||
}
|
|
||||||
return ts
|
|
||||||
}
|
}
|
||||||
+25
-21
@@ -15,7 +15,7 @@ var (
|
|||||||
listSeparators = regexp.MustCompile(`[\s,,、;;]+`)
|
listSeparators = regexp.MustCompile(`[\s,,、;;]+`)
|
||||||
shortRange = regexp.MustCompile(`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.)(\d{1,3})-(\d{1,3})$`)
|
shortRange = regexp.MustCompile(`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.)(\d{1,3})-(\d{1,3})$`)
|
||||||
fullRange = regexp.MustCompile(`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})-(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$`)
|
fullRange = regexp.MustCompile(`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})-(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$`)
|
||||||
hostnameRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$`)
|
hostnameRe = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9._-]*[A-Za-z0-9_])?$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func splitList(s string) []string {
|
func splitList(s string) []string {
|
||||||
@@ -83,11 +83,24 @@ func validHost(h string) bool {
|
|||||||
return net.ParseIP(h) != nil || hostnameRe.MatchString(h)
|
return net.ParseIP(h) != nil || hostnameRe.MatchString(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hostsOf expands one host item: an IPv4 range ("10.0.1.11-13") or a single IP / hostname
|
||||||
|
func hostsOf(item string) ([]string, error) {
|
||||||
|
hosts, isRange, err := expandIPv4Range(item)
|
||||||
|
if err != nil || isRange {
|
||||||
|
return hosts, err
|
||||||
|
}
|
||||||
|
if !validHost(item) {
|
||||||
|
return nil, fmt.Errorf("无效的主机 %q", item)
|
||||||
|
}
|
||||||
|
return []string{item}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ExpandTargets turns host and port lists into "host:port" targets, host by host.
|
// ExpandTargets turns host and port lists into "host:port" targets, host by host.
|
||||||
//
|
//
|
||||||
// hosts 用逗号、空格或换行分隔,每一项可以是 IP、主机名、IPv4 范围("10.0.1.11-13" 或
|
// hosts 用逗号、空格或换行分隔,每一项可以是 IP、主机名、IPv4 范围("10.0.1.11-13" 或
|
||||||
// "10.0.1.11-10.0.1.13"),也可以直接写成 "主机:端口";ports 形如 "22, 8080-8082",
|
// "10.0.1.11-10.0.1.13"),也可以直接写成 "主机:端口"(主机和端口都可以是范围,如
|
||||||
// 作用于没有写端口的主机。结果去重并保持输入顺序。
|
// "10.0.1.11-13:22"、"10.0.1.11:8080-8082");ports 形如 "22, 8080-8082",作用于没有写端口
|
||||||
|
// 的主机。结果去重并保持输入顺序。
|
||||||
func ExpandTargets(hosts, ports string) ([]string, error) {
|
func ExpandTargets(hosts, ports string) ([]string, error) {
|
||||||
portList, err := parsePorts(ports)
|
portList, err := parsePorts(ports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,32 +123,23 @@ func ExpandTargets(hosts, ports string) ([]string, error) {
|
|||||||
return nil, fmt.Errorf("请填写至少一个目标主机")
|
return nil, fmt.Errorf("请填写至少一个目标主机")
|
||||||
}
|
}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
// 自带端口的完整目标:"10.0.1.11:22"、"[fd00::1]:22"
|
itemHosts, itemPorts := item, portList
|
||||||
|
// 自带端口的完整目标:"10.0.1.11:22"、"10.0.1.11-13:22"、"[fd00::1]:22"
|
||||||
if h, p, err := net.SplitHostPort(item); err == nil {
|
if h, p, err := net.SplitHostPort(item); err == nil {
|
||||||
n, perr := strconv.Atoi(p)
|
if itemPorts, err = parsePorts(p); err != nil || len(itemPorts) == 0 {
|
||||||
if !validHost(h) || perr != nil || n <= 0 || n > 65535 {
|
|
||||||
return nil, fmt.Errorf("无效的目标 %q", item)
|
return nil, fmt.Errorf("无效的目标 %q", item)
|
||||||
}
|
}
|
||||||
if err := add(net.JoinHostPort(h, p)); err != nil {
|
itemHosts = h
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
hostsOfItem, isRange, err := expandIPv4Range(item)
|
hostList, err := hostsOf(itemHosts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !isRange {
|
if len(itemPorts) == 0 {
|
||||||
if !validHost(item) {
|
return nil, fmt.Errorf("%q 缺少端口,请写成 主机:端口", item)
|
||||||
return nil, fmt.Errorf("无效的主机 %q", item)
|
|
||||||
}
|
|
||||||
hostsOfItem = []string{item}
|
|
||||||
}
|
}
|
||||||
if len(portList) == 0 {
|
for _, h := range hostList {
|
||||||
return nil, fmt.Errorf("请填写端口,或把目标写成 主机:端口")
|
for _, p := range itemPorts {
|
||||||
}
|
|
||||||
for _, h := range hostsOfItem {
|
|
||||||
for _, p := range portList {
|
|
||||||
if err := add(net.JoinHostPort(h, strconv.Itoa(p))); err != nil {
|
if err := add(net.JoinHostPort(h, strconv.Itoa(p))); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+165
-105
@@ -364,33 +364,131 @@ func (cm *ConfigManager) AddTunnel(t protocol.TunnelConfig) (protocol.TunnelConf
|
|||||||
cm.mu.Lock()
|
cm.mu.Lock()
|
||||||
defer cm.mu.Unlock()
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
t.ID = uuid.New().String()
|
if err := cm.checkPortLocked("", t.ListenPort); err != nil {
|
||||||
t.AuthCode = generateAuthCode()
|
|
||||||
t.HistoricalBytesIn, t.HistoricalBytesOut, t.LastDurationSec = 0, 0, 0
|
|
||||||
t.CloseReason, t.FirstConnectedAt, t.ConnectedSec, t.LastConnectedSec = "", nil, 0, 0
|
|
||||||
if t.IdleCloseMinutes == nil {
|
|
||||||
v := DefaultIdleCloseMinutes
|
|
||||||
t.IdleCloseMinutes = &v
|
|
||||||
}
|
|
||||||
t.DynamicWhitelist = nil
|
|
||||||
if err := cm.checkPortLocked(t.ID, t.ListenPort); err != nil {
|
|
||||||
return t, err
|
return t, err
|
||||||
}
|
}
|
||||||
if !cm.groupExistsLocked(t.GroupID) {
|
if !cm.groupExistsLocked(t.GroupID) {
|
||||||
return t, fmt.Errorf("通道组不存在")
|
return t, fmt.Errorf("通道组不存在")
|
||||||
}
|
}
|
||||||
|
prepareNew(&t, time.Now())
|
||||||
|
cm.cfg.Tunnels = append(cm.cfg.Tunnels, t)
|
||||||
|
return cloneTunnel(t), cm.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareNew resets the program-maintained fields of a tunnel being created and opens it
|
||||||
|
func prepareNew(t *protocol.TunnelConfig, now time.Time) {
|
||||||
|
t.ID = uuid.New().String()
|
||||||
|
t.AuthCode = generateAuthCode()
|
||||||
|
t.HistoricalBytesIn, t.HistoricalBytesOut, t.LastDurationSec = 0, 0, 0
|
||||||
|
t.CloseReason, t.FirstConnectedAt, t.ConnectedSec, t.LastConnectedSec = "", nil, 0, 0
|
||||||
|
t.DynamicWhitelist = nil
|
||||||
|
if t.IdleCloseMinutes == nil {
|
||||||
|
v := DefaultIdleCloseMinutes
|
||||||
|
t.IdleCloseMinutes = &v
|
||||||
|
}
|
||||||
t.Enabled = true
|
t.Enabled = true
|
||||||
now := time.Now()
|
start := now
|
||||||
t.StartedAt = &now
|
t.StartedAt = &start
|
||||||
t.ExpiresAt = nil
|
t.ExpiresAt = nil
|
||||||
if t.DurationMinutes > 0 {
|
if t.DurationMinutes > 0 {
|
||||||
exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
|
exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
|
||||||
t.ExpiresAt = &exp
|
t.ExpiresAt = &exp
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cm.cfg.Tunnels = append(cm.cfg.Tunnels, t)
|
// NewTunnels is one submission of the add-tunnel form
|
||||||
return cloneTunnel(t), cm.save()
|
type NewTunnels struct {
|
||||||
|
// Template 通道设置。Target 可以写多个目标(见 ExpandTargets);ListenPort 只用于单个目标,
|
||||||
|
// 多个目标自动分配一段连续端口;GroupID 表示加入已有通道组。
|
||||||
|
Template protocol.TunnelConfig
|
||||||
|
// NewGroup 新建一个通道组并把这些通道放进去
|
||||||
|
NewGroup string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTunnels adds the tunnels of one add-tunnel form. Several targets become one tunnel each,
|
||||||
|
// named "名称 (主机)", and must go into a group; a group member without a name is named after the
|
||||||
|
// group. With dryRun nothing is saved and the result is a preview.
|
||||||
|
// Returns the group the tunnels joined (nil when ungrouped) and the created tunnels.
|
||||||
|
func (cm *ConfigManager) CreateTunnels(req NewTunnels, dryRun bool) (*TunnelGroup, []protocol.TunnelConfig, error) {
|
||||||
|
tpl := req.Template
|
||||||
|
targets, err := ExpandTargets(tpl.Target, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
newGroup := strings.TrimSpace(req.NewGroup)
|
||||||
|
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
var group *TunnelGroup
|
||||||
|
switch {
|
||||||
|
case newGroup != "":
|
||||||
|
if err := cm.checkGroupNameLocked("", newGroup); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
group = &TunnelGroup{ID: uuid.New().String(), Name: newGroup, Owner: strings.TrimSpace(tpl.Owner), CreatedAt: time.Now()}
|
||||||
|
case tpl.GroupID != "":
|
||||||
|
g, ok := cm.findGroupLocked(tpl.GroupID)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("通道组不存在")
|
||||||
|
}
|
||||||
|
group = &g
|
||||||
|
case len(targets) > 1 && !dryRun:
|
||||||
|
return nil, nil, fmt.Errorf("多个目标需要放进同一个通道组,请选择或新建通道组")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(tpl.Name)
|
||||||
|
base := name
|
||||||
|
if base == "" && group != nil {
|
||||||
|
base = group.Name
|
||||||
|
}
|
||||||
|
if base == "" && dryRun {
|
||||||
|
base = "通道"
|
||||||
|
}
|
||||||
|
names := []string{base}
|
||||||
|
if len(targets) > 1 || (name == "" && group != nil) {
|
||||||
|
names = memberNames(base, targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ports []int
|
||||||
|
if len(targets) == 1 && tpl.ListenPort > 0 {
|
||||||
|
if err := cm.checkPortLocked("", tpl.ListenPort); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ports = []int{tpl.ListenPort}
|
||||||
|
} else if ports, err = cm.allocatePortsLocked(len(targets)); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
created := make([]protocol.TunnelConfig, 0, len(targets))
|
||||||
|
for i, target := range targets {
|
||||||
|
t := cloneTunnel(tpl)
|
||||||
|
t.Name, t.Target, t.ListenPort, t.GroupID = names[i], target, ports[i], ""
|
||||||
|
if group != nil {
|
||||||
|
t.GroupID = group.ID
|
||||||
|
}
|
||||||
|
if err := normalizeAndValidate(&t); err != nil {
|
||||||
|
if len(targets) > 1 {
|
||||||
|
err = fmt.Errorf("%s:%w", target, err)
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
prepareNew(&t, now)
|
||||||
|
created = append(created, t)
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
return group, created, nil
|
||||||
|
}
|
||||||
|
if newGroup != "" {
|
||||||
|
cm.cfg.Groups = append(cm.cfg.Groups, *group)
|
||||||
|
}
|
||||||
|
cm.cfg.Tunnels = append(cm.cfg.Tunnels, created...)
|
||||||
|
out := make([]protocol.TunnelConfig, len(created))
|
||||||
|
for i, t := range created {
|
||||||
|
out[i] = cloneTunnel(t)
|
||||||
|
}
|
||||||
|
return group, out, cm.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PatchTunnel applies a partial JSON update: only fields present in patch change.
|
// PatchTunnel applies a partial JSON update: only fields present in patch change.
|
||||||
@@ -416,6 +514,11 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
|
|||||||
if err := json.Unmarshal(patch, &updated); err != nil {
|
if err := json.Unmarshal(patch, &updated); err != nil {
|
||||||
return existing, existing.ListenPort, fmt.Errorf("Invalid JSON")
|
return existing, existing.ListenPort, fmt.Errorf("Invalid JSON")
|
||||||
}
|
}
|
||||||
|
// new_group:新建一个通道组并把该通道移进去
|
||||||
|
var extra struct {
|
||||||
|
NewGroup string `json:"new_group"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(patch, &extra)
|
||||||
|
|
||||||
// 受保护字段
|
// 受保护字段
|
||||||
updated.ID = existing.ID
|
updated.ID = existing.ID
|
||||||
@@ -438,7 +541,14 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
|
|||||||
if err := cm.checkPortLocked(id, updated.ListenPort); err != nil {
|
if err := cm.checkPortLocked(id, updated.ListenPort); err != nil {
|
||||||
return existing, existing.ListenPort, err
|
return existing, existing.ListenPort, err
|
||||||
}
|
}
|
||||||
if !cm.groupExistsLocked(updated.GroupID) {
|
var newGroup *TunnelGroup
|
||||||
|
if name := strings.TrimSpace(extra.NewGroup); name != "" {
|
||||||
|
if err := cm.checkGroupNameLocked("", name); err != nil {
|
||||||
|
return existing, existing.ListenPort, err
|
||||||
|
}
|
||||||
|
newGroup = &TunnelGroup{ID: uuid.New().String(), Name: name, Owner: updated.Owner, CreatedAt: time.Now()}
|
||||||
|
updated.GroupID = newGroup.ID
|
||||||
|
} else if !cm.groupExistsLocked(updated.GroupID) {
|
||||||
return existing, existing.ListenPort, fmt.Errorf("通道组不存在")
|
return existing, existing.ListenPort, fmt.Errorf("通道组不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +565,9 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if newGroup != nil {
|
||||||
|
cm.cfg.Groups = append(cm.cfg.Groups, *newGroup)
|
||||||
|
}
|
||||||
cm.cfg.Tunnels[idx] = updated
|
cm.cfg.Tunnels[idx] = updated
|
||||||
return cloneTunnel(updated), existing.ListenPort, cm.save()
|
return cloneTunnel(updated), existing.ListenPort, cm.save()
|
||||||
}
|
}
|
||||||
@@ -739,6 +852,10 @@ func (cm *ConfigManager) RecommendPort() int {
|
|||||||
func (cm *ConfigManager) FindGroup(id string) (TunnelGroup, bool) {
|
func (cm *ConfigManager) FindGroup(id string) (TunnelGroup, bool) {
|
||||||
cm.mu.RLock()
|
cm.mu.RLock()
|
||||||
defer cm.mu.RUnlock()
|
defer cm.mu.RUnlock()
|
||||||
|
return cm.findGroupLocked(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cm *ConfigManager) findGroupLocked(id string) (TunnelGroup, bool) {
|
||||||
for _, g := range cm.cfg.Groups {
|
for _, g := range cm.cfg.Groups {
|
||||||
if g.ID == id {
|
if g.ID == id {
|
||||||
return g, true
|
return g, true
|
||||||
@@ -747,6 +864,22 @@ func (cm *ConfigManager) FindGroup(id string) (TunnelGroup, bool) {
|
|||||||
return TunnelGroup{}, false
|
return TunnelGroup{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkGroupNameLocked validates the name for group id ("" for a new group). Caller must hold cm.mu.
|
||||||
|
func (cm *ConfigManager) checkGroupNameLocked(id, name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("请填写通道组名称")
|
||||||
|
}
|
||||||
|
if len([]rune(name)) > 64 {
|
||||||
|
return fmt.Errorf("通道组名称不能超过 64 个字")
|
||||||
|
}
|
||||||
|
for _, g := range cm.cfg.Groups {
|
||||||
|
if g.ID != id && g.Name == name {
|
||||||
|
return fmt.Errorf("已有同名的通道组「%s」", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GroupMembers returns the tunnels of a group ordered by listen port
|
// GroupMembers returns the tunnels of a group ordered by listen port
|
||||||
func (cm *ConfigManager) GroupMembers(id string) []protocol.TunnelConfig {
|
func (cm *ConfigManager) GroupMembers(id string) []protocol.TunnelConfig {
|
||||||
cm.mu.RLock()
|
cm.mu.RLock()
|
||||||
@@ -765,101 +898,38 @@ func (cm *ConfigManager) groupMembersLocked(id string) []protocol.TunnelConfig {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupSpec describes a group and the members to add to it
|
// GroupSpec describes a group's name and owner, and existing tunnels to move into it.
|
||||||
|
// 新通道统一在添加通道表单里创建(见 CreateTunnels)。
|
||||||
type GroupSpec struct {
|
type GroupSpec struct {
|
||||||
Name string
|
Name string
|
||||||
Owner string
|
Owner string
|
||||||
// Targets 新建成员的目标(已展开的 主机:端口)
|
|
||||||
Targets []string
|
|
||||||
// Template 新建成员的通道设置:白名单、闲置关闭、最长开启时长、限速
|
|
||||||
Template protocol.TunnelConfig
|
|
||||||
// MoveIDs 加入该组的已有通道
|
|
||||||
MoveIDs []string
|
MoveIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// newMembersLocked builds (but does not store) the tunnels for spec.Targets. Caller must hold cm.mu.
|
// SaveGroup creates a group from existing tunnels (id == "") or renames / adds members to one.
|
||||||
func (cm *ConfigManager) newMembersLocked(groupID string, spec GroupSpec, now time.Time) ([]protocol.TunnelConfig, error) {
|
// Returns the group and the members whose config changed (moved in, or the group was renamed)
|
||||||
if len(spec.Targets) == 0 {
|
// — the caller pushes them to the relay server.
|
||||||
return nil, nil
|
func (cm *ConfigManager) SaveGroup(id string, spec GroupSpec) (TunnelGroup, []protocol.TunnelConfig, error) {
|
||||||
}
|
|
||||||
ports, err := cm.allocatePortsLocked(len(spec.Targets))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
names := memberNames(spec.Name, spec.Targets)
|
|
||||||
out := make([]protocol.TunnelConfig, 0, len(spec.Targets))
|
|
||||||
for i, target := range spec.Targets {
|
|
||||||
t := cloneTunnel(spec.Template)
|
|
||||||
t.ID = uuid.New().String()
|
|
||||||
t.Name, t.Target, t.ListenPort = names[i], target, ports[i]
|
|
||||||
t.GroupID = groupID
|
|
||||||
if strings.TrimSpace(t.Owner) == "" {
|
|
||||||
t.Owner = spec.Owner
|
|
||||||
}
|
|
||||||
if err := normalizeAndValidate(&t); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s:%w", target, err)
|
|
||||||
}
|
|
||||||
t.AuthCode = generateAuthCode()
|
|
||||||
t.HistoricalBytesIn, t.HistoricalBytesOut, t.LastDurationSec = 0, 0, 0
|
|
||||||
t.CloseReason, t.FirstConnectedAt, t.ConnectedSec, t.LastConnectedSec = "", nil, 0, 0
|
|
||||||
t.DynamicWhitelist = nil
|
|
||||||
if t.IdleCloseMinutes == nil {
|
|
||||||
v := DefaultIdleCloseMinutes
|
|
||||||
t.IdleCloseMinutes = &v
|
|
||||||
}
|
|
||||||
t.Enabled = true
|
|
||||||
start := now
|
|
||||||
t.StartedAt = &start
|
|
||||||
t.ExpiresAt = nil
|
|
||||||
if t.DurationMinutes > 0 {
|
|
||||||
exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
|
|
||||||
t.ExpiresAt = &exp
|
|
||||||
}
|
|
||||||
out = append(out, t)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PreviewGroupMembers returns the tunnels that saving spec would create, without saving anything
|
|
||||||
func (cm *ConfigManager) PreviewGroupMembers(spec GroupSpec) ([]protocol.TunnelConfig, error) {
|
|
||||||
if strings.TrimSpace(spec.Name) == "" {
|
|
||||||
spec.Name = "通道组"
|
|
||||||
}
|
|
||||||
spec.Name = strings.TrimSpace(spec.Name)
|
|
||||||
cm.mu.RLock()
|
|
||||||
defer cm.mu.RUnlock()
|
|
||||||
return cm.newMembersLocked("", spec, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveGroup creates a group (id == "") or renames / adds members to an existing one.
|
|
||||||
// Returns the group, the tunnels it created and the existing members whose config changed
|
|
||||||
// (moved in, or the group was renamed) — the caller pushes both to the relay server.
|
|
||||||
func (cm *ConfigManager) SaveGroup(id string, spec GroupSpec) (TunnelGroup, []protocol.TunnelConfig, []protocol.TunnelConfig, error) {
|
|
||||||
spec.Name = strings.TrimSpace(spec.Name)
|
spec.Name = strings.TrimSpace(spec.Name)
|
||||||
spec.Owner = strings.TrimSpace(spec.Owner)
|
spec.Owner = strings.TrimSpace(spec.Owner)
|
||||||
if spec.Name == "" {
|
|
||||||
return TunnelGroup{}, nil, nil, fmt.Errorf("请填写通道组名称")
|
|
||||||
}
|
|
||||||
if len([]rune(spec.Name)) > 64 {
|
|
||||||
return TunnelGroup{}, nil, nil, fmt.Errorf("通道组名称不能超过 64 个字")
|
|
||||||
}
|
|
||||||
|
|
||||||
cm.mu.Lock()
|
cm.mu.Lock()
|
||||||
defer cm.mu.Unlock()
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
if err := cm.checkGroupNameLocked(id, spec.Name); err != nil {
|
||||||
|
return TunnelGroup{}, nil, err
|
||||||
|
}
|
||||||
gi := -1
|
gi := -1
|
||||||
for i, g := range cm.cfg.Groups {
|
for i, g := range cm.cfg.Groups {
|
||||||
if id != "" && g.ID == id {
|
if id != "" && g.ID == id {
|
||||||
gi = i
|
gi = i
|
||||||
} else if g.Name == spec.Name {
|
|
||||||
return TunnelGroup{}, nil, nil, fmt.Errorf("已有同名的通道组「%s」", spec.Name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if id != "" && gi < 0 {
|
if id != "" && gi < 0 {
|
||||||
return TunnelGroup{}, nil, nil, fmt.Errorf("通道组不存在")
|
return TunnelGroup{}, nil, fmt.Errorf("通道组不存在")
|
||||||
}
|
}
|
||||||
if id == "" && len(spec.Targets) == 0 && len(spec.MoveIDs) == 0 {
|
if id == "" && len(spec.MoveIDs) == 0 {
|
||||||
return TunnelGroup{}, nil, nil, fmt.Errorf("请添加至少一个目标,或选择要加入的已有通道")
|
return TunnelGroup{}, nil, fmt.Errorf("请选择要放进该组的通道")
|
||||||
}
|
}
|
||||||
|
|
||||||
group := TunnelGroup{ID: id, Name: spec.Name, Owner: spec.Owner, CreatedAt: time.Now()}
|
group := TunnelGroup{ID: id, Name: spec.Name, Owner: spec.Owner, CreatedAt: time.Now()}
|
||||||
@@ -878,20 +948,15 @@ func (cm *ConfigManager) SaveGroup(id string, spec GroupSpec) (TunnelGroup, []pr
|
|||||||
}
|
}
|
||||||
found = true
|
found = true
|
||||||
if t.GroupID != "" && t.GroupID != group.ID {
|
if t.GroupID != "" && t.GroupID != group.ID {
|
||||||
return TunnelGroup{}, nil, nil, fmt.Errorf("「%s」已属于其它通道组,请先将其移出", t.Name)
|
return TunnelGroup{}, nil, fmt.Errorf("「%s」已属于其它通道组,请先将其移出", t.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
return TunnelGroup{}, nil, nil, fmt.Errorf("通道 %s 未找到", mid)
|
return TunnelGroup{}, nil, fmt.Errorf("通道 %s 未找到", mid)
|
||||||
}
|
}
|
||||||
move[mid] = true
|
move[mid] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := cm.newMembersLocked(group.ID, spec, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
return TunnelGroup{}, nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
renamed := gi >= 0 && cm.cfg.Groups[gi].Name != group.Name
|
renamed := gi >= 0 && cm.cfg.Groups[gi].Name != group.Name
|
||||||
var changed []protocol.TunnelConfig
|
var changed []protocol.TunnelConfig
|
||||||
for i, t := range cm.cfg.Tunnels {
|
for i, t := range cm.cfg.Tunnels {
|
||||||
@@ -903,17 +968,12 @@ func (cm *ConfigManager) SaveGroup(id string, spec GroupSpec) (TunnelGroup, []pr
|
|||||||
changed = append(changed, cloneTunnel(t))
|
changed = append(changed, cloneTunnel(t))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cm.cfg.Tunnels = append(cm.cfg.Tunnels, created...)
|
|
||||||
if gi >= 0 {
|
if gi >= 0 {
|
||||||
cm.cfg.Groups[gi] = group
|
cm.cfg.Groups[gi] = group
|
||||||
} else {
|
} else {
|
||||||
cm.cfg.Groups = append(cm.cfg.Groups, group)
|
cm.cfg.Groups = append(cm.cfg.Groups, group)
|
||||||
}
|
}
|
||||||
out := make([]protocol.TunnelConfig, len(created))
|
return group, changed, cm.save()
|
||||||
for i, t := range created {
|
|
||||||
out[i] = cloneTunnel(t)
|
|
||||||
}
|
|
||||||
return group, out, changed, cm.save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveGroup dissolves a group. With deleteMembers its tunnels are deleted too, otherwise they
|
// RemoveGroup dissolves a group. With deleteMembers its tunnels are deleted too, otherwise they
|
||||||
|
|||||||
+82
-36
@@ -26,6 +26,8 @@ func TestExpandTargets(t *testing.T) {
|
|||||||
{"10.0.1.1,10.0.1.2、10.0.1.1", "22", []string{"10.0.1.1:22", "10.0.1.2: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"}},
|
{"fd00::1", "22", []string{"[fd00::1]:22"}},
|
||||||
{"10.0.2.9:5432", "", []string{"10.0.2.9:5432"}},
|
{"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 {
|
for _, c := range cases {
|
||||||
got, err := ExpandTargets(c.hosts, c.ports)
|
got, err := ExpandTargets(c.hosts, c.ports)
|
||||||
@@ -42,6 +44,9 @@ func TestExpandTargets(t *testing.T) {
|
|||||||
{"10.0.1.11", "0"},
|
{"10.0.1.11", "0"},
|
||||||
{"10.0.1.11", "70000"},
|
{"10.0.1.11", "70000"},
|
||||||
{"10.0.1.1-40", "22, 23"},
|
{"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 {
|
if got, err := ExpandTargets(bad[0], bad[1]); err == nil {
|
||||||
t.Errorf("ExpandTargets(%q, %q) should fail, got %v", bad[0], bad[1], got)
|
t.Errorf("ExpandTargets(%q, %q) should fail, got %v", bad[0], bad[1], got)
|
||||||
@@ -77,9 +82,25 @@ func TestAllocatePortsPrefersContiguousBlock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type groupResp struct {
|
type batchResp struct {
|
||||||
Group TunnelGroup `json:"group"`
|
Group *TunnelGroup `json:"group"`
|
||||||
Created []protocol.TunnelConfig `json:"created"`
|
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 {
|
func (e *env) group(t *testing.T, id string) GroupView {
|
||||||
@@ -113,20 +134,25 @@ func TestGroupEndToEnd(t *testing.T) {
|
|||||||
waitFor(t, "agent connected", e.agent.IsConnected)
|
waitFor(t, "agent connected", e.agent.IsConnected)
|
||||||
echo1, echo2 := startEcho(t), startEcho(t)
|
echo1, echo2 := startEcho(t), startEcho(t)
|
||||||
|
|
||||||
status, body := e.call(t, "POST", "/api/groups", map[string]any{"name": "订单服务", "owner": "运维组", "hosts": echo1 + ", " + echo2})
|
g := e.addTunnels(t, map[string]any{
|
||||||
if status != http.StatusCreated {
|
"name": "订单服务", "owner": "运维组", "target": echo1 + ", " + echo2, "new_group": "订单服务",
|
||||||
t.Fatalf("create group: %d %s", status, body)
|
"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)
|
||||||
}
|
}
|
||||||
var g groupResp
|
if len(g.Tunnels) != 2 || g.Tunnels[1].ListenPort != g.Tunnels[0].ListenPort+1 {
|
||||||
_ = json.Unmarshal(body, &g)
|
t.Fatalf("members should get a contiguous port block: %+v", g.Tunnels)
|
||||||
if len(g.Created) != 2 || g.Created[1].ListenPort != g.Created[0].ListenPort+1 {
|
|
||||||
t.Fatalf("members should get a contiguous port block: %+v", g.Created)
|
|
||||||
}
|
}
|
||||||
m1, m2 := g.Created[0], g.Created[1]
|
m1, m2 := g.Tunnels[0], g.Tunnels[1]
|
||||||
for _, m := range g.Created {
|
for _, m := range g.Tunnels {
|
||||||
if m.GroupID != g.Group.ID || m.Owner != "运维组" || len(m.Whitelist) != 0 || !strings.HasPrefix(m.Name, "订单服务 (") {
|
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)
|
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 {
|
waitFor(t, "members online", func() bool {
|
||||||
return e.tunnel(t, m1.ID).Metrics.Status == "online" && e.tunnel(t, m2.ID).Metrics.Status == "online"
|
return e.tunnel(t, m1.ID).Metrics.Status == "online" && e.tunnel(t, m2.ID).Metrics.Status == "online"
|
||||||
@@ -135,7 +161,7 @@ func TestGroupEndToEnd(t *testing.T) {
|
|||||||
t.Fatal("group members are link-only by default")
|
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"})
|
status, body := e.call(t, "POST", "/api/groups/"+g.Group.ID+"/invites", map[string]any{"note": "小王", "lease_duration": "8h"})
|
||||||
if status != http.StatusCreated {
|
if status != http.StatusCreated {
|
||||||
t.Fatalf("group invite: %d %s", status, body)
|
t.Fatalf("group invite: %d %s", status, body)
|
||||||
}
|
}
|
||||||
@@ -155,7 +181,7 @@ func TestGroupEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
grantViaInvite(t, e, inv.Invite.Code, "小王")
|
grantViaInvite(t, e, inv.Invite.Code, "小王")
|
||||||
for _, m := range g.Created {
|
for _, m := range g.Tunnels {
|
||||||
if got, err := echoVia(m.ListenPort, "hi"); err != nil || got != "hi" {
|
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)
|
t.Fatalf("member %d not reachable after group grant: %q %v", m.ListenPort, got, err)
|
||||||
}
|
}
|
||||||
@@ -213,7 +239,7 @@ func TestGroupFromExistingTunnelsRenameAndMoveOut(t *testing.T) {
|
|||||||
if status != http.StatusCreated {
|
if status != http.StatusCreated {
|
||||||
t.Fatalf("group from existing tunnels: %d %s", status, body)
|
t.Fatalf("group from existing tunnels: %d %s", status, body)
|
||||||
}
|
}
|
||||||
var g groupResp
|
var g batchResp
|
||||||
_ = json.Unmarshal(body, &g)
|
_ = json.Unmarshal(body, &g)
|
||||||
if e.tunnel(t, ids[0]).GroupID != g.Group.ID || e.tunnel(t, ids[1]).GroupID != g.Group.ID {
|
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")
|
t.Fatal("existing tunnels should join the group")
|
||||||
@@ -271,38 +297,58 @@ func TestGroupFromExistingTunnelsRenameAndMoveOut(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGroupPreviewDoesNotSave(t *testing.T) {
|
func TestAddTunnelFormPreviewAndRules(t *testing.T) {
|
||||||
e := startEnv(t, testToken)
|
e := startEnv(t, testToken)
|
||||||
status, body := e.call(t, "POST", "/api/groups/preview", map[string]any{"name": "订单服务", "hosts": "10.0.1.11-12", "ports": "22"})
|
|
||||||
if status != http.StatusOK {
|
// 预览:不保存,展开目标并预分配连续端口
|
||||||
t.Fatalf("preview: %d %s", status, body)
|
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 {
|
||||||
var out struct {
|
t.Fatalf("preview: %+v", pv.Tunnels)
|
||||||
Members []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
ListenPort int `json:"listen_port"`
|
|
||||||
} `json:"members"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(body, &out)
|
|
||||||
if len(out.Members) != 2 || out.Members[0].Name != "订单服务 (10.0.1.11)" || out.Members[1].ListenPort != out.Members[0].ListenPort+1 {
|
|
||||||
t.Fatalf("preview: %+v", out.Members)
|
|
||||||
}
|
}
|
||||||
if len(e.cm.Get().Tunnels) != 0 {
|
if len(e.cm.Get().Tunnels) != 0 {
|
||||||
t.Fatal("preview must not create tunnels")
|
t.Fatal("preview must not create tunnels")
|
||||||
}
|
}
|
||||||
if status, _ := e.call(t, "POST", "/api/groups/preview", map[string]any{"hosts": "10.0.1.11"}); status != http.StatusBadRequest {
|
|
||||||
t.Fatal("missing ports should be rejected")
|
// 多个目标必须放进通道组
|
||||||
|
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) {
|
func TestGroupIdleCloseIsLinked(t *testing.T) {
|
||||||
e := startEnvWith(t, testToken, func(a *Agent) { a.idleUnit = 3 * time.Second })
|
e := startEnvWith(t, testToken, func(a *Agent) { a.idleUnit = 3 * time.Second })
|
||||||
waitFor(t, "agent connected", e.agent.IsConnected)
|
waitFor(t, "agent connected", e.agent.IsConnected)
|
||||||
_, body := e.call(t, "POST", "/api/groups", map[string]any{"name": "g", "hosts": "127.0.0.1", "ports": "1, 2", "idle_close_minutes": 1})
|
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})
|
||||||
var g groupResp
|
a, b := g.Tunnels[0], g.Tunnels[1]
|
||||||
_ = json.Unmarshal(body, &g)
|
|
||||||
a, b := g.Created[0], g.Created[1]
|
|
||||||
waitFor(t, "members online", func() bool {
|
waitFor(t, "members online", func() bool {
|
||||||
return e.tunnel(t, a.ID).Metrics.Status == "online" && e.tunnel(t, b.ID).Metrics.Status == "online"
|
return e.tunnel(t, a.ID).Metrics.Status == "online" && e.tunnel(t, b.ID).Metrics.Status == "online"
|
||||||
})
|
})
|
||||||
|
|||||||
+233
-195
@@ -327,6 +327,10 @@
|
|||||||
.check-item input { width: auto !important; }
|
.check-item input { width: auto !important; }
|
||||||
.check-item code { color: var(--text-muted); font-size: 12px; }
|
.check-item code { color: var(--text-muted); font-size: 12px; }
|
||||||
.preview-row { font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
|
.preview-row { font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
|
||||||
|
.plan-box { margin-top: 8px; padding: 8px 10px; border-radius: 6px; background: rgba(148, 163, 184, 0.08); color: var(--text-muted); font-size: 12px; line-height: 1.7; }
|
||||||
|
.plan-box .route-endpoint { color: var(--accent, #38bdf8); }
|
||||||
|
.add-member { background: none; border: 0; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 8px 0; }
|
||||||
|
.add-member:hover { color: var(--text); }
|
||||||
@media (max-width: 860px) { .member-name, .member-meta { display: none; } }
|
@media (max-width: 860px) { .member-name, .member-meta { display: none; } }
|
||||||
.drawer-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
.drawer-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
.drawer-content { flex: 1; overflow-y: auto; }
|
.drawer-content { flex: 1; overflow-y: auto; }
|
||||||
@@ -348,7 +352,6 @@
|
|||||||
<div id="statusDot" class="status-dot"></div>
|
<div id="statusDot" class="status-dot"></div>
|
||||||
<span id="statusText">正在连接服务器...</span>
|
<span id="statusText">正在连接服务器...</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-secondary" onclick="openGroupModal()" title="一次添加多个主机 / 端口,共用一条邀请链接">▤ 新建通道组</button>
|
|
||||||
<button class="btn-primary" onclick="openCreateModal()">+ 新建代理通道</button>
|
<button class="btn-primary" onclick="openCreateModal()">+ 新建代理通道</button>
|
||||||
<button class="btn-secondary" onclick="openSettingsModal()">⚙️ 设置</button>
|
<button class="btn-secondary" onclick="openSettingsModal()">⚙️ 设置</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -437,29 +440,40 @@
|
|||||||
</div>
|
</div>
|
||||||
<form id="tunnelForm" onsubmit="saveTunnel(event)">
|
<form id="tunnelForm" onsubmit="saveTunnel(event)">
|
||||||
<input type="hidden" id="tunnelId">
|
<input type="hidden" id="tunnelId">
|
||||||
<div class="form-group">
|
<div class="input-row">
|
||||||
<label>通道名称 / 备注</label>
|
<div class="form-group" style="flex: 1;">
|
||||||
<input type="text" id="tunnelName" required placeholder="例如:MySQL 实习库 或 财务系统 SSH">
|
<label>通道名称 / 备注</label>
|
||||||
</div>
|
<input type="text" id="tunnelName" placeholder="例如:MySQL 实习库 或 财务系统 SSH" oninput="schedulePlan()">
|
||||||
<div class="form-group" id="tunnelGroupWrap" style="display:none;">
|
</div>
|
||||||
<label>所属通道组</label>
|
<div class="form-group" style="flex: 1;">
|
||||||
<select id="tunnelGroup"></select>
|
<label>责任人(可选)</label>
|
||||||
|
<input type="text" id="tunnelOwner" placeholder="例如:张三 / 数据组">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>目标地址 (中转机 Mac 可直连的内网目标)</label>
|
<label id="tunnelTargetLabel">目标地址(中转机 Mac 可直连的内网目标,可填多个)</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="text" id="tunnelTarget" required placeholder="例如:10.0.0.10:3306">
|
<input type="text" id="tunnelTarget" required placeholder="例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22" oninput="schedulePlan()">
|
||||||
<button type="button" class="btn-secondary" onclick="probeTarget()">探测测试</button>
|
<button type="button" class="btn-secondary" onclick="probeTarget()">探测测试</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-hint" id="probeResult"></div>
|
<div class="input-hint" id="probeResult"></div>
|
||||||
|
<div class="plan-box" id="tunnelPlan" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group" id="tunnelPortWrap">
|
||||||
<label>对外监听端口 (范围: 33001 - 34000)</label>
|
<label>对外监听端口 (范围: 33001 - 34000)</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="number" id="tunnelPort" required min="33001" max="34000" placeholder="33660">
|
<input type="number" id="tunnelPort" min="33001" max="34000" placeholder="33660">
|
||||||
<button type="button" class="btn-secondary" onclick="autoRecommendPort()">推荐空闲端口</button>
|
<button type="button" class="btn-secondary" onclick="autoRecommendPort()">推荐空闲端口</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>通道组</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<select id="tunnelGroup" onchange="onTunnelGroupChange()"></select>
|
||||||
|
<input type="text" id="tunnelNewGroup" style="display:none;" placeholder="通道组名称" oninput="onTunnelGroupChange()">
|
||||||
|
</div>
|
||||||
|
<div class="input-hint" id="tunnelGroupHint"></div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>闲置自动关闭(没有连接、有效授权、待审批和可用邀请时开始计时)</label>
|
<label>闲置自动关闭(没有连接、有效授权、待审批和可用邀请时开始计时)</label>
|
||||||
<select id="tunnelIdleClose">
|
<select id="tunnelIdleClose">
|
||||||
@@ -480,6 +494,16 @@
|
|||||||
<option value="10080">7 天</option>
|
<option value="10080">7 天</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>单通道带宽限速(QoS 防挤占)</label>
|
||||||
|
<select id="tunnelBandwidth">
|
||||||
|
<option value="0" selected>不限速</option>
|
||||||
|
<option value="2">2 MB/s</option>
|
||||||
|
<option value="5">5 MB/s</option>
|
||||||
|
<option value="10">10 MB/s</option>
|
||||||
|
<option value="20">20 MB/s</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>访问控制</label>
|
<label>访问控制</label>
|
||||||
<select id="tunnelAccessMode" onchange="onAccessModeChange()">
|
<select id="tunnelAccessMode" onchange="onAccessModeChange()">
|
||||||
@@ -493,7 +517,7 @@
|
|||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn-secondary" onclick="closeTunnelModal()">取消</button>
|
<button type="button" class="btn-secondary" onclick="closeTunnelModal()">取消</button>
|
||||||
<button type="submit" class="btn-primary">保存通道</button>
|
<button type="submit" class="btn-primary" id="tunnelSaveBtn">保存通道</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,11 +583,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal: Tunnel Group -->
|
<!-- Modal: Edit Tunnel Group(新地址在新建代理通道表单里创建) -->
|
||||||
<div class="modal-overlay" id="groupModal">
|
<div class="modal-overlay" id="groupModal">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="groupModalTitle">新建通道组</h3>
|
<h3>编辑通道组</h3>
|
||||||
<button class="btn-secondary btn-sm" onclick="closeGroupModal()">✕</button>
|
<button class="btn-secondary btn-sm" onclick="closeGroupModal()">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form onsubmit="saveGroup(event)">
|
<form onsubmit="saveGroup(event)">
|
||||||
@@ -577,51 +601,14 @@
|
|||||||
<label>责任人(可选)</label>
|
<label>责任人(可选)</label>
|
||||||
<input type="text" id="groupOwner" placeholder="例如:张三 / 运维组">
|
<input type="text" id="groupOwner" placeholder="例如:张三 / 运维组">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label id="groupTargetsLabel">目标主机与端口</label>
|
|
||||||
<div class="input-row">
|
|
||||||
<input type="text" id="groupHosts" placeholder="主机:10.0.1.11-13 或 10.0.1.11, 10.0.1.12(也可写 主机:端口)" oninput="scheduleGroupPreview()">
|
|
||||||
<input type="text" id="groupPorts" style="max-width: 140px;" placeholder="端口:22, 8080" oninput="scheduleGroupPreview()">
|
|
||||||
</div>
|
|
||||||
<div class="input-hint" id="groupPreview"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" id="groupExistingWrap">
|
<div class="form-group" id="groupExistingWrap">
|
||||||
<label id="groupExistingLabel">或加入已有通道(可选)</label>
|
<label>把已有通道加入该组(可选)</label>
|
||||||
<div class="check-list" id="groupExisting"></div>
|
<div class="check-list" id="groupExisting"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="groupNewSettings" style="display:none;">
|
<div class="input-hint">要开放新的地址,请在通道组卡片上点「+ 添加地址」</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>新建通道:闲置自动关闭</label>
|
|
||||||
<select id="groupIdleClose">
|
|
||||||
<option value="15">15 分钟</option>
|
|
||||||
<option value="30" selected>30 分钟</option>
|
|
||||||
<option value="60">1 小时</option>
|
|
||||||
<option value="120">2 小时</option>
|
|
||||||
<option value="0">不自动关闭</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>新建通道:最长开启时长</label>
|
|
||||||
<select id="groupMaxDuration">
|
|
||||||
<option value="0" selected>不限制</option>
|
|
||||||
<option value="120">2 小时</option>
|
|
||||||
<option value="480">8 小时</option>
|
|
||||||
<option value="1440">1 天</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>新建通道:访问控制</label>
|
|
||||||
<select id="groupAccessMode" onchange="onGroupAccessModeChange()">
|
|
||||||
<option value="link">仅授权链接(推荐)</option>
|
|
||||||
<option value="specific">固定 IP / 网段 + 授权链接</option>
|
|
||||||
<option value="all">全部放通 *(不安全)</option>
|
|
||||||
</select>
|
|
||||||
<input type="text" id="groupWhitelist" style="margin-top: 8px; display: none;" placeholder="例如 192.168.1.100, 192.168.1.0/24(逗号分隔)">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn-secondary" onclick="closeGroupModal()">取消</button>
|
<button type="button" class="btn-secondary" onclick="closeGroupModal()">取消</button>
|
||||||
<button type="submit" class="btn-primary" id="groupSaveBtn">创建通道组</button>
|
<button type="submit" class="btn-primary">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -934,7 +921,7 @@
|
|||||||
|
|
||||||
grid.innerHTML = entries.join('');
|
grid.innerHTML = entries.join('');
|
||||||
if (currentTunnels.length === 0 && currentGroups.length === 0) {
|
if (currentTunnels.length === 0 && currentGroups.length === 0) {
|
||||||
grid.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">暂无中继通道,点击上方按钮添加第一个通道;需要一次开放多台主机时,用“新建通道组”</div>';
|
grid.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">暂无中继通道,点击上方按钮添加第一个通道;目标可以一次填多台主机,会放进同一个通道组</div>';
|
||||||
} else if (entries.length === 0) {
|
} else if (entries.length === 0) {
|
||||||
grid.innerHTML = `<div class="tunnel-card" style="text-align:center; padding: 40px; color: var(--text-muted);">
|
grid.innerHTML = `<div class="tunnel-card" style="text-align:center; padding: 40px; color: var(--text-muted);">
|
||||||
<div style="font-size: 15px; color: var(--text); margin-bottom: 6px;">没有匹配的通道</div>
|
<div style="font-size: 15px; color: var(--text); margin-bottom: 6px;">没有匹配的通道</div>
|
||||||
@@ -1076,15 +1063,20 @@
|
|||||||
await fetchTunnels();
|
await fetchTunnels();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
// 新建代理通道;groupId 非空时默认加入该通道组(通道组卡片上的「添加地址」)
|
||||||
|
function openCreateModal(groupId) {
|
||||||
document.getElementById('modalTitle').innerText = '新建代理通道';
|
document.getElementById('modalTitle').innerText = '新建代理通道';
|
||||||
document.getElementById('tunnelId').value = '';
|
document.getElementById('tunnelId').value = '';
|
||||||
document.getElementById('tunnelName').value = '';
|
document.getElementById('tunnelName').value = '';
|
||||||
|
document.getElementById('tunnelOwner').value = '';
|
||||||
document.getElementById('tunnelTarget').value = '';
|
document.getElementById('tunnelTarget').value = '';
|
||||||
|
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标,可填多个)';
|
||||||
|
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22';
|
||||||
setAccessForm([]);
|
setAccessForm([]);
|
||||||
setLifecycleForm(30, 0);
|
setLifecycleForm(30, 0, 0);
|
||||||
fillTunnelGroupSelect('');
|
fillTunnelGroupSelect(groupId || '');
|
||||||
document.getElementById('probeResult').innerText = '';
|
document.getElementById('probeResult').innerText = '';
|
||||||
|
resetPlan();
|
||||||
autoRecommendPort();
|
autoRecommendPort();
|
||||||
document.getElementById('tunnelModal').classList.add('open');
|
document.getElementById('tunnelModal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -1095,12 +1087,16 @@
|
|||||||
document.getElementById('modalTitle').innerText = '编辑代理通道';
|
document.getElementById('modalTitle').innerText = '编辑代理通道';
|
||||||
document.getElementById('tunnelId').value = t.id;
|
document.getElementById('tunnelId').value = t.id;
|
||||||
document.getElementById('tunnelName').value = t.name;
|
document.getElementById('tunnelName').value = t.name;
|
||||||
|
document.getElementById('tunnelOwner').value = t.owner || '';
|
||||||
document.getElementById('tunnelTarget').value = t.target;
|
document.getElementById('tunnelTarget').value = t.target;
|
||||||
|
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标)';
|
||||||
|
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306';
|
||||||
document.getElementById('tunnelPort').value = t.listen_port;
|
document.getElementById('tunnelPort').value = t.listen_port;
|
||||||
setAccessForm(t.whitelist || []);
|
setAccessForm(t.whitelist || []);
|
||||||
setLifecycleForm(t.effective_idle_close_minutes ?? 30, t.duration_minutes || 0);
|
setLifecycleForm(t.effective_idle_close_minutes ?? 30, t.duration_minutes || 0, t.max_bandwidth_mb || 0);
|
||||||
fillTunnelGroupSelect(t.group_id || '');
|
fillTunnelGroupSelect(t.group_id || '');
|
||||||
document.getElementById('probeResult').innerText = '';
|
document.getElementById('probeResult').innerText = '';
|
||||||
|
resetPlan();
|
||||||
document.getElementById('tunnelModal').classList.add('open');
|
document.getElementById('tunnelModal').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,43 +1114,177 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function probeOne(target) {
|
||||||
|
const res = await fetch('/api/probe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target })
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
async function probeTarget() {
|
async function probeTarget() {
|
||||||
const target = document.getElementById('tunnelTarget').value.trim();
|
const target = document.getElementById('tunnelTarget').value.trim();
|
||||||
if (!target) {
|
if (!target) {
|
||||||
alert('请先输入目标地址');
|
alert('请先输入目标地址');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const targets = isBatch() ? tunnelPlan.map(t => t.target) : [target];
|
||||||
const hint = document.getElementById('probeResult');
|
const hint = document.getElementById('probeResult');
|
||||||
hint.innerText = '探测中...';
|
hint.innerText = '探测中...';
|
||||||
hint.style.color = 'var(--text-muted)';
|
hint.style.color = 'var(--text-muted)';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/probe', {
|
const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
|
||||||
method: 'POST',
|
const failed = results.map((r, i) => [targets[i], r]).filter(([, r]) => !r.reachable);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (targets.length === 1) {
|
||||||
body: JSON.stringify({ target })
|
const data = results[0];
|
||||||
});
|
hint.innerText = data.reachable ? `✓ 连通性测试通过!延迟: ${data.rtt}` : `✗ 连通失败: ${data.error || '连接超时'}`;
|
||||||
const data = await res.json();
|
} else if (!failed.length) {
|
||||||
if (data.reachable) {
|
hint.innerText = `✓ 全部 ${targets.length} 个目标连通`;
|
||||||
hint.innerText = `✓ 连通性测试通过!延迟: ${data.rtt}`;
|
|
||||||
hint.style.color = 'var(--success)';
|
|
||||||
} else {
|
} else {
|
||||||
hint.innerText = `✗ 连通失败: ${data.error || '连接超时'}`;
|
const [t, r] = failed[0];
|
||||||
hint.style.color = 'var(--danger)';
|
hint.innerText = `✗ ${targets.length - failed.length}/${targets.length} 连通;${t} 连通失败: ${r.error || '连接超时'}` + (failed.length > 1 ? `(共 ${failed.length} 个失败)` : '');
|
||||||
}
|
}
|
||||||
|
hint.style.color = failed.length ? 'var(--danger)' : 'var(--success)';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
hint.innerText = '探测请求失败';
|
hint.innerText = '探测请求失败';
|
||||||
hint.style.color = 'var(--danger)';
|
hint.style.color = 'var(--danger)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 多个目标:预览将创建的通道,并放进通道组 ----
|
||||||
|
let tunnelPlan = [], tunnelPlanError = null, planTimer = null;
|
||||||
|
|
||||||
|
function resetPlan() {
|
||||||
|
tunnelPlan = [];
|
||||||
|
tunnelPlanError = null;
|
||||||
|
clearTimeout(planTimer);
|
||||||
|
renderPlan();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可能填了多个目标(逗号 / 空格分隔,或含范围 "10.0.1.11-13:22"),需要服务端展开
|
||||||
|
function mayBeMulti() {
|
||||||
|
if (document.getElementById('tunnelId').value) return false;
|
||||||
|
const target = document.getElementById('tunnelTarget').value.trim();
|
||||||
|
return target.split(/[\s,,、;;]+/).filter(Boolean).length > 1 || target.includes('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBatch() {
|
||||||
|
return mayBeMulti() && tunnelPlan.length > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID
|
||||||
|
function groupFields() {
|
||||||
|
const sel = document.getElementById('tunnelGroup').value;
|
||||||
|
if (sel === '__new__') {
|
||||||
|
const name = document.getElementById('tunnelNewGroup').value.trim() || document.getElementById('tunnelName').value.trim();
|
||||||
|
return { group_id: '', new_group: name };
|
||||||
|
}
|
||||||
|
return { group_id: sel, new_group: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tunnelPayload(port, whitelist) {
|
||||||
|
return {
|
||||||
|
name: document.getElementById('tunnelName').value.trim(),
|
||||||
|
owner: document.getElementById('tunnelOwner').value.trim(),
|
||||||
|
target: document.getElementById('tunnelTarget').value.trim(),
|
||||||
|
listen_port: port,
|
||||||
|
whitelist,
|
||||||
|
idle_close_minutes: parseInt(document.getElementById('tunnelIdleClose').value, 10),
|
||||||
|
duration_minutes: parseInt(document.getElementById('tunnelMaxDuration').value, 10),
|
||||||
|
max_bandwidth_mb: parseFloat(document.getElementById('tunnelBandwidth').value),
|
||||||
|
...groupFields()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePlan() {
|
||||||
|
clearTimeout(planTimer);
|
||||||
|
if (!mayBeMulti()) { tunnelPlan = []; tunnelPlanError = null; renderPlan(); return; }
|
||||||
|
planTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tunnels/batch', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...tunnelPayload(0, []), dry_run: true })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
tunnelPlan = (await res.json()).tunnels || [];
|
||||||
|
tunnelPlanError = null;
|
||||||
|
} else {
|
||||||
|
tunnelPlan = [];
|
||||||
|
tunnelPlanError = await apiError(res);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
tunnelPlan = [];
|
||||||
|
tunnelPlanError = '预览失败:' + e.message;
|
||||||
|
}
|
||||||
|
renderPlan();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlan() {
|
||||||
|
const batch = isBatch();
|
||||||
|
const box = document.getElementById('tunnelPlan');
|
||||||
|
document.getElementById('tunnelPortWrap').style.display = batch ? 'none' : 'block';
|
||||||
|
// 多个目标必须放进同一个通道组
|
||||||
|
const sel = document.getElementById('tunnelGroup');
|
||||||
|
const none = sel.querySelector('option[value=""]');
|
||||||
|
if (none) none.disabled = batch;
|
||||||
|
if (batch && sel.value === '') { sel.value = '__new__'; onTunnelGroupChange(true); }
|
||||||
|
updateGroupHint();
|
||||||
|
document.getElementById('tunnelSaveBtn').innerText = batch ? `创建 ${tunnelPlan.length} 个通道` : '保存通道';
|
||||||
|
if (mayBeMulti() && tunnelPlanError) {
|
||||||
|
box.style.display = 'block';
|
||||||
|
box.style.color = 'var(--danger)';
|
||||||
|
box.innerText = tunnelPlanError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!batch) { box.style.display = 'none'; box.innerHTML = ''; return; }
|
||||||
|
const shown = tunnelPlan.slice(0, 6);
|
||||||
|
box.style.display = 'block';
|
||||||
|
box.style.color = '';
|
||||||
|
box.innerHTML = shown.map(m => `<div class="preview-row"><span class="route-endpoint">${escapeHtml(publicHost)}:${m.listen_port}</span> <span class="route-arrow">➔</span> <span class="route-target">${escapeHtml(m.target)}</span></div>`).join('')
|
||||||
|
+ `<div>${tunnelPlan.length > shown.length ? '… ' : ''}共 ${tunnelPlan.length} 个地址,每个地址一个通道,访问端口自动连续分配</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillTunnelGroupSelect(selected) {
|
||||||
|
document.getElementById('tunnelGroup').innerHTML =
|
||||||
|
'<option value="">不分组</option><option value="__new__">新建通道组</option>' +
|
||||||
|
(currentGroups.length ? '<option disabled>──────────</option>' : '') +
|
||||||
|
currentGroups.map(g => `<option value="${escapeHtml(g.id)}" ${g.id === selected ? 'selected' : ''}>${escapeHtml(g.name)}</option>`).join('');
|
||||||
|
document.getElementById('tunnelNewGroup').value = '';
|
||||||
|
onTunnelGroupChange(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGroupHint() {
|
||||||
|
const sel = document.getElementById('tunnelGroup').value;
|
||||||
|
const isNew = sel === '__new__';
|
||||||
|
const name = document.getElementById('tunnelName').value.trim();
|
||||||
|
const newInput = document.getElementById('tunnelNewGroup');
|
||||||
|
newInput.style.display = isNew ? 'block' : 'none';
|
||||||
|
newInput.placeholder = name ? `组名,默认「${name}」` : '通道组名称';
|
||||||
|
document.getElementById('tunnelGroupHint').innerText = isBatch()
|
||||||
|
? '多个地址会放进同一个通道组:共用邀请和授权,同事授权一次即可访问全部地址'
|
||||||
|
: (sel ? '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址' : '');
|
||||||
|
// 放进通道组时名称可以不填,按组名命名
|
||||||
|
const g = isNew ? newInput.value.trim() : (currentGroups.find(x => x.id === sel) || {}).name;
|
||||||
|
document.getElementById('tunnelName').placeholder = (!document.getElementById('tunnelId').value && g)
|
||||||
|
? `可不填,默认「${g} (主机)」` : '例如:MySQL 实习库 或 财务系统 SSH';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTunnelGroupChange(skipPlan) {
|
||||||
|
updateGroupHint();
|
||||||
|
if (!skipPlan) schedulePlan();
|
||||||
|
}
|
||||||
|
|
||||||
// 闲置自动关闭 / 最长开启时长;旧配置中不在选项里的值临时加入下拉框
|
// 闲置自动关闭 / 最长开启时长;旧配置中不在选项里的值临时加入下拉框
|
||||||
function setLifecycleForm(idle, maxMinutes) {
|
function setLifecycleForm(idle, maxMinutes, bandwidth) {
|
||||||
const ensure = (sel, v, label) => {
|
const ensure = (sel, v, label) => {
|
||||||
if (![...sel.options].some(o => o.value === String(v))) sel.add(new Option(label, String(v)));
|
if (![...sel.options].some(o => o.value === String(v))) sel.add(new Option(label, String(v)));
|
||||||
sel.value = String(v);
|
sel.value = String(v);
|
||||||
};
|
};
|
||||||
ensure(document.getElementById('tunnelIdleClose'), idle, `${idle} 分钟`);
|
ensure(document.getElementById('tunnelIdleClose'), idle, `${idle} 分钟`);
|
||||||
ensure(document.getElementById('tunnelMaxDuration'), maxMinutes, fmtMinutes(maxMinutes));
|
ensure(document.getElementById('tunnelMaxDuration'), maxMinutes, fmtMinutes(maxMinutes));
|
||||||
|
ensure(document.getElementById('tunnelBandwidth'), bandwidth, `${bandwidth} MB/s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAccessForm(wl) {
|
function setAccessForm(wl) {
|
||||||
@@ -1177,9 +1307,13 @@
|
|||||||
async function saveTunnel(e) {
|
async function saveTunnel(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const id = document.getElementById('tunnelId').value;
|
const id = document.getElementById('tunnelId').value;
|
||||||
const name = document.getElementById('tunnelName').value.trim();
|
const batch = isBatch();
|
||||||
const target = document.getElementById('tunnelTarget').value.trim();
|
const sel = document.getElementById('tunnelGroup').value;
|
||||||
const port = parseInt(document.getElementById('tunnelPort').value, 10);
|
const port = parseInt(document.getElementById('tunnelPort').value, 10);
|
||||||
|
if (!batch && !(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; }
|
||||||
|
if (mayBeMulti() && tunnelPlanError) { alert(tunnelPlanError); return; }
|
||||||
|
if (!document.getElementById('tunnelName').value.trim() && (id || !sel)) { alert('请填写通道名称'); return; }
|
||||||
|
if (sel === '__new__' && !groupFields().new_group) { alert('请填写通道组名称'); return; }
|
||||||
const mode = document.getElementById('tunnelAccessMode').value;
|
const mode = document.getElementById('tunnelAccessMode').value;
|
||||||
let whitelist = [];
|
let whitelist = [];
|
||||||
if (mode === 'all') whitelist = ['*'];
|
if (mode === 'all') whitelist = ['*'];
|
||||||
@@ -1188,44 +1322,28 @@
|
|||||||
if (whitelist.length === 0) { alert('请填写允许访问的 IP 或网段,或选择“仅授权链接”'); return; }
|
if (whitelist.length === 0) { alert('请填写允许访问的 IP 或网段,或选择“仅授权链接”'); return; }
|
||||||
if (whitelist.includes('*')) { alert('如需放通全部,请选择“全部放通 *”'); return; }
|
if (whitelist.includes('*')) { alert('如需放通全部,请选择“全部放通 *”'); return; }
|
||||||
}
|
}
|
||||||
|
const payload = tunnelPayload(batch ? 0 : port, whitelist);
|
||||||
// 编辑时只提交表单中的字段,其余(责任人、时长、限速等)由后端保留
|
|
||||||
const payload = {
|
|
||||||
name,
|
|
||||||
target,
|
|
||||||
listen_port: port,
|
|
||||||
whitelist,
|
|
||||||
idle_close_minutes: parseInt(document.getElementById('tunnelIdleClose').value, 10),
|
|
||||||
duration_minutes: parseInt(document.getElementById('tunnelMaxDuration').value, 10),
|
|
||||||
group_id: document.getElementById('tunnelGroup').value
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = id
|
// 新建统一走 /api/tunnels/batch:单个目标、多个目标、新建或加入通道组都在这一个表单里
|
||||||
? await fetch(`/api/tunnels/${encodeURIComponent(id)}`, {
|
const res = await fetch(id ? `/api/tunnels/${encodeURIComponent(id)}` : '/api/tunnels/batch', {
|
||||||
method: 'PUT',
|
method: id ? 'PUT' : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
})
|
});
|
||||||
: await fetch('/api/tunnels', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
alert((id ? '保存失败: ' : '创建失败: ') + await apiError(res));
|
alert((id ? '保存失败: ' : '创建失败: ') + await apiError(res));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 新建“仅授权链接”通道后直接生成邀请
|
const data = await res.json();
|
||||||
if (!id && mode !== 'all') {
|
|
||||||
const created = await res.json();
|
|
||||||
closeTunnelModal();
|
|
||||||
await fetchTunnels();
|
|
||||||
setTimeout(() => openInviteModal('t:' + created.id, false), 800);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeTunnelModal();
|
closeTunnelModal();
|
||||||
await fetchTunnels();
|
await fetchTunnels();
|
||||||
|
// 新建后直接生成邀请:新建的通道组邀请整组;加入已有组时组的授权自动覆盖新地址,不用再邀请
|
||||||
|
const joinsExisting = sel !== '' && sel !== '__new__';
|
||||||
|
if (!id && mode !== 'all' && !joinsExisting) {
|
||||||
|
const key = data.group ? 'g:' + data.group.id : 't:' + data.tunnels[0].id;
|
||||||
|
setTimeout(() => openInviteModal(key, false), 800);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('保存错误: ' + err.message);
|
alert('保存错误: ' + err.message);
|
||||||
}
|
}
|
||||||
@@ -1668,7 +1786,8 @@
|
|||||||
<div class="member-list">
|
<div class="member-list">
|
||||||
${shown.map(t => memberRowHtml(g, t)).join('')}
|
${shown.map(t => memberRowHtml(g, t)).join('')}
|
||||||
${hidden > 0 ? `<div class="member-hint">已按搜索 / 筛选隐藏 ${hidden} 个地址</div>` : ''}
|
${hidden > 0 ? `<div class="member-hint">已按搜索 / 筛选隐藏 ${hidden} 个地址</div>` : ''}
|
||||||
${!members.length ? '<div class="member-hint">组内暂无通道,点击“编辑”添加目标或把已有通道加入该组</div>' : ''}
|
${!members.length ? '<div class="member-hint">组内暂无通道</div>' : ''}
|
||||||
|
<button class="add-member" onclick="openCreateModal('${gid}')" title="在该组里开放新的主机或端口,组的邀请和授权自动覆盖新地址">+ 添加地址</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tunnel-bottom">
|
<div class="tunnel-bottom">
|
||||||
<div class="metrics-summary">
|
<div class="metrics-summary">
|
||||||
@@ -1710,31 +1829,18 @@
|
|||||||
await fetchTunnels();
|
await fetchTunnels();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 新建 / 编辑通道组 ----
|
// ---- 编辑通道组:名称、责任人、把已有通道加入该组 ----
|
||||||
let groupPreviewTimer = null, groupPreviewOK = false;
|
|
||||||
|
|
||||||
function openGroupModal(id) {
|
function openGroupModal(id) {
|
||||||
const g = id ? currentGroups.find(x => x.id === id) : null;
|
const g = currentGroups.find(x => x.id === id);
|
||||||
document.getElementById('groupModalTitle').innerText = g ? '编辑通道组' : '新建通道组';
|
if (!g) return;
|
||||||
document.getElementById('groupSaveBtn').innerText = g ? '保存' : '创建通道组';
|
document.getElementById('groupId').value = g.id;
|
||||||
document.getElementById('groupTargetsLabel').innerText = g ? '追加目标(可选)' : '目标主机与端口';
|
document.getElementById('groupName').value = g.name;
|
||||||
document.getElementById('groupId').value = g ? g.id : '';
|
document.getElementById('groupOwner').value = g.owner || '';
|
||||||
document.getElementById('groupName').value = g ? g.name : '';
|
|
||||||
document.getElementById('groupOwner').value = g ? (g.owner || '') : '';
|
|
||||||
document.getElementById('groupHosts').value = '';
|
|
||||||
document.getElementById('groupPorts').value = '';
|
|
||||||
document.getElementById('groupIdleClose').value = '30';
|
|
||||||
document.getElementById('groupMaxDuration').value = '0';
|
|
||||||
document.getElementById('groupAccessMode').value = 'link';
|
|
||||||
document.getElementById('groupWhitelist').value = '';
|
|
||||||
onGroupAccessModeChange();
|
|
||||||
const free = currentTunnels.filter(t => !t.group_id);
|
const free = currentTunnels.filter(t => !t.group_id);
|
||||||
document.getElementById('groupExistingWrap').style.display = free.length ? 'block' : 'none';
|
document.getElementById('groupExistingWrap').style.display = free.length ? 'block' : 'none';
|
||||||
document.getElementById('groupExistingLabel').innerText = g ? '把已有通道加入该组(可选)' : '或加入已有通道(可选)';
|
|
||||||
document.getElementById('groupExisting').innerHTML = free.map(t => `
|
document.getElementById('groupExisting').innerHTML = free.map(t => `
|
||||||
<label class="check-item"><input type="checkbox" value="${escapeHtml(t.id)}">
|
<label class="check-item"><input type="checkbox" value="${escapeHtml(t.id)}">
|
||||||
<span>${escapeHtml(t.name)}</span><code>:${t.listen_port} → ${escapeHtml(t.target)}</code></label>`).join('');
|
<span>${escapeHtml(t.name)}</span><code>:${t.listen_port} → ${escapeHtml(t.target)}</code></label>`).join('');
|
||||||
renderGroupPreview(null);
|
|
||||||
document.getElementById('groupModal').classList.add('open');
|
document.getElementById('groupModal').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1742,81 +1848,20 @@
|
|||||||
document.getElementById('groupModal').classList.remove('open');
|
document.getElementById('groupModal').classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onGroupAccessModeChange() {
|
|
||||||
const mode = document.getElementById('groupAccessMode').value;
|
|
||||||
document.getElementById('groupWhitelist').style.display = mode === 'specific' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGroupPreview(result) {
|
|
||||||
const box = document.getElementById('groupPreview');
|
|
||||||
const hasHosts = document.getElementById('groupHosts').value.trim() !== '';
|
|
||||||
document.getElementById('groupNewSettings').style.display = hasHosts ? 'block' : 'none';
|
|
||||||
groupPreviewOK = !hasHosts;
|
|
||||||
if (!hasHosts || !result) {
|
|
||||||
box.innerHTML = '每个 主机 × 端口 会创建一个通道,并分配一段连续的访问端口';
|
|
||||||
box.style.color = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.error) {
|
|
||||||
box.innerText = result.error;
|
|
||||||
box.style.color = 'var(--danger)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
groupPreviewOK = result.members.length > 0;
|
|
||||||
box.style.color = '';
|
|
||||||
const shown = result.members.slice(0, 6);
|
|
||||||
box.innerHTML = shown.map(m => `<div class="preview-row"><span class="route-endpoint">${escapeHtml(publicHost)}:${m.listen_port}</span> <span class="route-arrow">➔</span> <span class="route-target">${escapeHtml(m.target)}</span></div>`).join('')
|
|
||||||
+ `<div>${result.members.length > shown.length ? '… ' : ''}共 ${result.members.length} 个地址</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleGroupPreview() {
|
|
||||||
clearTimeout(groupPreviewTimer);
|
|
||||||
groupPreviewTimer = setTimeout(async () => {
|
|
||||||
const hosts = document.getElementById('groupHosts').value;
|
|
||||||
if (!hosts.trim()) { renderGroupPreview(null); return; }
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/groups/preview', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: document.getElementById('groupName').value.trim() || '通道组', hosts, ports: document.getElementById('groupPorts').value })
|
|
||||||
});
|
|
||||||
renderGroupPreview(res.ok ? await res.json() : { error: await apiError(res) });
|
|
||||||
} catch (e) {
|
|
||||||
renderGroupPreview({ error: '预览失败:' + e.message });
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveGroup(e) {
|
async function saveGroup(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const id = document.getElementById('groupId').value;
|
const id = document.getElementById('groupId').value;
|
||||||
const hosts = document.getElementById('groupHosts').value.trim();
|
|
||||||
const tunnelIds = [...document.querySelectorAll('#groupExisting input:checked')].map(i => i.value);
|
|
||||||
if (!id && !hosts && !tunnelIds.length) { alert('请填写目标主机与端口,或选择要加入的已有通道'); return; }
|
|
||||||
if (hosts && !groupPreviewOK) { alert('请先修正目标主机与端口'); return; }
|
|
||||||
const mode = document.getElementById('groupAccessMode').value;
|
|
||||||
let whitelist = [];
|
|
||||||
if (mode === 'all') whitelist = ['*'];
|
|
||||||
if (mode === 'specific') {
|
|
||||||
whitelist = document.getElementById('groupWhitelist').value.split(',').map(s => s.trim()).filter(Boolean);
|
|
||||||
if (hosts && !whitelist.length) { alert('请填写允许访问的 IP 或网段,或选择“仅授权链接”'); return; }
|
|
||||||
}
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: document.getElementById('groupName').value.trim(),
|
name: document.getElementById('groupName').value.trim(),
|
||||||
owner: document.getElementById('groupOwner').value.trim(),
|
owner: document.getElementById('groupOwner').value.trim(),
|
||||||
hosts, ports: hosts ? document.getElementById('groupPorts').value : '',
|
tunnel_ids: [...document.querySelectorAll('#groupExisting input:checked')].map(i => i.value)
|
||||||
tunnel_ids: tunnelIds, whitelist,
|
|
||||||
idle_close_minutes: parseInt(document.getElementById('groupIdleClose').value, 10),
|
|
||||||
duration_minutes: parseInt(document.getElementById('groupMaxDuration').value, 10),
|
|
||||||
};
|
};
|
||||||
const res = await fetch(id ? `/api/groups/${encodeURIComponent(id)}` : '/api/groups', {
|
const res = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
|
||||||
method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
if (!res.ok) { alert('保存失败: ' + await apiError(res)); return; }
|
if (!res.ok) { alert('保存失败: ' + await apiError(res)); return; }
|
||||||
const data = await res.json();
|
|
||||||
closeGroupModal();
|
closeGroupModal();
|
||||||
await fetchTunnels();
|
await fetchTunnels();
|
||||||
// 新建通道组后直接打开整组的邀请设置
|
|
||||||
if (!id && mode !== 'all' && data.group) setTimeout(() => openInviteModal('g:' + data.group.id, false), 800);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 删除 / 解散通道组 ----
|
// ---- 删除 / 解散通道组 ----
|
||||||
@@ -1843,13 +1888,6 @@
|
|||||||
await fetchTunnels();
|
await fetchTunnels();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillTunnelGroupSelect(selected) {
|
|
||||||
const wrap = document.getElementById('tunnelGroupWrap');
|
|
||||||
wrap.style.display = currentGroups.length ? 'block' : 'none';
|
|
||||||
document.getElementById('tunnelGroup').innerHTML = '<option value="">不属于任何组</option>' +
|
|
||||||
currentGroups.map(g => `<option value="${escapeHtml(g.id)}" ${g.id === selected ? 'selected' : ''}>${escapeHtml(g.name)}</option>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function killConns(ids) {
|
async function killConns(ids) {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
await fetch('/api/connections/kill', {
|
await fetch('/api/connections/kill', {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 536 KiB After Width: | Height: | Size: 535 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 587 KiB After Width: | Height: | Size: 586 KiB |
+521
-469
File diff suppressed because it is too large.
Load diff
@@ -259,14 +259,15 @@ MainActor.assumeIsolated {
|
|||||||
render("readme-edit", CGSize(width: 490, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
|
render("readme-edit", CGSize(width: 490, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
|
||||||
let g = demo.groups[0]
|
let g = demo.groups[0]
|
||||||
render("group-card", CGSize(width: 980, height: 330), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
render("group-card", CGSize(width: 980, height: 330), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
||||||
onEdit: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
||||||
render("group-card-dark", CGSize(width: 980, height: 330), dark: true, GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
render("group-card-dark", CGSize(width: 980, height: 330), dark: true, GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
||||||
onEdit: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
||||||
render("group-card-narrow", CGSize(width: 720, height: 360), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
render("group-card-narrow", CGSize(width: 720, height: 360), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
||||||
onEdit: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
||||||
render("group-sheet-new", CGSize(width: 560, height: 640), GroupEditSheet(vm: demo, group: nil, isPresented: yes,
|
render("group-add-batch", CGSize(width: 490, height: 860), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes,
|
||||||
initialName: "订单服务", initialHosts: "10.0.3.11-13", initialPorts: "22, 8080"))
|
draftName: "订单服务", draftTarget: "10.0.3.11-13:22, 10.0.3.11-13:8080"))
|
||||||
render("group-sheet-edit", CGSize(width: 560, height: 520), GroupEditSheet(vm: demo, group: g, isPresented: yes))
|
render("group-add-member", CGSize(width: 490, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, groupId: g.id))
|
||||||
|
render("group-sheet-edit", CGSize(width: 520, height: 420), GroupEditSheet(vm: demo, group: g, isPresented: yes))
|
||||||
render("group-invite", CGSize(width: 560, height: 300), InviteSheet(vm: demo, scope: .group(g.id), autoGenerate: true, isPresented: yes))
|
render("group-invite", CGSize(width: 560, height: 300), InviteSheet(vm: demo, scope: .group(g.id), autoGenerate: true, isPresented: yes))
|
||||||
render("group-access", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 2, isPresented: yes))
|
render("group-access", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 2, isPresented: yes))
|
||||||
render("group-online", CGSize(width: 760, height: 400), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 0, isPresented: yes))
|
render("group-online", CGSize(width: 760, height: 400), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 0, isPresented: yes))
|
||||||
|
|||||||
Reference in new issue
Block a user