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