feat: 通道组——一次开放多台主机 / 多个端口,共用邀请与授权
- 批量创建:主机支持 IP、主机名、末段范围(10.0.1.11-13)和 主机:端口, 与端口列表展开为 主机 × 端口,自动分配连续的访问端口,创建前可预览 - 授权以组为单位:一条邀请链接、一次审批即可访问组内全部地址; 计时从首次连上任一地址开始,撤销一次全组断开;后加入组的通道自动获得组授权 - 网关授权状态按作用范围(通道 / 通道组)存储,通道的动态白名单统一由状态库重新计算, 通道授权与组授权互不干扰;组内最后一个通道关闭或组被解散时,组的邀请与授权失效 - 闲置联动:组内任一地址在用时整组都不算闲置;支持整组开关 - 已有通道可并入组或移出组;组可改名、追加目标、解散(保留或删除成员) - App 与网页面板:组卡片、新建 / 编辑通道组、组邀请与“连接与授权”,授权页列出全部地址 - 测试:网关 9 个、Agent 5 个通道组用例
This commit is contained in:
1 parent
86e159b3f0
commit
3797bef88a
22 files changed
+3894
-471
No files matched your search
@@ -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`**
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 |
+1197
-106
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in new issue
Block a user