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
+70
-2
@@ -34,6 +34,7 @@ type Agent struct {
|
||||
latestMetrics protocol.MetricsMessage
|
||||
regErrors map[string]string // tunnelID -> 服务器返回的注册失败原因
|
||||
inviteWaiters map[string]chan protocol.InviteRespMessage
|
||||
extendWaiters map[string]chan protocol.ExtendLeaseRespMessage
|
||||
|
||||
controlWriteMu sync.Mutex
|
||||
controlWriter *bufio.Writer
|
||||
@@ -57,6 +58,7 @@ func NewAgent(cm *ConfigManager) *Agent {
|
||||
cm: cm,
|
||||
regErrors: make(map[string]string),
|
||||
inviteWaiters: make(map[string]chan protocol.InviteRespMessage),
|
||||
extendWaiters: make(map[string]chan protocol.ExtendLeaseRespMessage),
|
||||
usage: make(map[string]*usageState),
|
||||
idleUnit: time.Minute,
|
||||
rates: newRateRecorder(),
|
||||
@@ -199,6 +201,18 @@ func (a *Agent) trackUsage(now time.Time) {
|
||||
groupBusy[gm.GroupID] = true
|
||||
}
|
||||
}
|
||||
// 等待批准的延长申请:通道(组)保持开启,避免关了又被批准重开
|
||||
renewing := make(map[string]bool)
|
||||
for _, r := range metrics.Renewals {
|
||||
if r.Request == nil {
|
||||
continue
|
||||
}
|
||||
if r.GroupID != "" {
|
||||
groupBusy[r.GroupID] = true
|
||||
} else {
|
||||
renewing[r.TunnelID] = true
|
||||
}
|
||||
}
|
||||
for _, t := range cfg.Tunnels {
|
||||
if m, ok := byID[t.ID]; ok && t.Enabled && t.GroupID != "" && m.Status == "online" && tunnelInUse(m) {
|
||||
groupBusy[t.GroupID] = true
|
||||
@@ -242,7 +256,7 @@ func (a *Agent) trackUsage(now time.Time) {
|
||||
firstConn = append(firstConn, t.ID)
|
||||
}
|
||||
}
|
||||
u.inUse = tunnelInUse(m) || (t.GroupID != "" && groupBusy[t.GroupID])
|
||||
u.inUse = tunnelInUse(m) || renewing[t.ID] || (t.GroupID != "" && groupBusy[t.GroupID])
|
||||
if u.inUse {
|
||||
u.lastActive = now
|
||||
}
|
||||
@@ -520,6 +534,17 @@ func (a *Agent) connectAndRun(cfg Config) error {
|
||||
delete(a.inviteWaiters, resp.RequestID)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
case protocol.TypeExtendResp:
|
||||
var resp protocol.ExtendLeaseRespMessage
|
||||
if json.Unmarshal(line, &resp) != nil {
|
||||
continue
|
||||
}
|
||||
a.mu.Lock()
|
||||
if ch, ok := a.extendWaiters[resp.RequestID]; ok {
|
||||
ch <- resp
|
||||
delete(a.extendWaiters, resp.RequestID)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
case protocol.TypeRegisterResp:
|
||||
var resp protocol.RegisterRespMessage
|
||||
if json.Unmarshal(line, &resp) != nil {
|
||||
@@ -743,14 +768,24 @@ func (a *Agent) wire(t protocol.TunnelConfig) protocol.TunnelConfig {
|
||||
return t
|
||||
}
|
||||
|
||||
// RemoveTunnel closes a tunnel on the relay server; its devices can still ask for an extension
|
||||
func (a *Agent) RemoveTunnel(id string) {
|
||||
a.unregister(id, false)
|
||||
}
|
||||
|
||||
// DeleteTunnel removes a deleted tunnel from the relay server, renewal records included
|
||||
func (a *Agent) DeleteTunnel(id string) {
|
||||
a.unregister(id, true)
|
||||
}
|
||||
|
||||
func (a *Agent) unregister(id string, deleted bool) {
|
||||
a.mu.Lock()
|
||||
delete(a.regErrors, id)
|
||||
a.mu.Unlock()
|
||||
if !a.IsConnected() {
|
||||
return
|
||||
}
|
||||
_ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id})
|
||||
_ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id, Deleted: deleted})
|
||||
}
|
||||
|
||||
// Scope selects what an invite, request or lease applies to: one tunnel, or a whole group
|
||||
@@ -792,6 +827,38 @@ func (a *Agent) CreateInvite(scope Scope, note string, validMinutes, maxUses int
|
||||
}
|
||||
}
|
||||
|
||||
// ExtendLease extends a device's access by minutes (approving its renewal requests) and returns
|
||||
// the new expiry
|
||||
func (a *Agent) ExtendLease(scope Scope, ip string, minutes int) (time.Time, error) {
|
||||
if !a.IsConnected() {
|
||||
return time.Time{}, fmt.Errorf("未连接中继服务器")
|
||||
}
|
||||
reqID := uuid.New().String()
|
||||
ch := make(chan protocol.ExtendLeaseRespMessage, 1)
|
||||
a.mu.Lock()
|
||||
a.extendWaiters[reqID] = ch
|
||||
a.mu.Unlock()
|
||||
defer func() {
|
||||
a.mu.Lock()
|
||||
delete(a.extendWaiters, reqID)
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
if err := a.sendControl(protocol.ExtendLeaseMessage{
|
||||
Type: protocol.TypeExtendLease, RequestID: reqID, TunnelID: scope.TunnelID, GroupID: scope.GroupID, IP: ip, Minutes: minutes,
|
||||
}); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
select {
|
||||
case resp := <-ch:
|
||||
if !resp.Success {
|
||||
return time.Time{}, fmt.Errorf("%s", resp.Error)
|
||||
}
|
||||
return resp.ExpiresAt, nil
|
||||
case <-time.After(5 * time.Second):
|
||||
return time.Time{}, fmt.Errorf("服务器未响应,请先把服务端更新到支持延长授权的版本")
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeInvite invalidates an invite link and the leases it granted
|
||||
func (a *Agent) RevokeInvite(scope Scope, code string) error {
|
||||
if !a.IsConnected() {
|
||||
@@ -859,5 +926,6 @@ func (a *Agent) GetLatestMetrics() protocol.MetricsMessage {
|
||||
m.Groups = append([]protocol.GroupMetrics(nil), m.Groups...)
|
||||
m.ActiveConns = append([]protocol.ActiveConnInfo(nil), m.ActiveConns...)
|
||||
m.SecurityEvents = append([]protocol.SecurityEvent(nil), m.SecurityEvents...)
|
||||
m.Renewals = append([]protocol.RenewalInfo(nil), m.Renewals...)
|
||||
return m
|
||||
}
|
||||
+106
-1
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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