feat: 授权延长——同事一键申请,管理员批准时决定时长;时长支持 30 分钟与自定义

同事端
- 用过邀请链接的设备再次打开链接时显示自己的授权状态:有效时可申请延长,
  访问结束(到期或通道关闭)后 2 小时内也可以;超过 2 小时或被撤销则提示重新获取邀请
- 「申请延长」只有一个按钮,提交后复用审批状态页,批准后显示新的到期时间
- 用过链接的设备不再自动重新放通,统一走申请延长

网关
- 授权结束后保留 2 小时的记录(通道关闭后仍保留,随状态文件持久化);撤销的不能延长,
  删除通道 / 解散组时一并清除(unregister 带 deleted 标记)
- 新增 extend_lease:有效授权从原到期时间顺延,已结束的从现在起算,不受通道最长开启时长截断,
  并批准该设备的延长申请;指标里带上全部可延长设备与申请(包括已关闭的通道)
- 授权时长支持分钟数("30m"、"90m",5 分钟到 30 天)

Agent / App / 网页面板
- POST .../leases/extend:通道已关闭时先自动开启(组则全组开启),并让通道至少开到授权结束
- 授权管理新增「申请延长」「最近结束 · 2 小时内可延长」,已授权设备可直接「延长」;
  卡片、菜单栏和系统通知提示延长申请,菜单栏可直接选时长批准
- 邀请的可访问时长、通道最长开启时长增加 30 分钟与自定义;网页面板弹窗层级高于侧边抽屉
- 邀请分享文案提示「授权结束后 2 小时内可重新打开此链接申请延长」
This commit is contained in:
DeepFal committed 2026-10-14 10:11:06 +08:00
1 parent 9a675ba96e
commit dceb59c0ca
16 files changed
+1707 -75

No files matched your search

+106 -1
View File
@@ -132,6 +132,19 @@ type TunnelWithMetrics struct {
EffectiveIdleCloseMinutes int `json:"effective_idle_close_minutes"`
// RateHistory 近 10 分钟流量曲线(仅开启中的通道)
RateHistory *RateSeries `json:"rate_history,omitempty"`
// Renewals 可延长的设备与延长申请(通道关闭后 2 小时内仍有)
Renewals []protocol.RenewalInfo `json:"renewals,omitempty"`
}
// renewalsOf picks the renewals of one tunnel (groupID empty) or one group
func renewalsOf(all []protocol.RenewalInfo, tunnelID, groupID string) []protocol.RenewalInfo {
var out []protocol.RenewalInfo
for _, r := range all {
if (groupID != "" && r.GroupID == groupID) || (groupID == "" && r.GroupID == "" && r.TunnelID == tunnelID) {
out = append(out, r)
}
}
return out
}
// tunnelsWithMetrics merges config, live metrics and registration errors into one consistent view.
@@ -167,6 +180,7 @@ func (s *APIServer) tunnelsWithMetrics(metrics protocol.MetricsMessage) []Tunnel
Metrics: tm,
Activity: s.agent.Activity(t),
EffectiveIdleCloseMinutes: IdleCloseMinutes(t),
Renewals: renewalsOf(metrics.Renewals, t.ID, ""),
}
if t.Enabled {
series := s.agent.RateSeries(t.ID)
@@ -265,7 +279,7 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
return
}
s.agent.ConsumeSessionBytes(tunnelID)
s.agent.RemoveTunnel(tunnelID)
s.agent.DeleteTunnel(tunnelID)
jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
@@ -376,6 +390,7 @@ func shareMessage(inv protocol.Invite, body, link string) string {
} else if inv.MaxUses > 1 {
b.WriteString(fmt.Sprintf("\n此链接最多 %d 台设备使用,请勿转发。", inv.MaxUses))
}
b.WriteString("\n授权结束后 2 小时内,可以重新打开此链接申请延长。")
return b.String()
}
@@ -386,6 +401,7 @@ func isAccessPath(p string) bool {
// handleAccess serves the access endpoints shared by tunnels and groups:
//
// POST leases/revoke {"ip": "..."}(ip 为空表示撤销全部)
// POST leases/extend {"ip": "...", "minutes": 60} 延长授权(同时批准该设备的延长申请)
// POST invites 新建邀请
// DELETE invites/{code} 作废邀请
// GET invites/{code}/share 分享文案
@@ -406,6 +422,26 @@ func (s *APIServer) handleAccess(w http.ResponseWriter, r *http.Request, sc Scop
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
case len(rest) == 2 && rest[0] == "leases" && rest[1] == "extend" && r.Method == http.MethodPost:
var req struct {
IP string `json:"ip"`
Minutes int `json:"minutes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.Minutes < 1 || req.Minutes > protocol.MaxLeaseMinutes {
jsonError(w, http.StatusBadRequest, "延长时长须在 1 分钟到 30 天之间")
return
}
exp, err := s.extendAccess(sc, strings.TrimSpace(req.IP), req.Minutes)
if err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]any{"expires_at": exp})
case rest[0] == "invites":
s.handleInvites(w, r, sc, rest[1:])
@@ -428,6 +464,75 @@ func (s *APIServer) handleAccess(w http.ResponseWriter, r *http.Request, sc Scop
}
}
// extendAccess extends a device's access in a tunnel or group. Closed tunnels are reopened first
// (a group reopens all its members), and each is kept open at least until the new expiry.
func (s *APIServer) extendAccess(sc Scope, ip string, minutes int) (time.Time, error) {
if ip == "" {
return time.Time{}, fmt.Errorf("缺少设备 IP")
}
var members []protocol.TunnelConfig
if sc.GroupID != "" {
members = s.cm.GroupMembers(sc.GroupID)
} else if t, ok := s.cm.FindTunnel(sc.TunnelID); ok {
members = []protocol.TunnelConfig{t}
}
if len(members) == 0 {
return time.Time{}, fmt.Errorf("通道不存在或组内没有通道")
}
if !s.canExtend(sc, ip) {
return time.Time{}, fmt.Errorf("这台设备没有可延长的授权(访问结束已超过 2 小时或已被撤销)")
}
on := true
for _, t := range members {
if !t.Enabled {
if _, err := s.setEnabled(t.ID, &on); err != nil {
return time.Time{}, err
}
}
}
exp, err := s.agent.ExtendLease(sc, ip, minutes)
if err != nil {
return time.Time{}, err
}
for _, t := range members {
if updated, changed, err := s.cm.ExtendUntil(t.ID, exp); err == nil && changed {
s.agent.SyncTunnel(updated, true)
}
}
return exp, nil
}
// canExtend reports whether ip has a running lease, or an access ended within two hours, in the scope.
// 先检查再开启通道,避免对没有可延长授权的设备白白打开通道。
func (s *APIServer) canExtend(sc Scope, ip string) bool {
metrics := s.agent.GetLatestMetrics()
for _, r := range renewalsOf(metrics.Renewals, sc.TunnelID, sc.GroupID) {
if r.IP == ip {
return true
}
}
var leases []protocol.DynamicLease
if sc.GroupID != "" {
for _, g := range metrics.Groups {
if g.GroupID == sc.GroupID {
leases = g.DynamicLeases
}
}
} else {
for _, t := range metrics.Tunnels {
if t.TunnelID == sc.TunnelID {
leases = t.DynamicLeases
}
}
}
for _, l := range leases {
if l.IP == ip {
return true
}
}
return false
}
// 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 != "" {