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
+113
-2
@@ -31,6 +31,8 @@ func (m *Manager) handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
|
||||
m.serveInviteForm(w, r, r.URL.Query().Get("code"))
|
||||
case path == "/auth/status":
|
||||
m.serveRequestStatus(w, r)
|
||||
case path == "/renew" && r.Method == http.MethodPost:
|
||||
m.serveRenewSubmit(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -108,6 +110,13 @@ func (m *Manager) inviteTarget(code string) (accessScope, protocol.Invite, strin
|
||||
|
||||
func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code string) {
|
||||
ip := clientIPOf(r)
|
||||
// 用过这个链接的设备:显示它的授权状态,到期前后都可以申请延长(链接或通道失效后也一样)
|
||||
if ip != "" {
|
||||
if da, ok := m.state.deviceAccess(strings.TrimSpace(code), ip); ok {
|
||||
m.serveDeviceAccess(w, r, strings.TrimSpace(code), ip, da)
|
||||
return
|
||||
}
|
||||
}
|
||||
sc, inv, problem := m.inviteTarget(code)
|
||||
if problem == "" && ip == "" {
|
||||
problem = "无法识别客户端 IP"
|
||||
@@ -142,6 +151,88 @@ func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code s
|
||||
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+sc.name, body)
|
||||
}
|
||||
|
||||
// serveDeviceAccess shows a returning device its access and, when it can, the renewal button
|
||||
func (m *Manager) serveDeviceAccess(w http.ResponseWriter, r *http.Request, code, ip string, da deviceAccess) {
|
||||
if da.pending != nil {
|
||||
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(da.pending.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
sc, open := m.resolveScope(da.scope)
|
||||
rows := func(validity string) string {
|
||||
if open {
|
||||
return m.detailRows(r, sc, ip, validity)
|
||||
}
|
||||
return closedRows(da, ip, validity)
|
||||
}
|
||||
renewForm := fmt.Sprintf(`
|
||||
<form method="post" action="/renew" class="form">
|
||||
<input type="hidden" name="code" value="%s">
|
||||
<button type="submit">申请延长</button>
|
||||
</form>`, html.EscapeString(code))
|
||||
switch {
|
||||
case da.lease != nil && da.lease.Deferred && open:
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "已获得访问权限", m.successBody(r, sc, ip, *da.lease))
|
||||
case da.lease != nil && da.lease.Deferred:
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "已获得访问权限", rows(leaseValidity(*da.lease)))
|
||||
case da.lease != nil:
|
||||
left := time.Until(da.lease.ExpiresAt).Round(time.Minute)
|
||||
body := rows(fmt.Sprintf("至 %s(剩余 %s)", da.lease.ExpiresAt.Format("01-02 15:04"), protocol.DescribeMinutes(max(1, int(left/time.Minute))))) +
|
||||
`<div class="tip">需要更多时间时可以申请延长,管理员批准后在原到期时间上顺延。</div>` + renewForm
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "访问已授权", body)
|
||||
case da.ended != nil && da.ended.Revoked:
|
||||
m.writePage(w, r, http.StatusForbidden, pageError, "授权已撤销", problemHTML(errRevoked.Error()))
|
||||
case da.ended != nil:
|
||||
closedTip := ""
|
||||
if !open {
|
||||
closedTip = "通道当前已关闭,批准后会自动重新开启。"
|
||||
}
|
||||
body := rows("已于 "+da.ended.EndedAt.Format("01-02 15:04")+" 结束") +
|
||||
fmt.Sprintf(`<div class="tip">可在 %s 前申请延长,管理员批准并确定时长后即可继续使用。%s</div>`,
|
||||
da.ended.EndedAt.Add(renewWindow).Format("15:04"), closedTip) + renewForm
|
||||
m.writePage(w, r, http.StatusOK, pageForm, "访问已结束", body)
|
||||
default:
|
||||
m.writePage(w, r, http.StatusGone, pageError, "访问已结束", problemHTML(errNothingToRenew.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
// closedRows describes a scope whose tunnels are all closed (only its last known name is left)
|
||||
func closedRows(da deviceAccess, ip, validity string) string {
|
||||
label := "目标通道"
|
||||
if _, isGroup := splitScope(da.scope); isGroup {
|
||||
label = "目标通道组"
|
||||
}
|
||||
name := da.name
|
||||
if name == "" {
|
||||
name = "(已关闭)"
|
||||
}
|
||||
return fmt.Sprintf(`
|
||||
<div class="row"><span class="label">%s</span><span class="value">%s</span></div>
|
||||
<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>`,
|
||||
label, html.EscapeString(name), html.EscapeString(ip), html.EscapeString(validity))
|
||||
}
|
||||
|
||||
func (m *Manager) serveRenewSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = io.NopCloser(io.LimitReader(r.Body, 8<<10))
|
||||
if err := r.ParseForm(); err != nil {
|
||||
m.writePage(w, r, http.StatusBadRequest, pageError, "请求无效", problemHTML("表单内容无法解析"))
|
||||
return
|
||||
}
|
||||
ip := clientIPOf(r)
|
||||
da, ok := m.state.deviceAccess(strings.TrimSpace(r.PostForm.Get("code")), ip)
|
||||
if ip == "" || !ok {
|
||||
m.writePage(w, r, http.StatusNotFound, pageError, "无法申请延长", problemHTML("没有找到这台设备的授权,请联系管理员获取新的邀请链接"))
|
||||
return
|
||||
}
|
||||
req, err := m.state.addRenewal(da.scope, ip)
|
||||
if err != nil {
|
||||
m.writePage(w, r, http.StatusConflict, pageError, "无法申请延长", problemHTML(err.Error()))
|
||||
return
|
||||
}
|
||||
log.Printf("[WebAuth] %s (%s) asked to extend access to %s", req.Name, ip, da.name)
|
||||
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(req.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func sanitizeName(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
@@ -201,15 +292,35 @@ func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
|
||||
m.writePage(w, r, http.StatusNotFound, pageError, "申请已失效", problemHTML("申请不存在或已过期,请重新打开邀请链接提交"))
|
||||
return
|
||||
}
|
||||
sc, open := m.resolveScope(scopeOf(req.TunnelID, req.GroupID))
|
||||
scope := scopeOf(req.TunnelID, req.GroupID)
|
||||
sc, open := m.resolveScope(scope)
|
||||
switch {
|
||||
case req.Status == "rejected" && req.Renewal:
|
||||
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的延长申请"))
|
||||
case req.Status == "rejected":
|
||||
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的访问申请"))
|
||||
case req.Renewal && req.Status == "approved" && !open:
|
||||
// 批准后 Agent 正在重新开启通道
|
||||
w.Header().Set("Refresh", "3")
|
||||
m.writePage(w, r, http.StatusOK, pagePending, "已批准,正在开启通道", `<div class="tip">管理员已批准延长,通道正在重新开启,此页面会自动刷新。</div>`)
|
||||
case req.Renewal && !open:
|
||||
da := deviceAccess{scope: scope, name: m.state.scopeDisplayName(scope)}
|
||||
w.Header().Set("Refresh", "5")
|
||||
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", closedRows(da, req.IP, "等待批准")+
|
||||
fmt.Sprintf(`<div class="tip">%s,您的延长申请已提交,管理员批准时会确定延长多久;通道当前已关闭,批准后会自动重新开启。此页面会自动刷新。</div>`, html.EscapeString(req.Name)))
|
||||
case !open:
|
||||
m.writePage(w, r, http.StatusOK, pageError, "通道未开启", problemHTML("对应通道当前未开启,请联系管理员"))
|
||||
case req.Status == "approved" && req.Renewal:
|
||||
lease, _ := m.state.lease(sc.key, req.IP)
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "延长已批准", m.detailRows(r, sc, req.IP, leaseValidity(lease))+
|
||||
`<div class="tip">管理员已批准延长,现在可以继续使用。到期后 2 小时内还可以重新打开邀请链接申请延长。</div>`)
|
||||
case req.Status == "approved":
|
||||
lease, _ := m.state.lease(sc.key, req.IP)
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, sc, req.IP, lease))
|
||||
case req.Renewal:
|
||||
w.Header().Set("Refresh", "5")
|
||||
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", m.detailRows(r, sc, req.IP, "等待批准")+
|
||||
fmt.Sprintf(`<div class="tip">%s,您的延长申请已提交,管理员批准时会确定延长多久。此页面会自动刷新。</div>`, html.EscapeString(req.Name)))
|
||||
default:
|
||||
duration := ""
|
||||
if _, inv, ok := m.state.findInvite(req.Invite); ok {
|
||||
@@ -244,7 +355,7 @@ func (m *Manager) successBody(r *http.Request, sc accessScope, ip string, l leas
|
||||
where = "上面任意一个访问地址"
|
||||
}
|
||||
return m.detailRows(r, sc, ip, leaseValidity(l)) +
|
||||
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。</div>`
|
||||
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后 2 小时内,可以重新打开这个邀请链接申请延长。</div>`
|
||||
}
|
||||
|
||||
// detailRows renders the info table; a group lists every access address with its target
|
||||
|
||||
Reference in new issue
Block a user