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:
1 parent
9a675ba96e
commit
dceb59c0ca
16 files changed
+1707
-75
No files matched your search
+106
-1
@@ -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 != "" {
|
||||
|
||||
Reference in new issue
Block a user