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

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

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

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

No files matched your search

+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