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
+1706 -74

No files matched your search

+2 -1
View File
@@ -51,10 +51,11 @@ PortRelay 把这件事变成三步:
**访问控制**
- 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。
- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(1 小时、2 小时、8 小时、当天有效,或直到通道关闭),以及是否需要审批。
- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(30 分钟、1 小时、2 小时、8 小时、当天有效、直到通道关闭,或自定义),以及是否需要审批。
- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
- **申请延长**:访问结束后 2 小时内(通道已关闭也可以),同事重新打开原链接点「申请延长」;你在 App、菜单栏或网页面板里选择延长多久即批准,关闭的通道会自动重新开启。你也可以对已授权或刚结束的设备主动延长。
- **通道组**:添加通道时目标是一个列表,每行一台主机、多个端口用逗号隔开(也可以直接粘贴一串 IP),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。
**通道生命周期**
+2 -1
View File
@@ -54,10 +54,11 @@ PortRelay turns that into three steps:
**Access control**
- Three access modes: **invite-only** (the default and safest), an IP / CIDR allowlist (which can be combined with invites), and open to all.
- Each invite link has its own expiry, device limit, access duration once granted (1 h, 2 h, 8 h, until end of day, or until the tunnel closes) and an optional approval step.
- Each invite link has its own expiry, device limit, access duration once granted (30 min, 1 h, 2 h, 8 h, until end of day, until the tunnel closes, or a custom length) and an optional approval step.
- 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.
- **Extensions.** Within two hours after access ends (even if the tunnel has been closed), a teammate reopens the original link and clicks 申请延长 (Request extension). You approve it by picking a duration in the app, the menu bar or the web panel, and a closed tunnel reopens on its own. You can also extend a running or just-ended grant yourself.
- **Tunnel groups**: the add-tunnel form takes a list of targets, one host per row with comma-separated ports (a pasted list of IPs is split into rows), creates one tunnel per address on a contiguous block of gateway ports and puts them in one group. 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**
+70 -2
View File
@@ -34,6 +34,7 @@ type Agent struct {
latestMetrics protocol.MetricsMessage
regErrors map[string]string // tunnelID -> 服务器返回的注册失败原因
inviteWaiters map[string]chan protocol.InviteRespMessage
extendWaiters map[string]chan protocol.ExtendLeaseRespMessage
controlWriteMu sync.Mutex
controlWriter *bufio.Writer
@@ -57,6 +58,7 @@ func NewAgent(cm *ConfigManager) *Agent {
cm: cm,
regErrors: make(map[string]string),
inviteWaiters: make(map[string]chan protocol.InviteRespMessage),
extendWaiters: make(map[string]chan protocol.ExtendLeaseRespMessage),
usage: make(map[string]*usageState),
idleUnit: time.Minute,
rates: newRateRecorder(),
@@ -199,6 +201,18 @@ func (a *Agent) trackUsage(now time.Time) {
groupBusy[gm.GroupID] = true
}
}
// 等待批准的延长申请:通道(组)保持开启,避免关了又被批准重开
renewing := make(map[string]bool)
for _, r := range metrics.Renewals {
if r.Request == nil {
continue
}
if r.GroupID != "" {
groupBusy[r.GroupID] = true
} else {
renewing[r.TunnelID] = 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
@@ -242,7 +256,7 @@ func (a *Agent) trackUsage(now time.Time) {
firstConn = append(firstConn, t.ID)
}
}
u.inUse = tunnelInUse(m) || (t.GroupID != "" && groupBusy[t.GroupID])
u.inUse = tunnelInUse(m) || renewing[t.ID] || (t.GroupID != "" && groupBusy[t.GroupID])
if u.inUse {
u.lastActive = now
}
@@ -520,6 +534,17 @@ func (a *Agent) connectAndRun(cfg Config) error {
delete(a.inviteWaiters, resp.RequestID)
}
a.mu.Unlock()
case protocol.TypeExtendResp:
var resp protocol.ExtendLeaseRespMessage
if json.Unmarshal(line, &resp) != nil {
continue
}
a.mu.Lock()
if ch, ok := a.extendWaiters[resp.RequestID]; ok {
ch <- resp
delete(a.extendWaiters, resp.RequestID)
}
a.mu.Unlock()
case protocol.TypeRegisterResp:
var resp protocol.RegisterRespMessage
if json.Unmarshal(line, &resp) != nil {
@@ -743,14 +768,24 @@ func (a *Agent) wire(t protocol.TunnelConfig) protocol.TunnelConfig {
return t
}
// RemoveTunnel closes a tunnel on the relay server; its devices can still ask for an extension
func (a *Agent) RemoveTunnel(id string) {
a.unregister(id, false)
}
// DeleteTunnel removes a deleted tunnel from the relay server, renewal records included
func (a *Agent) DeleteTunnel(id string) {
a.unregister(id, true)
}
func (a *Agent) unregister(id string, deleted bool) {
a.mu.Lock()
delete(a.regErrors, id)
a.mu.Unlock()
if !a.IsConnected() {
return
}
_ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id})
_ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id, Deleted: deleted})
}
// Scope selects what an invite, request or lease applies to: one tunnel, or a whole group
@@ -792,6 +827,38 @@ func (a *Agent) CreateInvite(scope Scope, note string, validMinutes, maxUses int
}
}
// ExtendLease extends a device's access by minutes (approving its renewal requests) and returns
// the new expiry
func (a *Agent) ExtendLease(scope Scope, ip string, minutes int) (time.Time, error) {
if !a.IsConnected() {
return time.Time{}, fmt.Errorf("未连接中继服务器")
}
reqID := uuid.New().String()
ch := make(chan protocol.ExtendLeaseRespMessage, 1)
a.mu.Lock()
a.extendWaiters[reqID] = ch
a.mu.Unlock()
defer func() {
a.mu.Lock()
delete(a.extendWaiters, reqID)
a.mu.Unlock()
}()
if err := a.sendControl(protocol.ExtendLeaseMessage{
Type: protocol.TypeExtendLease, RequestID: reqID, TunnelID: scope.TunnelID, GroupID: scope.GroupID, IP: ip, Minutes: minutes,
}); err != nil {
return time.Time{}, err
}
select {
case resp := <-ch:
if !resp.Success {
return time.Time{}, fmt.Errorf("%s", resp.Error)
}
return resp.ExpiresAt, nil
case <-time.After(5 * time.Second):
return time.Time{}, fmt.Errorf("服务器未响应,请先把服务端更新到支持延长授权的版本")
}
}
// RevokeInvite invalidates an invite link and the leases it granted
func (a *Agent) RevokeInvite(scope Scope, code string) error {
if !a.IsConnected() {
@@ -859,5 +926,6 @@ func (a *Agent) GetLatestMetrics() protocol.MetricsMessage {
m.Groups = append([]protocol.GroupMetrics(nil), m.Groups...)
m.ActiveConns = append([]protocol.ActiveConnInfo(nil), m.ActiveConns...)
m.SecurityEvents = append([]protocol.SecurityEvent(nil), m.SecurityEvents...)
m.Renewals = append([]protocol.RenewalInfo(nil), m.Renewals...)
return m
}
+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 != "" {
+5 -2
View File
@@ -16,6 +16,8 @@ type GroupView struct {
Metrics protocol.GroupMetrics `json:"metrics"`
// RateHistory 开启中成员的近 10 分钟流量之和
RateHistory *RateSeries `json:"rate_history,omitempty"`
// Renewals 可延长的设备与延长申请(组关闭后 2 小时内仍有)
Renewals []protocol.RenewalInfo `json:"renewals,omitempty"`
}
func (s *APIServer) groupsView(metrics protocol.MetricsMessage) []GroupView {
@@ -26,7 +28,8 @@ func (s *APIServer) groupsView(metrics protocol.MetricsMessage) []GroupView {
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}}
v := GroupView{TunnelGroup: g, TunnelIDs: make([]string, 0), Metrics: protocol.GroupMetrics{GroupID: g.ID},
Renewals: renewalsOf(metrics.Renewals, "", g.ID)}
if m, ok := shared[g.ID]; ok {
v.Metrics = m
}
@@ -183,7 +186,7 @@ func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
if deleteMembers {
s.agent.ConsumeSessionBytes(t.ID)
s.agent.dropUsage(t.ID)
s.agent.RemoveTunnel(t.ID)
s.agent.DeleteTunnel(t.ID)
} else if t.Enabled {
s.agent.SyncTunnel(t, true)
}
+19
View File
@@ -572,6 +572,25 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
return cloneTunnel(updated), existing.ListenPort, cm.save()
}
// ExtendUntil keeps an open tunnel open at least until t: an approved access extension outlasts the
// tunnel's maximum open time. Returns the tunnel and whether it changed.
func (cm *ConfigManager) ExtendUntil(id string, t time.Time) (protocol.TunnelConfig, bool, error) {
cm.mu.Lock()
defer cm.mu.Unlock()
for i, tc := range cm.cfg.Tunnels {
if tc.ID != id {
continue
}
if !tc.Enabled || tc.ExpiresAt == nil || !tc.ExpiresAt.Before(t) {
return cloneTunnel(tc), false, nil
}
v := t
cm.cfg.Tunnels[i].ExpiresAt = &v
return cloneTunnel(cm.cfg.Tunnels[i]), true, cm.save()
}
return protocol.TunnelConfig{}, false, fmt.Errorf("通道 %s 未找到", id)
}
// RefreshAuthCode generates a new auth code for a tunnel
func (cm *ConfigManager) RefreshAuthCode(id string) (protocol.TunnelConfig, error) {
cm.mu.Lock()
+97
View File
@@ -0,0 +1,97 @@
package client
import (
"encoding/json"
"net/http"
"testing"
"time"
)
func TestExtendReopensClosedTunnel(t *testing.T) {
e := startEnv(t, testToken)
waitFor(t, "agent connected", e.agent.IsConnected)
echo := startEcho(t)
// 最长开启 10 分钟的仅授权链接通道
one := e.addTunnels(t, map[string]any{"name": "db", "target": echo, "duration_minutes": 10, "whitelist": []string{}})
tn := one.Tunnels[0]
waitFor(t, "online", func() bool { return e.tunnel(t, tn.ID).Metrics.Status == "online" })
_, body := e.call(t, "POST", "/api/tunnels/"+tn.ID+"/invites", map[string]any{"lease_duration": "30m"})
var inv struct {
Invite struct{ Code string } `json:"invite"`
}
_ = json.Unmarshal(body, &inv)
grantViaInvite(t, e, inv.Invite.Code, "张三")
if got, err := echoVia(tn.ListenPort, "hi"); err != nil || got != "hi" {
t.Fatalf("granted device cannot connect: %q %v", got, err)
}
// 关闭通道:设备仍可在 2 小时内被延长
e.call(t, "POST", "/api/tunnels/"+tn.ID+"/toggle", map[string]bool{"enabled": false})
waitFor(t, "renewable after close", func() bool {
rs := e.tunnel(t, tn.ID).Renewals
return len(rs) == 1 && rs[0].IP == "127.0.0.1" && rs[0].EndedAt != nil
})
status, body := e.call(t, "POST", "/api/tunnels/"+tn.ID+"/leases/extend", map[string]any{"ip": "127.0.0.1", "minutes": 60})
if status != http.StatusOK {
t.Fatalf("extend: %d %s", status, body)
}
var out struct {
ExpiresAt time.Time `json:"expires_at"`
}
_ = json.Unmarshal(body, &out)
if d := time.Until(out.ExpiresAt); d < 59*time.Minute || d > 61*time.Minute {
t.Fatalf("an ended access is extended from now: %v", d)
}
reopened := e.tunnel(t, tn.ID)
if !reopened.Enabled || reopened.ExpiresAt == nil || reopened.ExpiresAt.Before(out.ExpiresAt) {
t.Fatalf("the tunnel must reopen and stay open until the extended access ends: %+v", reopened.TunnelConfig)
}
waitFor(t, "reachable again", func() bool {
got, err := echoVia(tn.ListenPort, "x")
return err == nil && got == "x"
})
// 没有可延长授权的设备:不打开通道
e.call(t, "POST", "/api/tunnels/"+tn.ID+"/toggle", map[string]bool{"enabled": false})
if status, _ := e.call(t, "POST", "/api/tunnels/"+tn.ID+"/leases/extend", map[string]any{"ip": "10.1.2.3", "minutes": 30}); status != http.StatusConflict {
t.Fatalf("unknown device: %d", status)
}
if e.tunnel(t, tn.ID).Enabled {
t.Fatal("a refused extension must not reopen the tunnel")
}
}
func TestExtendReopensWholeGroup(t *testing.T) {
e := startEnv(t, testToken)
waitFor(t, "agent connected", e.agent.IsConnected)
echo1, echo2 := startEcho(t), startEcho(t)
g := e.addTunnels(t, map[string]any{"name": "订单服务", "target": echo1 + ", " + echo2, "new_group": "订单服务"})
gid := g.Group.ID
waitFor(t, "online", func() bool {
return e.tunnel(t, g.Tunnels[0].ID).Metrics.Status == "online" && e.tunnel(t, g.Tunnels[1].ID).Metrics.Status == "online"
})
_, body := e.call(t, "POST", "/api/groups/"+gid+"/invites", map[string]any{})
var inv struct {
Invite struct{ Code string } `json:"invite"`
}
_ = json.Unmarshal(body, &inv)
grantViaInvite(t, e, inv.Invite.Code, "小王")
e.call(t, "POST", "/api/groups/"+gid+"/toggle", map[string]bool{"enabled": false})
waitFor(t, "group renewable after close", func() bool { return len(e.group(t, gid).Renewals) == 1 })
if status, body := e.call(t, "POST", "/api/groups/"+gid+"/leases/extend", map[string]any{"ip": "127.0.0.1", "minutes": 30}); status != http.StatusOK {
t.Fatalf("extend group: %d %s", status, body)
}
for _, m := range g.Tunnels {
if !e.tunnel(t, m.ID).Enabled {
t.Fatal("extending a group's access reopens every member")
}
}
waitFor(t, "every member reachable", func() bool {
_, err1 := echoVia(g.Tunnels[0].ListenPort, "a")
_, err2 := echoVia(g.Tunnels[1].ListenPort, "b")
return err1 == nil && err2 == nil
})
}
+160 -10
View File
@@ -240,7 +240,7 @@
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(8px);
display: none; align-items: center; justify-content: center; z-index: 1000;
display: none; align-items: center; justify-content: center; z-index: 1002;
}
.modal-overlay.open { display: flex; }
.modal-card {
@@ -339,6 +339,9 @@
.dim-toggle { display: inline-flex; border: 1px solid var(--card-border); border-radius: 6px; overflow: hidden; float: right; }
.dim-toggle button { background: none; border: 0; color: var(--text-muted); font-size: 11px; padding: 2px 10px; cursor: pointer; }
.dim-toggle button.on { background: rgba(56, 189, 248, 0.18); color: var(--text); }
.chip-row { display: flex; gap: 6px; flex-wrap: wrap; }
.chip-btn { background: rgba(148, 163, 184, 0.1); border: 1px solid var(--card-border); color: var(--text); font-size: 12px; padding: 4px 10px; border-radius: 6px; cursor: pointer; }
.chip-btn.on { background: rgba(56, 189, 248, 0.18); border-color: #38bdf8; color: #38bdf8; font-weight: 600; }
.add-member { background: none; border: 0; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 8px 0; }
.add-member:hover { color: var(--text); }
@media (max-width: 860px) { .member-name, .member-meta { display: none; } }
@@ -499,12 +502,14 @@
</div>
<div class="form-group">
<label>最长开启时长(可选,到时无论是否在用都会关闭)</label>
<select id="tunnelMaxDuration">
<select id="tunnelMaxDuration" onchange="onCustomDuration(this, false)">
<option value="0" selected>不限制</option>
<option value="30">30 分钟</option>
<option value="120">2 小时</option>
<option value="480">8 小时</option>
<option value="1440">1 天</option>
<option value="10080">7 天</option>
<option value="custom">自定义…</option>
</select>
</div>
<div class="form-group">
@@ -536,6 +541,32 @@
</div>
</div>
<!-- Modal: Extend access(批准延长申请 / 主动延长) -->
<div class="modal-overlay" id="extendModal">
<div class="modal-card" style="width: 420px;">
<div class="modal-header">
<h3 id="extendTitle">延长访问</h3>
<button class="btn-secondary btn-sm" onclick="closeExtendModal()">✕</button>
</div>
<div class="input-hint" id="extendHint" style="margin: -8px 0 14px;"></div>
<div class="form-group">
<label>延长多久</label>
<div class="chip-row" id="extendChips"></div>
<div class="input-row" style="margin-top: 8px;">
<input type="number" id="extendAmount" min="1" placeholder="自定义数量" oninput="updateExtend()">
<select id="extendUnit" style="max-width: 100px;" onchange="updateExtend()">
<option value="1">分钟</option>
<option value="60">小时</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeExtendModal()">取消</button>
<button type="button" class="btn-primary" id="extendConfirm" onclick="confirmExtend()">延长</button>
</div>
</div>
</div>
<!-- Modal: Invite -->
<div class="modal-overlay" id="inviteModal">
<div class="modal-card">
@@ -566,12 +597,14 @@
</div>
<div class="form-group">
<label>授权后可访问</label>
<select id="inviteLease">
<select id="inviteLease" onchange="onCustomDuration(this, true)">
<option value="30m">30 分钟</option>
<option value="1h">1 小时</option>
<option value="2h" selected>2 小时</option>
<option value="8h">8 小时</option>
<option value="today">当天有效</option>
<option value="tunnel">通道开启期间</option>
<option value="custom">自定义…</option>
</select>
<label style="display:flex; align-items:center; gap:8px; margin-top:10px; font-weight: normal;">
<input type="checkbox" id="inviteApproval" style="width:auto;"> 同事打开链接后需要我批准才放通
@@ -716,7 +749,30 @@
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), sec = secs % 60;
return h > 0 ? `${h}小时${m}分` : (m > 0 ? `${m}分${sec}秒` : `${sec}秒`);
};
const fmtMinutes = m => m >= 1440 && m % 1440 === 0 ? `${m / 1440} 天` : (m >= 60 && m % 60 === 0 ? `${m / 60} 小时` : `${m} 分钟`);
const fmtMinutes = m => m >= 1440 && m % 1440 === 0 ? `${m / 1440} 天`
: (m >= 60 && m % 60 === 0 ? `${m / 60} 小时` : (m > 60 ? `${Math.floor(m / 60)} 小时 ${m % 60} 分钟` : `${m} 分钟`));
// 自定义时长:输入数量 + 单位(如 90、90m、1.5h),返回分钟数;取消或无效返回 0
function askMinutes() {
const v = (prompt('自定义时长(如 90 表示 90 分钟,3h 表示 3 小时)') || '').trim().toLowerCase();
const m = /^(\d+(?:\.\d+)?)\s*(h|小时|m|分钟)?$/.exec(v);
if (!m) return 0;
const minutes = Math.round(parseFloat(m[1]) * (m[2] === 'h' || m[2] === '小时' ? 60 : 1));
return minutes >= 5 && minutes <= 43200 ? minutes : 0;
}
// 下拉框选「自定义…」:询问时长并加入为一个选项;lease 为 true 时取值形如 "90m"
function onCustomDuration(sel, lease) {
if (sel.value !== 'custom') { sel.dataset.prev = sel.value; return; }
const m = askMinutes();
if (!m) { sel.value = sel.dataset.prev || (lease ? '2h' : '0'); return; }
const value = lease ? `${m}m` : String(m);
if (![...sel.options].some(o => o.value === value)) {
sel.add(new Option(fmtMinutes(m), value), sel.querySelector('option[value="custom"]'));
}
sel.value = value;
sel.dataset.prev = value;
}
const ago = v => (Date.now() - new Date(v)) / 1000;
let totalRateHistory = null;
@@ -779,6 +835,8 @@
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('t:${id}')">🔔 待审批 ${m.pending.length}</span>`;
const asks = (t.renewals || []).filter(r => r.request).length;
if (asks) html += `<span class="chip orange" onclick="openDrawer('t:${id}')">⏱ 申请延长 ${asks}</span>`;
if ((m.dynamic_leases || []).length) html += `<span class="chip green" onclick="openDrawer('t:${id}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`;
return html;
}
@@ -1522,19 +1580,19 @@
}
function scopeInfo(key) {
const s = parseScope(key);
let name, members, m;
let name, members, m, renewals;
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 || {};
name = g.name; members = groupMembers(g); m = g.metrics || {}; renewals = g.renewals;
} else {
const t = currentTunnels.find(x => x.id === s.id);
if (!t) return null;
name = t.name; members = [t]; m = t.metrics || {};
name = t.name; members = [t]; m = t.metrics || {}; renewals = t.renewals;
}
return {
group: s.group, name, members, enabled: members.some(t => t.enabled),
leases: m.dynamic_leases || [], invites: m.invites || [], pending: m.pending || [],
leases: m.dynamic_leases || [], invites: m.invites || [], pending: m.pending || [], renewals: renewals || [],
};
}
@@ -1557,7 +1615,10 @@
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;';
const pad2 = n => String(n).padStart(2, '0');
const leaseTitle = d => ({ '1h': '1 小时', '8h': '8 小时', today: '当天有效', tunnel: '通道开启期间' }[d] || '2 小时');
const leaseTitle = d => ({ '1h': '1 小时', '8h': '8 小时', today: '当天有效', tunnel: '通道开启期间' }[d]
|| (/^\d+m$/.test(d || '') ? fmtMinutes(parseInt(d, 10)) : '2 小时'));
const fmtClock = v => { const d = new Date(v); const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
return d.toDateString() === new Date().toDateString() ? hm : `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${hm}`; };
const fmtDT = v => {
const d = new Date(v);
const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
@@ -1567,9 +1628,31 @@
function renderLeases(key) {
const info = scopeInfo(key);
if (!info) return { pending: '', auth: '' };
const { leases, pending, invites } = info;
const { leases, pending, invites, renewals } = info;
const k = escapeHtml(key);
let html = '';
const closedTag = info.enabled ? '' : ' <span style="font-size: 11px; color: var(--text-muted);">· 通道已关闭,批准后自动开启</span>';
const extendBtn = (r, asking) => `<button class="${asking ? 'btn-primary' : 'btn-secondary'} btn-sm" onclick='openExtendModal(${escapeHtml(JSON.stringify(key))}, ${escapeHtml(JSON.stringify(r.ip))}, ${escapeHtml(JSON.stringify(r.name || ''))}, ${asking})'>${asking ? '批准延长…' : '延长…'}</button>`;
const renewDetail = r => [
r.request ? `申请于 ${fmtClock(r.request.at)}` : '',
r.ended_at ? `访问已于 ${fmtClock(r.ended_at)} 结束` : '',
r.renew_before ? `${fmtClock(r.renew_before)} 前可延长` : '',
!r.ended_at ? '当前仍有效,批准后在原到期时间上顺延' : '',
].filter(Boolean).join(' · ');
const renewCard = r => `
<div style="${cardStyle}">
<div><b>${escapeHtml(r.name || '未署名')}</b> <code style="color:#38bdf8;">${escapeHtml(r.ip)}</code>${closedTag}
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${renewDetail(r)}</div></div>
<div class="drawer-row-actions">
${r.request ? `<button class="btn-secondary btn-sm" onclick="decideRequest('${k}', '${escapeHtml(r.request.id)}', false)">拒绝</button>` : ''}
${extendBtn(r, !!r.request)}
</div>
</div>`;
const asks = renewals.filter(r => r.request), ended = renewals.filter(r => !r.request && r.ended_at);
if (asks.length) {
html += `<b style="font-size:13px; display:block; margin: 4px 0 10px; color:#fbbf24;">申请延长 (${asks.length})</b>`;
html += asks.map(renewCard).join('');
}
if (pending.length) {
html += `<b style="font-size:13px; display:block; margin: 4px 0 10px; color:#fbbf24;">待审批 (${pending.length})</b>`;
@@ -1607,10 +1690,18 @@
<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>
<div class="drawer-row-actions">
${l.awaiting_connect ? '' : extendBtn(l, false)}
<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '${escapeHtml(l.ip)}')">撤销</button>
</div>
</div>`;
}).join('');
if (ended.length) {
html += `<b style="font-size:13px; display:block; margin: 8px 0 10px;">最近结束 · 2 小时内可延长 (${ended.length})</b>`;
html += ended.map(renewCard).join('');
}
html += `<b style="font-size:13px; display:block; margin: 8px 0 10px;">有效的邀请链接 (${invites.length})</b>`;
html += invites.length === 0
? '<div style="color: var(--text-muted); font-size: 12px; margin-bottom: 14px;">暂无有效的邀请链接</div>'
@@ -1716,6 +1807,63 @@
document.getElementById('inviteResult').style.display = 'block';
}
// ---- 延长访问:批准延长申请或主动延长,确定时长后立即生效;通道已关闭时会自动重新开启 ----
let extendTarget = null;
const EXTEND_OPTIONS = [[30, '30 分钟'], [60, '1 小时'], [120, '2 小时'], [480, '8 小时']];
function openExtendModal(key, ip, name, asking) {
const info = scopeInfo(key);
extendTarget = { key, ip };
document.getElementById('extendTitle').innerText = `${asking ? '批准延长' : '延长访问'} · ${name || ip}`;
document.getElementById('extendHint').innerText = (info && !info.enabled ? '通道当前已关闭,延长后会自动重新开启。' : '')
+ '访问还没结束时从原到期时间顺延;已结束时从现在开始计算。';
document.getElementById('extendChips').innerHTML = EXTEND_OPTIONS.map(([m, t]) =>
`<button type="button" class="chip-btn" data-m="${m}" onclick="setExtendMinutes(${m})">${t}</button>`).join('');
setExtendMinutes(60);
document.getElementById('extendModal').classList.add('open');
}
function closeExtendModal() {
document.getElementById('extendModal').classList.remove('open');
extendTarget = null;
}
function setExtendMinutes(m) {
const hours = m >= 60 && m % 60 === 0;
document.getElementById('extendAmount').value = hours ? m / 60 : m;
document.getElementById('extendUnit').value = hours ? '60' : '1';
updateExtend();
}
function extendMinutes() {
const n = parseInt(document.getElementById('extendAmount').value, 10);
const m = n * parseInt(document.getElementById('extendUnit').value, 10);
return n > 0 && m <= 43200 ? m : 0;
}
function updateExtend() {
const m = extendMinutes();
document.querySelectorAll('#extendChips .chip-btn').forEach(b => b.classList.toggle('on', +b.dataset.m === m));
const btn = document.getElementById('extendConfirm');
btn.disabled = !m;
btn.innerText = m ? `延长 ${fmtMinutes(m)}` : '请输入 1 分钟到 30 天';
}
async function confirmExtend() {
const m = extendMinutes();
if (!extendTarget || !m) return;
const btn = document.getElementById('extendConfirm');
btn.disabled = true;
btn.innerText = '处理中…';
const res = await fetch(`${scopeBase(extendTarget.key)}/leases/extend`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip: extendTarget.ip, minutes: m })
});
if (!res.ok) { alert('延长失败: ' + await apiError(res)); updateExtend(); return; }
closeExtendModal();
await fetchTunnels();
}
async function revokeLease(key, ip) {
if (!confirm(ip ? `撤销 ${ip} 的访问权限?该设备的连接会被立即断开。` : '撤销全部已授权设备?')) return;
const res = await fetch(`${scopeBase(key)}/leases/revoke`, {
@@ -1874,6 +2022,8 @@
}
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>`);
const asks = (g.renewals || []).filter(r => r.request).length;
if (asks) parts.push(`<span class="chip orange" onclick="openDrawer('${key}')">⏱ 申请延长 ${asks}</span>`);
if ((m.dynamic_leases || []).length) parts.push(`<span class="chip green" onclick="openDrawer('${key}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`);
return parts.join('');
}
+106 -12
View File
@@ -1,6 +1,11 @@
package protocol
import "time"
import (
"fmt"
"strconv"
"strings"
"time"
)
// Message types
const (
@@ -18,6 +23,8 @@ const (
TypeRevokeInvite = "revoke_invite"
TypeDecideRequest = "decide_request"
TypeDeleteGroup = "delete_group"
TypeExtendLease = "extend_lease"
TypeExtendResp = "extend_lease_resp"
TypeMetrics = "metrics_update"
TypePing = "ping"
TypePong = "pong"
@@ -94,6 +101,8 @@ type RegisterRespMessage struct {
type UnregisterMessage struct {
Type string `json:"type"`
TunnelID string `json:"tunnel_id"`
// Deleted 为 true 表示通道被删除:连同可延长的授权记录一起清除;否则只是关闭
Deleted bool `json:"deleted,omitempty"`
}
// UnregisterRespMessage response to unregister
@@ -130,7 +139,8 @@ type DeleteGroupMessage struct {
GroupID string `json:"group_id"`
}
// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久
// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久。
// 除下列取值外,也可以是分钟数,如 "30m"、"90m"(见 LeaseMinutes)。
const (
LeaseDuration1h = "1h"
LeaseDuration2h = "2h"
@@ -139,32 +149,75 @@ const (
LeaseDurationTunnel = "tunnel" // 通道开启期间有效
)
// 自定义授权时长的范围(分钟)
const (
MinLeaseMinutes = 5
MaxLeaseMinutes = 30 * 24 * 60
)
// LeaseMinutes returns how many minutes a fixed-length lease duration lasts ("" means 2 hours);
// false for "today", "tunnel" and invalid values
func LeaseMinutes(d string) (int, bool) {
switch d {
case "", LeaseDuration2h:
return 120, true
case LeaseDuration1h:
return 60, true
case LeaseDuration8h:
return 480, true
}
if !strings.HasSuffix(d, "m") {
return 0, false
}
n, err := strconv.Atoi(strings.TrimSuffix(d, "m"))
if err != nil || n < MinLeaseMinutes || n > MaxLeaseMinutes {
return 0, false
}
return n, true
}
// LeaseDurationOf is the lease duration value for a number of minutes
func LeaseDurationOf(minutes int) string {
return fmt.Sprintf("%dm", minutes)
}
// ValidLeaseDuration reports whether d is a supported lease duration ("" means default 2h)
func ValidLeaseDuration(d string) bool {
switch d {
case "", LeaseDuration1h, LeaseDuration2h, LeaseDuration8h, LeaseDurationToday, LeaseDurationTunnel:
if d == LeaseDurationToday || d == LeaseDurationTunnel {
return true
}
return false
_, ok := LeaseMinutes(d)
return ok
}
// DescribeMinutes renders a duration in minutes: "30 分钟"、"2 小时"、"1 小时 30 分钟"、"3 天"
func DescribeMinutes(n int) string {
switch {
case n >= 1440 && n%1440 == 0:
return fmt.Sprintf("%d 天", n/1440)
case n >= 60 && n%60 == 0:
return fmt.Sprintf("%d 小时", n/60)
case n > 60:
return fmt.Sprintf("%d 小时 %d 分钟", n/60, n%60)
default:
return fmt.Sprintf("%d 分钟", n)
}
}
// DescribeLeaseDuration is the human-readable lease duration
func DescribeLeaseDuration(d string) string {
switch d {
case LeaseDuration1h:
return "1 小时"
case LeaseDuration8h:
return "8 小时"
case LeaseDurationToday:
return "当天有效"
case LeaseDurationTunnel:
return "通道开启期间有效"
default:
return "2 小时"
}
if n, ok := LeaseMinutes(d); ok {
return DescribeMinutes(n)
}
return "2 小时"
}
// DynamicLease is a temporary whitelist entry granted through an invite link
type DynamicLease struct {
IP string `json:"ip"`
ExpiresAt time.Time `json:"expires_at"`
@@ -208,6 +261,45 @@ type AccessRequest struct {
Invite string `json:"invite,omitempty"`
At time.Time `json:"at"`
Status string `json:"status"` // pending / approved / rejected
// Renewal 为 true 表示授权已结束(或即将结束)的设备申请延长;由管理员批准时决定延长多久
Renewal bool `json:"renewal,omitempty"`
}
// RenewalInfo is a device whose access can still be extended: it ended less than two hours ago
// (even if its tunnel has been closed since), or it asked to extend an access that is still running.
type RenewalInfo struct {
TunnelID string `json:"tunnel_id,omitempty"`
GroupID string `json:"group_id,omitempty"`
IP string `json:"ip"`
Name string `json:"name,omitempty"`
Invite string `json:"invite,omitempty"`
// EndedAt 访问结束的时间(授权到期或通道关闭);授权仍有效时为空
EndedAt *time.Time `json:"ended_at,omitempty"`
// RenewBefore 可以延长的截止时间(结束后 2 小时)
RenewBefore *time.Time `json:"renew_before,omitempty"`
// Request 该设备待处理的延长申请
Request *AccessRequest `json:"request,omitempty"`
}
// ExtendLeaseMessage extends a device's access by Minutes: a running lease from its expiry, an
// ended one (within two hours) from now. It approves the pending renewal requests of that device,
// and is not capped by the tunnel's own maximum open time.
type ExtendLeaseMessage struct {
Type string `json:"type"`
RequestID string `json:"request_id"` // 用于匹配 ExtendLeaseRespMessage
TunnelID string `json:"tunnel_id"`
GroupID string `json:"group_id,omitempty"`
IP string `json:"ip"`
Minutes int `json:"minutes"`
}
// ExtendLeaseRespMessage answers ExtendLeaseMessage with the new expiry
type ExtendLeaseRespMessage struct {
Type string `json:"type"`
RequestID string `json:"request_id"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
}
// CreateInviteMessage asks the server to create an invite link
@@ -321,6 +413,8 @@ type MetricsMessage struct {
Groups []GroupMetrics `json:"groups,omitempty"`
ActiveConns []ActiveConnInfo `json:"active_conns"`
SecurityEvents []SecurityEvent `json:"security_events,omitempty"`
// Renewals 可延长的设备与延长申请,包括已关闭的通道
Renewals []RenewalInfo `json:"renewals,omitempty"`
}
// StreamHandshake sent as first line when a data stream is opened
+368 -10
View File
@@ -34,6 +34,8 @@ struct TunnelItem: Identifiable, Codable {
var rate_history: RateSeriesItem?
/// 所属通道组
var group_id: String?
/// 可延长的设备与延长申请(通道关闭后 2 小时内仍有)
var renewals: [RenewalItem]?
}
/// 近 10 分钟流量曲线:固定 120 个点(5 秒一个),最后一点为当前
@@ -113,6 +115,24 @@ struct AccessRequestItem: Identifiable, Codable {
var invite: String?
var at: String
var status: String
/// 授权已结束(或即将结束)的设备申请延长,批准时由管理员决定时长
var renewal: Bool?
}
/// 可延长的设备:访问结束 2 小时内(通道关闭后也保留),或提交了延长申请
struct RenewalItem: Identifiable, Codable {
var id: String { ip }
var tunnel_id: String?
var group_id: String?
var ip: String
var name: String?
var invite: String?
/// 访问结束时间;授权仍有效时为空
var ended_at: String?
/// 可以延长的截止时间
var renew_before: String?
/// 待处理的延长申请
var request: AccessRequestItem?
}
/// 通道组:组内通道共用邀请、授权与审批,闲置计时联动
@@ -125,6 +145,7 @@ struct GroupItem: Identifiable, Codable {
var tunnel_ids: [String]
var metrics: GroupMetricsItem?
var rate_history: RateSeriesItem?
var renewals: [RenewalItem]?
}
struct GroupMetricsItem: Codable {
@@ -400,11 +421,34 @@ func makeQRCode(_ text: String, size: CGFloat = 180) -> NSImage? {
}
let leaseDurationOptions: [(value: String, title: String)] = [
("1h", "1 小时"), ("2h", "2 小时"), ("8h", "8 小时"), ("today", "当天有效"), ("tunnel", "通道开启期间")
("30m", "30 分钟"), ("1h", "1 小时"), ("2h", "2 小时"), ("8h", "8 小时"), ("today", "当天有效"), ("tunnel", "通道开启期间")
]
/// 延长授权的常用时长(分钟)
let extendOptions: [(Int, String)] = [(30, "30 分钟"), (60, "1 小时"), (120, "2 小时"), (480, "8 小时")]
/// 自定义时长的上限:30 天
let maxCustomMinutes = 30 * 24 * 60
/// "30 分钟"、"2 小时"、"1 小时 30 分钟"、"3 天"
func describeMinutes(_ n: Int) -> String {
if n >= 1440 && n % 1440 == 0 { return "\(n / 1440) 天" }
if n >= 60 && n % 60 == 0 { return "\(n / 60) 小时" }
if n > 60 { return "\(n / 60) 小时 \(n % 60) 分钟" }
return "\(n) 分钟"
}
/// 自定义授权时长("90m")对应的分钟数
func customLeaseMinutes(_ v: String) -> Int? {
guard v.hasSuffix("m"), let n = Int(v.dropLast()) else { return nil }
return n
}
func leaseDurationTitle(_ v: String?) -> String {
leaseDurationOptions.first { $0.value == (v ?? "2h") }?.title ?? "2 小时"
let value = v ?? "2h"
if let opt = leaseDurationOptions.first(where: { $0.value == value }) { return opt.title }
if let n = customLeaseMinutes(value) { return describeMinutes(n) }
return "2 小时"
}
func copyToPasteboard(_ text: String) {
@@ -661,6 +705,136 @@ struct StringChip: View {
}
}
/// 自定义时长输入:数字 + 分钟 / 小时
struct CustomMinutesForm: View {
let actionTitle: String
let onDone: (Int) -> Void
@State private var amount: String
@State private var hours: Bool
init(actionTitle: String, initialMinutes: Int? = nil, onDone: @escaping (Int) -> Void) {
self.actionTitle = actionTitle
self.onDone = onDone
let m = initialMinutes ?? 90
let whole = m >= 60 && m % 60 == 0
_hours = State(initialValue: whole)
_amount = State(initialValue: whole ? "\(m / 60)" : "\(m)")
}
var minutes: Int? {
guard let n = Int(amount.trimmingCharacters(in: .whitespaces)), n > 0 else { return nil }
let m = hours ? n * 60 : n
return m <= maxCustomMinutes ? m : nil
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
TextField("数量", text: $amount)
.textFieldStyle(.roundedBorder)
.frame(width: 64)
.onSubmit { if let m = minutes { onDone(m) } }
Picker("", selection: $hours) {
Text("分钟").tag(false)
Text("小时").tag(true)
}
.pickerStyle(.segmented)
.labelsHidden()
.fixedSize()
Button(actionTitle) { if let m = minutes { onDone(m) } }
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(minutes == nil)
}
Text(verbatim: minutes.map { "共 \(describeMinutes($0))" } ?? "请输入 1 分钟到 30 天之间的时长")
.font(.system(size: 11))
.foregroundColor(minutes == nil ? .red : .secondary)
}
.padding(12)
}
}
/// 「自定义…」时长选项:选中时显示具体时长,点击弹出输入框
struct CustomDurationChip: View {
let isCustom: Bool
let currentMinutes: Int?
let apply: (Int) -> Void
@State private var editing = false
var body: some View {
Button(action: { editing = true }) {
ChipLabel(title: isCustom ? "自定义 · \(describeMinutes(currentMinutes ?? 0))" : "自定义…", selected: isCustom)
}
.buttonStyle(.borderless)
.popover(isPresented: $editing, arrowEdge: .bottom) {
CustomMinutesForm(actionTitle: "确定", initialMinutes: isCustom ? currentMinutes : nil) { m in
apply(m)
editing = false
}
}
}
}
/// 「延长 ▾」:选一个时长立即生效,并批准该设备的延长申请;通道已关闭时会自动重新开启
struct ExtendMenu: View {
@ObservedObject var vm: PortRelayViewModel
let scope: AccessScope
let ip: String
var title: String = "延长"
var prominent: Bool = false
@State private var showingCustom = false
@State private var working = false
@State private var error: String? = nil
var body: some View {
Menu {
ForEach(extendOptions, id: \.0) { opt in
Button("延长 \(opt.1)") { extend(opt.0) }
}
Divider()
Button("自定义时长…") { showingCustom = true }
} label: {
Text(working ? "处理中…" : title)
}
.menuStyle(.button)
.modifier(ExtendMenuStyle(prominent: prominent))
.controlSize(.small)
.fixedSize()
.disabled(working)
.help(vm.scopeEnabled(scope) ? "选择延长多久,立即生效" : "选择延长多久;通道已关闭,会自动重新开启")
.popover(isPresented: $showingCustom, arrowEdge: .bottom) {
CustomMinutesForm(actionTitle: "延长") { m in
showingCustom = false
extend(m)
}
}
.alert("延长失败", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("好") {}
} message: {
Text(verbatim: error ?? "")
}
}
func extend(_ minutes: Int) {
working = true
Task {
error = await vm.extendLease(scope: scope, ip: ip, minutes: minutes)
working = false
}
}
}
struct ExtendMenuStyle: ViewModifier {
let prominent: Bool
func body(content: Content) -> some View {
if prominent {
content.buttonStyle(.borderedProminent)
} else {
content.buttonStyle(.bordered)
}
}
}
// MARK: - Tunnel Search
/// 通道搜索:多个关键字(空格分隔)需全部命中;不区分大小写;中文支持全拼与拼音首字母
@@ -779,6 +953,14 @@ func formatDateTimeShort(_ str: String?) -> String {
return df.string(from: d)
}
/// 时刻(不带秒):今天为 "15:20",其他日期为 "10-13 15:20"
func formatClock(_ str: String?) -> String {
guard let d = parseISO8601(str) else { return "-" }
let df = DateFormatter()
df.dateFormat = Calendar.current.isDateInToday(d) ? "HH:mm" : "MM-dd HH:mm"
return df.string(from: d)
}
/// 同一 IP 的历史连接合并展示
struct HistoryIPGroup: Identifiable {
let ip: String
@@ -830,6 +1012,21 @@ class PortRelayViewModel: ObservableObject {
return out
}
/// 所有待处理的延长申请(单个通道的与通道组的,含已关闭的通道)
var renewalRequests: [(title: String, scope: AccessScope, renewal: RenewalItem)] {
var out: [(title: String, scope: AccessScope, renewal: RenewalItem)] = []
for t in tunnels {
for r in t.renewals ?? [] where r.request != nil { out.append((title: t.name, scope: .tunnel(t.id), renewal: r)) }
}
for g in groups {
for r in g.renewals ?? [] where r.request != nil { out.append((title: g.name, scope: .group(g.id), renewal: r)) }
}
return out
}
/// 菜单栏上提醒的待处理数:首次访问申请 + 延长申请
var attentionCount: Int { pendingRequests.count + renewalRequests.count }
// MARK: 通道组与授权范围
func members(of g: GroupItem) -> [TunnelItem] {
@@ -874,6 +1071,13 @@ class PortRelayViewModel: ObservableObject {
}
}
func scopeRenewals(_ s: AccessScope) -> [RenewalItem] {
switch s {
case .tunnel(let id): return tunnels.first { $0.id == id }?.renewals ?? []
case .group(let id): return group(id)?.renewals ?? []
}
}
func scopePending(_ s: AccessScope) -> [AccessRequestItem] {
switch s {
case .tunnel(let id): return tunnels.first { $0.id == id }?.metrics?.pending ?? []
@@ -1046,6 +1250,15 @@ class PortRelayViewModel: ObservableObject {
content.sound = .default
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: r.id, content: content, trigger: nil))
}
for (title, _, rn) in renewalRequests {
guard let r = rn.request, !notifiedRequests.contains(r.id) else { continue }
notifiedRequests.insert(r.id)
let content = UNMutableNotificationContent()
content.title = "延长申请:\(title)"
content.body = "\(r.name)(\(r.ip))申请延长访问,在菜单栏或授权管理里选择延长多久即可批准"
content.sound = .default
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: r.id, content: content, trigger: nil))
}
}
/// 菜单内容有变化时才需要重建(避免每秒重建打断已展开的菜单)
@@ -1053,7 +1266,8 @@ class PortRelayViewModel: ObservableObject {
// 分步拼接:一长串字符串相加会让旧版编译器类型推断超时
let tunnelSig: String = tunnels.map { "\($0.id):\($0.name):\($0.listen_port):\($0.enabled):\($0.group_id ?? "")" }.joined(separator: ",")
let groupSig: String = groups.map { "\($0.id):\($0.name)" }.joined(separator: ",")
let pendingSig: String = pendingRequests.map { $0.request.id }.joined(separator: ",")
let requestIDs: [String] = pendingRequests.map { $0.request.id } + renewalRequests.compactMap { $0.renewal.request?.id }
let pendingSig: String = requestIDs.joined(separator: ",")
let sig = [String(isConnected), tunnelSig, groupSig, pendingSig].joined(separator: "|")
if sig == lastMenuSignature { return false }
lastMenuSignature = sig
@@ -1376,6 +1590,18 @@ class PortRelayViewModel: ObservableObject {
return err
}
/// 延长某台设备的访问(同时批准它的延长申请);通道已关闭时会自动重新开启。返回错误信息,成功为 nil
func extendLease(scope: AccessScope, ip: String, minutes: Int) async -> String? {
let requestID = scopeRenewals(scope).first { $0.ip == ip }?.request?.id
let (_, err) = await apiCall("POST", "\(scope.apiPath)/leases/extend", body: ["ip": ip, "minutes": minutes])
if err == nil, let rid = requestID {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [rid])
}
try? await Task.sleep(nanoseconds: 1_200_000_000)
await refreshData()
return err
}
/// 撤销临时放通;ip 为 nil 时撤销该通道(或通道组)全部临时放通。返回错误信息,成功为 nil
func revokeLease(scope: AccessScope, ip: String?) async -> String? {
guard let url = URL(string: "http://127.0.0.1:33080\(scope.apiPath)/leases/revoke") else { return "无效地址" }
@@ -2267,6 +2493,13 @@ struct TunnelCardView: View {
.buttonStyle(.plain)
.help("有同事申请访问,点击审批")
}
if let asks = tunnel.renewals?.filter({ $0.request != nil }), !asks.isEmpty {
Button(action: onViewLeases) {
capsule("clock.arrow.circlepath", "申请延长 \(asks.count)", .orange)
}
.buttonStyle(.plain)
.help("有同事申请延长访问,点击选择延长多久")
}
if let leases = tunnel.metrics?.dynamic_leases, !leases.isEmpty {
Button(action: onViewLeases) {
HStack(spacing: 2) {
@@ -2342,6 +2575,7 @@ struct GroupCardView: View {
var rateOut: Int64 { enabledMembers.reduce(0) { $0 + ($1.metrics?.rate_out ?? 0) } }
var groupLeases: [DynamicLeaseItem] { group.metrics?.dynamic_leases ?? [] }
var groupPending: [AccessRequestItem] { group.metrics?.pending ?? [] }
var groupRenewalAsks: [RenewalItem] { (group.renewals ?? []).filter { $0.request != nil } }
var statusBadge: (text: String, color: Color) {
if members.isEmpty { return ("暂无通道", .secondary) }
@@ -2617,6 +2851,13 @@ struct GroupCardView: View {
.buttonStyle(.plain)
.help("有同事申请访问整组,点击审批")
}
if !groupRenewalAsks.isEmpty {
Button(action: { onViewClients(2) }) {
cardCapsule("clock.arrow.circlepath", "申请延长 \(groupRenewalAsks.count)", .orange)
}
.buttonStyle(.plain)
.help("有同事申请延长整组的访问,点击选择延长多久")
}
if !groupLeases.isEmpty {
Button(action: { onViewClients(2) }) {
cardCapsule("person.crop.circle.badge.checkmark", "已授权 \(groupLeases.count) 台", .green)
@@ -2825,6 +3066,11 @@ struct ClientDetailsSheet: View {
var leases: [DynamicLeaseItem] { vm.scopeLeases(scope) }
var invites: [InviteItem] { vm.scopeInvites(scope) }
var pendingRequests: [AccessRequestItem] { vm.scopePending(scope) }
var renewals: [RenewalItem] { vm.scopeRenewals(scope) }
/// 申请延长的设备
var renewalAsks: [RenewalItem] { renewals.filter { $0.request != nil } }
/// 访问已结束、2 小时内还能延长的设备
var recentlyEnded: [RenewalItem] { renewals.filter { $0.request == nil && $0.ended_at != nil } }
var subtitle: String {
if scope.isGroup {
@@ -2929,7 +3175,7 @@ struct ClientDetailsSheet: View {
Picker("", selection: $selectedTab) {
Text(verbatim: clientGroups.isEmpty ? "当前在线客户端" : "当前在线 (\(clientGroups.count) 台 · \(activeClientsForTunnel.count) 连接)").tag(0)
Text(verbatim: historyGroups.isEmpty ? "历史接入台账" : "历史接入台账 (\(historyGroups.count) 台)").tag(1)
Text(verbatim: "授权管理 (\(leases.count + pendingRequests.count))").tag(2)
Text(verbatim: "授权管理 (\(leases.count + pendingRequests.count + renewalAsks.count))").tag(2)
}
.pickerStyle(.segmented)
.padding(.vertical, 4)
@@ -3434,6 +3680,13 @@ struct ClientDetailsSheet: View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
if !renewalAsks.isEmpty {
sectionHeader("申请延长", renewalAsks.count)
ForEach(renewalAsks) { rn in
renewalRow(rn)
}
}
if !pendingRequests.isEmpty {
sectionHeader("待审批", pendingRequests.count)
ForEach(pendingRequests) { r in
@@ -3510,6 +3763,9 @@ struct ClientDetailsSheet: View {
}
.layoutPriority(1)
Spacer(minLength: 8)
if lease.awaiting_connect != true {
ExtendMenu(vm: vm, scope: scope, ip: lease.ip)
}
Button("撤销", role: .destructive) { revoke(ip: lease.ip) }
.controlSize(.small)
.disabled(isRevoking)
@@ -3520,6 +3776,13 @@ struct ClientDetailsSheet: View {
.rowCard()
}
if !recentlyEnded.isEmpty {
sectionHeader("最近结束 · 2 小时内可延长", recentlyEnded.count)
ForEach(recentlyEnded) { rn in
renewalRow(rn)
}
}
sectionHeader("有效的邀请链接", invites.count)
if invites.isEmpty {
emptyHint("暂无有效的邀请链接")
@@ -3583,6 +3846,59 @@ struct ClientDetailsSheet: View {
}
}
/// 申请延长 / 最近结束的设备:选时长即延长(同时批准申请)
func renewalRow(_ rn: RenewalItem) -> some View {
let asking = rn.request != nil
return HStack(spacing: 10) {
Image(systemName: asking ? "clock.arrow.circlepath" : "clock")
.foregroundColor(asking ? .orange : .secondary)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(verbatim: rn.name?.isEmpty == false ? rn.name! : "未署名")
.font(.system(size: 13, weight: .semibold))
.lineLimit(1)
Text(verbatim: rn.ip)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.accentColor)
if !vm.scopeEnabled(scope) {
TagView("通道已关闭,批准后自动开启")
}
}
Text(verbatim: renewalDetail(rn))
.font(.system(size: 11))
.foregroundColor(.secondary)
.lineLimit(1)
}
.layoutPriority(1)
Spacer(minLength: 8)
if let r = rn.request {
Button("拒绝") { decide(r, approve: false) }
.controlSize(.small)
.disabled(isRevoking)
}
ExtendMenu(vm: vm, scope: scope, ip: rn.ip, title: asking ? "批准延长" : "延长", prominent: asking)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.rowCard(tint: asking ? .orange : nil)
}
func renewalDetail(_ rn: RenewalItem) -> String {
var parts: [String] = []
if let r = rn.request {
parts.append("申请于 \(formatClock(r.at))")
}
if let end = rn.ended_at {
parts.append("访问已于 \(formatClock(end)) 结束")
if let before = rn.renew_before {
parts.append("\(formatClock(before)) 前可延长")
}
} else if let lease = leases.first(where: { $0.ip == rn.ip }) {
parts.append("当前有效至 \(formatClock(lease.expires_at)),批准后在此基础上顺延")
}
return parts.joined(separator: " · ")
}
func decide(_ r: AccessRequestItem, approve: Bool) {
isRevoking = true
Task {
@@ -4220,14 +4536,13 @@ struct TunnelEditSheet: View {
.padding(.top, 2)
HStack(spacing: 6) {
DurationChip(title: "不限制", minutes: 0, selectedMinutes: $durationMinutes)
DurationChip(title: "30 分钟", minutes: 30, selectedMinutes: $durationMinutes)
DurationChip(title: "2 小时", minutes: 120, selectedMinutes: $durationMinutes)
DurationChip(title: "8 小时", minutes: 480, selectedMinutes: $durationMinutes)
DurationChip(title: "1 天", minutes: 1440, selectedMinutes: $durationMinutes)
DurationChip(title: "7 天", minutes: 10080, selectedMinutes: $durationMinutes)
// 兼容旧配置中的其他时长
if ![0, 120, 480, 1440, 10080].contains(durationMinutes) {
DurationChip(title: formatSecondsToDuration(Int64(durationMinutes) * 60), minutes: durationMinutes, selectedMinutes: $durationMinutes)
}
CustomDurationChip(isCustom: ![0, 30, 120, 480, 1440, 10080].contains(durationMinutes),
currentMinutes: durationMinutes) { durationMinutes = $0 }
}
}
}
@@ -4661,6 +4976,8 @@ struct InviteSheet: View {
ForEach(leaseDurationOptions, id: \.value) { opt in
StringChip(title: opt.title, value: opt.value, selection: $leaseDuration)
}
CustomDurationChip(isCustom: !leaseDurationOptions.contains { $0.value == leaseDuration },
currentMinutes: customLeaseMinutes(leaseDuration)) { leaseDuration = "\($0)m" }
}
HStack(spacing: 6) {
Text("审批")
@@ -4691,7 +5008,7 @@ struct InviteSheet: View {
}
}
.padding(20)
.frame(width: 560)
.frame(width: 600)
.onAppear {
if autoGenerate { generate() }
}
@@ -4874,7 +5191,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
func updateStatusItemTitle(activeCount: Int) {
guard let button = self.statusItem?.button else { return }
let isConnected = PortRelayViewModel.shared.isConnected
let pendingCount = PortRelayViewModel.shared.pendingRequests.count
let pendingCount = PortRelayViewModel.shared.attentionCount
var iconName = isConnected ? (activeCount > 0 ? "arrow.left.arrow.right.circle.fill" : "arrow.left.arrow.right.circle") : "exclamationmark.triangle"
if pendingCount > 0 { iconName = "bell.badge.fill" }
button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: "PortRelay")
@@ -4900,6 +5217,33 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
menu.addItem(NSMenuItem.separator())
// Renewal requests: pick how long to extend
let renewals = vm.renewalRequests
if !renewals.isEmpty {
let header = NSMenuItem(title: "申请延长 (\(renewals.count)):", action: nil, keyEquivalent: "")
header.isEnabled = false
menu.addItem(header)
for (title, scope, rn) in renewals {
guard let r = rn.request else { continue }
let item = NSMenuItem(title: "\(r.name)(\(r.ip))→ \(title)", action: nil, keyEquivalent: "")
let sub = NSMenu()
for opt in extendOptions {
let ext = NSMenuItem(title: "批准延长 \(opt.1)", action: #selector(extendAction(_:)), keyEquivalent: "")
ext.representedObject = [scope.key, r.ip, String(opt.0)]
ext.target = self
sub.addItem(ext)
}
sub.addItem(NSMenuItem.separator())
let reject = NSMenuItem(title: "拒绝", action: #selector(decideAction(_:)), keyEquivalent: "")
reject.representedObject = [scope.key, r.id, "0"]
reject.target = self
sub.addItem(reject)
item.submenu = sub
menu.addItem(item)
}
menu.addItem(NSMenuItem.separator())
}
// Pending approvals
let pending = vm.pendingRequests
if !pending.isEmpty {
@@ -5004,6 +5348,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
}
}
@objc func extendAction(_ sender: NSMenuItem) {
guard let v = sender.representedObject as? [String], v.count == 3, let minutes = Int(v[2]) else { return }
let id = String(v[0].dropFirst(2))
let scope: AccessScope = v[0].hasPrefix("g:") ? .group(id) : .tunnel(id)
Task {
if let err = await PortRelayViewModel.shared.extendLease(scope: scope, ip: v[1], minutes: minutes) {
let content = UNMutableNotificationContent()
content.title = "延长失败"
content.body = err
try? await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
}
}
}
@objc func decideAction(_ sender: NSMenuItem) {
guard let v = sender.representedObject as? [String], v.count == 3 else { return }
let id = String(v[0].dropFirst(2))
+45 -12
View File
@@ -240,7 +240,7 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
case protocol.TypeUnregister:
var msg protocol.UnregisterMessage
if json.Unmarshal(raw, &msg) == nil {
m.unregisterTunnel(as, msg.TunnelID)
m.unregisterTunnel(as, msg.TunnelID, msg.Deleted)
as.send(protocol.UnregisterRespMessage{Type: protocol.TypeUnregisterResp, TunnelID: msg.TunnelID, Success: true})
}
@@ -274,6 +274,12 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
m.decideRequest(scopeOf(msg.TunnelID, msg.GroupID), msg.RequestID, msg.Approve)
}
case protocol.TypeExtendLease:
var msg protocol.ExtendLeaseMessage
if json.Unmarshal(raw, &msg) == nil {
as.send(m.extendLease(msg))
}
case protocol.TypeDeleteGroup:
var msg protocol.DeleteGroupMessage
if json.Unmarshal(raw, &msg) == nil && msg.GroupID != "" {
@@ -349,14 +355,19 @@ func (m *Manager) registerTunnel(as *agentSession, cfg protocol.TunnelConfig) pr
return registerResp(cfg, nil)
}
func (m *Manager) unregisterTunnel(as *agentSession, tunnelID string) {
func (m *Manager) unregisterTunnel(as *agentSession, tunnelID string, deleted bool) {
m.mu.Lock()
defer m.mu.Unlock()
if m.active != as {
return
}
// 主动关闭/删除通道时,放通、邀请链接与待审批申请一并失效(断线重连不会走到这里)
m.state.forgetScope(tunnelID)
// 主动关闭通道时,放通、邀请链接与待审批申请一并失效,已授权的设备 2 小时内仍可申请延长;
// 删除通道则全部清除(断线重连不会走到这里)
if deleted {
m.state.dropScope(tunnelID)
} else {
m.state.closeScope(tunnelID)
}
if t, exists := m.tunnels[tunnelID]; exists {
t.Close()
delete(m.portMap, t.ListenPort())
@@ -501,8 +512,12 @@ func scopeExpiresOf(tunnels []*Tunnel) *time.Time {
// 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 {
m.state.setName(t.ID(), t.Name())
leases := m.state.leaseExpiries(t.ID())
if gid := t.GroupID(); gid != "" {
if name := t.Snapshot().GroupName; name != "" {
m.state.setName(groupScope(gid), name)
}
for ip, exp := range m.state.leaseExpiries(groupScope(gid)) {
if cur, ok := leases[ip]; !ok || exp.After(cur) {
leases[ip] = exp
@@ -521,19 +536,19 @@ func (m *Manager) syncScope(scope string) int {
return kicked
}
// forgetGroupIfEmptyLocked drops a group's shared access state once none of its tunnels is open.
// Caller must hold m.mu.
// forgetGroupIfEmptyLocked closes a group's shared access state once none of its tunnels is open
// (ended devices can still ask for an extension). Caller must hold m.mu.
func (m *Manager) forgetGroupIfEmptyLocked(groupID string) {
if groupID == "" || len(m.groupMembersLocked(groupID)) > 0 {
return
}
m.state.forgetScope(groupScope(groupID))
m.state.closeScope(groupScope(groupID))
}
// deleteGroup drops a dissolved group's invites, leases and requests
// deleteGroup drops a dissolved group's invites, leases, requests and renewal records
func (m *Manager) deleteGroup(groupID string) {
scope := groupScope(groupID)
m.state.forgetScope(scope)
m.state.dropScope(scope)
kicked := m.syncScope(scope)
log.Printf("[WebAuth] Tunnel group %s dissolved, disconnected %d client(s)", groupID, kicked)
}
@@ -569,9 +584,6 @@ func (m *Manager) revokeInvite(scope, code string) {
func (m *Manager) decideRequest(scope, requestID string, approve bool) {
tunnels := m.scopeTunnels(scope)
if len(tunnels) == 0 {
return
}
r, _, err := m.state.decide(scope, requestID, approve, scopeExpiresOf(tunnels))
if err != nil {
return
@@ -582,6 +594,26 @@ func (m *Manager) decideRequest(scope, requestID string, approve bool) {
log.Printf("[WebAuth] Request from %s (%s) on %s: %s", r.Name, r.IP, m.scopeName(scope), r.Status)
}
// extendLease extends a device's access (approving its renewal requests); the scope may be
// closed — the Agent reopens it, and registering re-applies the lease.
func (m *Manager) extendLease(msg protocol.ExtendLeaseMessage) protocol.ExtendLeaseRespMessage {
resp := protocol.ExtendLeaseRespMessage{Type: protocol.TypeExtendResp, RequestID: msg.RequestID}
scope := scopeOf(msg.TunnelID, msg.GroupID)
if msg.Minutes < 1 || msg.Minutes > protocol.MaxLeaseMinutes || msg.IP == "" {
resp.Error = "延长时长须在 1 分钟到 30 天之间"
return resp
}
exp, err := m.state.extendLease(scope, msg.IP, msg.Minutes)
if err != nil {
resp.Error = err.Error()
return resp
}
m.syncScope(scope)
resp.Success, resp.ExpiresAt = true, exp
log.Printf("[WebAuth] Extended %s on %s by %s, until %s", msg.IP, m.scopeName(scope), protocol.DescribeMinutes(msg.Minutes), exp.Format("01-02 15:04"))
return resp
}
// onClientConnect starts the countdown of a device's lease on its first connection.
// 组授权在首次连上组内任意一个通道时开始计时。
func (m *Manager) onClientConnect(tunnelID, ip string) {
@@ -678,6 +710,7 @@ func (m *Manager) metricsLoop(as *agentSession) {
Groups: groupMetrics,
ActiveConns: allActiveConns,
SecurityEvents: secCopy,
Renewals: m.state.renewalList(),
})
}
}
+3 -2
View File
@@ -575,8 +575,9 @@ func TestRevokeInviteRemovesItsLeases(t *testing.T) {
if hit(cfg.ListenPort) == "OK" {
t.Fatal("leases of revoked invite still active")
}
if status, _ := httpGet(t, addr, "/i/"+inv.Code, nil); status != 404 {
t.Fatal("revoked invite still usable")
// 用过它的设备看到「授权已撤销」,不能申请延长
if status, body := httpGet(t, addr, "/i/"+inv.Code, nil); status != 403 || !strings.Contains(body, "授权已撤销") {
t.Fatalf("revoked invite still usable: %d", status)
}
}
+245
View File
@@ -0,0 +1,245 @@
package server
import (
"encoding/json"
"io"
"net/url"
"strings"
"testing"
"time"
"github.com/DeepFal/PortRelay/core/protocol"
)
// linkOnly registers a tunnel that only invited devices can reach
func linkOnly(t *testing.T, a *testAgent, id string) protocol.TunnelConfig {
t.Helper()
cfg := tunnelCfg(id, freePort(t))
cfg.Whitelist = nil
if resp := a.register(t, protocol.TypeRegisterTunnel, cfg); !resp.Success {
t.Fatal(resp.Error)
}
return cfg
}
// grantVia uses an invite from 127.0.0.1 and connects once so the countdown starts
func grantVia(t *testing.T, m *Manager, addr string, cfg protocol.TunnelConfig, code string) {
t.Helper()
if status, _, body := postForm(t, addr, code, "张三"); status != 200 {
t.Fatalf("grant: %d %s", status, body)
}
if hit(cfg.ListenPort) != "OK" {
t.Fatal("granted device cannot connect")
}
waitFor(t, func() bool { l := m.state.leaseList(cfg.ID); return len(l) == 1 && !l[0].AwaitingConnect })
}
// expire moves a lease's end into the past by ago
func expire(m *Manager, scope, ip string, ago time.Duration) {
m.state.mu.Lock()
l := m.state.data[scope].Leases[ip]
l.ExpiresAt = time.Now().Add(-ago)
m.state.data[scope].Leases[ip] = l
m.state.mu.Unlock()
m.syncScope(scope)
}
// renew presses「申请延长」and returns the status page path
func renew(t *testing.T, addr, code string) string {
t.Helper()
resp, err := noRedirect.PostForm("http://"+addr+"/renew", url.Values{"code": {code}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 303 || !strings.HasPrefix(resp.Header.Get("Location"), "/auth/status?id=") {
t.Fatalf("renew: %d %s", resp.StatusCode, b)
}
return resp.Header.Get("Location")
}
func (a *testAgent) extend(t *testing.T, tunnelID, ip string, minutes int) protocol.ExtendLeaseRespMessage {
t.Helper()
a.send(t, protocol.ExtendLeaseMessage{Type: protocol.TypeExtendLease, RequestID: "e1", TunnelID: tunnelID, IP: ip, Minutes: minutes})
var resp protocol.ExtendLeaseRespMessage
_ = json.Unmarshal(a.next(t), &resp)
return resp
}
func TestRenewAfterExpiry(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := linkOnly(t, a, "a")
inv := a.createInviteWith(t, "a", 1, "30m")
grantVia(t, m, addr, cfg, inv.Code)
// 授权有效时再打开链接:显示剩余时间与「申请延长」,不会重新授权
if _, body := httpGet(t, addr, "/i/"+inv.Code, nil); !strings.Contains(body, "访问已授权") || !strings.Contains(body, "申请延长") {
t.Fatalf("running access page: %s", body)
}
expire(m, "a", "127.0.0.1", time.Minute)
waitFor(t, func() bool { return hit(cfg.ListenPort) != "OK" })
if _, body := httpGet(t, addr, "/i/"+inv.Code, nil); !strings.Contains(body, "访问已结束") || !strings.Contains(body, "申请延长") {
t.Fatalf("ended access page: %s", body)
}
// 用过链接的设备不能再直接授权
if status, _, _ := postForm(t, addr, inv.Code, "张三"); status != 409 {
t.Fatalf("a returning device must not be re-granted directly, got %d", status)
}
status := renew(t, addr, inv.Code)
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "等待管理员批准") {
t.Fatalf("pending renewal page: %s", body)
}
if again := renew(t, addr, inv.Code); again != status {
t.Fatal("one device should have one pending renewal")
}
rl := m.state.renewalList()
if len(rl) != 1 || rl[0].Request == nil || rl[0].EndedAt == nil || rl[0].TunnelID != "a" || rl[0].Name != "张三" {
t.Fatalf("renewal list: %+v", rl)
}
if p := m.state.pendingList("a"); len(p) != 0 {
t.Fatalf("renewals are not first-time requests: %+v", p)
}
resp := a.extend(t, "a", "127.0.0.1", 45)
if !resp.Success || time.Until(resp.ExpiresAt) < 44*time.Minute || time.Until(resp.ExpiresAt) > 46*time.Minute {
t.Fatalf("extend an ended lease from now: %+v", resp)
}
if hit(cfg.ListenPort) != "OK" {
t.Fatal("extended device cannot connect")
}
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "延长已批准") {
t.Fatalf("approved renewal page: %s", body)
}
if rl := m.state.renewalList(); len(rl) != 0 {
t.Fatalf("nothing left to renew: %+v", rl)
}
}
func TestRenewAfterTunnelClosed(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := linkOnly(t, a, "a")
inv := a.createInvite(t, "a", 1)
grantVia(t, m, addr, cfg, inv.Code)
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "a"})
a.next(t)
_, body := httpGet(t, addr, "/i/"+inv.Code, nil)
if !strings.Contains(body, "访问已结束") || !strings.Contains(body, "通道当前已关闭") || !strings.Contains(body, "目标通道") {
t.Fatalf("closed tunnel page: %s", body)
}
status := renew(t, addr, inv.Code)
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "等待管理员批准") || !strings.Contains(body, "自动重新开启") {
t.Fatalf("pending renewal on a closed tunnel: %s", body)
}
// 拒绝也不需要通道开启
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: "a", RequestID: m.state.renewalList()[0].Request.ID, Approve: false})
waitFor(t, func() bool {
_, b := httpGet(t, addr, status, nil)
return strings.Contains(b, "拒绝了您的延长申请")
})
// 再申请一次,批准后 Agent 重新开启通道,授权随注册生效
status = renew(t, addr, inv.Code)
if resp := a.extend(t, "a", "127.0.0.1", 60); !resp.Success {
t.Fatalf("extend while closed: %s", resp.Error)
}
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "正在开启通道") {
t.Fatalf("approved while closed: %s", body)
}
if resp := a.register(t, protocol.TypeRegisterTunnel, cfg); !resp.Success {
t.Fatal(resp.Error)
}
if hit(cfg.ListenPort) != "OK" {
t.Fatal("the reopened tunnel must admit the extended device")
}
}
func TestRenewWindowRevokeAndDelete(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := linkOnly(t, a, "a")
inv := a.createInvite(t, "a", 0)
grantVia(t, m, addr, cfg, inv.Code)
// 结束超过 2 小时:只能重新邀请
expire(m, "a", "127.0.0.1", renewWindow+time.Minute)
if _, body := httpGet(t, addr, "/i/"+inv.Code, nil); !strings.Contains(body, "超过 2 小时") {
t.Fatalf("expired window page: %s", body)
}
if resp := a.extend(t, "a", "127.0.0.1", 30); resp.Success {
t.Fatal("cannot extend after the renewal window")
}
// 撤销的授权不能延长
grantTestLease(m, "a", "10.9.9.9", time.Now().Add(time.Hour))
a.send(t, protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, TunnelID: "a", IP: "10.9.9.9"})
waitFor(t, func() bool { return len(m.state.leaseList("a")) == 0 })
if resp := a.extend(t, "a", "10.9.9.9", 30); resp.Success || !strings.Contains(resp.Error, "撤销") {
t.Fatalf("revoked access must not be extended: %+v", resp)
}
// 删除通道:连同记录一起清除
grantTestLease(m, "a", "10.9.9.8", time.Now().Add(time.Hour))
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "a", Deleted: true})
a.next(t)
if rl := m.state.renewalList(); len(rl) != 0 {
t.Fatalf("a deleted tunnel leaves nothing to renew: %+v", rl)
}
if resp := a.extend(t, "a", "10.9.9.8", 30); resp.Success {
t.Fatal("cannot extend on a deleted tunnel")
}
}
func TestExtendRunningLeaseIsNotCappedByTunnelTTL(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := tunnelCfg("a", freePort(t))
cfg.Whitelist = nil
end := time.Now().Add(10 * time.Minute)
cfg.ExpiresAt = &end
a.register(t, protocol.TypeRegisterTunnel, cfg)
inv := a.createInvite(t, "a", 1)
grantVia(t, m, addr, cfg, inv.Code)
before := m.state.leaseList("a")[0].ExpiresAt
if before.After(end.Add(time.Second)) {
t.Fatalf("a regular grant is capped by the tunnel TTL: %v > %v", before, end)
}
resp := a.extend(t, "a", "127.0.0.1", 60)
if !resp.Success || !resp.ExpiresAt.Equal(before.Add(time.Hour)) {
t.Fatalf("a running lease extends from its expiry: %+v (was %v)", resp, before)
}
}
func TestCustomLeaseDurations(t *testing.T) {
for d, want := range map[string]int{"30m": 30, "90m": 90, "1h": 60, "": 120, "8h": 480} {
if n, ok := protocol.LeaseMinutes(d); !ok || n != want {
t.Errorf("LeaseMinutes(%q) = %d, %v", d, n, ok)
}
}
for _, bad := range []string{"3m", "abc", "90", "100000m"} {
if protocol.ValidLeaseDuration(bad) {
t.Errorf("%q should be invalid", bad)
}
}
if got := protocol.DescribeLeaseDuration("90m"); got != "1 小时 30 分钟" {
t.Errorf("describe 90m: %q", got)
}
now := time.Now()
if got := leaseExpiry("45m", nil, now); !got.Equal(now.Add(45 * time.Minute)) {
t.Errorf("leaseExpiry 45m: %v", got.Sub(now))
}
}
+353 -19
View File
@@ -25,10 +25,17 @@ const (
leaseStartWindow = 24 * time.Hour
)
// renewWindow 访问结束(授权到期或通道关闭)后多久内还能申请延长;之后清除记录,需要重新邀请
var renewWindow = 2 * time.Hour
var (
errInviteNotFound = errors.New("邀请链接无效或已过期")
errInviteUsedUp = errors.New("该邀请链接的可用设备数已用完,请向管理员索取新的链接")
errRequestGone = errors.New("申请不存在或已过期")
errAlreadyUsed = errors.New("这台设备已经用过该邀请链接,请重新打开链接申请延长")
errNothingToRenew = errors.New("访问已结束超过 2 小时,请联系管理员获取新的邀请链接")
errRevoked = errors.New("授权已被管理员撤销,如需继续使用请联系管理员")
errNotStarted = errors.New("授权尚未开始计时(首次连接后才开始),无需延长")
)
type leaseInfo struct {
@@ -89,10 +96,27 @@ func (s *stateStore) lease(scope, ip string) (leaseInfo, bool) {
return leaseInfo{}, false
}
// leaseRecord remembers a device whose access ended, so it can ask for an extension
type leaseRecord struct {
EndedAt time.Time `json:"ended_at"`
Name string `json:"name,omitempty"`
Invite string `json:"invite,omitempty"`
// Revoked 为 true 表示被管理员撤销,不能申请延长
Revoked bool `json:"revoked,omitempty"`
}
func (r leaseRecord) renewable(now time.Time) bool {
return !r.Revoked && now.Before(r.EndedAt.Add(renewWindow))
}
type tunnelState struct {
Leases map[string]leaseInfo `json:"leases,omitempty"` // ip -> lease
Invites map[string]*protocol.Invite `json:"invites,omitempty"` // code -> invite
Requests map[string]*protocol.AccessRequest `json:"requests,omitempty"` // id -> request
// Ended 访问已结束的设备(ip -> 记录),结束后 renewWindow 内可申请延长;通道关闭后仍保留
Ended map[string]leaseRecord `json:"ended,omitempty"`
// Name 通道或组最近的名称,通道关闭后用于申请延长页面
Name string `json:"name,omitempty"`
}
// 授权状态的作用范围:单个通道(键为通道 ID,与旧数据兼容)或整个通道组(键为 "group:<组ID>")
@@ -177,7 +201,7 @@ func (m *Manager) EnableLeasePersistence(path string) error {
// looksLikeState distinguishes the new format from the legacy one (both are JSON objects of objects)
func looksLikeState(d map[string]*tunnelState) bool {
for _, st := range d {
if st == nil || (st.Leases == nil && st.Invites == nil && st.Requests == nil) {
if st == nil || (st.Leases == nil && st.Invites == nil && st.Requests == nil && st.Ended == nil) {
return false
}
}
@@ -199,9 +223,21 @@ func (s *stateStore) scopeLocked(id string) *tunnelState {
if st.Requests == nil {
st.Requests = make(map[string]*protocol.AccessRequest)
}
if st.Ended == nil {
st.Ended = make(map[string]leaseRecord)
}
return st
}
// endLeaseLocked turns a lease into an ended record (renewable unless revoked)
func endLeaseLocked(st *tunnelState, ip string, l leaseInfo, at time.Time, revoked bool) {
if st.Ended == nil {
st.Ended = make(map[string]leaseRecord)
}
st.Ended[ip] = leaseRecord{EndedAt: at, Name: l.Name, Invite: l.Invite, Revoked: revoked}
delete(st.Leases, ip)
}
func randomCode(n int) string {
const chars = "23456789abcdefghjkmnpqrstuvwxyz"
b := make([]byte, n)
@@ -225,13 +261,25 @@ func (s *stateStore) removeLease(scope, ip string) int {
if st == nil {
return 0
}
// 被撤销的授权不能再申请延长
now := time.Now()
n := 0
if ip == "" {
n = len(st.Leases)
st.Leases = make(map[string]leaseInfo)
} else if _, ok := st.Leases[ip]; ok {
n = 1
delete(st.Leases, ip)
for lip, l := range st.Leases {
if ip == "" || lip == ip {
endLeaseLocked(st, lip, l, now, true)
n++
}
}
for lip, r := range st.Ended {
if ip == "" || lip == ip {
r.Revoked = true
st.Ended[lip] = r
}
}
for id, r := range st.Requests {
if r.Renewal && r.Status == "pending" && (ip == "" || r.IP == ip) {
delete(st.Requests, id)
}
}
s.saveLocked()
return n
@@ -271,8 +319,54 @@ func (s *stateStore) leaseList(scope string) []protocol.DynamicLease {
return out
}
// forgetScope drops all access state of a tunnel or group (explicitly closed, deleted or dissolved)
func (s *stateStore) forgetScope(scope string) {
// closeScope runs when a tunnel, or the last open tunnel of a group, is closed: invites and
// first-time requests become invalid, running leases end — but ended devices are remembered for
// renewWindow so they can still ask for an extension.
func (s *stateStore) closeScope(scope string) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[scope]
if st == nil {
return
}
now := time.Now()
for ip, l := range st.Leases {
end := now
if l.ExpiresAt.Before(now) {
end = l.ExpiresAt
}
endLeaseLocked(st, ip, l, end, false)
}
st.Invites = nil
for id, r := range st.Requests {
if !r.Renewal && r.Status == "pending" {
delete(st.Requests, id)
}
}
s.saveLocked()
}
// scopeDisplayName is the last known name of a scope (for pages shown after it closed)
func (s *stateStore) scopeDisplayName(scope string) string {
s.mu.Lock()
defer s.mu.Unlock()
if st := s.data[scope]; st != nil {
return st.Name
}
return ""
}
// setName records the display name of a scope that has access state
func (s *stateStore) setName(scope, name string) {
s.mu.Lock()
defer s.mu.Unlock()
if st := s.data[scope]; st != nil && name != "" && st.Name != name {
st.Name = name
}
}
// dropScope drops all access state of a deleted tunnel or a dissolved group, renewal records included
func (s *stateStore) dropScope(scope string) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.data[scope]; ok {
@@ -379,6 +473,13 @@ func (s *stateStore) useInviteAndGrant(scope, code, name, ip string, scopeExpire
if !ok || !time.Now().Before(inv.ExpiresAt) {
return leaseInfo{}, errInviteNotFound
}
// 已授权的设备再次提交不重新计时;用过链接、授权已结束的设备要申请延长
if l, ok := st.Leases[ip]; ok && time.Now().Before(l.ExpiresAt) {
return l, nil
}
if usedByLocked(inv, ip) {
return leaseInfo{}, errAlreadyUsed
}
if !capacityOKLocked(st, inv, ip) {
return leaseInfo{}, errInviteUsedUp
}
@@ -386,10 +487,20 @@ func (s *stateStore) useInviteAndGrant(scope, code, name, ip string, scopeExpire
s.recordUseLocked(st, code, name, ip)
l := newLease(name, code, inv.LeaseDuration, scopeExpires, time.Now())
st.Leases[ip] = l
delete(st.Ended, ip)
s.saveLocked()
return l, nil
}
func usedByLocked(inv *protocol.Invite, ip string) bool {
for _, u := range inv.Uses {
if u.IP == ip {
return true
}
}
return false
}
// revokeInvite removes the invite and the leases it granted; returns the affected IPs
func (s *stateStore) revokeInvite(scope, code string) []string {
s.mu.Lock()
@@ -400,10 +511,17 @@ func (s *stateStore) revokeInvite(scope, code string) []string {
}
delete(st.Invites, code)
var ips []string
now := time.Now()
for ip, l := range st.Leases {
if l.Invite == code {
ips = append(ips, ip)
delete(st.Leases, ip)
endLeaseLocked(st, ip, l, now, true)
}
}
for ip, r := range st.Ended {
if r.Invite == code {
r.Revoked = true
st.Ended[ip] = r
}
}
for id, r := range st.Requests {
@@ -449,12 +567,15 @@ func (s *stateStore) addRequest(scope, code, name, ip string) (protocol.AccessRe
}
st = s.scopeLocked(scope)
for _, r := range st.Requests {
if r.IP == ip && r.Status == "pending" {
if r.IP == ip && r.Status == "pending" && !r.Renewal {
r.Name = name
s.saveLocked()
return *r, nil
}
}
if usedByLocked(inv, ip) {
return protocol.AccessRequest{}, errAlreadyUsed
}
if !capacityOKLocked(st, inv, ip) {
return protocol.AccessRequest{}, errInviteUsedUp
}
@@ -507,6 +628,24 @@ func (s *stateStore) decide(scope, id string, approve bool, scopeExpires *time.T
if inv, ok := st.Invites[r.Invite]; ok {
duration = inv.LeaseDuration
}
if r.Renewal {
// 延长申请通常由 extendLease 按管理员选择的时长批准;这里按邀请的时长(默认 2 小时)处理
if !approve {
r.Status, r.At = "rejected", time.Now()
s.saveLocked()
return *r, time.Time{}, nil
}
minutes, ok := protocol.LeaseMinutes(duration)
if !ok {
minutes = 120
}
exp, err := s.extendLocked(st, r.IP, minutes, time.Now())
if err != nil {
return protocol.AccessRequest{}, time.Time{}, err
}
s.saveLocked()
return *r, exp, nil
}
l := newLease(r.Name, r.Invite, duration, scopeExpires, time.Now())
exp := l.ExpiresAt
r.At = time.Now()
@@ -514,6 +653,7 @@ func (s *stateStore) decide(scope, id string, approve bool, scopeExpires *time.T
r.Status = "approved"
s.recordUseLocked(st, r.Invite, r.Name, r.IP)
st.Leases[r.IP] = l
delete(st.Ended, r.IP)
} else {
r.Status = "rejected"
}
@@ -528,7 +668,7 @@ func (s *stateStore) pendingList(scope string) []protocol.AccessRequest {
var out []protocol.AccessRequest
if st := s.data[scope]; st != nil {
for _, r := range st.Requests {
if r.Status == "pending" {
if r.Status == "pending" && !r.Renewal {
out = append(out, *r)
}
}
@@ -537,6 +677,195 @@ func (s *stateStore) pendingList(scope string) []protocol.AccessRequest {
return out
}
// ---- renewals ----
// deviceAccess is what a device that opens an invite link already has in the invite's scope
type deviceAccess struct {
scope string
name string // 通道或组的名称(通道关闭后为最近一次的名称)
lease *leaseInfo
ended *leaseRecord
pending *protocol.AccessRequest // 待处理的延长申请
// usedBefore 用过该链接,但访问已结束超过 renewWindow
usedBefore bool
}
// deviceAccess looks up ip's access in the scope of invite code, also after the invite or the
// tunnel is gone; false when the device never used this invite (a new visitor)
func (s *stateStore) deviceAccess(code, ip string) (deviceAccess, bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked()
now := time.Now()
for scope, st := range s.data {
inv := st.Invites[code]
l, hasLease := st.Leases[ip]
rec, hasRec := st.Ended[ip]
mine := (inv != nil && usedByLocked(inv, ip)) || (hasLease && l.Invite == code) || (hasRec && rec.Invite == code)
if !mine {
continue
}
da := deviceAccess{scope: scope, name: st.Name}
switch {
case hasLease && now.Before(l.ExpiresAt):
da.lease = &l
case hasRec && (rec.Revoked || rec.renewable(now)):
da.ended = &rec
default:
da.usedBefore = true
}
for _, r := range st.Requests {
if r.IP == ip && r.Renewal && r.Status == "pending" {
c := *r
da.pending = &c
}
}
return da, true
}
return deviceAccess{}, false
}
// addRenewal files (or re-uses) a renewal request for ip: its lease must be running, or have
// ended less than renewWindow ago without being revoked
func (s *stateStore) addRenewal(scope, ip string) (protocol.AccessRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked()
st := s.data[scope]
if st == nil {
return protocol.AccessRequest{}, errNothingToRenew
}
now := time.Now()
var name, invite string
if l, ok := st.Leases[ip]; ok && now.Before(l.ExpiresAt) {
if l.Deferred {
return protocol.AccessRequest{}, errNotStarted
}
name, invite = l.Name, l.Invite
} else if rec, ok := st.Ended[ip]; ok && rec.Revoked {
return protocol.AccessRequest{}, errRevoked
} else if ok && rec.renewable(now) {
name, invite = rec.Name, rec.Invite
} else {
return protocol.AccessRequest{}, errNothingToRenew
}
st = s.scopeLocked(scope)
for _, r := range st.Requests {
if r.IP == ip && r.Renewal && r.Status == "pending" {
return *r, nil
}
}
r := &protocol.AccessRequest{ID: randomCode(16), Name: name, IP: ip, Invite: invite, At: now, Status: "pending", Renewal: true}
if id, isGroup := splitScope(scope); isGroup {
r.GroupID = id
} else {
r.TunnelID = id
}
st.Requests[r.ID] = r
s.saveLocked()
return *r, nil
}
// extendLease extends ip's access in scope by minutes and approves its pending renewal requests.
// 不受通道最长开启时长限制:管理员批准的时长优先。
func (s *stateStore) extendLease(scope, ip string, minutes int) (time.Time, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked()
st := s.data[scope]
if st == nil {
return time.Time{}, errNothingToRenew
}
exp, err := s.extendLocked(s.scopeLocked(scope), ip, minutes, time.Now())
if err != nil {
return time.Time{}, err
}
s.saveLocked()
return exp, nil
}
// extendLocked: a running lease is extended from its expiry (one not yet started starts now),
// an ended one is restored from now. Caller must hold s.mu and save.
func (s *stateStore) extendLocked(st *tunnelState, ip string, minutes int, now time.Time) (time.Time, error) {
d := time.Duration(minutes) * time.Minute
if l, ok := st.Leases[ip]; ok && now.Before(l.ExpiresAt) {
if l.Deferred {
l.Deferred, l.StartedAt, l.ExpiresAt = false, &now, now.Add(d)
} else {
l.ExpiresAt = l.ExpiresAt.Add(d)
}
st.Leases[ip] = l
} else if rec, ok := st.Ended[ip]; ok && rec.Revoked {
return time.Time{}, errRevoked
} else if ok && rec.renewable(now) {
st.Leases[ip] = leaseInfo{Name: rec.Name, Invite: rec.Invite, Duration: protocol.LeaseDurationOf(minutes), StartedAt: &now, ExpiresAt: now.Add(d)}
delete(st.Ended, ip)
} else {
return time.Time{}, errNothingToRenew
}
for _, r := range st.Requests {
if r.IP == ip && r.Renewal && r.Status == "pending" {
r.Status, r.At = "approved", now
}
}
return st.Leases[ip].ExpiresAt, nil
}
// renewalList reports, across all scopes (closed ones included), the devices that can still be
// extended and the pending renewal requests
func (s *stateStore) renewalList() []protocol.RenewalInfo {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked()
now := time.Now()
var out []protocol.RenewalInfo
for scope, st := range s.data {
id, isGroup := splitScope(scope)
base := protocol.RenewalInfo{TunnelID: id}
if isGroup {
base = protocol.RenewalInfo{GroupID: id}
}
byIP := make(map[string]int)
for ip, rec := range st.Ended {
if !rec.renewable(now) {
continue
}
info := base
end, before := rec.EndedAt, rec.EndedAt.Add(renewWindow)
info.IP, info.Name, info.Invite, info.EndedAt, info.RenewBefore = ip, rec.Name, rec.Invite, &end, &before
byIP[ip] = len(out)
out = append(out, info)
}
for _, r := range st.Requests {
if !r.Renewal || r.Status != "pending" {
continue
}
c := *r
if i, ok := byIP[r.IP]; ok {
out[i].Request = &c
continue
}
info := base
info.IP, info.Name, info.Invite, info.Request = r.IP, r.Name, r.Invite, &c
out = append(out, info)
}
}
sort.Slice(out, func(i, j int) bool {
if (out[i].Request != nil) != (out[j].Request != nil) {
return out[i].Request != nil
}
ti, tj := now, now
if out[i].EndedAt != nil {
ti = *out[i].EndedAt
}
if out[j].EndedAt != nil {
tj = *out[j].EndedAt
}
return ti.After(tj)
})
return out
}
// ---- housekeeping ----
func (s *stateStore) pruneRequestsLocked() {
@@ -560,7 +889,12 @@ func (s *stateStore) pruneLocked() {
}
for ip, l := range st.Leases {
if !now.Before(l.ExpiresAt) {
delete(st.Leases, ip)
endLeaseLocked(st, ip, l, l.ExpiresAt, false)
}
}
for ip, r := range st.Ended {
if !now.Before(r.EndedAt.Add(renewWindow)) {
delete(st.Ended, ip)
}
}
for code, inv := range st.Invites {
@@ -568,7 +902,7 @@ func (s *stateStore) pruneLocked() {
delete(st.Invites, code)
}
}
if len(st.Leases) == 0 && len(st.Invites) == 0 && len(st.Requests) == 0 {
if len(st.Leases) == 0 && len(st.Invites) == 0 && len(st.Requests) == 0 && len(st.Ended) == 0 {
delete(s.data, id)
}
}
@@ -607,10 +941,6 @@ func (s *stateStore) saveLocked() {
func leaseExpiry(duration string, scopeExpires *time.Time, now time.Time) time.Time {
var exp time.Time
switch duration {
case protocol.LeaseDuration1h:
exp = now.Add(time.Hour)
case protocol.LeaseDuration8h:
exp = now.Add(8 * time.Hour)
case protocol.LeaseDurationToday:
y, mo, d := now.Date()
exp = time.Date(y, mo, d, 23, 59, 59, 0, now.Location())
@@ -618,7 +948,11 @@ func leaseExpiry(duration string, scopeExpires *time.Time, now time.Time) time.T
// 通道开启期间有效:通道被主动关闭时放通会被清除;设一个上限避免永久放通
exp = now.Add(30 * 24 * time.Hour)
default:
exp = now.Add(2 * time.Hour)
minutes, ok := protocol.LeaseMinutes(duration)
if !ok {
minutes = 120
}
exp = now.Add(time.Duration(minutes) * time.Minute)
}
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(exp) {
exp = *scopeExpires
+113 -2
View File
@@ -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
+12
View File
@@ -281,6 +281,18 @@ MainActor.assumeIsolated {
render("group-history-addr", CGSize(width: 760, height: 520), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 1, byAddress: true, isPresented: yes))
render("group-edit-member", CGSize(width: 520, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes))
// 授权延长:申请延长、最近结束、已授权设备的「延长」
let rvm = makeDemoVM()
rvm.tunnels[0].renewals = decode([RenewalItem].self, """
[{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.35","name":"李四","invite":"ab1","ended_at":"\(t(-1200))","renew_before":"\(t(6000))",
"request":{"id":"rn1","tunnel_id":"\(rvm.tunnels[0].id)","name":"李四","ip":"192.168.1.35","invite":"ab1","at":"\(t(-60))","status":"pending","renewal":true}},
{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.36","name":"王五","invite":"ab2","ended_at":"\(t(-3000))","renew_before":"\(t(4200))"}]
""")
render("renew-access", CGSize(width: 760, height: 620), ClientDetailsSheet(vm: rvm, tunnel: rvm.tunnels[0], initialTab: 2, isPresented: yes))
render("renew-card", CGSize(width: 980, height: 220), TunnelCardView(tunnel: rvm.tunnels[0], vm: rvm, onEdit: {}, onViewClients: {}, onViewLeases: {}, onInvite: {}).padding(20))
render("renew-invite-form", CGSize(width: 600, height: 400), InviteSheet(vm: rvm, tunnel: rvm.tunnels[0], autoGenerate: false, isPresented: yes))
render("renew-custom", CGSize(width: 300, height: 90), CustomMinutesForm(actionTitle: "延长") { _ in })
// README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
if only.hasPrefix("gif") {
let frames = Int(ProcessInfo.processInfo.environment["FRAMES"] ?? "") ?? 40