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

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

No files matched your search

+10
View File
@@ -55,11 +55,13 @@ PortRelay 把这件事变成三步:
- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
- **通道组**:一次开放多台主机、多个端口(例如 `10.0.1.11-13` × `22, 8080`),自动分配一段连续端口。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。
**通道生命周期**
- **闲置自动关闭**:没有连接、没有有效授权、没有待处理申请,也没有还能使用的邀请链接时开始计时,默认 30 分钟后关闭。
- 可选的**最长开启时长**,到点无论是否在用都关闭。
- 通道组内任一地址在用,整组都不算闲置,一起开关、一起关闭。
- 记录首次连接时间、实际连接时长和每次关闭的原因。
- 编辑通道不会打断现有连接;更换端口时先绑定新端口再释放旧端口。
@@ -101,6 +103,12 @@ PortRelay 把这件事变成三步:
</tr>
</table>
**通道组**:组卡片汇总整组的状态与流量,下面逐行列出每个访问地址,可以单独开关、单独邀请或移出组。
<p align="center">
<img src="docs/images/app-group.png" alt="通道组" width="100%">
</p>
**连接与授权管理**:待审批的申请、已授权设备(在线状态与剩余时间),以及仍然有效的邀请链接,都在同一处管理。
<p align="center">
@@ -204,6 +212,8 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
需要一次开放多台主机时(比如一组服务器),点 **新建通道组**,主机填 `10.0.1.11-13`、端口填 `22`,预览确认端口映射后创建,再点 **邀请整组**。已有的通道也可以在新建组时勾选加入。
## 配置参考
**网关 `portrelay-server`**
+10
View File
@@ -58,11 +58,13 @@ 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.
- **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.
- **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 lifecycle**
- **Idle auto-close.** The idle timer starts when a tunnel has no connections, no active grants, no pending requests and no invite links with uses left. By default the tunnel closes after 30 minutes of idle time.
- An optional **maximum open time** closes the tunnel at a fixed point, whether or not it is in use.
- While any address in a group is in use, the whole group counts as in use; members are switched on and off together.
- PortRelay records the first-connection time, the actual connected time and why each tunnel was closed.
- Editing a tunnel keeps existing connections. Moving a tunnel binds the new port before releasing the old one.
@@ -104,6 +106,12 @@ PortRelay turns that into three steps:
</tr>
</table>
**Tunnel groups.** A group card sums up the state and traffic of the whole group and lists every access address. Each address can be switched, invited or moved out on its own.
<p align="center">
<img src="docs/images/app-group.png" alt="Tunnel group" width="100%">
</p>
**Access management.** Pending requests, authorized devices (with online state and remaining time) and active invite links are all managed in one place.
<p align="center">
@@ -213,6 +221,8 @@ 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.
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.
## Configuration
**Gateway: `portrelay-server`**
+70 -14
View File
@@ -166,10 +166,14 @@ type TunnelActivity struct {
// inUse: 有在线连接、有效授权(含尚未首次连接的)、待审批申请或仍可使用的邀请
func tunnelInUse(m protocol.TunnelMetrics) bool {
if m.ActiveConns > 0 || len(m.DynamicLeases) > 0 || len(m.Pending) > 0 {
return m.ActiveConns > 0 || accessInUse(m.DynamicLeases, m.Pending, m.Invites)
}
func accessInUse(leases []protocol.DynamicLease, pending []protocol.AccessRequest, invites []protocol.Invite) bool {
if len(leases) > 0 || len(pending) > 0 {
return true
}
for _, inv := range m.Invites {
for _, inv := range invites {
if inv.MaxUses <= 0 || len(inv.Uses) < inv.MaxUses {
return true
}
@@ -188,6 +192,18 @@ func (a *Agent) trackUsage(now time.Time) {
for _, m := range metrics.Tunnels {
byID[m.TunnelID] = m
}
// 通道组整体在用:组内任一通道在用,或组的邀请 / 授权 / 申请仍有效。组内通道一起计闲置、一起关闭。
groupBusy := make(map[string]bool)
for _, gm := range metrics.Groups {
if accessInUse(gm.DynamicLeases, gm.Pending, gm.Invites) {
groupBusy[gm.GroupID] = true
}
}
for _, t := range cfg.Tunnels {
if m, ok := byID[t.ID]; ok && t.Enabled && t.GroupID != "" && m.Status == "online" && tunnelInUse(m) {
groupBusy[t.GroupID] = true
}
}
type closing struct {
id, reason string
@@ -226,7 +242,7 @@ func (a *Agent) trackUsage(now time.Time) {
firstConn = append(firstConn, t.ID)
}
}
u.inUse = tunnelInUse(m)
u.inUse = tunnelInUse(m) || (t.GroupID != "" && groupBusy[t.GroupID])
if u.inUse {
u.lastActive = now
}
@@ -292,6 +308,20 @@ func (a *Agent) RateSeries(id string) RateSeries {
return a.rates.series(id, time.Now())
}
// GroupRateSeries sums the rate curves of several tunnels (a tunnel group)
func (a *Agent) GroupRateSeries(ids []string) RateSeries {
now := time.Now()
sum := (&rateHistory{}).series(now)
for _, id := range ids {
s := a.rates.series(id, now)
for k := range sum.Up {
sum.Up[k] += s.Up[k]
sum.Down[k] += s.Down[k]
}
}
return sum
}
// Activity returns the live usage of an enabled tunnel (nil when not tracked yet)
func (a *Agent) Activity(t protocol.TunnelConfig) *TunnelActivity {
a.usageMu.Lock()
@@ -448,7 +478,7 @@ func (a *Agent) connectAndRun(cfg Config) error {
for _, t := range a.cm.Get().Tunnels {
if t.Enabled {
log.Printf("[Agent] 注册通道: %s (:%d -> %s) [责任人: %s]", t.Name, t.ListenPort, t.Target, t.Owner)
_ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: t})
_ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: a.wire(t)})
}
}
@@ -696,12 +726,23 @@ func (a *Agent) SyncTunnel(t protocol.TunnelConfig, edited bool) {
return
}
if edited {
_ = a.sendControl(protocol.UpdateTunnelMessage{Type: protocol.TypeUpdateTunnel, Tunnel: t})
_ = a.sendControl(protocol.UpdateTunnelMessage{Type: protocol.TypeUpdateTunnel, Tunnel: a.wire(t)})
} else {
_ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: t})
_ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: a.wire(t)})
}
}
// wire fills in what the relay server needs besides the stored config (the group's display name)
func (a *Agent) wire(t protocol.TunnelConfig) protocol.TunnelConfig {
t.GroupName = ""
if t.GroupID != "" {
if g, ok := a.cm.FindGroup(t.GroupID); ok {
t.GroupName = g.Name
}
}
return t
}
func (a *Agent) RemoveTunnel(id string) {
a.mu.Lock()
delete(a.regErrors, id)
@@ -712,8 +753,14 @@ func (a *Agent) RemoveTunnel(id string) {
_ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id})
}
// Scope selects what an invite, request or lease applies to: one tunnel, or a whole group
type Scope struct {
TunnelID string
GroupID string
}
// CreateInvite asks the relay server for a new invite link and waits for the result
func (a *Agent) CreateInvite(tunnelID, note string, validMinutes, maxUses int, leaseDuration string, requireApproval bool) (protocol.Invite, error) {
func (a *Agent) CreateInvite(scope Scope, note string, validMinutes, maxUses int, leaseDuration string, requireApproval bool) (protocol.Invite, error) {
if !a.IsConnected() {
return protocol.Invite{}, fmt.Errorf("未连接中继服务器")
}
@@ -729,7 +776,7 @@ func (a *Agent) CreateInvite(tunnelID, note string, validMinutes, maxUses int, l
}()
if err := a.sendControl(protocol.CreateInviteMessage{
Type: protocol.TypeCreateInvite, RequestID: reqID, TunnelID: tunnelID,
Type: protocol.TypeCreateInvite, RequestID: reqID, TunnelID: scope.TunnelID, GroupID: scope.GroupID,
Note: note, ValidMinutes: validMinutes, MaxUses: maxUses, LeaseDuration: leaseDuration, RequireApproval: requireApproval,
}); err != nil {
return protocol.Invite{}, err
@@ -746,27 +793,35 @@ func (a *Agent) CreateInvite(tunnelID, note string, validMinutes, maxUses int, l
}
// RevokeInvite invalidates an invite link and the leases it granted
func (a *Agent) RevokeInvite(tunnelID, code string) error {
func (a *Agent) RevokeInvite(scope Scope, code string) error {
if !a.IsConnected() {
return fmt.Errorf("未连接中继服务器")
}
return a.sendControl(protocol.RevokeInviteMessage{Type: protocol.TypeRevokeInvite, TunnelID: tunnelID, Code: code})
return a.sendControl(protocol.RevokeInviteMessage{Type: protocol.TypeRevokeInvite, TunnelID: scope.TunnelID, GroupID: scope.GroupID, Code: code})
}
// DecideRequest approves or rejects a pending access request
func (a *Agent) DecideRequest(tunnelID, requestID string, approve bool) error {
func (a *Agent) DecideRequest(scope Scope, requestID string, approve bool) error {
if !a.IsConnected() {
return fmt.Errorf("未连接中继服务器")
}
return a.sendControl(protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: tunnelID, RequestID: requestID, Approve: approve})
return a.sendControl(protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: scope.TunnelID, GroupID: scope.GroupID, RequestID: requestID, Approve: approve})
}
// RevokeLease removes a temporary whitelist lease on the relay server (all leases when ip is empty)
func (a *Agent) RevokeLease(tunnelID, ip string) error {
func (a *Agent) RevokeLease(scope Scope, ip string) error {
if !a.IsConnected() {
return fmt.Errorf("未连接中继服务器")
}
return a.sendControl(protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, TunnelID: tunnelID, IP: ip})
return a.sendControl(protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, TunnelID: scope.TunnelID, GroupID: scope.GroupID, IP: ip})
}
// DeleteGroup tells the relay server to drop a dissolved group's invites and leases
func (a *Agent) DeleteGroup(groupID string) {
if !a.IsConnected() {
return
}
_ = a.sendControl(protocol.DeleteGroupMessage{Type: protocol.TypeDeleteGroup, GroupID: groupID})
}
func (a *Agent) KillConn(connID string) {
@@ -801,6 +856,7 @@ func (a *Agent) GetLatestMetrics() protocol.MetricsMessage {
defer a.mu.RUnlock()
m := a.latestMetrics
m.Tunnels = append([]protocol.TunnelMetrics(nil), m.Tunnels...)
m.Groups = append([]protocol.GroupMetrics(nil), m.Groups...)
m.ActiveConns = append([]protocol.ActiveConnInfo(nil), m.ActiveConns...)
m.SecurityEvents = append([]protocol.SecurityEvent(nil), m.SecurityEvents...)
return m
+186 -77
View File
@@ -42,6 +42,8 @@ func (s *APIServer) Handler() http.Handler {
mux.HandleFunc("/api/status", s.handleStatus)
mux.HandleFunc("/api/tunnels", s.handleTunnels)
mux.HandleFunc("/api/tunnels/", s.handleTunnelItem)
mux.HandleFunc("/api/groups", s.handleGroups)
mux.HandleFunc("/api/groups/", s.handleGroupItem)
mux.HandleFunc("/api/probe", s.handleProbe)
mux.HandleFunc("/api/recommend-port", s.handleRecommendPort)
mux.HandleFunc("/api/connections", s.handleConnections)
@@ -212,47 +214,13 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
return
}
// POST /api/tunnels/{id}/leases/revoke {"ip": "..."};ip 为空表示撤销该通道全部临时放通
if len(parts) == 5 && parts[3] == "leases" && parts[4] == "revoke" && r.Method == http.MethodPost {
var req struct {
IP string `json:"ip"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
// 授权相关:leases/revoke、invites、requests(与通道组共用同一套处理)
if len(parts) >= 4 && isAccessPath(parts[3]) {
if _, ok := s.cm.FindTunnel(tunnelID); !ok {
jsonError(w, http.StatusNotFound, "通道未找到")
return
}
if err := s.agent.RevokeLease(tunnelID, strings.TrimSpace(req.IP)); err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
return
}
// 邀请链接:POST 新建;DELETE /invites/{code} 作废;GET /invites/{code}/share 获取分享文案
if len(parts) >= 4 && parts[3] == "invites" {
s.handleInvites(w, r, tunnelID, parts[4:])
return
}
// POST /api/tunnels/{id}/requests/{rid} {"approve": true|false}
if len(parts) == 5 && parts[3] == "requests" && r.Method == http.MethodPost {
var req struct {
Approve bool `json:"approve"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := s.agent.DecideRequest(tunnelID, parts[4], req.Approve); err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"})
s.handleAccess(w, r, Scope{TunnelID: tunnelID}, parts[3:])
return
}
@@ -303,44 +271,59 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
// handleToggle 支持 {"enabled": true|false} 指定目标状态(幂等,防止重复点击来回翻转);不带 body 时取反
func (s *APIServer) handleToggle(w http.ResponseWriter, r *http.Request, tunnelID string) {
want, err := decodeToggle(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
t, err := s.setEnabled(tunnelID, want)
if err != nil {
jsonError(w, http.StatusNotFound, err.Error())
return
}
jsonResponse(w, http.StatusOK, t)
}
// decodeToggle reads {"enabled": bool}; nil means "flip"
func decodeToggle(r *http.Request) (*bool, error) {
var req struct {
Enabled *bool `json:"enabled"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
return nil, err
}
}
return req.Enabled, nil
}
// setEnabled turns a tunnel on or off (want nil flips it), merging session traffic and syncing the server
func (s *APIServer) setEnabled(tunnelID string, want *bool) (protocol.TunnelConfig, error) {
current, err := s.cm.CurrentEnabled(tunnelID)
if err != nil {
jsonError(w, http.StatusNotFound, err.Error())
return
return protocol.TunnelConfig{}, err
}
want := !current
if req.Enabled != nil {
want = *req.Enabled
target := !current
if want != nil {
target = *want
}
var sessIn, sessOut int64
if !want && current {
if !target && current {
s.agent.FlushUsage(tunnelID)
}
if want != current {
if target != current {
// 关闭时把本次会话流量并入历史;开启时清掉可能残留的旧指标
sessIn, sessOut = s.agent.ConsumeSessionBytes(tunnelID)
}
t, changed, err := s.cm.SetTunnelEnabled(tunnelID, want, sessIn, sessOut)
t, changed, err := s.cm.SetTunnelEnabled(tunnelID, target, sessIn, sessOut)
if err != nil {
jsonError(w, http.StatusNotFound, err.Error())
return
return t, err
}
if changed {
s.agent.dropUsage(tunnelID)
s.agent.SyncTunnel(t, false)
}
jsonResponse(w, http.StatusOK, t)
return t, nil
}
// inviteBase is the URL prefix of invite links: http://<对外访问地址>:<控制端口>/i/
@@ -358,13 +341,26 @@ func (s *APIServer) inviteBase(cfg Config) string {
// shareText is the ready-to-send message (e.g. for WeChat) for an invite
func shareText(cfg Config, t protocol.TunnelConfig, inv protocol.Invite, link string) string {
return shareMessage(inv, fmt.Sprintf("通道:%s\n访问地址:%s:%d", t.Name, cfg.PublicHostOrDefault(), t.ListenPort), link)
}
// groupShareText lists every open address of a group with the target behind it
func groupShareText(cfg Config, g TunnelGroup, members []protocol.TunnelConfig, inv protocol.Invite, link string) string {
var b strings.Builder
fmt.Fprintf(&b, "通道组:%s(%d 个地址)\n访问地址:", g.Name, len(members))
for _, t := range members {
fmt.Fprintf(&b, "\n %s:%d → %s", cfg.PublicHostOrDefault(), t.ListenPort, t.Target)
}
return shareMessage(inv, b.String(), link)
}
func shareMessage(inv protocol.Invite, body, link string) string {
var b strings.Builder
b.WriteString("【PortRelay 访问邀请】")
if inv.Note != "" {
b.WriteString("(给 " + inv.Note + ")")
}
b.WriteString("\n通道:" + t.Name)
b.WriteString(fmt.Sprintf("\n访问地址:%s:%d", cfg.PublicHostOrDefault(), t.ListenPort))
b.WriteString("\n" + body)
action := "填写姓名后即可访问"
if inv.RequireApproval {
action = "填写姓名提交申请,管理员批准后即可访问"
@@ -379,12 +375,104 @@ func shareText(cfg Config, t protocol.TunnelConfig, inv protocol.Invite, link st
return b.String()
}
func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnelID string, rest []string) {
t, ok := s.cm.FindTunnel(tunnelID)
if !ok {
jsonError(w, http.StatusNotFound, "通道未找到")
return
func isAccessPath(p string) bool {
return p == "leases" || p == "invites" || p == "requests"
}
// handleAccess serves the access endpoints shared by tunnels and groups:
//
// POST leases/revoke {"ip": "..."}(ip 为空表示撤销全部)
// POST invites 新建邀请
// DELETE invites/{code} 作废邀请
// GET invites/{code}/share 分享文案
// POST requests/{rid} {"approve": true|false}
func (s *APIServer) handleAccess(w http.ResponseWriter, r *http.Request, sc Scope, rest []string) {
switch {
case len(rest) == 2 && rest[0] == "leases" && rest[1] == "revoke" && r.Method == http.MethodPost:
var req struct {
IP string `json:"ip"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := s.agent.RevokeLease(sc, strings.TrimSpace(req.IP)); err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
case rest[0] == "invites":
s.handleInvites(w, r, sc, rest[1:])
case len(rest) == 2 && rest[0] == "requests" && r.Method == http.MethodPost:
var req struct {
Approve bool `json:"approve"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := s.agent.DecideRequest(sc, rest[1], req.Approve); err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"})
default:
http.NotFound(w, r)
}
}
// scopeShare builds the share message for an invite of a tunnel or group
func (s *APIServer) scopeShare(cfg Config, sc Scope, inv protocol.Invite, link string) string {
if sc.GroupID != "" {
g, _ := s.cm.FindGroup(sc.GroupID)
var open []protocol.TunnelConfig
for _, t := range s.cm.GroupMembers(sc.GroupID) {
if t.Enabled {
open = append(open, t)
}
}
return groupShareText(cfg, g, open, inv, link)
}
t, _ := s.cm.FindTunnel(sc.TunnelID)
return shareText(cfg, t, inv, link)
}
// scopeOpen reports whether the tunnel, or at least one tunnel of the group, is enabled
func (s *APIServer) scopeOpen(sc Scope) bool {
if sc.GroupID != "" {
for _, t := range s.cm.GroupMembers(sc.GroupID) {
if t.Enabled {
return true
}
}
return false
}
t, ok := s.cm.FindTunnel(sc.TunnelID)
return ok && t.Enabled
}
// scopeInvites returns the live invites of a tunnel or group
func scopeInvites(m protocol.MetricsMessage, sc Scope) []protocol.Invite {
if sc.GroupID != "" {
for _, g := range m.Groups {
if g.GroupID == sc.GroupID {
return g.Invites
}
}
return nil
}
for _, t := range m.Tunnels {
if t.TunnelID == sc.TunnelID {
return t.Invites
}
}
return nil
}
func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, sc Scope, rest []string) {
cfg := s.cm.Get()
switch {
@@ -415,11 +503,15 @@ func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnel
if req.LeaseDuration == "" {
req.LeaseDuration = protocol.LeaseDuration2h
}
if !t.Enabled {
jsonError(w, http.StatusConflict, "通道未开启,请先开启通道再生成邀请链接")
if !s.scopeOpen(sc) {
msg := "通道未开启,请先开启通道再生成邀请链接"
if sc.GroupID != "" {
msg = "通道组内没有开启的通道,请先开启再生成邀请链接"
}
jsonError(w, http.StatusConflict, msg)
return
}
inv, err := s.agent.CreateInvite(tunnelID, strings.TrimSpace(req.Note), req.ValidMinutes, maxUses, req.LeaseDuration, req.RequireApproval)
inv, err := s.agent.CreateInvite(sc, strings.TrimSpace(req.Note), req.ValidMinutes, maxUses, req.LeaseDuration, req.RequireApproval)
if err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
@@ -428,27 +520,22 @@ func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnel
jsonResponse(w, http.StatusCreated, map[string]any{
"invite": inv,
"link": link,
"share_text": shareText(cfg, t, inv, link),
"share_text": s.scopeShare(cfg, sc, inv, link),
})
case len(rest) == 1 && r.Method == http.MethodDelete:
if err := s.agent.RevokeInvite(tunnelID, rest[0]); err != nil {
if err := s.agent.RevokeInvite(sc, rest[0]); err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
case len(rest) == 2 && rest[1] == "share" && r.Method == http.MethodGet:
for _, m := range s.agent.GetLatestMetrics().Tunnels {
if m.TunnelID != tunnelID {
continue
}
for _, inv := range m.Invites {
if inv.Code == rest[0] {
link := s.inviteBase(cfg) + inv.Code
jsonResponse(w, http.StatusOK, map[string]any{"invite": inv, "link": link, "share_text": shareText(cfg, t, inv, link)})
return
}
for _, inv := range scopeInvites(s.agent.GetLatestMetrics(), sc) {
if inv.Code == rest[0] {
link := s.inviteBase(cfg) + inv.Code
jsonResponse(w, http.StatusOK, map[string]any{"invite": inv, "link": link, "share_text": s.scopeShare(cfg, sc, inv, link)})
return
}
}
jsonError(w, http.StatusNotFound, "邀请链接不存在或已过期")
@@ -504,10 +591,10 @@ func (s *APIServer) handleRecommendPort(w http.ResponseWriter, r *http.Request)
// handleConnections returns live client connections (optionally for one tunnel) and recent security events
func (s *APIServer) handleConnections(w http.ResponseWriter, r *http.Request) {
metrics := s.agent.GetLatestMetrics()
tunnelID := r.URL.Query().Get("tunnel_id")
match := s.tunnelFilter(r)
conns := make([]protocol.ActiveConnInfo, 0, len(metrics.ActiveConns))
for _, c := range metrics.ActiveConns {
if tunnelID == "" || c.TunnelID == tunnelID {
if match(c.TunnelID) {
conns = append(conns, c)
}
}
@@ -537,8 +624,29 @@ func (s *APIServer) handleKillConn(w http.ResponseWriter, r *http.Request) {
}
func (s *APIServer) handleClientHistory(w http.ResponseWriter, r *http.Request) {
tunnelID := r.URL.Query().Get("tunnel_id")
jsonResponse(w, http.StatusOK, s.cm.GetClientHistory(tunnelID))
match := s.tunnelFilter(r)
out := make([]protocol.ClientHistoryRecord, 0)
for _, rec := range s.cm.GetClientHistory("") {
if match(rec.TunnelID) {
out = append(out, rec)
}
}
jsonResponse(w, http.StatusOK, out)
}
// tunnelFilter matches tunnels selected by ?tunnel_id= or ?group_id= (everything when neither is set)
func (s *APIServer) tunnelFilter(r *http.Request) func(id string) bool {
if gid := r.URL.Query().Get("group_id"); gid != "" {
ids := make(map[string]bool)
for _, t := range s.cm.GroupMembers(gid) {
ids[t.ID] = true
}
return func(id string) bool { return ids[id] }
}
if tid := r.URL.Query().Get("tunnel_id"); tid != "" {
return func(id string) bool { return id == tid }
}
return func(string) bool { return true }
}
func (s *APIServer) handleSettings(w http.ResponseWriter, r *http.Request) {
@@ -595,6 +703,7 @@ func (s *APIServer) handleSSE(w http.ResponseWriter, r *http.Request) {
payload := s.statusPayload()
payload["metrics"] = metrics
payload["tunnels"] = s.tunnelsWithMetrics(metrics)
payload["groups"] = s.groupsView(metrics)
data, _ := json.Marshal(payload)
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return false
+277
View File
@@ -0,0 +1,277 @@
package client
import (
"encoding/json"
"io"
"net/http"
"strings"
"github.com/DeepFal/PortRelay/core/protocol"
)
// GroupView joins a tunnel group with its members and live shared access state
type GroupView struct {
TunnelGroup
TunnelIDs []string `json:"tunnel_ids"` // 成员,按监听端口排序
Metrics protocol.GroupMetrics `json:"metrics"`
// RateHistory 开启中成员的近 10 分钟流量之和
RateHistory *RateSeries `json:"rate_history,omitempty"`
}
func (s *APIServer) groupsView(metrics protocol.MetricsMessage) []GroupView {
shared := make(map[string]protocol.GroupMetrics, len(metrics.Groups))
for _, g := range metrics.Groups {
shared[g.GroupID] = g
}
cfg := s.cm.Get()
out := make([]GroupView, 0, len(cfg.Groups))
for _, g := range cfg.Groups {
v := GroupView{TunnelGroup: g, TunnelIDs: make([]string, 0), Metrics: protocol.GroupMetrics{GroupID: g.ID}}
if m, ok := shared[g.ID]; ok {
v.Metrics = m
}
var open []string
for _, t := range s.cm.GroupMembers(g.ID) {
v.TunnelIDs = append(v.TunnelIDs, t.ID)
if t.Enabled {
open = append(open, t.ID)
}
}
if len(open) > 0 {
series := s.agent.GroupRateSeries(open)
v.RateHistory = &series
}
out = append(out, v)
}
return out
}
// groupRequest creates a group or adds members to one
type groupRequest struct {
Name string `json:"name"`
Owner string `json:"owner"`
// Hosts / Ports 展开为新建成员,见 ExpandTargets
Hosts string `json:"hosts"`
Ports string `json:"ports"`
// TunnelIDs 加入该组的已有通道
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) {
spec := GroupSpec{
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) {
var req groupRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err == io.EOF {
err = nil
}
return req, err
}
// syncGroupChanges pushes newly created members and changed existing ones to the relay server
func (s *APIServer) syncGroupChanges(created, changed []protocol.TunnelConfig) {
for _, t := range created {
s.agent.SyncTunnel(t, false)
}
for _, t := range changed {
if t.Enabled {
s.agent.SyncTunnel(t, true)
}
}
}
// handleGroups: GET 列表;POST 新建通道组(批量新建目标 + / 或加入已有通道)
func (s *APIServer) handleGroups(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, s.groupsView(s.agent.GetLatestMetrics()))
case http.MethodPost:
req, err := decodeGroupRequest(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
spec, err := req.spec()
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
g, created, changed, err := s.cm.SaveGroup("", spec)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
s.syncGroupChanges(created, changed)
jsonResponse(w, http.StatusCreated, map[string]any{"group": g, "created": nonNil(created)})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// handleGroupItem:
//
// POST /api/groups/preview 预览批量展开结果(不保存)
// PUT /api/groups/{id} 改名 / 责任人,并可追加成员
// DELETE /api/groups/{id}?delete_members=1 解散(默认保留成员为独立通道)
// POST /api/groups/{id}/toggle {"enabled": bool} 整组开关
// ... /api/groups/{id}/leases|invites|requests 与单个通道相同的授权接口
func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 3 || parts[2] == "" {
jsonError(w, http.StatusBadRequest, "Missing group ID")
return
}
if parts[2] == "preview" && len(parts) == 3 && r.Method == http.MethodPost {
s.handleGroupPreview(w, r)
return
}
id := parts[2]
g, ok := s.cm.FindGroup(id)
if !ok {
jsonError(w, http.StatusNotFound, "通道组不存在")
return
}
if len(parts) >= 4 && isAccessPath(parts[3]) {
s.handleAccess(w, r, Scope{GroupID: id}, parts[3:])
return
}
if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost {
want, err := decodeToggle(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if want == nil {
// 不指定时:有任一成员开启则整组关闭,否则整组开启
on := false
for _, t := range s.cm.GroupMembers(id) {
on = on || t.Enabled
}
flip := !on
want = &flip
}
out := make([]protocol.TunnelConfig, 0)
for _, t := range s.cm.GroupMembers(id) {
updated, err := s.setEnabled(t.ID, want)
if err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
out = append(out, updated)
}
jsonResponse(w, http.StatusOK, out)
return
}
if len(parts) != 3 {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodPut:
req, err := decodeGroupRequest(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if strings.TrimSpace(req.Name) == "" {
req.Name = g.Name
}
spec, err := req.spec()
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
updated, created, changed, err := s.cm.SaveGroup(id, spec)
if err != nil {
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:
deleteMembers := r.URL.Query().Get("delete_members") == "1"
members, err := s.cm.RemoveGroup(id, deleteMembers)
if err != nil {
jsonError(w, http.StatusNotFound, err.Error())
return
}
for _, t := range members {
if deleteMembers {
s.agent.ConsumeSessionBytes(t.ID)
s.agent.dropUsage(t.ID)
s.agent.RemoveTunnel(t.ID)
} else if t.Enabled {
s.agent.SyncTunnel(t, true)
}
}
s.agent.DeleteGroup(id)
jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// handleGroupPreview expands hosts × ports and assigns ports without saving
func (s *APIServer) handleGroupPreview(w http.ResponseWriter, r *http.Request) {
req, err := decodeGroupRequest(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
spec, err := req.spec()
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
members, err := s.cm.PreviewGroupMembers(spec)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
type item struct {
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
}
+209
View File
@@ -0,0 +1,209 @@
package client
import (
"fmt"
"net"
"regexp"
"strconv"
"strings"
)
// maxBatchTargets 一次批量创建的目标上限,避免误操作占满端口池
const maxBatchTargets = 64
var (
listSeparators = regexp.MustCompile(`[\s,,、;;]+`)
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})$`)
hostnameRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$`)
)
func splitList(s string) []string {
var out []string
for _, item := range listSeparators.Split(strings.TrimSpace(s), -1) {
if item != "" {
out = append(out, item)
}
}
return out
}
// parsePorts accepts "22, 8080-8082"
func parsePorts(s string) ([]int, error) {
var out []int
for _, item := range splitList(s) {
lo, hi := item, item
if i := strings.Index(item, "-"); i > 0 {
lo, hi = item[:i], item[i+1:]
}
a, err1 := strconv.Atoi(lo)
b, err2 := strconv.Atoi(hi)
if err1 != nil || err2 != nil || a <= 0 || b > 65535 || a > b {
return nil, fmt.Errorf("无效的端口 %q", item)
}
if b-a >= maxBatchTargets {
return nil, fmt.Errorf("端口范围 %q 过大", item)
}
for p := a; p <= b; p++ {
out = append(out, p)
}
}
return out, nil
}
// expandIPv4Range expands "10.0.1.11-13" or "10.0.1.11-10.0.1.13"
func expandIPv4Range(item string) ([]string, bool, error) {
var from, to net.IP
if m := shortRange.FindStringSubmatch(item); m != nil {
from, to = net.ParseIP(m[1]+m[2]).To4(), net.ParseIP(m[1]+m[3]).To4()
} else if m := fullRange.FindStringSubmatch(item); m != nil {
from, to = net.ParseIP(m[1]).To4(), net.ParseIP(m[2]).To4()
} else {
return nil, false, nil
}
if from == nil || to == nil {
return nil, true, fmt.Errorf("无效的地址范围 %q", item)
}
a := uint32(from[0])<<24 | uint32(from[1])<<16 | uint32(from[2])<<8 | uint32(from[3])
b := uint32(to[0])<<24 | uint32(to[1])<<16 | uint32(to[2])<<8 | uint32(to[3])
if a > b {
return nil, true, fmt.Errorf("地址范围 %q 起止颠倒", item)
}
if b-a >= maxBatchTargets {
return nil, true, fmt.Errorf("地址范围 %q 过大", item)
}
var out []string
for v := a; v <= b; v++ {
out = append(out, net.IPv4(byte(v>>24), byte(v>>16), byte(v>>8), byte(v)).String())
}
return out, true, nil
}
func validHost(h string) bool {
return net.ParseIP(h) != nil || hostnameRe.MatchString(h)
}
// ExpandTargets turns host and port lists into "host:port" targets, host by host.
//
// hosts 用逗号、空格或换行分隔,每一项可以是 IP、主机名、IPv4 范围("10.0.1.11-13" 或
// "10.0.1.11-10.0.1.13"),也可以直接写成 "主机:端口";ports 形如 "22, 8080-8082",
// 作用于没有写端口的主机。结果去重并保持输入顺序。
func ExpandTargets(hosts, ports string) ([]string, error) {
portList, err := parsePorts(ports)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
var out []string
add := func(t string) error {
if !seen[t] {
seen[t] = true
out = append(out, t)
}
if len(out) > maxBatchTargets {
return fmt.Errorf("一次最多添加 %d 个目标", maxBatchTargets)
}
return nil
}
items := splitList(hosts)
if len(items) == 0 {
return nil, fmt.Errorf("请填写至少一个目标主机")
}
for _, item := range items {
// 自带端口的完整目标:"10.0.1.11:22"、"[fd00::1]:22"
if h, p, err := net.SplitHostPort(item); err == nil {
n, perr := strconv.Atoi(p)
if !validHost(h) || perr != nil || n <= 0 || n > 65535 {
return nil, fmt.Errorf("无效的目标 %q", item)
}
if err := add(net.JoinHostPort(h, p)); err != nil {
return nil, err
}
continue
}
hostsOfItem, isRange, err := expandIPv4Range(item)
if err != nil {
return nil, err
}
if !isRange {
if !validHost(item) {
return nil, fmt.Errorf("无效的主机 %q", item)
}
hostsOfItem = []string{item}
}
if len(portList) == 0 {
return nil, fmt.Errorf("请填写端口,或把目标写成 主机:端口")
}
for _, h := range hostsOfItem {
for _, p := range portList {
if err := add(net.JoinHostPort(h, strconv.Itoa(p))); err != nil {
return nil, err
}
}
}
}
return out, nil
}
// memberNames names batch-created tunnels after the group: "组名 (主机)" when every target
// shares one port, otherwise "组名 (主机:端口)".
func memberNames(group string, targets []string) []string {
ports := make(map[string]bool)
for _, t := range targets {
if _, p, err := net.SplitHostPort(t); err == nil {
ports[p] = true
}
}
names := make([]string, len(targets))
for i, t := range targets {
label := t
if len(ports) == 1 {
if h, _, err := net.SplitHostPort(t); err == nil {
label = h
}
}
names[i] = fmt.Sprintf("%s (%s)", group, label)
}
return names
}
// allocatePortsLocked picks n free listen ports, preferring one contiguous block so the mapping
// is easy to read. Caller must hold cm.mu.
func (cm *ConfigManager) allocatePortsLocked(n int) ([]int, error) {
used := make(map[int]bool)
for _, t := range cm.cfg.Tunnels {
used[t.ListenPort] = true
}
// 与单个通道的推荐顺序一致:先 33660 往上,再从端口池开头补
var order []int
for p := 33660; p <= PortMax; p++ {
order = append(order, p)
}
for p := PortMin; p < 33660; p++ {
order = append(order, p)
}
free := func(p int) bool { return p != ControlPort && !used[p] }
for i := range order {
ok := true
for k := 0; k < n; k++ {
if i+k >= len(order) || !free(order[i+k]) || (k > 0 && order[i+k] != order[i+k-1]+1) {
ok = false
break
}
}
if ok {
return order[i : i+n], nil
}
}
var out []int
for _, p := range order {
if free(p) {
out = append(out, p)
if len(out) == n {
return out, nil
}
}
}
return nil, fmt.Errorf("端口池剩余 %d 个空闲端口,不够分配 %d 个", len(out), n)
}
+250
View File
@@ -11,6 +11,7 @@ import (
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
@@ -49,12 +50,21 @@ func generateAuthCode() string {
return string(b)
}
// TunnelGroup 通道组:组内通道共用邀请、授权与审批,闲置计时联动
type TunnelGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Config represents persistent client settings
type Config struct {
ServerAddr string `json:"server_addr"` // 中继服务器控制地址 (host:port)
Token string `json:"token"`
PublicHost string `json:"public_host,omitempty"` // 访问者连接通道时使用的网关地址
Tunnels []protocol.TunnelConfig `json:"tunnels"`
Groups []TunnelGroup `json:"groups,omitempty"`
ClientHistory []protocol.ClientHistoryRecord `json:"client_history,omitempty"`
}
@@ -208,6 +218,7 @@ func (cm *ConfigManager) Get() Config {
for i, t := range cm.cfg.Tunnels {
out.Tunnels[i] = cloneTunnel(t)
}
out.Groups = append([]TunnelGroup(nil), cm.cfg.Groups...)
out.ClientHistory = append([]protocol.ClientHistoryRecord(nil), cm.cfg.ClientHistory...)
return out
}
@@ -316,9 +327,24 @@ func normalizeAndValidate(t *protocol.TunnelConfig) error {
// 放通时长与是否审批已改为在每个邀请上设置,通道级字段不再使用
t.LeaseDuration = ""
t.RequireApproval = false
// 组名只在同步到服务端时填写,配置里以组 ID 为准
t.GroupName = ""
return nil
}
// groupExistsLocked reports whether a group ID is known ("" means no group). Caller must hold cm.mu.
func (cm *ConfigManager) groupExistsLocked(id string) bool {
if id == "" {
return true
}
for _, g := range cm.cfg.Groups {
if g.ID == id {
return true
}
}
return false
}
// checkPortLocked returns an error if another tunnel already uses the port. Caller must hold cm.mu.
func (cm *ConfigManager) checkPortLocked(id string, port int) error {
for _, existing := range cm.cfg.Tunnels {
@@ -350,6 +376,9 @@ func (cm *ConfigManager) AddTunnel(t protocol.TunnelConfig) (protocol.TunnelConf
if err := cm.checkPortLocked(t.ID, t.ListenPort); err != nil {
return t, err
}
if !cm.groupExistsLocked(t.GroupID) {
return t, fmt.Errorf("通道组不存在")
}
t.Enabled = true
now := time.Now()
@@ -409,6 +438,9 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
if err := cm.checkPortLocked(id, updated.ListenPort); err != nil {
return existing, existing.ListenPort, err
}
if !cm.groupExistsLocked(updated.GroupID) {
return existing, existing.ListenPort, fmt.Errorf("通道组不存在")
}
// 运行中修改时长:从本次开启时间重新计算到期时间
if updated.Enabled && updated.DurationMinutes != existing.DurationMinutes {
@@ -700,3 +732,221 @@ func (cm *ConfigManager) RecommendPort() int {
}
return 0
}
// ---- 通道组 ----
// FindGroup returns the group with the given ID
func (cm *ConfigManager) FindGroup(id string) (TunnelGroup, bool) {
cm.mu.RLock()
defer cm.mu.RUnlock()
for _, g := range cm.cfg.Groups {
if g.ID == id {
return g, true
}
}
return TunnelGroup{}, false
}
// GroupMembers returns the tunnels of a group ordered by listen port
func (cm *ConfigManager) GroupMembers(id string) []protocol.TunnelConfig {
cm.mu.RLock()
defer cm.mu.RUnlock()
return cm.groupMembersLocked(id)
}
func (cm *ConfigManager) groupMembersLocked(id string) []protocol.TunnelConfig {
var out []protocol.TunnelConfig
for _, t := range cm.cfg.Tunnels {
if id != "" && t.GroupID == id {
out = append(out, cloneTunnel(t))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ListenPort < out[j].ListenPort })
return out
}
// GroupSpec describes a group and the members to add to it
type GroupSpec struct {
Name string
Owner string
// Targets 新建成员的目标(已展开的 主机:端口)
Targets []string
// Template 新建成员的通道设置:白名单、闲置关闭、最长开启时长、限速
Template protocol.TunnelConfig
// MoveIDs 加入该组的已有通道
MoveIDs []string
}
// newMembersLocked builds (but does not store) the tunnels for spec.Targets. Caller must hold cm.mu.
func (cm *ConfigManager) newMembersLocked(groupID string, spec GroupSpec, now time.Time) ([]protocol.TunnelConfig, error) {
if len(spec.Targets) == 0 {
return nil, nil
}
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.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()
defer cm.mu.Unlock()
gi := -1
for i, g := range cm.cfg.Groups {
if id != "" && g.ID == id {
gi = i
} else if g.Name == spec.Name {
return TunnelGroup{}, nil, nil, fmt.Errorf("已有同名的通道组「%s」", spec.Name)
}
}
if id != "" && gi < 0 {
return TunnelGroup{}, nil, nil, fmt.Errorf("通道组不存在")
}
if id == "" && len(spec.Targets) == 0 && len(spec.MoveIDs) == 0 {
return TunnelGroup{}, nil, nil, fmt.Errorf("请添加至少一个目标,或选择要加入的已有通道")
}
group := TunnelGroup{ID: id, Name: spec.Name, Owner: spec.Owner, CreatedAt: time.Now()}
if gi >= 0 {
group.CreatedAt = cm.cfg.Groups[gi].CreatedAt
} else {
group.ID = uuid.New().String()
}
move := make(map[string]bool)
for _, mid := range spec.MoveIDs {
found := false
for _, t := range cm.cfg.Tunnels {
if t.ID != mid {
continue
}
found = true
if t.GroupID != "" && t.GroupID != group.ID {
return TunnelGroup{}, nil, nil, fmt.Errorf("「%s」已属于其它通道组,请先将其移出", t.Name)
}
}
if !found {
return TunnelGroup{}, nil, nil, fmt.Errorf("通道 %s 未找到", mid)
}
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
var changed []protocol.TunnelConfig
for i, t := range cm.cfg.Tunnels {
switch {
case move[t.ID] && t.GroupID != group.ID:
cm.cfg.Tunnels[i].GroupID = group.ID
changed = append(changed, cloneTunnel(cm.cfg.Tunnels[i]))
case renamed && t.GroupID == group.ID:
changed = append(changed, cloneTunnel(t))
}
}
cm.cfg.Tunnels = append(cm.cfg.Tunnels, created...)
if gi >= 0 {
cm.cfg.Groups[gi] = group
} else {
cm.cfg.Groups = append(cm.cfg.Groups, group)
}
out := make([]protocol.TunnelConfig, len(created))
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
// stay as standalone tunnels. Returns the affected members (as they are after the change).
func (cm *ConfigManager) RemoveGroup(id string, deleteMembers bool) ([]protocol.TunnelConfig, error) {
cm.mu.Lock()
defer cm.mu.Unlock()
groups := make([]TunnelGroup, 0, len(cm.cfg.Groups))
found := false
for _, g := range cm.cfg.Groups {
if g.ID == id {
found = true
continue
}
groups = append(groups, g)
}
if !found {
return nil, fmt.Errorf("通道组不存在")
}
var affected []protocol.TunnelConfig
kept := make([]protocol.TunnelConfig, 0, len(cm.cfg.Tunnels))
for _, t := range cm.cfg.Tunnels {
if t.GroupID != id {
kept = append(kept, t)
continue
}
t.GroupID = ""
affected = append(affected, cloneTunnel(t))
if !deleteMembers {
kept = append(kept, t)
}
}
cm.cfg.Groups = groups
cm.cfg.Tunnels = kept
return affected, cm.save()
}
+339
View File
@@ -0,0 +1,339 @@
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"}},
}
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"},
} {
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 groupResp struct {
Group TunnelGroup `json:"group"`
Created []protocol.TunnelConfig `json:"created"`
}
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)
status, body := e.call(t, "POST", "/api/groups", map[string]any{"name": "订单服务", "owner": "运维组", "hosts": echo1 + ", " + echo2})
if status != http.StatusCreated {
t.Fatalf("create group: %d %s", status, body)
}
var g groupResp
_ = json.Unmarshal(body, &g)
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]
for _, m := range g.Created {
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)
}
}
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.Created {
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 groupResp
_ = 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 TestGroupPreviewDoesNotSave(t *testing.T) {
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)
}
var out struct {
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 {
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")
}
}
func TestGroupIdleCloseIsLinked(t *testing.T) {
e := startEnvWith(t, testToken, func(a *Agent) { a.idleUnit = 3 * time.Second })
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})
var g groupResp
_ = json.Unmarshal(body, &g)
a, b := g.Created[0], g.Created[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 })
}
+614 -112
View File
@@ -247,6 +247,7 @@
background: #1e293b; border: 1px solid var(--card-border);
border-radius: 16px; width: 520px; max-width: 90vw; padding: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
max-height: 90vh; overflow-y: auto;
}
.modal-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;
@@ -301,6 +302,32 @@
padding: 24px; display: flex; flex-direction: column;
}
.drawer.open { right: 0; }
/* 通道组 */
.group-icon { color: #38bdf8; font-size: 16px; }
.group-summary { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font-size: 13px; color: var(--text-muted); }
.group-summary .route-endpoint { font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; }
.member-list { border-top: 1px solid rgba(148, 163, 184, 0.14); margin-bottom: 14px; }
.member-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid rgba(148, 163, 184, 0.1); font-size: 13px; }
.member-row.off { opacity: 0.6; }
.member-route { display: flex; align-items: center; gap: 8px; font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; white-space: nowrap; }
.member-route .route-arrow { font-size: 13px; }
.member-name { color: var(--text-muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }
.member-meta { margin-left: auto; color: var(--text-muted); font-size: 12px; white-space: nowrap; font-variant-numeric: tabular-nums; }
.member-hint { color: var(--text-muted); font-size: 12px; padding: 8px 0; }
.member-row .chip { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 5px; cursor: pointer; background: rgba(52, 211, 153, 0.12); color: #34d399; }
.mdot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: #64748b; }
.mdot.on { background: #34d399; } .mdot.err { background: #f87171; } .mdot.wait { background: #fbbf24; }
.member-actions { display: flex; gap: 2px; }
.icon-btn { background: none; border: 0; cursor: pointer; font-size: 13px; padding: 2px 4px; border-radius: 4px; opacity: 0.75; }
.icon-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.08); opacity: 1; }
.icon-btn:disabled { opacity: 0.25; cursor: default; }
.check-list { max-height: 140px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
.form-group label.check-item { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 13px; font-weight: normal; color: var(--text); cursor: pointer; }
.check-item input { width: auto !important; }
.check-item code { color: var(--text-muted); font-size: 12px; }
.preview-row { font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
@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-content { flex: 1; overflow-y: auto; }
</style>
@@ -321,6 +348,7 @@
<div id="statusDot" class="status-dot"></div>
<span id="statusText">正在连接服务器...</span>
</div>
<button class="btn-secondary" onclick="openGroupModal()" title="一次添加多个主机 / 端口,共用一条邀请链接">▤ 新建通道组</button>
<button class="btn-primary" onclick="openCreateModal()">+ 新建代理通道</button>
<button class="btn-secondary" onclick="openSettingsModal()">⚙️ 设置</button>
</div>
@@ -413,6 +441,10 @@
<label>通道名称 / 备注</label>
<input type="text" id="tunnelName" required placeholder="例如:MySQL 实习库 或 财务系统 SSH">
</div>
<div class="form-group" id="tunnelGroupWrap" style="display:none;">
<label>所属通道组</label>
<select id="tunnelGroup"></select>
</div>
<div class="form-group">
<label>目标地址 (中转机 Mac 可直连的内网目标)</label>
<div class="input-row">
@@ -527,6 +559,90 @@
</div>
</div>
<!-- Modal: Tunnel Group -->
<div class="modal-overlay" id="groupModal">
<div class="modal-card">
<div class="modal-header">
<h3 id="groupModalTitle">新建通道组</h3>
<button class="btn-secondary btn-sm" onclick="closeGroupModal()">✕</button>
</div>
<form onsubmit="saveGroup(event)">
<input type="hidden" id="groupId">
<div class="input-hint" style="margin: -8px 0 14px;">组内地址共用邀请与授权:同事授权一次即可访问整组;组内任一地址在用时,整组都不会闲置关闭。</div>
<div class="form-group">
<label>通道组名称</label>
<input type="text" id="groupName" required placeholder="例如:订单服务集群">
</div>
<div class="form-group">
<label>责任人(可选)</label>
<input type="text" id="groupOwner" placeholder="例如:张三 / 运维组">
</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">
<label id="groupExistingLabel">或加入已有通道(可选)</label>
<div class="check-list" id="groupExisting"></div>
</div>
<div id="groupNewSettings" style="display:none;">
<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">
<button type="button" class="btn-secondary" onclick="closeGroupModal()">取消</button>
<button type="submit" class="btn-primary" id="groupSaveBtn">创建通道组</button>
</div>
</form>
</div>
</div>
<!-- Modal: Delete Tunnel Group -->
<div class="modal-overlay" id="groupDeleteModal">
<div class="modal-card" style="width: 460px;">
<div class="modal-header">
<h3 id="groupDeleteTitle">删除通道组</h3>
<button class="btn-secondary btn-sm" onclick="closeGroupDelete()">✕</button>
</div>
<div class="input-hint" style="font-size: 13px; line-height: 1.7;">组的邀请链接与授权会立即失效。解散后组内通道变回独立通道;删除则一并注销所有端口并断开连接。</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeGroupDelete()">取消</button>
<button type="button" class="btn-secondary" id="groupDissolveBtn" onclick="confirmGroupDelete(false)">解散,保留通道</button>
<button type="button" class="btn-danger" onclick="confirmGroupDelete(true)">删除组和全部通道</button>
</div>
</div>
</div>
<!-- Modal: Settings -->
<div class="modal-overlay" id="settingsModal">
<div class="modal-card">
@@ -572,6 +688,7 @@
<script>
let currentTunnels = [];
let currentGroups = [];
let publicHost = '';
let latestMetrics = null;
@@ -661,8 +778,8 @@
const m = t.metrics || {}, id = escapeHtml(t.id);
let html = '';
if (t.max_bandwidth_mb) html += `<span class="chip purple">限速 ${t.max_bandwidth_mb} MB/s</span>`;
if ((m.pending || []).length) html += `<span class="chip orange" onclick="openDrawer('${id}')">🔔 待审批 ${m.pending.length}</span>`;
if ((m.dynamic_leases || []).length) html += `<span class="chip green" onclick="openDrawer('${id}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`;
if ((m.pending || []).length) html += `<span class="chip orange" onclick="openDrawer('t:${id}')">🔔 待审批 ${m.pending.length}</span>`;
if ((m.dynamic_leases || []).length) html += `<span class="chip green" onclick="openDrawer('t:${id}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`;
return html;
}
@@ -718,8 +835,9 @@
async function fetchTunnels() {
try {
const res = await fetch('/api/tunnels');
currentTunnels = await res.json();
const [tr, gr] = await Promise.all([fetch('/api/tunnels'), fetch('/api/groups')]);
currentTunnels = await tr.json();
if (gr.ok) currentGroups = await gr.json();
renderTunnels();
} catch (e) {
console.error(e);
@@ -790,70 +908,34 @@
totalBlocked += (m.blocked_attempts || 0);
});
const query = (document.getElementById('tunnelSearch') || {}).value || '';
const visible = currentTunnels.filter(t =>
(statusFilter === 'all' || (statusFilter === 'running') === !!t.enabled) && tunnelMatches(t, query));
const statusOK = t => statusFilter === 'all' || (statusFilter === 'running') === !!t.enabled;
// 独立通道与通道组按配置顺序混排:组出现在它第一个成员的位置;组名命中时显示整组
const entries = [], seen = new Set();
let matched = 0;
currentTunnels.forEach(t => {
const g = t.group_id && currentGroups.find(x => x.id === t.group_id);
if (g) {
if (seen.has(g.id)) return;
seen.add(g.id);
const groupHit = query.trim() !== '' && tunnelMatches({ name: g.name, owner: g.owner, target: '', listen_port: '' }, query);
const shown = groupMembers(g).filter(m => statusOK(m) && (groupHit || tunnelMatches(m, query)));
if (shown.length) { entries.push(groupCardHtml(g, groupMembers(g), shown)); matched += shown.length; }
} else if (statusOK(t) && tunnelMatches(t, query)) {
entries.push(tunnelCardHtml(t)); matched++;
}
});
currentGroups.filter(g => !seen.has(g.id) && !(g.tunnel_ids || []).length).forEach(g => {
if (statusFilter === 'all' && tunnelMatches({ name: g.name, owner: g.owner, target: '', listen_port: '' }, query)) entries.push(groupCardHtml(g, [], []));
});
const filtering = query.trim() !== '' || statusFilter !== 'all';
document.getElementById('tunnelCount').innerText = filtering
? `匹配 ${visible.length} / 共 ${currentTunnels.length} 个` : `${currentTunnels.length} 个通道`;
? `匹配 ${matched} / 共 ${currentTunnels.length} 个`
: `${currentTunnels.length} 个通道` + (currentGroups.length ? ` · ${currentGroups.length} 个组` : '');
grid.innerHTML = visible.map(t => {
const m = t.metrics || {};
const badge = {
online: ['badge-online', '运行中'],
syncing: ['badge-syncing', '同步中'],
offline: ['badge-syncing', '等待连接服务器'],
error: ['badge-error', '异常'],
disabled: ['badge-disabled', '已暂停'],
}[t.enabled ? (m.status || 'syncing') : 'disabled'] || ['badge-syncing', '同步中'];
const [badgeClass, badgeText] = badge;
const errorTitle = m.status === 'error' && m.error ? ` title="${escapeHtml(m.error)}"` : '';
const sessionBytes = (m.bytes_in || 0) + (m.bytes_out || 0);
const histBytes = (t.historical_bytes_in || 0) + (t.historical_bytes_out || 0) + (t.enabled ? sessionBytes : 0);
return `
<div class="tunnel-card ${t.enabled ? '' : 'disabled'}">
<div class="tunnel-body">
<div class="tunnel-info">
<div class="tunnel-title-row">
<span class="tunnel-name">${escapeHtml(t.name)}</span>
<span class="badge ${badgeClass}"${errorTitle}>${badgeText}</span>
${accessPillHtml(t)}
</div>
${m.status === 'error' && m.error ? `<div style="color:#f87171; font-size:12px;">⚠️ 服务器注册失败:${escapeHtml(m.error)}</div>` : ''}
<div class="route-box">
<span class="route-endpoint">${escapeHtml(publicHost)}:${t.listen_port}</span>
<span class="route-arrow">➔</span>
<span class="route-target">${escapeHtml(t.target)}</span>
</div>
<div class="lifecycle-row">${lifecycleHtml(t)}${badgeChipsHtml(t)}</div>
</div>
${sideHtml(t)}
</div>
<div class="tunnel-bottom">
<div class="metrics-summary">
<span>连接 <b>${m.active_conns || 0}</b></span>
<span>本次 <b>${formatBytes(sessionBytes)}</b></span>
<span>历史 <b>${formatBytes(histBytes)}</b></span>
${t.enabled ? `<span class="rate-fallback">速率 <b>↑ ${formatRate(m.rate_in || 0)} ↓ ${formatRate(m.rate_out || 0)}</b></span>` : ''}
${m.blocked_attempts ? `<span style="color: #f87171;">拦截 <b>${m.blocked_attempts}</b></span>` : ''}
</div>
<div class="tunnel-actions">
<button class="btn-primary btn-sm" ${t.enabled ? '' : 'disabled title="请先开启通道"'} onclick="openInviteModal('${escapeHtml(t.id)}')">📨 邀请</button>
<button class="btn-secondary btn-sm" onclick="openDrawer('${escapeHtml(t.id)}')">👥 连接与授权</button>
<button class="btn-secondary btn-sm" onclick="editTunnel('${escapeHtml(t.id)}')">✏️ 编辑</button>
<button class="btn-danger btn-sm" onclick="deleteTunnel('${escapeHtml(t.id)}')">🗑️ 删除</button>
</div>
</div>
</div>
`;
}).join('');
if (currentTunnels.length === 0) {
grid.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">暂无中继通道,点击上方按钮添加第一个通道</div>';
} else if (visible.length === 0) {
grid.innerHTML = entries.join('');
if (currentTunnels.length === 0 && currentGroups.length === 0) {
grid.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">暂无中继通道,点击上方按钮添加第一个通道;需要一次开放多台主机时,用“新建通道组”</div>';
} else if (entries.length === 0) {
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: 12px; margin-bottom: 12px;">${query.trim() ? '关键字:' + escapeHtml(query.trim()) : ''}${statusFilter !== 'all' ? ' 筛选:' + (statusFilter === 'running' ? '运行中' : '已暂停') : ''}</div>
@@ -871,6 +953,62 @@
document.getElementById('statBlocked').innerText = totalBlocked;
}
function tunnelCardHtml(t) {
const m = t.metrics || {};
const badge = {
online: ['badge-online', '运行中'],
syncing: ['badge-syncing', '同步中'],
offline: ['badge-syncing', '等待连接服务器'],
error: ['badge-error', '异常'],
disabled: ['badge-disabled', '已暂停'],
}[t.enabled ? (m.status || 'syncing') : 'disabled'] || ['badge-syncing', '同步中'];
const [badgeClass, badgeText] = badge;
const errorTitle = m.status === 'error' && m.error ? ` title="${escapeHtml(m.error)}"` : '';
const sessionBytes = (m.bytes_in || 0) + (m.bytes_out || 0);
const histBytes = (t.historical_bytes_in || 0) + (t.historical_bytes_out || 0) + (t.enabled ? sessionBytes : 0);
return `
<div class="tunnel-card ${t.enabled ? '' : 'disabled'}">
<div class="tunnel-body">
<div class="tunnel-info">
<div class="tunnel-title-row">
<span class="tunnel-name">${escapeHtml(t.name)}</span>
<span class="badge ${badgeClass}"${errorTitle}>${badgeText}</span>
${accessPillHtml(t)}
</div>
${m.status === 'error' && m.error ? `<div style="color:#f87171; font-size:12px;">⚠️ 服务器注册失败:${escapeHtml(m.error)}</div>` : ''}
<div class="route-box">
<span class="route-endpoint">${escapeHtml(publicHost)}:${t.listen_port}</span>
<span class="route-arrow">➔</span>
<span class="route-target">${escapeHtml(t.target)}</span>
</div>
<div class="lifecycle-row">${lifecycleHtml(t)}${badgeChipsHtml(t)}</div>
</div>
${sideHtml(t)}
</div>
<div class="tunnel-bottom">
<div class="metrics-summary">
<span>连接 <b>${m.active_conns || 0}</b></span>
<span>本次 <b>${formatBytes(sessionBytes)}</b></span>
<span>历史 <b>${formatBytes(histBytes)}</b></span>
${t.enabled ? `<span class="rate-fallback">速率 <b>↑ ${formatRate(m.rate_in || 0)} ↓ ${formatRate(m.rate_out || 0)}</b></span>` : ''}
${m.blocked_attempts ? `<span style="color: #f87171;">拦截 <b>${m.blocked_attempts}</b></span>` : ''}
</div>
<div class="tunnel-actions">
<button class="btn-primary btn-sm" ${t.enabled ? '' : 'disabled title="请先开启通道"'} onclick="openInviteModal('t:${escapeHtml(t.id)}')">📨 邀请</button>
<button class="btn-secondary btn-sm" onclick="openDrawer('t:${escapeHtml(t.id)}')">👥 连接与授权</button>
<button class="btn-secondary btn-sm" onclick="editTunnel('${escapeHtml(t.id)}')">✏️ 编辑</button>
<button class="btn-danger btn-sm" onclick="deleteTunnel('${escapeHtml(t.id)}')">🗑️ 删除</button>
</div>
</div>
</div>
`;
}
function setupSSE() {
const evtSource = new EventSource('/api/stream');
evtSource.onmessage = (e) => {
@@ -879,6 +1017,7 @@
renderStatus(data);
// 服务端推送完整通道列表(含开关状态),到期自动关闭等变化也能实时反映
if (Array.isArray(data.groups)) currentGroups = data.groups;
if (Array.isArray(data.tunnels)) {
currentTunnels = data.tunnels;
renderTunnels();
@@ -944,6 +1083,7 @@
document.getElementById('tunnelTarget').value = '';
setAccessForm([]);
setLifecycleForm(30, 0);
fillTunnelGroupSelect('');
document.getElementById('probeResult').innerText = '';
autoRecommendPort();
document.getElementById('tunnelModal').classList.add('open');
@@ -959,6 +1099,7 @@
document.getElementById('tunnelPort').value = t.listen_port;
setAccessForm(t.whitelist || []);
setLifecycleForm(t.effective_idle_close_minutes ?? 30, t.duration_minutes || 0);
fillTunnelGroupSelect(t.group_id || '');
document.getElementById('probeResult').innerText = '';
document.getElementById('tunnelModal').classList.add('open');
}
@@ -1055,7 +1196,8 @@
listen_port: port,
whitelist,
idle_close_minutes: parseInt(document.getElementById('tunnelIdleClose').value, 10),
duration_minutes: parseInt(document.getElementById('tunnelMaxDuration').value, 10)
duration_minutes: parseInt(document.getElementById('tunnelMaxDuration').value, 10),
group_id: document.getElementById('tunnelGroup').value
};
try {
@@ -1079,7 +1221,7 @@
const created = await res.json();
closeTunnelModal();
await fetchTunnels();
setTimeout(() => openInviteModal(created.id, false), 800);
setTimeout(() => openInviteModal('t:' + created.id, false), 800);
return;
}
closeTunnelModal();
@@ -1121,19 +1263,50 @@
await fetchStatus();
}
// ---- 授权作用范围:'t:<通道ID>' 或 'g:<通道组ID>'(组内通道共用邀请与授权)----
function parseScope(key) {
return key.startsWith('g:') ? { group: true, id: key.slice(2) } : { group: false, id: key.startsWith('t:') ? key.slice(2) : key };
}
function scopeBase(key) {
const s = parseScope(key);
return (s.group ? '/api/groups/' : '/api/tunnels/') + encodeURIComponent(s.id);
}
function groupMembers(g) {
return (g.tunnel_ids || []).map(id => currentTunnels.find(t => t.id === id)).filter(Boolean);
}
function scopeInfo(key) {
const s = parseScope(key);
let name, members, m;
if (s.group) {
const g = currentGroups.find(x => x.id === s.id);
if (!g) return null;
name = g.name; members = groupMembers(g); m = g.metrics || {};
} else {
const t = currentTunnels.find(x => x.id === s.id);
if (!t) return null;
name = t.name; members = [t]; m = t.metrics || {};
}
return {
group: s.group, name, members, enabled: members.some(t => t.enabled),
leases: m.dynamic_leases || [], invites: m.invites || [], pending: m.pending || [],
};
}
// Drawer
let drawerTunnelId = null;
function openDrawer(tunnelId) {
const t = currentTunnels.find(item => item.id === tunnelId);
drawerTunnelId = tunnelId;
document.getElementById('drawerTitle').innerText = `${t ? t.name : ''} - 连接与授权`;
let drawerScope = null;
function openDrawer(key) {
if (!key.includes(':')) key = 't:' + key;
const info = scopeInfo(key);
drawerScope = key;
document.getElementById('drawerTitle').innerText = info
? `${info.name}${info.group ? `(${info.members.length} 个地址)` : ''} - 连接与授权` : '连接与授权';
renderDrawerClients();
document.getElementById('clientDrawer').classList.add('open');
}
function closeDrawer() {
document.getElementById('clientDrawer').classList.remove('open');
drawerTunnelId = null;
drawerScope = null;
}
const cardStyle = 'background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; display:flex; justify-content:space-between; align-items:center; gap: 10px;';
@@ -1145,13 +1318,11 @@
return d.toDateString() === new Date().toDateString() ? `${hm}:${pad2(d.getSeconds())}` : `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${hm}`;
};
function renderLeases(t) {
if (!t) return { pending: '', auth: '' };
const m = t.metrics || {};
const leases = m.dynamic_leases || [];
const pending = m.pending || [];
const invites = m.invites || [];
const id = escapeHtml(t.id);
function renderLeases(key) {
const info = scopeInfo(key);
if (!info) return { pending: '', auth: '' };
const { leases, pending, invites } = info;
const k = escapeHtml(key);
let html = '';
if (pending.length) {
@@ -1161,8 +1332,8 @@
<div><b>${escapeHtml(r.name)}</b> <code style="color:#38bdf8;">${escapeHtml(r.ip)}</code>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">申请于 ${fmtDT(r.at)} · 批准后可访问 ${leaseTitle((invites.find(i => i.code === r.invite) || {}).lease_duration)}</div></div>
<div class="drawer-row-actions">
<button class="btn-secondary btn-sm" onclick="decideRequest('${id}', '${escapeHtml(r.id)}', false)">拒绝</button>
<button class="btn-primary btn-sm" onclick="decideRequest('${id}', '${escapeHtml(r.id)}', true)">批准</button>
<button class="btn-secondary btn-sm" onclick="decideRequest('${k}', '${escapeHtml(r.id)}', false)">拒绝</button>
<button class="btn-primary btn-sm" onclick="decideRequest('${k}', '${escapeHtml(r.id)}', true)">批准</button>
</div>
</div>`).join('');
}
@@ -1173,10 +1344,13 @@
<div style="display:flex; justify-content:space-between; align-items:center; margin: 14px 0 10px;">
<b style="font-size:13px;">已授权设备 (${leases.length})</b>
<div style="display:flex; gap:6px;">
${t.enabled ? `<button class="btn-primary btn-sm" onclick="openInviteModal('${id}')">新建邀请</button>` : ''}
${leases.length ? `<button class="btn-danger btn-sm" onclick="revokeLease('${id}', '')">全部撤销</button>` : ''}
${info.enabled ? `<button class="btn-primary btn-sm" onclick="openInviteModal('${k}')">新建邀请</button>` : ''}
${leases.length ? `<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '')">全部撤销</button>` : ''}
</div>
</div>`;
if (info.group) {
html += '<div style="color: var(--text-muted); font-size: 12px; margin: -4px 0 10px;">组内所有地址共用邀请与授权:同事授权一次即可访问整组,计时从首次连上任一地址开始。</div>';
}
html += leases.length === 0
? '<div style="color: var(--text-muted); font-size: 12px; margin-bottom: 14px;">暂无。点击“新建邀请”把链接发给同事,对方打开并填写姓名后即可访问。</div>'
: leases.map(l => {
@@ -1187,7 +1361,7 @@
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${l.awaiting_connect
? `已授权,尚未连接 · 首次连接后可访问 ${leaseTitle(l.duration)} · 请在 ${fmtDT(l.expires_at)} 前连接`
: `${l.started_at ? '首次连接 ' + fmtDT(l.started_at) + ' · ' : ''}有效期至 ${fmtDT(l.expires_at)} · 剩余约 ${mins} 分钟`}</div></div>
<button class="btn-danger btn-sm" onclick="revokeLease('${id}', '${escapeHtml(l.ip)}')">撤销</button>
<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '${escapeHtml(l.ip)}')">撤销</button>
</div>`;
}).join('');
@@ -1203,16 +1377,16 @@
<div><b>${inv.note ? '给 ' + escapeHtml(inv.note) : '未备注的邀请'}</b>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${opts}<br>链接有效至 ${fmtDT(inv.expires_at)} · 已使用 ${usage}${used.length ? '(' + used.map(u => escapeHtml(u.name)).join('、') + ')' : ''}</div></div>
<div class="drawer-row-actions">
<button class="btn-secondary btn-sm" onclick="copyInviteShare('${id}', '${escapeHtml(inv.code)}')">复制文案</button>
<button class="btn-danger btn-sm" onclick="revokeInvite('${id}', '${escapeHtml(inv.code)}')">作废</button>
<button class="btn-secondary btn-sm" onclick="copyInviteShare('${k}', '${escapeHtml(inv.code)}')">复制文案</button>
<button class="btn-danger btn-sm" onclick="revokeInvite('${k}', '${escapeHtml(inv.code)}')">作废</button>
</div>
</div>`;
}).join('');
return { pending: pendingHtml, auth: html + '<div style="height: 10px;"></div>' };
}
async function decideRequest(tunnelId, requestId, approve) {
const res = await fetch(`/api/tunnels/${encodeURIComponent(tunnelId)}/requests/${encodeURIComponent(requestId)}`, {
async function decideRequest(key, requestId, approve) {
const res = await fetch(`${scopeBase(key)}/requests/${encodeURIComponent(requestId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approve })
@@ -1220,9 +1394,9 @@
if (!res.ok) alert('操作失败: ' + await apiError(res));
}
async function revokeInvite(tunnelId, code) {
async function revokeInvite(key, code) {
if (!confirm('作废该邀请链接?通过它获得的访问权限也会被收回。')) return;
const res = await fetch(`/api/tunnels/${encodeURIComponent(tunnelId)}/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
const res = await fetch(`${scopeBase(key)}/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
if (!res.ok) alert('作废失败: ' + await apiError(res));
}
@@ -1236,19 +1410,22 @@
}
}
async function copyInviteShare(tunnelId, code) {
const res = await fetch(`/api/tunnels/${encodeURIComponent(tunnelId)}/invites/${encodeURIComponent(code)}/share`);
async function copyInviteShare(key, code) {
const res = await fetch(`${scopeBase(key)}/invites/${encodeURIComponent(code)}/share`);
if (!res.ok) { alert('获取失败: ' + await apiError(res)); return; }
const data = await res.json();
if (await copyText(data.share_text)) alert('分享文案已复制');
}
// Invite modal
let inviteTunnelId = null;
function openInviteModal(tunnelId, autoGenerate) {
const t = currentTunnels.find(item => item.id === tunnelId);
inviteTunnelId = tunnelId;
document.getElementById('inviteTitle').innerText = `邀请访问「${t ? t.name : ''}」`;
// Invite modal(通道或通道组)
let inviteScope = null;
function openInviteModal(key, autoGenerate) {
if (!key.includes(':')) key = 't:' + key;
const info = scopeInfo(key);
inviteScope = key;
document.getElementById('inviteTitle').innerText = info
? (info.group ? `邀请访问「${info.name}」(${info.members.filter(t => t.enabled).length} 个地址)` : `邀请访问「${info.name}」`)
: '邀请访问';
resetInviteModal();
document.getElementById('inviteModal').classList.add('open');
if (autoGenerate) generateInvite();
@@ -1256,7 +1433,7 @@
function closeInviteModal() {
document.getElementById('inviteModal').classList.remove('open');
inviteTunnelId = null;
inviteScope = null;
}
function resetInviteModal() {
@@ -1268,8 +1445,8 @@
}
async function generateInvite() {
if (!inviteTunnelId) return;
const res = await fetch(`/api/tunnels/${encodeURIComponent(inviteTunnelId)}/invites`, {
if (!inviteScope) return;
const res = await fetch(`${scopeBase(inviteScope)}/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1282,7 +1459,9 @@
});
if (!res.ok) { alert('生成失败: ' + await apiError(res)); return; }
const data = await res.json();
document.getElementById('inviteShareText').value = data.share_text;
const box = document.getElementById('inviteShareText');
box.value = data.share_text;
box.rows = Math.min(16, Math.max(8, data.share_text.split('\n').length + 2));
document.getElementById('inviteCopyText').onclick = async () => { if (await copyText(data.share_text)) document.getElementById('inviteCopyText').innerText = '已复制'; };
document.getElementById('inviteCopyLink').onclick = async () => { if (await copyText(data.link)) document.getElementById('inviteCopyLink').innerText = '已复制'; };
document.getElementById('inviteCopyText').innerText = '复制分享文案';
@@ -1291,9 +1470,9 @@
document.getElementById('inviteResult').style.display = 'block';
}
async function revokeLease(tunnelId, ip) {
if (!confirm(ip ? `撤销 ${ip} 的访问权限?该设备的连接会被立即断开。` : '撤销该通道全部已授权设备?')) return;
const res = await fetch(`/api/tunnels/${encodeURIComponent(tunnelId)}/leases/revoke`, {
async function revokeLease(key, ip) {
if (!confirm(ip ? `撤销 ${ip} 的访问权限?该设备的连接会被立即断开。` : '撤销全部已授权设备?')) return;
const res = await fetch(`${scopeBase(key)}/leases/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip })
@@ -1302,13 +1481,17 @@
}
function renderDrawerClients() {
if (!drawerTunnelId) return;
const list = ((latestMetrics && latestMetrics.active_conns) || []).filter(c => c.tunnel_id === drawerTunnelId);
if (!drawerScope) return;
const info = scopeInfo(drawerScope);
if (!info) return;
const memberIds = new Set(info.members.map(t => t.id));
const targetOf = new Map(info.members.map(t => [t.id, t.target]));
const list = ((latestMetrics && latestMetrics.active_conns) || []).filter(c => memberIds.has(c.tunnel_id));
const container = document.getElementById('drawerClientsList');
const parts = renderLeases(currentTunnels.find(item => item.id === drawerTunnelId));
const parts = renderLeases(drawerScope);
if (list.length === 0) {
container.innerHTML = parts.pending + '<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (0)</b><div style="text-align: center; color: var(--text-muted); padding: 16px 0 6px; font-size: 13px;">当前暂无客户端连接此端口</div>' + parts.auth;
container.innerHTML = parts.pending + '<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (0)</b><div style="text-align: center; color: var(--text-muted); padding: 16px 0 6px; font-size: 13px;">当前暂无客户端连接</div>' + parts.auth;
return;
}
@@ -1319,8 +1502,7 @@
const ip = m ? m[1] : c.client_ip;
(groups[ip] = groups[ip] || []).push(c);
});
const tunnel = currentTunnels.find(item => item.id === drawerTunnelId);
const leaseNames = new Map(((tunnel && tunnel.metrics && tunnel.metrics.dynamic_leases) || []).map(l => [l.ip, l.name || '']));
const leaseNames = new Map(info.leases.map(l => [l.ip, l.name || '']));
const sum = (arr, k) => arr.reduce((a, c) => a + (c[k] || 0), 0);
const ips = Object.keys(groups).sort((x, y) => groups[y].length - groups[x].length || x.localeCompare(y));
@@ -1328,6 +1510,7 @@
const conns = groups[ip];
const first = conns.map(c => new Date(c.connected_at)).sort((x, y) => x - y)[0];
const ids = conns.map(c => c.conn_id);
const targets = [...new Set(conns.map(c => targetOf.get(c.tunnel_id)))].filter(Boolean);
return `
<div style="background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 14px; margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
@@ -1340,6 +1523,7 @@
</div>
<div style="font-size: 12px; color: var(--text-muted); line-height: 1.6;">
<div>最早接入: ${first ? fmtDT(first) : '-'}</div>
${info.group ? `<div>访问目标: <code>${targets.map(escapeHtml).join('、')}</code></div>` : ''}
<div>传输流量: 上行 ${formatBytes(sum(conns, 'bytes_in'))} / 下行 ${formatBytes(sum(conns, 'bytes_out'))}</div>
<div>当前速率: ↑ ${formatRate(sum(conns, 'rate_in'))} / ↓ ${formatRate(sum(conns, 'rate_out'))}</div>
</div>
@@ -1348,6 +1532,324 @@
}).join('') + parts.auth;
}
// ---- 通道组卡片 ----
const sumOf = (arr, f) => arr.reduce((a, x) => a + (f(x) || 0), 0);
function groupStatus(members) {
const on = members.filter(t => t.enabled);
if (!members.length) return ['badge-disabled', '暂无通道'];
if (!on.length) return ['badge-disabled', '已暂停'];
if (on.some(t => (t.metrics || {}).status === 'error')) return ['badge-error', '部分异常'];
if (on.some(t => (t.metrics || {}).status === 'offline')) return ['badge-syncing', '等待连接服务器'];
if (on.length < members.length) return ['badge-syncing', `${on.length}/${members.length} 运行中`];
return ['badge-online', '运行中'];
}
function groupAccessPill(members) {
if (!members.length) return '';
if (members.some(t => (t.whitelist || []).includes('*'))) return '<span class="access-pill all">⚠️ 含全部放通 *</span>';
const kinds = new Set(members.map(t => [...(t.whitelist || [])].sort().join(',')));
if (kinds.size > 1) return '<span class="access-pill fixed">成员访问方式不同</span>';
return (members[0].whitelist || []).length === 0 ? '<span class="access-pill">🔒 仅授权链接</span>' : '<span class="access-pill fixed">白名单 + 授权链接</span>';
}
function portRangeText(members) {
const ports = members.map(t => t.listen_port).sort((a, b) => a - b);
if (!ports.length) return '';
if (ports.length === 1) return String(ports[0]);
return ports[ports.length - 1] - ports[0] + 1 === ports.length ? `${ports[0]}–${ports[ports.length - 1]}` : ports.join(', ');
}
function groupLifecycleHtml(g, members) {
const on = members.filter(t => t.enabled);
const parts = [];
if (on.length) {
const min = arr => arr.filter(Boolean).sort()[0];
parts.push(`<span>开启 ${fmtDT(min(on.map(t => t.started_at)))}</span>`);
const first = min(on.map(t => t.first_connected_at));
parts.push(`<span>${first ? '首次连接 ' + fmtDT(first) : '尚无连接'}</span>`);
parts.push(`<span class="strong" title="组内连接在线时间最长的一个地址的累计时长">实际连接 ${fmtSecs(Math.max(...on.map(t => t.activity ? t.activity.connected_sec : (t.connected_sec || 0))))}</span>`);
const acts = on.map(t => t.activity).filter(Boolean);
if (acts.some(a => a.in_use)) {
parts.push('<span class="ok" title="组内有在线连接、有效授权、待审批申请或可用的邀请链接">● 使用中</span>');
} else if (acts.length) {
const act = acts.filter(a => a.auto_close_at).sort((a, b) => a.auto_close_at.localeCompare(b.auto_close_at))[0];
if (act) {
const left = -ago(act.auto_close_at);
parts.push(`<span class="${left < 300 ? 'warn' : ''}">已闲置 ${fmtSecs(ago(act.idle_since))},${fmtSecs(left)} 后整组自动关闭</span>`);
} else {
parts.push(`<span>已闲置 ${fmtSecs(ago(acts[0].idle_since))}(不自动关闭)</span>`);
}
}
const exp = min(on.map(t => t.expires_at));
if (exp) parts.push(`<span>最长开启至 ${fmtDT(exp)}</span>`);
} else if (members.length) {
if (members[0].close_reason) parts.push(`<span>关闭原因:${escapeHtml(members[0].close_reason)}</span>`);
const idle = members[0].effective_idle_close_minutes ?? 30;
parts.push(`<span>闲置自动关闭:${idle > 0 ? fmtMinutes(idle) : '关'}</span>`);
}
const m = g.metrics || {}, key = escapeHtml('g:' + g.id);
if ((m.pending || []).length) parts.push(`<span class="chip orange" onclick="openDrawer('${key}')">🔔 待审批 ${m.pending.length}</span>`);
if ((m.dynamic_leases || []).length) parts.push(`<span class="chip green" onclick="openDrawer('${key}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`);
return parts.join('');
}
function memberRowHtml(g, t) {
const m = t.metrics || {}, id = escapeHtml(t.id);
const dot = !t.enabled ? 'off' : (m.status === 'online' ? 'on' : (m.status === 'error' ? 'err' : 'wait'));
const own = (m.dynamic_leases || []).length + (m.pending || []).length;
// 批量创建的成员按“组名 (主机)”自动命名,不重复显示;加入组的已有通道保留原名
const custom = !t.name.startsWith(g.name + ' (');
return `
<div class="member-row ${t.enabled ? '' : 'off'}">
<span class="mdot ${dot}" title="${t.enabled ? escapeHtml(m.error || '运行中') : '已关闭'}"></span>
<span class="member-route"><span class="route-endpoint">${escapeHtml(publicHost)}:${t.listen_port}</span><span class="route-arrow">➔</span><span class="route-target">${escapeHtml(t.target)}</span></span>
${custom ? `<span class="member-name">${escapeHtml(t.name)}</span>` : ''}
${own ? `<span class="chip green" onclick="openDrawer('t:${id}')">单独授权 ${own}</span>` : ''}
<span class="member-meta">${m.active_conns ? m.active_conns + ' 连接 · ' : ''}${t.enabled ? `↑ ${formatRate(m.rate_in || 0)} ↓ ${formatRate(m.rate_out || 0)}` : '已关闭'}</span>
<label class="switch mini" title="${t.enabled ? '单独关闭这个地址' : '单独开启这个地址'}">
<input type="checkbox" ${t.enabled ? 'checked' : ''} onchange="toggleTunnel('${id}', this.checked)">
<span class="slider"></span>
</label>
<span class="member-actions">
<button class="icon-btn" title="单独邀请这个地址" ${t.enabled ? '' : 'disabled'} onclick="openInviteModal('t:${id}')">📨</button>
<button class="icon-btn" title="连接与授权" onclick="openDrawer('t:${id}')">👥</button>
<button class="icon-btn" title="编辑" onclick="editTunnel('${id}')">✏️</button>
<button class="icon-btn" title="移出通道组" onclick="moveOutOfGroup('${id}')">⤴️</button>
<button class="icon-btn" title="删除" onclick="deleteTunnel('${id}')">🗑️</button>
</span>
</div>`;
}
function groupCardHtml(g, members, shown) {
const on = members.filter(t => t.enabled), gid = escapeHtml(g.id), key = escapeHtml('g:' + g.id);
const [badgeClass, badgeText] = groupStatus(members);
const rateIn = sumOf(on, t => (t.metrics || {}).rate_in), rateOut = sumOf(on, t => (t.metrics || {}).rate_out);
const session = sumOf(on, t => ((t.metrics || {}).bytes_in || 0) + ((t.metrics || {}).bytes_out || 0));
const history = sumOf(members, t => (t.historical_bytes_in || 0) + (t.historical_bytes_out || 0)) + session;
const s = g.rate_history, peak = s ? Math.max(0, ...s.down, ...s.up) : 0;
const toggle = `<label class="switch mini" title="${on.length ? '关闭整组' : '开启整组'}">
<input type="checkbox" ${on.length ? 'checked' : ''} ${members.length ? '' : 'disabled'} onchange="toggleGroup('${gid}', this.checked)">
<span class="slider"></span>
</label>`;
const side = on.length ? `<div class="tunnel-side with-chart">
<div class="side-top">
<div class="traffic-legend">
<span><i style="background:${DOWN_COLOR}"></i>↓ ${formatRate(rateOut)}</span>
<span><i style="background:${UP_COLOR}"></i>↑ ${formatRate(rateIn)}</span>
</div>
${toggle}
</div>
<div class="traffic-panel">
<div class="traffic-chart">${peak ? sparkSVG(s, 260, 46, 'g' + gid) : '<div class="empty">近 10 分钟无流量</div>'}</div>
<div class="traffic-foot"><span>近 10 分钟</span><span>${peak ? '峰值 ' + formatRate(peak) : ''}</span></div>
</div>
</div>` : `<div class="tunnel-side"><div class="side-top">${toggle}</div></div>`;
const hidden = members.length - shown.length;
return `
<div class="tunnel-card group-card ${on.length || !members.length ? '' : 'disabled'}">
<div class="tunnel-body">
<div class="tunnel-info">
<div class="tunnel-title-row">
<span class="group-icon" title="通道组">▤</span>
<span class="tunnel-name">${escapeHtml(g.name)}</span>
<span class="badge ${badgeClass}">${badgeText}</span>
${groupAccessPill(members)}
</div>
<div class="group-summary">
<span>${members.length} 个地址</span>
${members.length ? `<span class="route-endpoint">${escapeHtml(publicHost)}:${portRangeText(members)}</span>
<button class="btn-secondary btn-sm" onclick="copyGroupAddresses('${gid}')">复制全部地址</button>` : ''}
</div>
<div class="lifecycle-row">${groupLifecycleHtml(g, members)}</div>
</div>
${side}
</div>
<div class="member-list">
${shown.map(t => memberRowHtml(g, t)).join('')}
${hidden > 0 ? `<div class="member-hint">已按搜索 / 筛选隐藏 ${hidden} 个地址</div>` : ''}
${!members.length ? '<div class="member-hint">组内暂无通道,点击“编辑”添加目标或把已有通道加入该组</div>' : ''}
</div>
<div class="tunnel-bottom">
<div class="metrics-summary">
<span>连接 <b>${sumOf(on, t => (t.metrics || {}).active_conns)}</b></span>
<span>本次 <b>${formatBytes(session)}</b></span>
<span>历史 <b>${formatBytes(history)}</b></span>
${on.length ? `<span class="rate-fallback">速率 <b>↑ ${formatRate(rateIn)} ↓ ${formatRate(rateOut)}</b></span>` : ''}
</div>
<div class="tunnel-actions">
<button class="btn-primary btn-sm" ${on.length ? '' : 'disabled title="请先开启通道组"'} onclick="openInviteModal('${key}')">📨 邀请整组</button>
<button class="btn-secondary btn-sm" onclick="openDrawer('${key}')">👥 连接与授权</button>
<button class="btn-secondary btn-sm" onclick="openGroupModal('${gid}')">✏️ 编辑</button>
<button class="btn-danger btn-sm" onclick="openGroupDelete('${gid}')">🗑️ 删除</button>
</div>
</div>
</div>`;
}
async function copyGroupAddresses(groupId) {
const g = currentGroups.find(x => x.id === groupId);
if (!g) return;
const text = groupMembers(g).map(t => `${publicHost}:${t.listen_port} → ${t.target}`).join('\n');
if (await copyText(text)) alert('已复制全部访问地址');
}
async function toggleGroup(id, enabled) {
const res = await fetch(`/api/groups/${encodeURIComponent(id)}/toggle`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled })
});
if (!res.ok) alert('操作失败: ' + await apiError(res));
await fetchTunnels();
}
async function moveOutOfGroup(id) {
const res = await fetch(`/api/tunnels/${encodeURIComponent(id)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_id: '' })
});
if (!res.ok) alert('操作失败: ' + await apiError(res));
await fetchTunnels();
}
// ---- 新建 / 编辑通道组 ----
let groupPreviewTimer = null, groupPreviewOK = false;
function openGroupModal(id) {
const g = id ? currentGroups.find(x => x.id === id) : null;
document.getElementById('groupModalTitle').innerText = g ? '编辑通道组' : '新建通道组';
document.getElementById('groupSaveBtn').innerText = g ? '保存' : '创建通道组';
document.getElementById('groupTargetsLabel').innerText = g ? '追加目标(可选)' : '目标主机与端口';
document.getElementById('groupId').value = g ? g.id : '';
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);
document.getElementById('groupExistingWrap').style.display = free.length ? 'block' : 'none';
document.getElementById('groupExistingLabel').innerText = g ? '把已有通道加入该组(可选)' : '或加入已有通道(可选)';
document.getElementById('groupExisting').innerHTML = free.map(t => `
<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('');
renderGroupPreview(null);
document.getElementById('groupModal').classList.add('open');
}
function closeGroupModal() {
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) {
e.preventDefault();
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 = {
name: document.getElementById('groupName').value.trim(),
owner: document.getElementById('groupOwner').value.trim(),
hosts, ports: hosts ? document.getElementById('groupPorts').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', {
method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
if (!res.ok) { alert('保存失败: ' + await apiError(res)); return; }
const data = await res.json();
closeGroupModal();
await fetchTunnels();
// 新建通道组后直接打开整组的邀请设置
if (!id && mode !== 'all' && data.group) setTimeout(() => openInviteModal('g:' + data.group.id, false), 800);
}
// ---- 删除 / 解散通道组 ----
let groupToDelete = null;
function openGroupDelete(id) {
const g = currentGroups.find(x => x.id === id);
if (!g) return;
groupToDelete = id;
document.getElementById('groupDeleteTitle').innerText = `删除通道组「${g.name}」?`;
document.getElementById('groupDissolveBtn').innerText = `解散,保留 ${groupMembers(g).length} 个通道`;
document.getElementById('groupDeleteModal').classList.add('open');
}
function closeGroupDelete() {
document.getElementById('groupDeleteModal').classList.remove('open');
groupToDelete = null;
}
async function confirmGroupDelete(withMembers) {
if (!groupToDelete) return;
const res = await fetch(`/api/groups/${encodeURIComponent(groupToDelete)}${withMembers ? '?delete_members=1' : ''}`, { method: 'DELETE' });
if (!res.ok) alert('删除失败: ' + await apiError(res));
closeGroupDelete();
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) {
for (const id of ids) {
await fetch('/api/connections/kill', {
+12
View File
@@ -96,6 +96,18 @@ func (w *Whitelist) RestoreDynamic(leases map[string]time.Time) {
}
}
// ReplaceDynamic sets the dynamic leases to exactly the given set (the access state store is the source of truth)
func (w *Whitelist) ReplaceDynamic(leases map[string]time.Time) {
w.mu.Lock()
defer w.mu.Unlock()
w.dynamic = make(map[string]time.Time, len(leases))
for ip, exp := range leases {
if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil {
w.dynamic[parsed.String()] = exp
}
}
}
// RemoveDynamic revokes the lease for ip (all leases when ip is empty) and returns how many were removed
func (w *Whitelist) RemoveDynamic(ip string) int {
w.mu.Lock()
+28 -2
View File
@@ -17,6 +17,7 @@ const (
TypeInviteResp = "invite_resp"
TypeRevokeInvite = "revoke_invite"
TypeDecideRequest = "decide_request"
TypeDeleteGroup = "delete_group"
TypeMetrics = "metrics_update"
TypePing = "ping"
TypePong = "pong"
@@ -68,6 +69,10 @@ type TunnelConfig struct {
Notes string `json:"notes,omitempty"`
LeaseDuration string `json:"lease_duration,omitempty"` // 已废弃:放通时长改为在每个邀请上设置,保留字段仅为兼容旧配置
RequireApproval bool `json:"require_approval,omitempty"` // 已废弃:是否审批改为在每个邀请上设置,保留字段仅为兼容旧配置
// GroupID 所属通道组:组内通道共用邀请、授权与审批,闲置计时联动
GroupID string `json:"group_id,omitempty"`
// GroupName 由 Agent 同步到服务端时填写,用于授权页展示(不持久化)
GroupName string `json:"group_name,omitempty"`
}
// RegisterTunnelMessage registers a tunnel on server
@@ -110,13 +115,21 @@ type KillConnMessage struct {
ConnID string `json:"conn_id"`
}
// RevokeLeaseMessage removes a temporary (web-auth) whitelist lease; empty IP revokes all leases of the tunnel
// RevokeLeaseMessage removes a temporary (web-auth) whitelist lease; empty IP revokes all leases of the scope.
// 设置 GroupID 时作用于整个通道组的授权(TunnelID 忽略),下同。
type RevokeLeaseMessage struct {
Type string `json:"type"`
TunnelID string `json:"tunnel_id"`
GroupID string `json:"group_id,omitempty"`
IP string `json:"ip,omitempty"`
}
// DeleteGroupMessage drops the invites, leases and requests of a dissolved tunnel group
type DeleteGroupMessage struct {
Type string `json:"type"`
GroupID string `json:"group_id"`
}
// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久
const (
LeaseDuration1h = "1h"
@@ -185,10 +198,11 @@ type Invite struct {
Uses []InviteUse `json:"uses,omitempty"`
}
// AccessRequest is an access application on a tunnel that requires approval
// AccessRequest is an access application on a tunnel (or tunnel group) that requires approval
type AccessRequest struct {
ID string `json:"id"`
TunnelID string `json:"tunnel_id"`
GroupID string `json:"group_id,omitempty"`
Name string `json:"name"`
IP string `json:"ip"`
Invite string `json:"invite,omitempty"`
@@ -201,6 +215,7 @@ type CreateInviteMessage struct {
Type string `json:"type"`
RequestID string `json:"request_id"`
TunnelID string `json:"tunnel_id"`
GroupID string `json:"group_id,omitempty"`
Note string `json:"note,omitempty"`
ValidMinutes int `json:"valid_minutes"`
MaxUses int `json:"max_uses"`
@@ -223,6 +238,7 @@ type InviteRespMessage struct {
type RevokeInviteMessage struct {
Type string `json:"type"`
TunnelID string `json:"tunnel_id"`
GroupID string `json:"group_id,omitempty"`
Code string `json:"code"`
}
@@ -230,6 +246,7 @@ type RevokeInviteMessage struct {
type DecideRequestMessage struct {
Type string `json:"type"`
TunnelID string `json:"tunnel_id"`
GroupID string `json:"group_id,omitempty"`
RequestID string `json:"request_id"`
Approve bool `json:"approve"`
}
@@ -279,6 +296,14 @@ type TunnelMetrics struct {
Pending []AccessRequest `json:"pending,omitempty"`
}
// GroupMetrics carries the access state shared by all tunnels of a group
type GroupMetrics struct {
GroupID string `json:"group_id"`
DynamicLeases []DynamicLease `json:"dynamic_leases,omitempty"`
Invites []Invite `json:"invites,omitempty"`
Pending []AccessRequest `json:"pending,omitempty"`
}
// SecurityEvent records blocked or flagged connection attempts
type SecurityEvent struct {
ID string `json:"id"`
@@ -293,6 +318,7 @@ type SecurityEvent struct {
type MetricsMessage struct {
Type string `json:"type"`
Tunnels []TunnelMetrics `json:"tunnels"`
Groups []GroupMetrics `json:"groups,omitempty"`
ActiveConns []ActiveConnInfo `json:"active_conns"`
SecurityEvents []SecurityEvent `json:"security_events,omitempty"`
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 587 KiB

File diff suppressed because it is too large. Load diff
+259
View File
@@ -0,0 +1,259 @@
package server
import (
"encoding/json"
"fmt"
"io"
"net"
"strings"
"testing"
"time"
"github.com/DeepFal/PortRelay/core/protocol"
)
// groupMember is a link-only tunnel that belongs to group "g"
func groupMember(id string, port int, target string) protocol.TunnelConfig {
cfg := tunnelCfg(id, port)
cfg.Whitelist = nil
cfg.Target = target
cfg.GroupID = "g"
cfg.GroupName = "订单服务"
return cfg
}
func (a *testAgent) createGroupInvite(t *testing.T, groupID string, lease string, approval bool) protocol.Invite {
t.Helper()
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "g1", GroupID: groupID, ValidMinutes: 60, MaxUses: 1, LeaseDuration: lease, RequireApproval: approval})
var resp protocol.InviteRespMessage
_ = json.Unmarshal(a.next(t), &resp)
if !resp.Success {
t.Fatalf("create group invite failed: %s", resp.Error)
}
return resp.Invite
}
// setupGroup registers two members of group "g" plus one ungrouped link-only tunnel
func setupGroup(t *testing.T) (*Manager, *testAgent, string, [3]int) {
t.Helper()
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
ports := [3]int{freePort(t), freePort(t), freePort(t)}
a.register(t, protocol.TypeRegisterTunnel, groupMember("m1", ports[0], "10.0.1.11:22"))
a.register(t, protocol.TypeRegisterTunnel, groupMember("m2", ports[1], "10.0.1.12:22"))
other := tunnelCfg("other", ports[2])
other.Whitelist = nil
a.register(t, protocol.TypeRegisterTunnel, other)
return m, a, addr, ports
}
// holdConn opens a client connection that stays up until the server closes it
func holdConn(t *testing.T, port int) net.Conn {
t.Helper()
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
buf := make([]byte, 2)
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := io.ReadFull(c, buf); err != nil {
t.Fatalf("connection to %d should be allowed: %v", port, err)
}
return c
}
func expectClosed(t *testing.T, c net.Conn) {
t.Helper()
_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
if _, err := c.Read(make([]byte, 1)); err == nil {
t.Fatal("connection should have been closed")
} else if ne, ok := err.(net.Error); ok && ne.Timeout() {
t.Fatal("connection still open after access was revoked")
}
}
func TestGroupInviteGrantsEveryMember(t *testing.T) {
m, a, addr, p := setupGroup(t)
if hit(p[0]) == "OK" {
t.Fatal("link-only member must be closed before any grant")
}
inv := a.createGroupInvite(t, "g", "", false)
status, body := httpGet(t, addr, "/i/"+inv.Code, nil)
if status != 200 {
t.Fatalf("invite page: %d %s", status, body)
}
for _, want := range []string{"目标通道组", "订单服务(2 个地址)", fmt.Sprintf("127.0.0.1:%d", p[0]), fmt.Sprintf("127.0.0.1:%d", p[1]), "10.0.1.11:22", "10.0.1.12:22"} {
if !strings.Contains(body, want) {
t.Errorf("invite page lacks %q", want)
}
}
if status, _, body := postForm(t, addr, inv.Code, "张三"); status != 200 || !strings.Contains(body, "任意一个访问地址") {
t.Fatalf("grant failed: %d %s", status, body)
}
if hit(p[0]) != "OK" || hit(p[1]) != "OK" {
t.Fatal("a group grant must open every member")
}
if hit(p[2]) == "OK" {
t.Fatal("a group grant must not open tunnels outside the group")
}
if l := m.state.leaseList(groupScope("g")); len(l) != 1 || l[0].Name != "张三" {
t.Fatalf("group lease not recorded: %+v", l)
}
if l := m.state.leaseList("m1"); len(l) != 0 {
t.Fatalf("group grant must not create per-tunnel leases: %+v", l)
}
}
func TestGroupLeaseStartsOnFirstConnectionToAnyMember(t *testing.T) {
m, a, addr, p := setupGroup(t)
inv := a.createGroupInvite(t, "g", protocol.LeaseDuration2h, false)
postForm(t, addr, inv.Code, "张三")
if l := m.state.leaseList(groupScope("g")); len(l) != 1 || !l[0].AwaitingConnect {
t.Fatalf("lease should wait for the first connection: %+v", l)
}
if hit(p[1]) != "OK" {
t.Fatal("member 2 should be reachable")
}
waitFor(t, func() bool {
l := m.state.leaseList(groupScope("g"))
return len(l) == 1 && !l[0].AwaitingConnect
})
exp := m.state.leaseList(groupScope("g"))[0].ExpiresAt
if d := time.Until(exp); d < 119*time.Minute || d > 121*time.Minute {
t.Fatalf("countdown should be 2h from first connection, got %v", d)
}
// 其它成员的放通随之更新为同一到期时间
if got := m.tunnel("m1").dynamicLeases()["127.0.0.1"]; !got.Equal(exp) {
t.Fatalf("member 1 expiry %v, want %v", got, exp)
}
}
func TestGroupRevokeDisconnectsAllMembers(t *testing.T) {
_, a, addr, p := setupGroup(t)
inv := a.createGroupInvite(t, "g", "", false)
postForm(t, addr, inv.Code, "张三")
c1, c2 := holdConn(t, p[0]), holdConn(t, p[1])
a.send(t, protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, GroupID: "g"})
expectClosed(t, c1)
expectClosed(t, c2)
if hit(p[0]) == "OK" || hit(p[1]) == "OK" {
t.Fatal("members still open after revoking the group grant")
}
}
func TestGroupApprovalFlow(t *testing.T) {
m, a, addr, p := setupGroup(t)
inv := a.createGroupInvite(t, "g", "", true)
status, loc, _ := postForm(t, addr, inv.Code, "李四")
if status != 303 || !strings.HasPrefix(loc, "/auth/status?id=") {
t.Fatalf("approval invite should redirect to the status page: %d %q", status, loc)
}
pending := m.state.pendingList(groupScope("g"))
if len(pending) != 1 || pending[0].GroupID != "g" || pending[0].TunnelID != "" {
t.Fatalf("pending request should belong to the group: %+v", pending)
}
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "等待管理员批准") || !strings.Contains(body, "10.0.1.12:22") {
t.Fatalf("pending page: %s", body)
}
if hit(p[0]) == "OK" {
t.Fatal("must stay closed until approved")
}
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, GroupID: "g", RequestID: pending[0].ID, Approve: true})
waitFor(t, func() bool { return hit(p[0]) == "OK" && hit(p[1]) == "OK" })
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "申请已批准") {
t.Fatalf("status page after approval: %s", body)
}
}
func TestGroupStateDroppedWhenLastMemberCloses(t *testing.T) {
_, a, addr, _ := setupGroup(t)
inv := a.createGroupInvite(t, "g", "", false)
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "m1"})
a.next(t)
if status, body := httpGet(t, addr, "/i/"+inv.Code, nil); status != 200 || !strings.Contains(body, "(1 个地址)") {
t.Fatalf("invite should still work with one open member: %d", status)
}
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "m2"})
a.next(t)
if status, _ := httpGet(t, addr, "/i/"+inv.Code, nil); status != 404 {
t.Fatalf("invite must be gone once every member is closed, got %d", status)
}
}
func TestGroupMembershipChangesFollowAccess(t *testing.T) {
_, a, addr, p := setupGroup(t)
inv := a.createGroupInvite(t, "g", "", false)
postForm(t, addr, inv.Code, "张三")
c2 := holdConn(t, p[1])
// 移出组:组授权不再适用,现有连接断开
out := groupMember("m2", p[1], "10.0.1.12:22")
out.GroupID, out.GroupName = "", ""
if resp := a.register(t, protocol.TypeUpdateTunnel, out); !resp.Success {
t.Fatal(resp.Error)
}
expectClosed(t, c2)
if hit(p[1]) == "OK" || hit(p[0]) != "OK" {
t.Fatal("only remaining members keep the group grant")
}
// 后加入组的通道自动获得组授权
in := groupMember("other", p[2], "10.0.1.13:22")
if resp := a.register(t, protocol.TypeUpdateTunnel, in); !resp.Success {
t.Fatal(resp.Error)
}
if hit(p[2]) != "OK" {
t.Fatal("a tunnel joining the group should get the group grant")
}
}
func TestTunnelAndGroupLeasesAreIndependent(t *testing.T) {
m, _, _, p := setupGroup(t)
exp := time.Now().Add(time.Hour)
grantTestLease(m, "m1", "127.0.0.1", exp)
grantTestLease(m, groupScope("g"), "127.0.0.1", exp)
m.revokeLease(groupScope("g"), "127.0.0.1")
if hit(p[0]) != "OK" {
t.Fatal("the per-tunnel grant must survive revoking the group grant")
}
if hit(p[1]) == "OK" {
t.Fatal("member without its own grant must close")
}
}
func TestDeleteGroupDropsSharedAccess(t *testing.T) {
m, a, addr, p := setupGroup(t)
inv := a.createGroupInvite(t, "g", "", false)
postForm(t, addr, inv.Code, "张三")
a.send(t, protocol.DeleteGroupMessage{Type: protocol.TypeDeleteGroup, GroupID: "g"})
waitFor(t, func() bool { return hit(p[0]) != "OK" })
if _, _, ok := m.state.findInvite(inv.Code); ok {
t.Fatal("invites of a dissolved group must be dropped")
}
}
func TestGroupInviteNeedsAnOpenMember(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "x", GroupID: "empty", ValidMinutes: 60, MaxUses: 1})
var resp protocol.InviteRespMessage
_ = json.Unmarshal(a.next(t), &resp)
if resp.Success || !strings.Contains(resp.Error, "通道组") {
t.Fatalf("invite for a group without open tunnels should fail: %+v", resp)
}
}
+179 -42
View File
@@ -10,6 +10,7 @@ import (
"log"
"net"
"net/http"
"sort"
"strings"
"sync"
"time"
@@ -252,7 +253,7 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
case protocol.TypeRevokeLease:
var msg protocol.RevokeLeaseMessage
if json.Unmarshal(raw, &msg) == nil {
m.revokeLease(msg.TunnelID, msg.IP)
m.revokeLease(scopeOf(msg.TunnelID, msg.GroupID), msg.IP)
}
case protocol.TypeCreateInvite:
@@ -264,13 +265,19 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
case protocol.TypeRevokeInvite:
var msg protocol.RevokeInviteMessage
if json.Unmarshal(raw, &msg) == nil {
m.revokeInvite(msg.TunnelID, msg.Code)
m.revokeInvite(scopeOf(msg.TunnelID, msg.GroupID), msg.Code)
}
case protocol.TypeDecideRequest:
var msg protocol.DecideRequestMessage
if json.Unmarshal(raw, &msg) == nil {
m.decideRequest(msg.TunnelID, msg.RequestID, msg.Approve)
m.decideRequest(scopeOf(msg.TunnelID, msg.GroupID), msg.RequestID, msg.Approve)
}
case protocol.TypeDeleteGroup:
var msg protocol.DeleteGroupMessage
if json.Unmarshal(raw, &msg) == nil && msg.GroupID != "" {
m.deleteGroup(msg.GroupID)
}
case protocol.TypeKillConn:
@@ -330,12 +337,13 @@ func (m *Manager) registerTunnel(as *agentSession, cfg protocol.TunnelConfig) pr
if err != nil {
return registerResp(cfg, err)
}
if old == nil {
// 服务端重启或 Agent 重连后,恢复该通道尚未过期的临时放通
t.restoreDynamicLeases(m.state.leaseExpiries(cfg.ID))
}
m.tunnels[cfg.ID] = t
m.portMap[cfg.ListenPort] = cfg.ID
// 服务端重启或 Agent 重连后,按授权状态恢复该通道(及其所在组)尚未过期的临时放通
m.syncAccess(t)
if old != nil && old.GroupID() != cfg.GroupID {
m.forgetGroupIfEmptyLocked(old.GroupID())
}
log.Printf("[Server] Registered tunnel '%s' listening on 0.0.0.0:%d", cfg.Name, cfg.ListenPort)
return registerResp(cfg, nil)
@@ -348,12 +356,14 @@ func (m *Manager) unregisterTunnel(as *agentSession, tunnelID string) {
return
}
// 主动关闭/删除通道时,放通、邀请链接与待审批申请一并失效(断线重连不会走到这里)
m.state.forgetTunnel(tunnelID)
m.state.forgetScope(tunnelID)
if t, exists := m.tunnels[tunnelID]; exists {
t.Close()
delete(m.portMap, t.ListenPort())
delete(m.tunnels, tunnelID)
log.Printf("[Server] Unregistered tunnel '%s' (port %d freed)", t.Name(), t.ListenPort())
// 组内最后一个开启的通道关闭后,组的邀请与授权一并失效(与单个通道关闭的语义一致)
m.forgetGroupIfEmptyLocked(t.GroupID())
}
}
@@ -367,8 +377,13 @@ func (m *Manager) updateTunnel(as *agentSession, cfg protocol.TunnelConfig) prot
}
defer m.mu.Unlock()
prevGroup := t.GroupID()
if t.ListenPort() == cfg.ListenPort {
t.UpdateConfig(cfg)
m.syncAccess(t)
if prevGroup != cfg.GroupID {
m.forgetGroupIfEmptyLocked(prevGroup)
}
log.Printf("[Server] Updated tunnel '%s' whitelist/metadata", cfg.Name)
return registerResp(cfg, nil)
}
@@ -384,6 +399,10 @@ func (m *Manager) updateTunnel(as *agentSession, cfg protocol.TunnelConfig) prot
delete(m.portMap, t.ListenPort())
m.tunnels[cfg.ID] = nt
m.portMap[cfg.ListenPort] = cfg.ID
m.syncAccess(nt)
if prevGroup != cfg.GroupID {
m.forgetGroupIfEmptyLocked(prevGroup)
}
log.Printf("[Server] Moved tunnel '%s' from port %d to %d", cfg.Name, t.ListenPort(), cfg.ListenPort)
return registerResp(cfg, nil)
}
@@ -399,21 +418,15 @@ func (m *Manager) killConn(connID string) {
}
}
func (m *Manager) revokeLease(tunnelID, ip string) {
m.mu.RLock()
t := m.tunnels[tunnelID]
m.mu.RUnlock()
if t == nil {
return
}
revoked, kicked := t.RevokeDynamic(ip)
m.state.removeLease(tunnelID, ip)
func (m *Manager) revokeLease(scope, ip string) {
revoked := m.state.removeLease(scope, ip)
kicked := m.syncScope(scope)
target := ip
if target == "" {
target = "all"
}
log.Printf("[WebAuth] Revoked %d temporary lease(s) (%s) on tunnel %s, disconnected %d client(s)",
revoked, target, t.Name(), kicked)
log.Printf("[WebAuth] Revoked %d temporary lease(s) (%s) on %s, disconnected %d client(s)",
revoked, target, m.scopeName(scope), kicked)
}
func (m *Manager) tunnel(id string) *Tunnel {
@@ -422,11 +435,117 @@ func (m *Manager) tunnel(id string) *Tunnel {
return m.tunnels[id]
}
// ---- 作用范围:单个通道或通道组 ----
// groupMembersLocked returns the open tunnels of a group ordered by port. Caller must hold m.mu.
func (m *Manager) groupMembersLocked(groupID string) []*Tunnel {
if groupID == "" {
return nil
}
var out []*Tunnel
for _, t := range m.tunnels {
if t.GroupID() == groupID {
out = append(out, t)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ListenPort() < out[j].ListenPort() })
return out
}
// scopeTunnels returns the open tunnels an access scope applies to
func (m *Manager) scopeTunnels(scope string) []*Tunnel {
id, isGroup := splitScope(scope)
m.mu.RLock()
defer m.mu.RUnlock()
if isGroup {
return m.groupMembersLocked(id)
}
if t := m.tunnels[id]; t != nil {
return []*Tunnel{t}
}
return nil
}
// scopeName is a display name for logs and pages
func (m *Manager) scopeName(scope string) string {
tunnels := m.scopeTunnels(scope)
if len(tunnels) == 0 {
return scope
}
if _, isGroup := splitScope(scope); isGroup {
if name := tunnels[0].Snapshot().GroupName; name != "" {
return name
}
return "通道组"
}
return tunnels[0].Name()
}
// scopeExpiresOf caps leases by how long the scope stays open: a tunnel's own TTL, or for a
// group the TTL of its longest-lived member (nil when any member has no TTL)
func scopeExpiresOf(tunnels []*Tunnel) *time.Time {
var latest *time.Time
for _, t := range tunnels {
exp := t.Snapshot().ExpiresAt
if exp == nil {
return nil
}
if latest == nil || exp.After(*latest) {
v := *exp
latest = &v
}
}
return latest
}
// syncAccess makes a tunnel's temporary allowlist match the access state — its own leases plus
// those of its group — and disconnects clients that are no longer permitted.
func (m *Manager) syncAccess(t *Tunnel) int {
leases := m.state.leaseExpiries(t.ID())
if gid := t.GroupID(); gid != "" {
for ip, exp := range m.state.leaseExpiries(groupScope(gid)) {
if cur, ok := leases[ip]; !ok || exp.After(cur) {
leases[ip] = exp
}
}
}
return t.SetDynamic(leases)
}
// syncScope re-applies the access state to every open tunnel of a scope
func (m *Manager) syncScope(scope string) int {
kicked := 0
for _, t := range m.scopeTunnels(scope) {
kicked += m.syncAccess(t)
}
return kicked
}
// forgetGroupIfEmptyLocked drops a group's shared access state once none of its tunnels is open.
// Caller must hold m.mu.
func (m *Manager) forgetGroupIfEmptyLocked(groupID string) {
if groupID == "" || len(m.groupMembersLocked(groupID)) > 0 {
return
}
m.state.forgetScope(groupScope(groupID))
}
// deleteGroup drops a dissolved group's invites, leases and requests
func (m *Manager) deleteGroup(groupID string) {
scope := groupScope(groupID)
m.state.forgetScope(scope)
kicked := m.syncScope(scope)
log.Printf("[WebAuth] Tunnel group %s dissolved, disconnected %d client(s)", groupID, kicked)
}
func (m *Manager) createInvite(msg protocol.CreateInviteMessage) protocol.InviteRespMessage {
resp := protocol.InviteRespMessage{Type: protocol.TypeInviteResp, RequestID: msg.RequestID}
t := m.tunnel(msg.TunnelID)
scope := scopeOf(msg.TunnelID, msg.GroupID)
open := len(m.scopeTunnels(scope)) > 0
switch {
case t == nil:
case !open && msg.GroupID != "":
resp.Error = "通道组内没有开启的通道,无法生成邀请链接"
case !open:
resp.Error = "通道未开启,无法生成邀请链接"
case msg.ValidMinutes <= 0 || msg.ValidMinutes > 30*24*60:
resp.Error = "邀请有效期须在 1 分钟到 30 天之间"
@@ -435,50 +554,53 @@ func (m *Manager) createInvite(msg protocol.CreateInviteMessage) protocol.Invite
case !protocol.ValidLeaseDuration(msg.LeaseDuration):
resp.Error = "无效的授权时长"
default:
resp.Invite = m.state.createInvite(msg.TunnelID, truncateRunes(msg.Note, 64), time.Duration(msg.ValidMinutes)*time.Minute, msg.MaxUses, msg.LeaseDuration, msg.RequireApproval)
resp.Invite = m.state.createInvite(scope, truncateRunes(msg.Note, 64), time.Duration(msg.ValidMinutes)*time.Minute, msg.MaxUses, msg.LeaseDuration, msg.RequireApproval)
resp.Success = true
log.Printf("[WebAuth] Created invite for tunnel %s (valid %dm, max %d device(s))", t.Name(), msg.ValidMinutes, msg.MaxUses)
log.Printf("[WebAuth] Created invite for %s (valid %dm, max %d device(s))", m.scopeName(scope), msg.ValidMinutes, msg.MaxUses)
}
return resp
}
func (m *Manager) revokeInvite(tunnelID, code string) {
ips := m.state.revokeInvite(tunnelID, code)
kicked := 0
if t := m.tunnel(tunnelID); t != nil {
for _, ip := range ips {
_, k := t.RevokeDynamic(ip)
kicked += k
}
}
log.Printf("[WebAuth] Revoked invite on tunnel %s: %d lease(s) removed, %d client(s) disconnected", tunnelID, len(ips), kicked)
func (m *Manager) revokeInvite(scope, code string) {
ips := m.state.revokeInvite(scope, code)
kicked := m.syncScope(scope)
log.Printf("[WebAuth] Revoked invite on %s: %d lease(s) removed, %d client(s) disconnected", m.scopeName(scope), len(ips), kicked)
}
func (m *Manager) decideRequest(tunnelID, requestID string, approve bool) {
t := m.tunnel(tunnelID)
if t == nil {
func (m *Manager) decideRequest(scope, requestID string, approve bool) {
tunnels := m.scopeTunnels(scope)
if len(tunnels) == 0 {
return
}
r, exp, err := m.state.decide(tunnelID, requestID, approve, t.Snapshot().ExpiresAt)
r, _, err := m.state.decide(scope, requestID, approve, scopeExpiresOf(tunnels))
if err != nil {
return
}
if approve {
t.AddDynamicWhitelist(r.IP, exp)
m.syncScope(scope)
}
log.Printf("[WebAuth] Request from %s (%s) on tunnel %s: %s", r.Name, r.IP, t.Name(), r.Status)
log.Printf("[WebAuth] Request from %s (%s) on %s: %s", r.Name, r.IP, m.scopeName(scope), r.Status)
}
// onClientConnect starts the countdown of a device's lease on its first connection
// onClientConnect starts the countdown of a device's lease on its first connection.
// 组授权在首次连上组内任意一个通道时开始计时。
func (m *Manager) onClientConnect(tunnelID, ip string) {
t := m.tunnel(tunnelID)
if t == nil {
return
}
if exp, started := m.state.startLease(tunnelID, ip, t.Snapshot().ExpiresAt, time.Now()); started {
t.AddDynamicWhitelist(ip, exp)
now := time.Now()
if exp, started := m.state.startLease(t.ID(), ip, t.Snapshot().ExpiresAt, now); started {
m.syncAccess(t)
log.Printf("[WebAuth] %s connected for the first time on tunnel %s, access valid until %s", ip, t.Name(), exp.Format("01-02 15:04"))
}
if gid := t.GroupID(); gid != "" {
scope := groupScope(gid)
if exp, started := m.state.startLease(scope, ip, scopeExpiresOf(m.scopeTunnels(scope)), now); started {
m.syncScope(scope)
log.Printf("[WebAuth] %s connected for the first time in group %s, access valid until %s", ip, m.scopeName(scope), exp.Format("01-02 15:04"))
}
}
}
func truncateRunes(s string, n int) string {
@@ -516,7 +638,11 @@ func (m *Manager) metricsLoop(as *agentSession) {
}
tunnelMetricsList := make([]protocol.TunnelMetrics, 0, len(m.tunnels))
allActiveConns := make([]protocol.ActiveConnInfo, 0)
groupIDs := make(map[string]bool)
for _, t := range m.tunnels {
if gid := t.GroupID(); gid != "" {
groupIDs[gid] = true
}
// 授权到期(或白名单变更)后,已建立的连接也要断开,“授权后可访问 X”才名副其实
for _, addr := range t.EnforceAccess() {
log.Printf("[Tunnel %d] access expired, disconnected %s", t.ListenPort(), addr)
@@ -529,6 +655,16 @@ func (m *Manager) metricsLoop(as *agentSession) {
tunnelMetricsList = append(tunnelMetricsList, tm)
allActiveConns = append(allActiveConns, conns...)
}
groupMetrics := make([]protocol.GroupMetrics, 0, len(groupIDs))
for gid := range groupIDs {
scope := groupScope(gid)
groupMetrics = append(groupMetrics, protocol.GroupMetrics{
GroupID: gid,
DynamicLeases: m.state.leaseList(scope),
Invites: m.state.inviteList(scope),
Pending: m.state.pendingList(scope),
})
}
m.mu.RUnlock()
m.secEventsMu.Lock()
@@ -539,6 +675,7 @@ func (m *Manager) metricsLoop(as *agentSession) {
as.send(protocol.MetricsMessage{
Type: protocol.TypeMetrics,
Tunnels: tunnelMetricsList,
Groups: groupMetrics,
ActiveConns: allActiveConns,
SecurityEvents: secCopy,
})
+10 -5
View File
@@ -326,6 +326,14 @@ func TestStaleSessionTeardownKeepsNewSessionTunnels(t *testing.T) {
}
}
// grantTestLease stores a running lease in the access state and applies it to the scope's tunnels
func grantTestLease(m *Manager, scope, ip string, exp time.Time) {
m.state.mu.Lock()
m.state.scopeLocked(scope).Leases[ip] = leaseInfo{ExpiresAt: exp}
m.state.mu.Unlock()
m.syncScope(scope)
}
func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
@@ -339,7 +347,7 @@ func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
m.mu.RLock()
tun := m.tunnels["a"]
m.mu.RUnlock()
tun.AddDynamicWhitelist("10.1.2.3", time.Now().Add(time.Hour))
grantTestLease(m, "a", "10.1.2.3", time.Now().Add(time.Hour))
cfg.Name = "renamed"
cfg.Whitelist = []string{"127.0.0.1"}
@@ -623,10 +631,7 @@ func TestExpiredLeaseDisconnectsActiveConnection(t *testing.T) {
cfg.Whitelist = nil
a.register(t, protocol.TypeRegisterTunnel, cfg)
m.mu.RLock()
tun := m.tunnels["a"]
m.mu.RUnlock()
tun.AddDynamicWhitelist("127.0.0.1", time.Now().Add(1500*time.Millisecond))
grantTestLease(m, "a", "127.0.0.1", time.Now().Add(1500*time.Millisecond))
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.ListenPort))
if err != nil {
+88 -65
View File
@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -42,27 +43,27 @@ type leaseInfo struct {
// newLease creates a lease whose countdown starts at the device's first connection.
// Leases valid for the whole tunnel lifetime start immediately.
func newLease(name, invite, duration string, tunnelExpires *time.Time, now time.Time) leaseInfo {
func newLease(name, invite, duration string, scopeExpires *time.Time, now time.Time) leaseInfo {
l := leaseInfo{Name: name, Invite: invite, Duration: duration}
if duration == protocol.LeaseDurationTunnel {
l.StartedAt = &now
l.ExpiresAt = leaseExpiry(duration, tunnelExpires, now)
l.ExpiresAt = leaseExpiry(duration, scopeExpires, now)
return l
}
l.Deferred = true
l.ExpiresAt = now.Add(leaseStartWindow)
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(l.ExpiresAt) {
l.ExpiresAt = *tunnelExpires
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(l.ExpiresAt) {
l.ExpiresAt = *scopeExpires
}
return l
}
// startLease starts the countdown of a deferred lease on the device's first connection.
// Returns the new expiry and true when the lease was started now.
func (s *stateStore) startLease(tunnelID, ip string, tunnelExpires *time.Time, now time.Time) (time.Time, bool) {
func (s *stateStore) startLease(scope, ip string, scopeExpires *time.Time, now time.Time) (time.Time, bool) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return time.Time{}, false
}
@@ -72,16 +73,16 @@ func (s *stateStore) startLease(tunnelID, ip string, tunnelExpires *time.Time, n
}
l.Deferred = false
l.StartedAt = &now
l.ExpiresAt = leaseExpiry(l.Duration, tunnelExpires, now)
l.ExpiresAt = leaseExpiry(l.Duration, scopeExpires, now)
st.Leases[ip] = l
s.saveLocked()
return l.ExpiresAt, true
}
func (s *stateStore) lease(tunnelID, ip string) (leaseInfo, bool) {
func (s *stateStore) lease(scope, ip string) (leaseInfo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if st := s.data[tunnelID]; st != nil {
if st := s.data[scope]; st != nil {
l, ok := st.Leases[ip]
return l, ok && time.Now().Before(l.ExpiresAt)
}
@@ -94,12 +95,33 @@ type tunnelState struct {
Requests map[string]*protocol.AccessRequest `json:"requests,omitempty"` // id -> request
}
// stateStore keeps per-tunnel access state (leases, invites, approval requests).
// 授权状态的作用范围:单个通道(键为通道 ID,与旧数据兼容)或整个通道组(键为 "group:<组ID>")
const groupScopePrefix = "group:"
func groupScope(groupID string) string { return groupScopePrefix + groupID }
// scopeOf returns the state key of a tunnel, or of its group when groupID is set
func scopeOf(tunnelID, groupID string) string {
if groupID != "" {
return groupScope(groupID)
}
return tunnelID
}
// splitScope returns the tunnel or group ID of a scope and whether it is a group scope
func splitScope(scope string) (id string, isGroup bool) {
if strings.HasPrefix(scope, groupScopePrefix) {
return strings.TrimPrefix(scope, groupScopePrefix), true
}
return scope, false
}
// stateStore keeps per-scope access state (leases, invites, approval requests).
// It lives in memory and is optionally persisted to a file so it survives restarts.
type stateStore struct {
path string
mu sync.Mutex
data map[string]*tunnelState // tunnelID -> state
data map[string]*tunnelState // scope(通道 ID 或 group:<组ID>)-> state
}
func newStateStore() *stateStore {
@@ -116,7 +138,7 @@ func (m *Manager) EnableLeasePersistence(path string) error {
switch {
case err == nil:
if err := json.Unmarshal(raw, &data); err != nil || !looksLikeState(data) {
// 兼容旧格式:tunnelID -> ip -> 到期时间
// 兼容旧格式:通道 ID -> ip -> 到期时间
var legacy map[string]map[string]time.Time
if json.Unmarshal(raw, &legacy) == nil {
data = make(map[string]*tunnelState)
@@ -162,7 +184,7 @@ func looksLikeState(d map[string]*tunnelState) bool {
return true
}
func (s *stateStore) tunnelLocked(id string) *tunnelState {
func (s *stateStore) scopeLocked(id string) *tunnelState {
st := s.data[id]
if st == nil {
st = &tunnelState{}
@@ -195,35 +217,32 @@ func randomCode(n int) string {
// ---- leases ----
func (s *stateStore) grantLease(tunnelID, ip string, exp time.Time, name, invite string) {
// removeLease removes one lease, or all leases of the scope when ip is empty; returns how many were removed
func (s *stateStore) removeLease(scope, ip string) int {
s.mu.Lock()
defer s.mu.Unlock()
s.tunnelLocked(tunnelID).Leases[ip] = leaseInfo{ExpiresAt: exp, Name: name, Invite: invite}
s.saveLocked()
}
// removeLease removes one lease, or all leases of the tunnel when ip is empty
func (s *stateStore) removeLease(tunnelID, ip string) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return
return 0
}
n := 0
if ip == "" {
n = len(st.Leases)
st.Leases = make(map[string]leaseInfo)
} else {
} else if _, ok := st.Leases[ip]; ok {
n = 1
delete(st.Leases, ip)
}
s.saveLocked()
return n
}
func (s *stateStore) leaseExpiries(tunnelID string) map[string]time.Time {
func (s *stateStore) leaseExpiries(scope string) map[string]time.Time {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
out := make(map[string]time.Time)
if st := s.data[tunnelID]; st != nil {
if st := s.data[scope]; st != nil {
for ip, l := range st.Leases {
if now.Before(l.ExpiresAt) {
out[ip] = l.ExpiresAt
@@ -233,12 +252,12 @@ func (s *stateStore) leaseExpiries(tunnelID string) map[string]time.Time {
return out
}
func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
func (s *stateStore) leaseList(scope string) []protocol.DynamicLease {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
var out []protocol.DynamicLease
if st := s.data[tunnelID]; st != nil {
if st := s.data[scope]; st != nil {
for ip, l := range st.Leases {
if now.Before(l.ExpiresAt) {
out = append(out, protocol.DynamicLease{
@@ -252,19 +271,19 @@ func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
return out
}
// forgetTunnel drops all access state of a tunnel (explicitly closed or deleted)
func (s *stateStore) forgetTunnel(tunnelID string) {
// forgetScope drops all access state of a tunnel or group (explicitly closed, deleted or dissolved)
func (s *stateStore) forgetScope(scope string) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.data[tunnelID]; ok {
delete(s.data, tunnelID)
if _, ok := s.data[scope]; ok {
delete(s.data, scope)
s.saveLocked()
}
}
// ---- invites ----
func (s *stateStore) createInvite(tunnelID, note string, valid time.Duration, maxUses int, leaseDuration string, requireApproval bool) protocol.Invite {
func (s *stateStore) createInvite(scope, note string, valid time.Duration, maxUses int, leaseDuration string, requireApproval bool) protocol.Invite {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
@@ -280,12 +299,12 @@ func (s *stateStore) createInvite(tunnelID, note string, valid time.Duration, ma
LeaseDuration: leaseDuration,
RequireApproval: requireApproval,
}
s.tunnelLocked(tunnelID).Invites[inv.Code] = inv
s.scopeLocked(scope).Invites[inv.Code] = inv
s.saveLocked()
return *inv
}
// findInvite returns the tunnel owning an unexpired invite
// findInvite returns the scope (tunnel or group) owning an unexpired invite
func (s *stateStore) findInvite(code string) (string, protocol.Invite, bool) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -317,10 +336,10 @@ func capacityOKLocked(st *tunnelState, inv *protocol.Invite, ip string) bool {
}
// checkInvite validates that ip may use the invite right now
func (s *stateStore) checkInvite(tunnelID, code, ip string) error {
func (s *stateStore) checkInvite(scope, code, ip string) error {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return errInviteNotFound
}
@@ -349,10 +368,10 @@ func (s *stateStore) recordUseLocked(st *tunnelState, code, name, ip string) {
}
// useInviteAndGrant records the use and stores the lease atomically; returns the lease
func (s *stateStore) useInviteAndGrant(tunnelID, code, name, ip string, tunnelExpires *time.Time) (leaseInfo, error) {
func (s *stateStore) useInviteAndGrant(scope, code, name, ip string, scopeExpires *time.Time) (leaseInfo, error) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return leaseInfo{}, errInviteNotFound
}
@@ -363,19 +382,19 @@ func (s *stateStore) useInviteAndGrant(tunnelID, code, name, ip string, tunnelEx
if !capacityOKLocked(st, inv, ip) {
return leaseInfo{}, errInviteUsedUp
}
st = s.tunnelLocked(tunnelID)
st = s.scopeLocked(scope)
s.recordUseLocked(st, code, name, ip)
l := newLease(name, code, inv.LeaseDuration, tunnelExpires, time.Now())
l := newLease(name, code, inv.LeaseDuration, scopeExpires, time.Now())
st.Leases[ip] = l
s.saveLocked()
return l, nil
}
// revokeInvite removes the invite and the leases it granted; returns the affected IPs
func (s *stateStore) revokeInvite(tunnelID, code string) []string {
func (s *stateStore) revokeInvite(scope, code string) []string {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return nil
}
@@ -396,12 +415,12 @@ func (s *stateStore) revokeInvite(tunnelID, code string) []string {
return ips
}
func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
func (s *stateStore) inviteList(scope string) []protocol.Invite {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
var out []protocol.Invite
if st := s.data[tunnelID]; st != nil {
if st := s.data[scope]; st != nil {
for _, inv := range st.Invites {
if now.Before(inv.ExpiresAt) {
c := *inv
@@ -417,10 +436,10 @@ func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
// ---- approval requests ----
// addRequest files a pending request (re-using an existing pending one from the same IP)
func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.AccessRequest, error) {
func (s *stateStore) addRequest(scope, code, name, ip string) (protocol.AccessRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return protocol.AccessRequest{}, errInviteNotFound
}
@@ -428,7 +447,7 @@ func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.Acces
if !ok || !time.Now().Before(inv.ExpiresAt) {
return protocol.AccessRequest{}, errInviteNotFound
}
st = s.tunnelLocked(tunnelID)
st = s.scopeLocked(scope)
for _, r := range st.Requests {
if r.IP == ip && r.Status == "pending" {
r.Name = name
@@ -440,13 +459,17 @@ func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.Acces
return protocol.AccessRequest{}, errInviteUsedUp
}
r := &protocol.AccessRequest{
ID: randomCode(16),
TunnelID: tunnelID,
Name: name,
IP: ip,
Invite: code,
At: time.Now(),
Status: "pending",
ID: randomCode(16),
Name: name,
IP: ip,
Invite: code,
At: time.Now(),
Status: "pending",
}
if id, isGroup := splitScope(scope); isGroup {
r.GroupID = id
} else {
r.TunnelID = id
}
st.Requests[r.ID] = r
s.saveLocked()
@@ -467,11 +490,11 @@ func (s *stateStore) request(id string) (protocol.AccessRequest, bool) {
// decide marks a pending request; on approval it records the invite use and stores the lease,
// lasting as long as the originating invite specifies (capped by the tunnel's own TTL).
func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *time.Time) (protocol.AccessRequest, time.Time, error) {
func (s *stateStore) decide(scope, id string, approve bool, scopeExpires *time.Time) (protocol.AccessRequest, time.Time, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneRequestsLocked()
st := s.data[tunnelID]
st := s.data[scope]
if st == nil {
return protocol.AccessRequest{}, time.Time{}, errRequestGone
}
@@ -479,12 +502,12 @@ func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *ti
if !ok || r.Status != "pending" {
return protocol.AccessRequest{}, time.Time{}, errRequestGone
}
st = s.tunnelLocked(tunnelID)
st = s.scopeLocked(scope)
duration := ""
if inv, ok := st.Invites[r.Invite]; ok {
duration = inv.LeaseDuration
}
l := newLease(r.Name, r.Invite, duration, tunnelExpires, time.Now())
l := newLease(r.Name, r.Invite, duration, scopeExpires, time.Now())
exp := l.ExpiresAt
r.At = time.Now()
if approve {
@@ -498,12 +521,12 @@ func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *ti
return *r, exp, nil
}
func (s *stateStore) pendingList(tunnelID string) []protocol.AccessRequest {
func (s *stateStore) pendingList(scope string) []protocol.AccessRequest {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneRequestsLocked()
var out []protocol.AccessRequest
if st := s.data[tunnelID]; st != nil {
if st := s.data[scope]; st != nil {
for _, r := range st.Requests {
if r.Status == "pending" {
out = append(out, *r)
@@ -581,7 +604,7 @@ func (s *stateStore) saveLocked() {
// leaseExpiry computes when a lease granted now ends, per the invite's lease duration,
// never outliving the tunnel's own TTL.
func leaseExpiry(duration string, tunnelExpires *time.Time, now time.Time) time.Time {
func leaseExpiry(duration string, scopeExpires *time.Time, now time.Time) time.Time {
var exp time.Time
switch duration {
case protocol.LeaseDuration1h:
@@ -597,8 +620,8 @@ func leaseExpiry(duration string, tunnelExpires *time.Time, now time.Time) time.
default:
exp = now.Add(2 * time.Hour)
}
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(exp) {
exp = *tunnelExpires
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(exp) {
exp = *scopeExpires
}
return exp
}
+19 -4
View File
@@ -118,13 +118,28 @@ func (t *Tunnel) ID() string {
return t.Config.ID
}
func (t *Tunnel) restoreDynamicLeases(leases map[string]time.Time) {
if len(leases) == 0 {
return
// SetDynamic replaces the temporary leases with the given set and disconnects
// active clients that are no longer permitted. Returns how many were disconnected.
func (t *Tunnel) SetDynamic(leases map[string]time.Time) int {
t.mu.Lock()
defer t.mu.Unlock()
t.whitelist.ReplaceDynamic(leases)
kicked := 0
for id, ac := range t.activeConns {
if ok, _ := t.whitelist.IsAllowed(ac.ClientIP); !ok {
_ = ac.Conn.Close()
delete(t.activeConns, id)
kicked++
}
}
return kicked
}
// GroupID returns the tunnel group this tunnel belongs to ("" when none)
func (t *Tunnel) GroupID() string {
t.mu.RLock()
defer t.mu.RUnlock()
t.whitelist.RestoreDynamic(leases)
return t.Config.GroupID
}
// Name returns the tunnel display name
+89 -40
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"unicode"
"github.com/DeepFal/PortRelay/core/protocol"
@@ -58,31 +59,61 @@ func (m *Manager) accessHost(r *http.Request) string {
return r.Host
}
// inviteTarget resolves an invite code to its (currently registered) tunnel
func (m *Manager) inviteTarget(code string) (*Tunnel, protocol.Invite, string) {
// accessScope is what an invite or request grants access to: one tunnel or a whole tunnel group
type accessScope struct {
key string
group bool
name string
tunnels []protocol.TunnelConfig // 当前开启的通道,按端口排序
expires *time.Time
}
// resolveScope looks up the open tunnels of a scope; false when none is open
func (m *Manager) resolveScope(scope string) (accessScope, bool) {
tunnels := m.scopeTunnels(scope)
if len(tunnels) == 0 {
return accessScope{}, false
}
_, isGroup := splitScope(scope)
sc := accessScope{key: scope, group: isGroup, expires: scopeExpiresOf(tunnels)}
for _, t := range tunnels {
sc.tunnels = append(sc.tunnels, t.Snapshot())
}
sc.name = sc.tunnels[0].Name
if isGroup {
sc.name = sc.tunnels[0].GroupName
if sc.name == "" {
sc.name = "通道组"
}
}
return sc, true
}
// inviteTarget resolves an invite code to the tunnel or group it grants access to
func (m *Manager) inviteTarget(code string) (accessScope, protocol.Invite, string) {
code = strings.TrimSpace(code)
if code == "" {
return nil, protocol.Invite{}, "缺少邀请码"
return accessScope{}, protocol.Invite{}, "缺少邀请码"
}
tunnelID, inv, ok := m.state.findInvite(code)
scope, inv, ok := m.state.findInvite(code)
if !ok {
return nil, protocol.Invite{}, "链接无效或已过期,请向管理员索取新的邀请链接"
return accessScope{}, protocol.Invite{}, "链接无效或已过期,请向管理员索取新的邀请链接"
}
t := m.tunnel(tunnelID)
if t == nil {
return nil, protocol.Invite{}, "对应通道当前未开启,请联系管理员"
sc, ok := m.resolveScope(scope)
if !ok {
return accessScope{}, protocol.Invite{}, "对应通道当前未开启,请联系管理员"
}
return t, inv, ""
return sc, inv, ""
}
func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code string) {
ip := clientIPOf(r)
t, inv, problem := m.inviteTarget(code)
sc, inv, problem := m.inviteTarget(code)
if problem == "" && ip == "" {
problem = "无法识别客户端 IP"
}
if problem == "" {
if err := m.state.checkInvite(t.ID(), inv.Code, ip); err != nil {
if err := m.state.checkInvite(sc.key, inv.Code, ip); err != nil {
problem = err.Error()
}
}
@@ -91,7 +122,6 @@ func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code s
return
}
cfg := t.Snapshot()
action := "确认并获取访问权限"
approvalTip := ""
if inv.RequireApproval {
@@ -107,9 +137,9 @@ func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code s
<button type="submit">%s</button>
</form>
%s`,
m.detailRows(r, cfg, ip, durationText(inv.LeaseDuration)),
m.detailRows(r, sc, ip, durationText(inv.LeaseDuration)),
html.EscapeString(inv.Code), maxNameRunes, action, approvalTip)
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+cfg.Name, body)
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+sc.name, body)
}
func sanitizeName(s string) string {
@@ -130,7 +160,7 @@ func (m *Manager) serveInviteSubmit(w http.ResponseWriter, r *http.Request) {
}
ip := clientIPOf(r)
name := sanitizeName(r.PostForm.Get("name"))
t, inv, problem := m.inviteTarget(r.PostForm.Get("code"))
sc, inv, problem := m.inviteTarget(r.PostForm.Get("code"))
switch {
case problem != "":
case ip == "":
@@ -143,27 +173,26 @@ func (m *Manager) serveInviteSubmit(w http.ResponseWriter, r *http.Request) {
return
}
cfg := t.Snapshot()
if inv.RequireApproval {
req, err := m.state.addRequest(cfg.ID, inv.Code, name, ip)
req, err := m.state.addRequest(sc.key, inv.Code, name, ip)
if err != nil {
m.writePage(w, r, http.StatusConflict, pageError, "无法提交申请", problemHTML(err.Error()))
return
}
log.Printf("[WebAuth] Access request from %s (%s) on tunnel %s awaiting approval", name, ip, cfg.Name)
log.Printf("[WebAuth] Access request from %s (%s) on %s awaiting approval", name, ip, sc.name)
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(req.ID), http.StatusSeeOther)
return
}
lease, err := m.state.useInviteAndGrant(cfg.ID, inv.Code, name, ip, cfg.ExpiresAt)
lease, err := m.state.useInviteAndGrant(sc.key, inv.Code, name, ip, sc.expires)
if err != nil {
m.writePage(w, r, http.StatusConflict, pageError, "无法完成授权", problemHTML(err.Error()))
return
}
t.AddDynamicWhitelist(ip, lease.ExpiresAt)
log.Printf("[WebAuth] Granted %s (%s) access to tunnel %s (port %d): %s",
name, ip, cfg.Name, cfg.ListenPort, leaseValidity(lease))
m.writePage(w, r, http.StatusOK, pageSuccess, "授权成功", m.successBody(r, cfg, ip, lease))
m.syncScope(sc.key)
log.Printf("[WebAuth] Granted %s (%s) access to %s (%d port(s)): %s",
name, ip, sc.name, len(sc.tunnels), leaseValidity(lease))
m.writePage(w, r, http.StatusOK, pageSuccess, "授权成功", m.successBody(r, sc, ip, lease))
}
func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
@@ -172,23 +201,21 @@ func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
m.writePage(w, r, http.StatusNotFound, pageError, "申请已失效", problemHTML("申请不存在或已过期,请重新打开邀请链接提交"))
return
}
t := m.tunnel(req.TunnelID)
sc, open := m.resolveScope(scopeOf(req.TunnelID, req.GroupID))
switch {
case req.Status == "rejected":
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的访问申请"))
case t == nil:
case !open:
m.writePage(w, r, http.StatusOK, pageError, "通道未开启", problemHTML("对应通道当前未开启,请联系管理员"))
case req.Status == "approved":
cfg := t.Snapshot()
lease, _ := m.state.lease(cfg.ID, req.IP)
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, cfg, req.IP, lease))
lease, _ := m.state.lease(sc.key, req.IP)
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, sc, req.IP, lease))
default:
cfg := t.Snapshot()
duration := ""
if _, inv, ok := m.state.findInvite(req.Invite); ok {
duration = inv.LeaseDuration
}
body := m.detailRows(r, cfg, req.IP, durationText(duration)) +
body := m.detailRows(r, sc, req.IP, durationText(duration)) +
fmt.Sprintf(`<div class="tip">%s,您的申请已提交,正在等待管理员批准。此页面会自动刷新。</div>`, html.EscapeString(req.Name))
w.Header().Set("Refresh", "5")
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", body)
@@ -211,20 +238,37 @@ func leaseValidity(l leaseInfo) string {
return "至 " + l.ExpiresAt.Format("01-02 15:04")
}
func (m *Manager) successBody(r *http.Request, cfg protocol.TunnelConfig, ip string, l leaseInfo) string {
return m.detailRows(r, cfg, ip, leaseValidity(l)) +
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接上面的访问地址,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。</div>`
func (m *Manager) successBody(r *http.Request, sc accessScope, ip string, l leaseInfo) string {
where := "上面的访问地址"
if sc.group {
where = "上面任意一个访问地址"
}
return m.detailRows(r, sc, ip, leaseValidity(l)) +
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。</div>`
}
// detailRows renders the tunnel info table
func (m *Manager) detailRows(r *http.Request, cfg protocol.TunnelConfig, ip string, validity string) string {
validity = html.EscapeString(validity)
return fmt.Sprintf(`
<div class="row"><span class="label">目标通道</span><span class="value">%s</span></div>
<div class="row"><span class="label">访问地址</span><span class="value mono">%s:%d</span></div>
// detailRows renders the info table; a group lists every access address with its target
func (m *Manager) detailRows(r *http.Request, sc accessScope, ip string, validity string) string {
host := html.EscapeString(m.accessHost(r))
var target, addresses string
if sc.group {
target = fmt.Sprintf(`<div class="row"><span class="label">目标通道组</span><span class="value">%s(%d 个地址)</span></div>`,
html.EscapeString(sc.name), len(sc.tunnels))
var b strings.Builder
for _, t := range sc.tunnels {
fmt.Fprintf(&b, `<div class="map"><span class="mono">%s:%d</span><span class="to">%s</span></div>`,
host, t.ListenPort, html.EscapeString(t.Target))
}
addresses = `<div class="row stack"><span class="label">访问地址</span><div class="maps">` + b.String() + `</div></div>`
} else {
t := sc.tunnels[0]
target = fmt.Sprintf(`<div class="row"><span class="label">目标通道</span><span class="value">%s</span></div>`, html.EscapeString(t.Name))
addresses = fmt.Sprintf(`<div class="row"><span class="label">访问地址</span><span class="value mono">%s:%d</span></div>`, host, t.ListenPort)
}
return target + addresses + fmt.Sprintf(`
<div class="row"><span class="label">您的设备 IP</span><span class="value mono">%s</span></div>
<div class="row"><span class="label">访问有效期</span><span class="value highlight">%s</span></div>`,
html.EscapeString(cfg.Name), html.EscapeString(m.accessHost(r)), cfg.ListenPort, html.EscapeString(ip), validity)
html.EscapeString(ip), html.EscapeString(validity))
}
func problemHTML(msg string) string {
@@ -284,6 +328,11 @@ func renderPage(kind pageKind, title, body string) string {
.label { color:var(--sub); white-space:nowrap; }
.value { font-weight:600; text-align:right; word-break:break-all; }
.mono { font-family:ui-monospace,Menlo,Monaco,Consolas,monospace; background:var(--code-bg); padding:2px 6px; border-radius:4px; }
.row.stack { align-items:flex-start; }
.maps { display:flex; flex-direction:column; gap:6px; align-items:flex-end; }
.map { display:flex; align-items:center; gap:8px; font-weight:600; }
.map .to { color:var(--sub); font-weight:400; font-size:12px; }
.map .to::before { content:"→ "; }
.highlight { color:var(--accent); }
.tip { margin-top:20px; padding:12px 14px; background:var(--code-bg); border-radius:8px; font-size:12px; line-height:1.5; color:var(--sub); }
.tip.error { color:#FF3B30; background:rgba(255,59,48,0.1); font-weight:600; text-align:center; }
+48 -4
View File
@@ -127,6 +127,18 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
"owner":"运维组","duration_minutes":0,"started_at":"\(t(-2100))","effective_idle_close_minutes":30,"idle_close_minutes":30,"first_connected_at":"\(t(-2000))","connected_sec":640,
"activity":{"in_use":false,"idle_since":"\(t(-720))","auto_close_at":"\(t(1080))","connected_sec":640},
"metrics":{"tunnel_id":"t3","status":"online","active_conns":0,"total_conns":4,"bytes_in":1983000,"bytes_out":204000,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
{"id":"g1a","name":"订单服务 (10.0.3.11)","target":"10.0.3.11:22","listen_port":33670,"whitelist":[],"enabled":true,"group_id":"g1",
"owner":"运维组","started_at":"\(t(-1800))","effective_idle_close_minutes":30,"idle_close_minutes":30,"first_connected_at":"\(t(-1500))","connected_sec":\(1320 + Int(dt)),
"activity":{"in_use":true,"connected_sec":\(1320 + Int(dt))},
"metrics":{"tunnel_id":"g1a","status":"online","active_conns":1,"total_conns":9,"bytes_in":3203180,"bytes_out":18039104,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
{"id":"g1b","name":"订单服务 (10.0.3.12)","target":"10.0.3.12:22","listen_port":33671,"whitelist":[],"enabled":true,"group_id":"g1",
"owner":"运维组","started_at":"\(t(-1800))","effective_idle_close_minutes":30,"idle_close_minutes":30,"first_connected_at":"\(t(-1400))","connected_sec":\(1100 + Int(dt)),
"activity":{"in_use":true,"connected_sec":\(1100 + Int(dt))},
"metrics":{"tunnel_id":"g1b","status":"online","active_conns":2,"total_conns":14,"bytes_in":2203180,"bytes_out":12039104,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
{"id":"g1c","name":"订单服务 (10.0.3.13)","target":"10.0.3.13:22","listen_port":33672,"whitelist":[],"enabled":true,"group_id":"g1",
"owner":"运维组","started_at":"\(t(-1800))","effective_idle_close_minutes":30,"idle_close_minutes":30,"connected_sec":0,
"activity":{"in_use":true,"connected_sec":0},
"metrics":{"tunnel_id":"g1c","status":"online","active_conns":0,"total_conns":0,"bytes_in":0,"bytes_out":0,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
{"id":"t4","name":"Redis 缓存","target":"10.0.1.20:6379","listen_port":33663,"whitelist":[],"enabled":false,"last_duration_sec":1905,"duration_minutes":0,"effective_idle_close_minutes":30,"last_connected_sec":742,"close_reason":"闲置 30 分钟自动关闭",
"historical_bytes_in":3145728,"historical_bytes_out":524288}
]
@@ -136,9 +148,24 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
vm.tunnels[2].rate_history = RateSeriesItem(end: Int64(Date().timeIntervalSince1970), step: 5,
up: (0..<120).map { $0 < 30 ? demoRate($0, seed: 5, base: 300, burst: 2000) : 0 },
down: (0..<120).map { $0 < 30 ? demoRate($0, seed: 6, base: 1500, burst: 9000) : 0 })
for (i, seed) in [(3, 3.3), (4, 4.6)] {
vm.tunnels[i].rate_history = demoSeries(tick: tick, seed: seed, base: 4000, burst: 26000, upScale: 0.3)
}
vm.tunnels[5].rate_history = RateSeriesItem(end: Int64(Date().timeIntervalSince1970), step: 5,
up: Array(repeating: 0, count: 120), down: Array(repeating: 0, count: 120))
let ga = vm.tunnels[3].rate_history!, gb = vm.tunnels[4].rate_history!
vm.groups = decode([GroupItem].self, """
[{"id":"g1","name":"订单服务","owner":"运维组","created_at":"\(t(-1800))","tunnel_ids":["g1a","g1b","g1c"],
"metrics":{"group_id":"g1",
"dynamic_leases":[{"ip":"192.168.1.42","expires_at":"\(t(27000))","name":"小王","invite":"gx1","duration":"8h","started_at":"\(t(-1500))"}],
"pending":[{"id":"gr1","tunnel_id":"","group_id":"g1","name":"小李","ip":"192.168.1.57","invite":"gx2","at":"\(t(-40))","status":"pending"}],
"invites":[{"code":"gx1","note":"小王","created_at":"\(t(-1700))","expires_at":"\(t(84000))","max_uses":1,"lease_duration":"8h","uses":[{"name":"小王","ip":"192.168.1.42","at":"\(t(-1600))"}]},
{"code":"gx2","note":"测试组","created_at":"\(t(-100))","expires_at":"\(t(86000))","max_uses":3,"lease_duration":"today","require_approval":true}]}}]
""")
vm.groups[0].rate_history = RateSeriesItem(end: ga.end, step: 5, up: zip(ga.up, gb.up).map { $0 + $1 }, down: zip(ga.down, gb.down).map { $0 + $1 })
// 卡片与顶部统计的实时速率取曲线最后一个点
var totalIn: Int64 = 0, totalOut: Int64 = 0
for i in 0..<2 {
for i in [0, 1, 3, 4] {
let h = vm.tunnels[i].rate_history!
vm.tunnels[i].metrics?.rate_in = h.up.last ?? 0
vm.tunnels[i].metrics?.rate_out = h.down.last ?? 0
@@ -152,6 +179,9 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
{"conn_id":"c2","tunnel_id":"t1","client_ip":"192.168.1.21:50395","connected_at":"\(t(-577))","bytes_in":406528,"bytes_out":3984588,"rate_in":1126,"rate_out":29798},
{"conn_id":"c3","tunnel_id":"t1","client_ip":"192.168.1.21:53023","connected_at":"\(t(-526))","bytes_in":244213760,"bytes_out":633856,"rate_in":0,"rate_out":0},
{"conn_id":"c4","tunnel_id":"t2","client_ip":"192.168.1.52:61001","connected_at":"\(t(-3000))","bytes_in":120000,"bytes_out":8800000,"rate_in":120,"rate_out":9100},
{"conn_id":"c6","tunnel_id":"g1a","client_ip":"192.168.1.42:50211","connected_at":"\(t(-900))","bytes_in":320000,"bytes_out":1800000,"rate_in":900,"rate_out":5100},
{"conn_id":"c7","tunnel_id":"g1b","client_ip":"192.168.1.42:50388","connected_at":"\(t(-600))","bytes_in":220000,"bytes_out":1200000,"rate_in":700,"rate_out":3900},
{"conn_id":"c8","tunnel_id":"g1b","client_ip":"192.168.1.42:50390","connected_at":"\(t(-300))","bytes_in":20000,"bytes_out":90000,"rate_in":0,"rate_out":0},
{"conn_id":"c5","tunnel_id":"t2","client_ip":"192.168.1.60:61201","connected_at":"\(t(-300))","bytes_in":42000,"bytes_out":1800000,"rate_in":80,"rate_out":3100}
]
""")
@@ -162,7 +192,7 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
{"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1}
]
""")
vm.totalActiveConns = 5; vm.totalHistoricalConns = 1633; vm.totalRateIn = totalIn; vm.totalRateOut = totalOut
vm.totalActiveConns = 8; vm.totalHistoricalConns = 1656; vm.totalRateIn = totalIn; vm.totalRateOut = totalOut
vm.totalTransferred = 60000000000; vm.totalBlockedAttempts = 12
vm.runDiagnostics()
return vm
@@ -221,12 +251,26 @@ MainActor.assumeIsolated {
// README 截图
let demo = makeDemoVM()
render("readme-main-light", CGSize(width: 980, height: 880), ContentView(vm: demo))
render("readme-main-dark", CGSize(width: 980, height: 880), dark: true, ContentView(vm: demo))
render("readme-main-light", CGSize(width: 980, height: 1180), ContentView(vm: demo))
render("readme-main-dark", CGSize(width: 980, height: 1180), dark: true, ContentView(vm: demo))
render("readme-invite", CGSize(width: 560, height: 250), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes))
render("readme-clients", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 0, isPresented: yes))
render("readme-access", CGSize(width: 760, height: 490), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 2, isPresented: yes))
render("readme-edit", CGSize(width: 490, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
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,
onEdit: {}, 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,
onEdit: {}, 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,
onEdit: {}, 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,
initialName: "订单服务", initialHosts: "10.0.3.11-13", initialPorts: "22, 8080"))
render("group-sheet-edit", CGSize(width: 560, height: 520), 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-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-edit-member", CGSize(width: 490, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes))
// README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
if only.hasPrefix("gif") {