diff --git a/README.md b/README.md index 46935e4..a344f79 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,11 @@ PortRelay 把这件事变成三步: **访问控制** - 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。 -- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(1 小时、2 小时、8 小时、当天有效,或直到通道关闭),以及是否需要审批。 +- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(30 分钟、1 小时、2 小时、8 小时、当天有效、直到通道关闭,或自定义),以及是否需要审批。 - 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。 - **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。 - 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。 +- **申请延长**:访问结束后 2 小时内(通道已关闭也可以),同事重新打开原链接点「申请延长」;你在 App、菜单栏或网页面板里选择延长多久即批准,关闭的通道会自动重新开启。你也可以对已授权或刚结束的设备主动延长。 - **通道组**:添加通道时目标是一个列表,每行一台主机、多个端口用逗号隔开(也可以直接粘贴一串 IP),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。 **通道生命周期** diff --git a/README_EN.md b/README_EN.md index 2e42079..f87584a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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** diff --git a/client/agent.go b/client/agent.go index 49d77af..620223d 100644 --- a/client/agent.go +++ b/client/agent.go @@ -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 } diff --git a/client/api.go b/client/api.go index 04657f8..17c25ce 100644 --- a/client/api.go +++ b/client/api.go @@ -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 != "" { diff --git a/client/api_groups.go b/client/api_groups.go index 41f9ba4..0c18b72 100644 --- a/client/api_groups.go +++ b/client/api_groups.go @@ -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) } diff --git a/client/config.go b/client/config.go index f58c7c3..5d48485 100644 --- a/client/config.go +++ b/client/config.go @@ -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() diff --git a/client/renew_test.go b/client/renew_test.go new file mode 100644 index 0000000..77c99cd --- /dev/null +++ b/client/renew_test.go @@ -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 + }) +} diff --git a/cmd/portrelay-agent/web/index.html b/cmd/portrelay-agent/web/index.html index 6e226f0..d2e55ed 100644 --- a/cmd/portrelay-agent/web/index.html +++ b/cmd/portrelay-agent/web/index.html @@ -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 @@
- + +
@@ -536,6 +541,32 @@
+ + + `; }).join(''); + if (ended.length) { + html += `最近结束 · 2 小时内可延长 (${ended.length})`; + html += ended.map(renewCard).join(''); + } + html += `有效的邀请链接 (${invites.length})`; html += invites.length === 0 ? '
暂无有效的邀请链接
' @@ -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]) => + ``).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(`🔔 待审批 ${m.pending.length}`); + const asks = (g.renewals || []).filter(r => r.request).length; + if (asks) parts.push(`⏱ 申请延长 ${asks}`); if ((m.dynamic_leases || []).length) parts.push(`已授权 ${m.dynamic_leases.length} 台 ›`); return parts.join(''); } diff --git a/core/protocol/messages.go b/core/protocol/messages.go index c8843e3..c603aa3 100644 --- a/core/protocol/messages.go +++ b/core/protocol/messages.go @@ -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 diff --git a/mac-native/PortRelayApp.swift b/mac-native/PortRelayApp.swift index 30ae3da..5883d8d 100644 --- a/mac-native/PortRelayApp.swift +++ b/mac-native/PortRelayApp.swift @@ -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)) diff --git a/server/manager.go b/server/manager.go index 7805734..acf4467 100644 --- a/server/manager.go +++ b/server/manager.go @@ -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(), }) } } diff --git a/server/manager_test.go b/server/manager_test.go index 94dd32d..b048aaf 100644 --- a/server/manager_test.go +++ b/server/manager_test.go @@ -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) } } diff --git a/server/renew_test.go b/server/renew_test.go new file mode 100644 index 0000000..6aa56e4 --- /dev/null +++ b/server/renew_test.go @@ -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)) + } +} diff --git a/server/state.go b/server/state.go index b19ffcc..e8b0cff 100644 --- a/server/state.go +++ b/server/state.go @@ -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 diff --git a/server/webauth.go b/server/webauth.go index 1c78bc5..c833c8e 100644 --- a/server/webauth.go +++ b/server/webauth.go @@ -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(` +
+ + +
`, 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))))) + + `
需要更多时间时可以申请延长,管理员批准后在原到期时间上顺延。
` + 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(`
可在 %s 前申请延长,管理员批准并确定时长后即可继续使用。%s
`, + 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(` +
%s%s
+
您的设备 IP%s
+
访问有效期%s
`, + 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, "已批准,正在开启通道", `
管理员已批准延长,通道正在重新开启,此页面会自动刷新。
`) + 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(`
%s,您的延长申请已提交,管理员批准时会确定延长多久;通道当前已关闭,批准后会自动重新开启。此页面会自动刷新。
`, 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))+ + `
管理员已批准延长,现在可以继续使用。到期后 2 小时内还可以重新打开邀请链接申请延长。
`) 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(`
%s,您的延长申请已提交,管理员批准时会确定延长多久。此页面会自动刷新。
`, 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)) + - `
您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。
` + `
您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后 2 小时内,可以重新打开这个邀请链接申请延长。
` } // detailRows renders the info table; a group lists every access address with its target diff --git a/tools/ui-snapshots/main.swift b/tools/ui-snapshots/main.swift index b23efb3..581a2fb 100644 --- a/tools/ui-snapshots/main.swift +++ b/tools/ui-snapshots/main.swift @@ -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