feat: 授权延长——同事一键申请,管理员批准时决定时长;时长支持 30 分钟与自定义
同事端 - 用过邀请链接的设备再次打开链接时显示自己的授权状态:有效时可申请延长, 访问结束(到期或通道关闭)后 2 小时内也可以;超过 2 小时或被撤销则提示重新获取邀请 - 「申请延长」只有一个按钮,提交后复用审批状态页,批准后显示新的到期时间 - 用过链接的设备不再自动重新放通,统一走申请延长 网关 - 授权结束后保留 2 小时的记录(通道关闭后仍保留,随状态文件持久化);撤销的不能延长, 删除通道 / 解散组时一并清除(unregister 带 deleted 标记) - 新增 extend_lease:有效授权从原到期时间顺延,已结束的从现在起算,不受通道最长开启时长截断, 并批准该设备的延长申请;指标里带上全部可延长设备与申请(包括已关闭的通道) - 授权时长支持分钟数("30m"、"90m",5 分钟到 30 天) Agent / App / 网页面板 - POST .../leases/extend:通道已关闭时先自动开启(组则全组开启),并让通道至少开到授权结束 - 授权管理新增「申请延长」「最近结束 · 2 小时内可延长」,已授权设备可直接「延长」; 卡片、菜单栏和系统通知提示延长申请,菜单栏可直接选时长批准 - 邀请的可访问时长、通道最长开启时长增加 30 分钟与自定义;网页面板弹窗层级高于侧边抽屉 - 邀请分享文案提示「授权结束后 2 小时内可重新打开此链接申请延长」
This commit is contained in:
1 parent
9a675ba96e
commit
dceb59c0ca
16 files changed
+1707
-75
No files matched your search
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user