feat: 通道组——一次开放多台主机 / 多个端口,共用邀请与授权
- 批量创建:主机支持 IP、主机名、末段范围(10.0.1.11-13)和 主机:端口, 与端口列表展开为 主机 × 端口,自动分配连续的访问端口,创建前可预览 - 授权以组为单位:一条邀请链接、一次审批即可访问组内全部地址; 计时从首次连上任一地址开始,撤销一次全组断开;后加入组的通道自动获得组授权 - 网关授权状态按作用范围(通道 / 通道组)存储,通道的动态白名单统一由状态库重新计算, 通道授权与组授权互不干扰;组内最后一个通道关闭或组被解散时,组的邀请与授权失效 - 闲置联动:组内任一地址在用时整组都不算闲置;支持整组开关 - 已有通道可并入组或移出组;组可改名、追加目标、解散(保留或删除成员) - App 与网页面板:组卡片、新建 / 编辑通道组、组邀请与“连接与授权”,授权页列出全部地址 - 测试:网关 9 个、Agent 5 个通道组用例
This commit is contained in:
1 parent
86e159b3f0
commit
3797bef88a
22 files changed
+3894
-471
No files matched your search
@@ -0,0 +1,259 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
)
|
||||
|
||||
// groupMember is a link-only tunnel that belongs to group "g"
|
||||
func groupMember(id string, port int, target string) protocol.TunnelConfig {
|
||||
cfg := tunnelCfg(id, port)
|
||||
cfg.Whitelist = nil
|
||||
cfg.Target = target
|
||||
cfg.GroupID = "g"
|
||||
cfg.GroupName = "订单服务"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (a *testAgent) createGroupInvite(t *testing.T, groupID string, lease string, approval bool) protocol.Invite {
|
||||
t.Helper()
|
||||
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "g1", GroupID: groupID, ValidMinutes: 60, MaxUses: 1, LeaseDuration: lease, RequireApproval: approval})
|
||||
var resp protocol.InviteRespMessage
|
||||
_ = json.Unmarshal(a.next(t), &resp)
|
||||
if !resp.Success {
|
||||
t.Fatalf("create group invite failed: %s", resp.Error)
|
||||
}
|
||||
return resp.Invite
|
||||
}
|
||||
|
||||
// setupGroup registers two members of group "g" plus one ungrouped link-only tunnel
|
||||
func setupGroup(t *testing.T) (*Manager, *testAgent, string, [3]int) {
|
||||
t.Helper()
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
ports := [3]int{freePort(t), freePort(t), freePort(t)}
|
||||
a.register(t, protocol.TypeRegisterTunnel, groupMember("m1", ports[0], "10.0.1.11:22"))
|
||||
a.register(t, protocol.TypeRegisterTunnel, groupMember("m2", ports[1], "10.0.1.12:22"))
|
||||
other := tunnelCfg("other", ports[2])
|
||||
other.Whitelist = nil
|
||||
a.register(t, protocol.TypeRegisterTunnel, other)
|
||||
return m, a, addr, ports
|
||||
}
|
||||
|
||||
// holdConn opens a client connection that stays up until the server closes it
|
||||
func holdConn(t *testing.T, port int) net.Conn {
|
||||
t.Helper()
|
||||
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { c.Close() })
|
||||
buf := make([]byte, 2)
|
||||
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, err := io.ReadFull(c, buf); err != nil {
|
||||
t.Fatalf("connection to %d should be allowed: %v", port, err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func expectClosed(t *testing.T, c net.Conn) {
|
||||
t.Helper()
|
||||
_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if _, err := c.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("connection should have been closed")
|
||||
} else if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
t.Fatal("connection still open after access was revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupInviteGrantsEveryMember(t *testing.T) {
|
||||
m, a, addr, p := setupGroup(t)
|
||||
if hit(p[0]) == "OK" {
|
||||
t.Fatal("link-only member must be closed before any grant")
|
||||
}
|
||||
inv := a.createGroupInvite(t, "g", "", false)
|
||||
|
||||
status, body := httpGet(t, addr, "/i/"+inv.Code, nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("invite page: %d %s", status, body)
|
||||
}
|
||||
for _, want := range []string{"目标通道组", "订单服务(2 个地址)", fmt.Sprintf("127.0.0.1:%d", p[0]), fmt.Sprintf("127.0.0.1:%d", p[1]), "10.0.1.11:22", "10.0.1.12:22"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("invite page lacks %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
if status, _, body := postForm(t, addr, inv.Code, "张三"); status != 200 || !strings.Contains(body, "任意一个访问地址") {
|
||||
t.Fatalf("grant failed: %d %s", status, body)
|
||||
}
|
||||
if hit(p[0]) != "OK" || hit(p[1]) != "OK" {
|
||||
t.Fatal("a group grant must open every member")
|
||||
}
|
||||
if hit(p[2]) == "OK" {
|
||||
t.Fatal("a group grant must not open tunnels outside the group")
|
||||
}
|
||||
if l := m.state.leaseList(groupScope("g")); len(l) != 1 || l[0].Name != "张三" {
|
||||
t.Fatalf("group lease not recorded: %+v", l)
|
||||
}
|
||||
if l := m.state.leaseList("m1"); len(l) != 0 {
|
||||
t.Fatalf("group grant must not create per-tunnel leases: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupLeaseStartsOnFirstConnectionToAnyMember(t *testing.T) {
|
||||
m, a, addr, p := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", protocol.LeaseDuration2h, false)
|
||||
postForm(t, addr, inv.Code, "张三")
|
||||
if l := m.state.leaseList(groupScope("g")); len(l) != 1 || !l[0].AwaitingConnect {
|
||||
t.Fatalf("lease should wait for the first connection: %+v", l)
|
||||
}
|
||||
|
||||
if hit(p[1]) != "OK" {
|
||||
t.Fatal("member 2 should be reachable")
|
||||
}
|
||||
waitFor(t, func() bool {
|
||||
l := m.state.leaseList(groupScope("g"))
|
||||
return len(l) == 1 && !l[0].AwaitingConnect
|
||||
})
|
||||
exp := m.state.leaseList(groupScope("g"))[0].ExpiresAt
|
||||
if d := time.Until(exp); d < 119*time.Minute || d > 121*time.Minute {
|
||||
t.Fatalf("countdown should be 2h from first connection, got %v", d)
|
||||
}
|
||||
// 其它成员的放通随之更新为同一到期时间
|
||||
if got := m.tunnel("m1").dynamicLeases()["127.0.0.1"]; !got.Equal(exp) {
|
||||
t.Fatalf("member 1 expiry %v, want %v", got, exp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupRevokeDisconnectsAllMembers(t *testing.T) {
|
||||
_, a, addr, p := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", "", false)
|
||||
postForm(t, addr, inv.Code, "张三")
|
||||
c1, c2 := holdConn(t, p[0]), holdConn(t, p[1])
|
||||
|
||||
a.send(t, protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, GroupID: "g"})
|
||||
expectClosed(t, c1)
|
||||
expectClosed(t, c2)
|
||||
if hit(p[0]) == "OK" || hit(p[1]) == "OK" {
|
||||
t.Fatal("members still open after revoking the group grant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupApprovalFlow(t *testing.T) {
|
||||
m, a, addr, p := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", "", true)
|
||||
|
||||
status, loc, _ := postForm(t, addr, inv.Code, "李四")
|
||||
if status != 303 || !strings.HasPrefix(loc, "/auth/status?id=") {
|
||||
t.Fatalf("approval invite should redirect to the status page: %d %q", status, loc)
|
||||
}
|
||||
pending := m.state.pendingList(groupScope("g"))
|
||||
if len(pending) != 1 || pending[0].GroupID != "g" || pending[0].TunnelID != "" {
|
||||
t.Fatalf("pending request should belong to the group: %+v", pending)
|
||||
}
|
||||
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "等待管理员批准") || !strings.Contains(body, "10.0.1.12:22") {
|
||||
t.Fatalf("pending page: %s", body)
|
||||
}
|
||||
if hit(p[0]) == "OK" {
|
||||
t.Fatal("must stay closed until approved")
|
||||
}
|
||||
|
||||
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, GroupID: "g", RequestID: pending[0].ID, Approve: true})
|
||||
waitFor(t, func() bool { return hit(p[0]) == "OK" && hit(p[1]) == "OK" })
|
||||
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "申请已批准") {
|
||||
t.Fatalf("status page after approval: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupStateDroppedWhenLastMemberCloses(t *testing.T) {
|
||||
_, a, addr, _ := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", "", false)
|
||||
|
||||
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "m1"})
|
||||
a.next(t)
|
||||
if status, body := httpGet(t, addr, "/i/"+inv.Code, nil); status != 200 || !strings.Contains(body, "(1 个地址)") {
|
||||
t.Fatalf("invite should still work with one open member: %d", status)
|
||||
}
|
||||
|
||||
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "m2"})
|
||||
a.next(t)
|
||||
if status, _ := httpGet(t, addr, "/i/"+inv.Code, nil); status != 404 {
|
||||
t.Fatalf("invite must be gone once every member is closed, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMembershipChangesFollowAccess(t *testing.T) {
|
||||
_, a, addr, p := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", "", false)
|
||||
postForm(t, addr, inv.Code, "张三")
|
||||
c2 := holdConn(t, p[1])
|
||||
|
||||
// 移出组:组授权不再适用,现有连接断开
|
||||
out := groupMember("m2", p[1], "10.0.1.12:22")
|
||||
out.GroupID, out.GroupName = "", ""
|
||||
if resp := a.register(t, protocol.TypeUpdateTunnel, out); !resp.Success {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
expectClosed(t, c2)
|
||||
if hit(p[1]) == "OK" || hit(p[0]) != "OK" {
|
||||
t.Fatal("only remaining members keep the group grant")
|
||||
}
|
||||
|
||||
// 后加入组的通道自动获得组授权
|
||||
in := groupMember("other", p[2], "10.0.1.13:22")
|
||||
if resp := a.register(t, protocol.TypeUpdateTunnel, in); !resp.Success {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
if hit(p[2]) != "OK" {
|
||||
t.Fatal("a tunnel joining the group should get the group grant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelAndGroupLeasesAreIndependent(t *testing.T) {
|
||||
m, _, _, p := setupGroup(t)
|
||||
exp := time.Now().Add(time.Hour)
|
||||
grantTestLease(m, "m1", "127.0.0.1", exp)
|
||||
grantTestLease(m, groupScope("g"), "127.0.0.1", exp)
|
||||
|
||||
m.revokeLease(groupScope("g"), "127.0.0.1")
|
||||
if hit(p[0]) != "OK" {
|
||||
t.Fatal("the per-tunnel grant must survive revoking the group grant")
|
||||
}
|
||||
if hit(p[1]) == "OK" {
|
||||
t.Fatal("member without its own grant must close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGroupDropsSharedAccess(t *testing.T) {
|
||||
m, a, addr, p := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", "", false)
|
||||
postForm(t, addr, inv.Code, "张三")
|
||||
|
||||
a.send(t, protocol.DeleteGroupMessage{Type: protocol.TypeDeleteGroup, GroupID: "g"})
|
||||
waitFor(t, func() bool { return hit(p[0]) != "OK" })
|
||||
if _, _, ok := m.state.findInvite(inv.Code); ok {
|
||||
t.Fatal("invites of a dissolved group must be dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupInviteNeedsAnOpenMember(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "x", GroupID: "empty", ValidMinutes: 60, MaxUses: 1})
|
||||
var resp protocol.InviteRespMessage
|
||||
_ = json.Unmarshal(a.next(t), &resp)
|
||||
if resp.Success || !strings.Contains(resp.Error, "通道组") {
|
||||
t.Fatalf("invite for a group without open tunnels should fail: %+v", resp)
|
||||
}
|
||||
}
|
||||
+179
-42
@@ -10,6 +10,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -252,7 +253,7 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
|
||||
case protocol.TypeRevokeLease:
|
||||
var msg protocol.RevokeLeaseMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.revokeLease(msg.TunnelID, msg.IP)
|
||||
m.revokeLease(scopeOf(msg.TunnelID, msg.GroupID), msg.IP)
|
||||
}
|
||||
|
||||
case protocol.TypeCreateInvite:
|
||||
@@ -264,13 +265,19 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
|
||||
case protocol.TypeRevokeInvite:
|
||||
var msg protocol.RevokeInviteMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.revokeInvite(msg.TunnelID, msg.Code)
|
||||
m.revokeInvite(scopeOf(msg.TunnelID, msg.GroupID), msg.Code)
|
||||
}
|
||||
|
||||
case protocol.TypeDecideRequest:
|
||||
var msg protocol.DecideRequestMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.decideRequest(msg.TunnelID, msg.RequestID, msg.Approve)
|
||||
m.decideRequest(scopeOf(msg.TunnelID, msg.GroupID), msg.RequestID, msg.Approve)
|
||||
}
|
||||
|
||||
case protocol.TypeDeleteGroup:
|
||||
var msg protocol.DeleteGroupMessage
|
||||
if json.Unmarshal(raw, &msg) == nil && msg.GroupID != "" {
|
||||
m.deleteGroup(msg.GroupID)
|
||||
}
|
||||
|
||||
case protocol.TypeKillConn:
|
||||
@@ -330,12 +337,13 @@ func (m *Manager) registerTunnel(as *agentSession, cfg protocol.TunnelConfig) pr
|
||||
if err != nil {
|
||||
return registerResp(cfg, err)
|
||||
}
|
||||
if old == nil {
|
||||
// 服务端重启或 Agent 重连后,恢复该通道尚未过期的临时放通
|
||||
t.restoreDynamicLeases(m.state.leaseExpiries(cfg.ID))
|
||||
}
|
||||
m.tunnels[cfg.ID] = t
|
||||
m.portMap[cfg.ListenPort] = cfg.ID
|
||||
// 服务端重启或 Agent 重连后,按授权状态恢复该通道(及其所在组)尚未过期的临时放通
|
||||
m.syncAccess(t)
|
||||
if old != nil && old.GroupID() != cfg.GroupID {
|
||||
m.forgetGroupIfEmptyLocked(old.GroupID())
|
||||
}
|
||||
|
||||
log.Printf("[Server] Registered tunnel '%s' listening on 0.0.0.0:%d", cfg.Name, cfg.ListenPort)
|
||||
return registerResp(cfg, nil)
|
||||
@@ -348,12 +356,14 @@ func (m *Manager) unregisterTunnel(as *agentSession, tunnelID string) {
|
||||
return
|
||||
}
|
||||
// 主动关闭/删除通道时,放通、邀请链接与待审批申请一并失效(断线重连不会走到这里)
|
||||
m.state.forgetTunnel(tunnelID)
|
||||
m.state.forgetScope(tunnelID)
|
||||
if t, exists := m.tunnels[tunnelID]; exists {
|
||||
t.Close()
|
||||
delete(m.portMap, t.ListenPort())
|
||||
delete(m.tunnels, tunnelID)
|
||||
log.Printf("[Server] Unregistered tunnel '%s' (port %d freed)", t.Name(), t.ListenPort())
|
||||
// 组内最后一个开启的通道关闭后,组的邀请与授权一并失效(与单个通道关闭的语义一致)
|
||||
m.forgetGroupIfEmptyLocked(t.GroupID())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,8 +377,13 @@ func (m *Manager) updateTunnel(as *agentSession, cfg protocol.TunnelConfig) prot
|
||||
}
|
||||
defer m.mu.Unlock()
|
||||
|
||||
prevGroup := t.GroupID()
|
||||
if t.ListenPort() == cfg.ListenPort {
|
||||
t.UpdateConfig(cfg)
|
||||
m.syncAccess(t)
|
||||
if prevGroup != cfg.GroupID {
|
||||
m.forgetGroupIfEmptyLocked(prevGroup)
|
||||
}
|
||||
log.Printf("[Server] Updated tunnel '%s' whitelist/metadata", cfg.Name)
|
||||
return registerResp(cfg, nil)
|
||||
}
|
||||
@@ -384,6 +399,10 @@ func (m *Manager) updateTunnel(as *agentSession, cfg protocol.TunnelConfig) prot
|
||||
delete(m.portMap, t.ListenPort())
|
||||
m.tunnels[cfg.ID] = nt
|
||||
m.portMap[cfg.ListenPort] = cfg.ID
|
||||
m.syncAccess(nt)
|
||||
if prevGroup != cfg.GroupID {
|
||||
m.forgetGroupIfEmptyLocked(prevGroup)
|
||||
}
|
||||
log.Printf("[Server] Moved tunnel '%s' from port %d to %d", cfg.Name, t.ListenPort(), cfg.ListenPort)
|
||||
return registerResp(cfg, nil)
|
||||
}
|
||||
@@ -399,21 +418,15 @@ func (m *Manager) killConn(connID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) revokeLease(tunnelID, ip string) {
|
||||
m.mu.RLock()
|
||||
t := m.tunnels[tunnelID]
|
||||
m.mu.RUnlock()
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
revoked, kicked := t.RevokeDynamic(ip)
|
||||
m.state.removeLease(tunnelID, ip)
|
||||
func (m *Manager) revokeLease(scope, ip string) {
|
||||
revoked := m.state.removeLease(scope, ip)
|
||||
kicked := m.syncScope(scope)
|
||||
target := ip
|
||||
if target == "" {
|
||||
target = "all"
|
||||
}
|
||||
log.Printf("[WebAuth] Revoked %d temporary lease(s) (%s) on tunnel %s, disconnected %d client(s)",
|
||||
revoked, target, t.Name(), kicked)
|
||||
log.Printf("[WebAuth] Revoked %d temporary lease(s) (%s) on %s, disconnected %d client(s)",
|
||||
revoked, target, m.scopeName(scope), kicked)
|
||||
}
|
||||
|
||||
func (m *Manager) tunnel(id string) *Tunnel {
|
||||
@@ -422,11 +435,117 @@ func (m *Manager) tunnel(id string) *Tunnel {
|
||||
return m.tunnels[id]
|
||||
}
|
||||
|
||||
// ---- 作用范围:单个通道或通道组 ----
|
||||
|
||||
// groupMembersLocked returns the open tunnels of a group ordered by port. Caller must hold m.mu.
|
||||
func (m *Manager) groupMembersLocked(groupID string) []*Tunnel {
|
||||
if groupID == "" {
|
||||
return nil
|
||||
}
|
||||
var out []*Tunnel
|
||||
for _, t := range m.tunnels {
|
||||
if t.GroupID() == groupID {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ListenPort() < out[j].ListenPort() })
|
||||
return out
|
||||
}
|
||||
|
||||
// scopeTunnels returns the open tunnels an access scope applies to
|
||||
func (m *Manager) scopeTunnels(scope string) []*Tunnel {
|
||||
id, isGroup := splitScope(scope)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if isGroup {
|
||||
return m.groupMembersLocked(id)
|
||||
}
|
||||
if t := m.tunnels[id]; t != nil {
|
||||
return []*Tunnel{t}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scopeName is a display name for logs and pages
|
||||
func (m *Manager) scopeName(scope string) string {
|
||||
tunnels := m.scopeTunnels(scope)
|
||||
if len(tunnels) == 0 {
|
||||
return scope
|
||||
}
|
||||
if _, isGroup := splitScope(scope); isGroup {
|
||||
if name := tunnels[0].Snapshot().GroupName; name != "" {
|
||||
return name
|
||||
}
|
||||
return "通道组"
|
||||
}
|
||||
return tunnels[0].Name()
|
||||
}
|
||||
|
||||
// scopeExpiresOf caps leases by how long the scope stays open: a tunnel's own TTL, or for a
|
||||
// group the TTL of its longest-lived member (nil when any member has no TTL)
|
||||
func scopeExpiresOf(tunnels []*Tunnel) *time.Time {
|
||||
var latest *time.Time
|
||||
for _, t := range tunnels {
|
||||
exp := t.Snapshot().ExpiresAt
|
||||
if exp == nil {
|
||||
return nil
|
||||
}
|
||||
if latest == nil || exp.After(*latest) {
|
||||
v := *exp
|
||||
latest = &v
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
// 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 {
|
||||
leases := m.state.leaseExpiries(t.ID())
|
||||
if gid := t.GroupID(); gid != "" {
|
||||
for ip, exp := range m.state.leaseExpiries(groupScope(gid)) {
|
||||
if cur, ok := leases[ip]; !ok || exp.After(cur) {
|
||||
leases[ip] = exp
|
||||
}
|
||||
}
|
||||
}
|
||||
return t.SetDynamic(leases)
|
||||
}
|
||||
|
||||
// syncScope re-applies the access state to every open tunnel of a scope
|
||||
func (m *Manager) syncScope(scope string) int {
|
||||
kicked := 0
|
||||
for _, t := range m.scopeTunnels(scope) {
|
||||
kicked += m.syncAccess(t)
|
||||
}
|
||||
return kicked
|
||||
}
|
||||
|
||||
// forgetGroupIfEmptyLocked drops a group's shared access state once none of its tunnels is open.
|
||||
// Caller must hold m.mu.
|
||||
func (m *Manager) forgetGroupIfEmptyLocked(groupID string) {
|
||||
if groupID == "" || len(m.groupMembersLocked(groupID)) > 0 {
|
||||
return
|
||||
}
|
||||
m.state.forgetScope(groupScope(groupID))
|
||||
}
|
||||
|
||||
// deleteGroup drops a dissolved group's invites, leases and requests
|
||||
func (m *Manager) deleteGroup(groupID string) {
|
||||
scope := groupScope(groupID)
|
||||
m.state.forgetScope(scope)
|
||||
kicked := m.syncScope(scope)
|
||||
log.Printf("[WebAuth] Tunnel group %s dissolved, disconnected %d client(s)", groupID, kicked)
|
||||
}
|
||||
|
||||
func (m *Manager) createInvite(msg protocol.CreateInviteMessage) protocol.InviteRespMessage {
|
||||
resp := protocol.InviteRespMessage{Type: protocol.TypeInviteResp, RequestID: msg.RequestID}
|
||||
t := m.tunnel(msg.TunnelID)
|
||||
scope := scopeOf(msg.TunnelID, msg.GroupID)
|
||||
open := len(m.scopeTunnels(scope)) > 0
|
||||
switch {
|
||||
case t == nil:
|
||||
case !open && msg.GroupID != "":
|
||||
resp.Error = "通道组内没有开启的通道,无法生成邀请链接"
|
||||
case !open:
|
||||
resp.Error = "通道未开启,无法生成邀请链接"
|
||||
case msg.ValidMinutes <= 0 || msg.ValidMinutes > 30*24*60:
|
||||
resp.Error = "邀请有效期须在 1 分钟到 30 天之间"
|
||||
@@ -435,50 +554,53 @@ func (m *Manager) createInvite(msg protocol.CreateInviteMessage) protocol.Invite
|
||||
case !protocol.ValidLeaseDuration(msg.LeaseDuration):
|
||||
resp.Error = "无效的授权时长"
|
||||
default:
|
||||
resp.Invite = m.state.createInvite(msg.TunnelID, truncateRunes(msg.Note, 64), time.Duration(msg.ValidMinutes)*time.Minute, msg.MaxUses, msg.LeaseDuration, msg.RequireApproval)
|
||||
resp.Invite = m.state.createInvite(scope, truncateRunes(msg.Note, 64), time.Duration(msg.ValidMinutes)*time.Minute, msg.MaxUses, msg.LeaseDuration, msg.RequireApproval)
|
||||
resp.Success = true
|
||||
log.Printf("[WebAuth] Created invite for tunnel %s (valid %dm, max %d device(s))", t.Name(), msg.ValidMinutes, msg.MaxUses)
|
||||
log.Printf("[WebAuth] Created invite for %s (valid %dm, max %d device(s))", m.scopeName(scope), msg.ValidMinutes, msg.MaxUses)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (m *Manager) revokeInvite(tunnelID, code string) {
|
||||
ips := m.state.revokeInvite(tunnelID, code)
|
||||
kicked := 0
|
||||
if t := m.tunnel(tunnelID); t != nil {
|
||||
for _, ip := range ips {
|
||||
_, k := t.RevokeDynamic(ip)
|
||||
kicked += k
|
||||
}
|
||||
}
|
||||
log.Printf("[WebAuth] Revoked invite on tunnel %s: %d lease(s) removed, %d client(s) disconnected", tunnelID, len(ips), kicked)
|
||||
func (m *Manager) revokeInvite(scope, code string) {
|
||||
ips := m.state.revokeInvite(scope, code)
|
||||
kicked := m.syncScope(scope)
|
||||
log.Printf("[WebAuth] Revoked invite on %s: %d lease(s) removed, %d client(s) disconnected", m.scopeName(scope), len(ips), kicked)
|
||||
}
|
||||
|
||||
func (m *Manager) decideRequest(tunnelID, requestID string, approve bool) {
|
||||
t := m.tunnel(tunnelID)
|
||||
if t == nil {
|
||||
func (m *Manager) decideRequest(scope, requestID string, approve bool) {
|
||||
tunnels := m.scopeTunnels(scope)
|
||||
if len(tunnels) == 0 {
|
||||
return
|
||||
}
|
||||
r, exp, err := m.state.decide(tunnelID, requestID, approve, t.Snapshot().ExpiresAt)
|
||||
r, _, err := m.state.decide(scope, requestID, approve, scopeExpiresOf(tunnels))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if approve {
|
||||
t.AddDynamicWhitelist(r.IP, exp)
|
||||
m.syncScope(scope)
|
||||
}
|
||||
log.Printf("[WebAuth] Request from %s (%s) on tunnel %s: %s", r.Name, r.IP, t.Name(), r.Status)
|
||||
log.Printf("[WebAuth] Request from %s (%s) on %s: %s", r.Name, r.IP, m.scopeName(scope), r.Status)
|
||||
}
|
||||
|
||||
// onClientConnect starts the countdown of a device's lease on its first connection
|
||||
// onClientConnect starts the countdown of a device's lease on its first connection.
|
||||
// 组授权在首次连上组内任意一个通道时开始计时。
|
||||
func (m *Manager) onClientConnect(tunnelID, ip string) {
|
||||
t := m.tunnel(tunnelID)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
if exp, started := m.state.startLease(tunnelID, ip, t.Snapshot().ExpiresAt, time.Now()); started {
|
||||
t.AddDynamicWhitelist(ip, exp)
|
||||
now := time.Now()
|
||||
if exp, started := m.state.startLease(t.ID(), ip, t.Snapshot().ExpiresAt, now); started {
|
||||
m.syncAccess(t)
|
||||
log.Printf("[WebAuth] %s connected for the first time on tunnel %s, access valid until %s", ip, t.Name(), exp.Format("01-02 15:04"))
|
||||
}
|
||||
if gid := t.GroupID(); gid != "" {
|
||||
scope := groupScope(gid)
|
||||
if exp, started := m.state.startLease(scope, ip, scopeExpiresOf(m.scopeTunnels(scope)), now); started {
|
||||
m.syncScope(scope)
|
||||
log.Printf("[WebAuth] %s connected for the first time in group %s, access valid until %s", ip, m.scopeName(scope), exp.Format("01-02 15:04"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
@@ -516,7 +638,11 @@ func (m *Manager) metricsLoop(as *agentSession) {
|
||||
}
|
||||
tunnelMetricsList := make([]protocol.TunnelMetrics, 0, len(m.tunnels))
|
||||
allActiveConns := make([]protocol.ActiveConnInfo, 0)
|
||||
groupIDs := make(map[string]bool)
|
||||
for _, t := range m.tunnels {
|
||||
if gid := t.GroupID(); gid != "" {
|
||||
groupIDs[gid] = true
|
||||
}
|
||||
// 授权到期(或白名单变更)后,已建立的连接也要断开,“授权后可访问 X”才名副其实
|
||||
for _, addr := range t.EnforceAccess() {
|
||||
log.Printf("[Tunnel %d] access expired, disconnected %s", t.ListenPort(), addr)
|
||||
@@ -529,6 +655,16 @@ func (m *Manager) metricsLoop(as *agentSession) {
|
||||
tunnelMetricsList = append(tunnelMetricsList, tm)
|
||||
allActiveConns = append(allActiveConns, conns...)
|
||||
}
|
||||
groupMetrics := make([]protocol.GroupMetrics, 0, len(groupIDs))
|
||||
for gid := range groupIDs {
|
||||
scope := groupScope(gid)
|
||||
groupMetrics = append(groupMetrics, protocol.GroupMetrics{
|
||||
GroupID: gid,
|
||||
DynamicLeases: m.state.leaseList(scope),
|
||||
Invites: m.state.inviteList(scope),
|
||||
Pending: m.state.pendingList(scope),
|
||||
})
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.secEventsMu.Lock()
|
||||
@@ -539,6 +675,7 @@ func (m *Manager) metricsLoop(as *agentSession) {
|
||||
as.send(protocol.MetricsMessage{
|
||||
Type: protocol.TypeMetrics,
|
||||
Tunnels: tunnelMetricsList,
|
||||
Groups: groupMetrics,
|
||||
ActiveConns: allActiveConns,
|
||||
SecurityEvents: secCopy,
|
||||
})
|
||||
|
||||
+10
-5
@@ -326,6 +326,14 @@ func TestStaleSessionTeardownKeepsNewSessionTunnels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// grantTestLease stores a running lease in the access state and applies it to the scope's tunnels
|
||||
func grantTestLease(m *Manager, scope, ip string, exp time.Time) {
|
||||
m.state.mu.Lock()
|
||||
m.state.scopeLocked(scope).Leases[ip] = leaseInfo{ExpiresAt: exp}
|
||||
m.state.mu.Unlock()
|
||||
m.syncScope(scope)
|
||||
}
|
||||
|
||||
func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
@@ -339,7 +347,7 @@ func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
|
||||
m.mu.RLock()
|
||||
tun := m.tunnels["a"]
|
||||
m.mu.RUnlock()
|
||||
tun.AddDynamicWhitelist("10.1.2.3", time.Now().Add(time.Hour))
|
||||
grantTestLease(m, "a", "10.1.2.3", time.Now().Add(time.Hour))
|
||||
|
||||
cfg.Name = "renamed"
|
||||
cfg.Whitelist = []string{"127.0.0.1"}
|
||||
@@ -623,10 +631,7 @@ func TestExpiredLeaseDisconnectsActiveConnection(t *testing.T) {
|
||||
cfg.Whitelist = nil
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
|
||||
m.mu.RLock()
|
||||
tun := m.tunnels["a"]
|
||||
m.mu.RUnlock()
|
||||
tun.AddDynamicWhitelist("127.0.0.1", time.Now().Add(1500*time.Millisecond))
|
||||
grantTestLease(m, "a", "127.0.0.1", time.Now().Add(1500*time.Millisecond))
|
||||
|
||||
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.ListenPort))
|
||||
if err != nil {
|
||||
|
||||
+88
-65
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -42,27 +43,27 @@ type leaseInfo struct {
|
||||
|
||||
// newLease creates a lease whose countdown starts at the device's first connection.
|
||||
// Leases valid for the whole tunnel lifetime start immediately.
|
||||
func newLease(name, invite, duration string, tunnelExpires *time.Time, now time.Time) leaseInfo {
|
||||
func newLease(name, invite, duration string, scopeExpires *time.Time, now time.Time) leaseInfo {
|
||||
l := leaseInfo{Name: name, Invite: invite, Duration: duration}
|
||||
if duration == protocol.LeaseDurationTunnel {
|
||||
l.StartedAt = &now
|
||||
l.ExpiresAt = leaseExpiry(duration, tunnelExpires, now)
|
||||
l.ExpiresAt = leaseExpiry(duration, scopeExpires, now)
|
||||
return l
|
||||
}
|
||||
l.Deferred = true
|
||||
l.ExpiresAt = now.Add(leaseStartWindow)
|
||||
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(l.ExpiresAt) {
|
||||
l.ExpiresAt = *tunnelExpires
|
||||
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(l.ExpiresAt) {
|
||||
l.ExpiresAt = *scopeExpires
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// startLease starts the countdown of a deferred lease on the device's first connection.
|
||||
// Returns the new expiry and true when the lease was started now.
|
||||
func (s *stateStore) startLease(tunnelID, ip string, tunnelExpires *time.Time, now time.Time) (time.Time, bool) {
|
||||
func (s *stateStore) startLease(scope, ip string, scopeExpires *time.Time, now time.Time) (time.Time, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -72,16 +73,16 @@ func (s *stateStore) startLease(tunnelID, ip string, tunnelExpires *time.Time, n
|
||||
}
|
||||
l.Deferred = false
|
||||
l.StartedAt = &now
|
||||
l.ExpiresAt = leaseExpiry(l.Duration, tunnelExpires, now)
|
||||
l.ExpiresAt = leaseExpiry(l.Duration, scopeExpires, now)
|
||||
st.Leases[ip] = l
|
||||
s.saveLocked()
|
||||
return l.ExpiresAt, true
|
||||
}
|
||||
|
||||
func (s *stateStore) lease(tunnelID, ip string) (leaseInfo, bool) {
|
||||
func (s *stateStore) lease(scope, ip string) (leaseInfo, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
if st := s.data[scope]; st != nil {
|
||||
l, ok := st.Leases[ip]
|
||||
return l, ok && time.Now().Before(l.ExpiresAt)
|
||||
}
|
||||
@@ -94,12 +95,33 @@ type tunnelState struct {
|
||||
Requests map[string]*protocol.AccessRequest `json:"requests,omitempty"` // id -> request
|
||||
}
|
||||
|
||||
// stateStore keeps per-tunnel access state (leases, invites, approval requests).
|
||||
// 授权状态的作用范围:单个通道(键为通道 ID,与旧数据兼容)或整个通道组(键为 "group:<组ID>")
|
||||
const groupScopePrefix = "group:"
|
||||
|
||||
func groupScope(groupID string) string { return groupScopePrefix + groupID }
|
||||
|
||||
// scopeOf returns the state key of a tunnel, or of its group when groupID is set
|
||||
func scopeOf(tunnelID, groupID string) string {
|
||||
if groupID != "" {
|
||||
return groupScope(groupID)
|
||||
}
|
||||
return tunnelID
|
||||
}
|
||||
|
||||
// splitScope returns the tunnel or group ID of a scope and whether it is a group scope
|
||||
func splitScope(scope string) (id string, isGroup bool) {
|
||||
if strings.HasPrefix(scope, groupScopePrefix) {
|
||||
return strings.TrimPrefix(scope, groupScopePrefix), true
|
||||
}
|
||||
return scope, false
|
||||
}
|
||||
|
||||
// stateStore keeps per-scope access state (leases, invites, approval requests).
|
||||
// It lives in memory and is optionally persisted to a file so it survives restarts.
|
||||
type stateStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
data map[string]*tunnelState // tunnelID -> state
|
||||
data map[string]*tunnelState // scope(通道 ID 或 group:<组ID>)-> state
|
||||
}
|
||||
|
||||
func newStateStore() *stateStore {
|
||||
@@ -116,7 +138,7 @@ func (m *Manager) EnableLeasePersistence(path string) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
if err := json.Unmarshal(raw, &data); err != nil || !looksLikeState(data) {
|
||||
// 兼容旧格式:tunnelID -> ip -> 到期时间
|
||||
// 兼容旧格式:通道 ID -> ip -> 到期时间
|
||||
var legacy map[string]map[string]time.Time
|
||||
if json.Unmarshal(raw, &legacy) == nil {
|
||||
data = make(map[string]*tunnelState)
|
||||
@@ -162,7 +184,7 @@ func looksLikeState(d map[string]*tunnelState) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *stateStore) tunnelLocked(id string) *tunnelState {
|
||||
func (s *stateStore) scopeLocked(id string) *tunnelState {
|
||||
st := s.data[id]
|
||||
if st == nil {
|
||||
st = &tunnelState{}
|
||||
@@ -195,35 +217,32 @@ func randomCode(n int) string {
|
||||
|
||||
// ---- leases ----
|
||||
|
||||
func (s *stateStore) grantLease(tunnelID, ip string, exp time.Time, name, invite string) {
|
||||
// removeLease removes one lease, or all leases of the scope when ip is empty; returns how many were removed
|
||||
func (s *stateStore) removeLease(scope, ip string) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.tunnelLocked(tunnelID).Leases[ip] = leaseInfo{ExpiresAt: exp, Name: name, Invite: invite}
|
||||
s.saveLocked()
|
||||
}
|
||||
|
||||
// removeLease removes one lease, or all leases of the tunnel when ip is empty
|
||||
func (s *stateStore) removeLease(tunnelID, ip string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
if ip == "" {
|
||||
n = len(st.Leases)
|
||||
st.Leases = make(map[string]leaseInfo)
|
||||
} else {
|
||||
} else if _, ok := st.Leases[ip]; ok {
|
||||
n = 1
|
||||
delete(st.Leases, ip)
|
||||
}
|
||||
s.saveLocked()
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *stateStore) leaseExpiries(tunnelID string) map[string]time.Time {
|
||||
func (s *stateStore) leaseExpiries(scope string) map[string]time.Time {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
out := make(map[string]time.Time)
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
if st := s.data[scope]; st != nil {
|
||||
for ip, l := range st.Leases {
|
||||
if now.Before(l.ExpiresAt) {
|
||||
out[ip] = l.ExpiresAt
|
||||
@@ -233,12 +252,12 @@ func (s *stateStore) leaseExpiries(tunnelID string) map[string]time.Time {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
|
||||
func (s *stateStore) leaseList(scope string) []protocol.DynamicLease {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
var out []protocol.DynamicLease
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
if st := s.data[scope]; st != nil {
|
||||
for ip, l := range st.Leases {
|
||||
if now.Before(l.ExpiresAt) {
|
||||
out = append(out, protocol.DynamicLease{
|
||||
@@ -252,19 +271,19 @@ func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
|
||||
return out
|
||||
}
|
||||
|
||||
// forgetTunnel drops all access state of a tunnel (explicitly closed or deleted)
|
||||
func (s *stateStore) forgetTunnel(tunnelID string) {
|
||||
// forgetScope drops all access state of a tunnel or group (explicitly closed, deleted or dissolved)
|
||||
func (s *stateStore) forgetScope(scope string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.data[tunnelID]; ok {
|
||||
delete(s.data, tunnelID)
|
||||
if _, ok := s.data[scope]; ok {
|
||||
delete(s.data, scope)
|
||||
s.saveLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- invites ----
|
||||
|
||||
func (s *stateStore) createInvite(tunnelID, note string, valid time.Duration, maxUses int, leaseDuration string, requireApproval bool) protocol.Invite {
|
||||
func (s *stateStore) createInvite(scope, note string, valid time.Duration, maxUses int, leaseDuration string, requireApproval bool) protocol.Invite {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
@@ -280,12 +299,12 @@ func (s *stateStore) createInvite(tunnelID, note string, valid time.Duration, ma
|
||||
LeaseDuration: leaseDuration,
|
||||
RequireApproval: requireApproval,
|
||||
}
|
||||
s.tunnelLocked(tunnelID).Invites[inv.Code] = inv
|
||||
s.scopeLocked(scope).Invites[inv.Code] = inv
|
||||
s.saveLocked()
|
||||
return *inv
|
||||
}
|
||||
|
||||
// findInvite returns the tunnel owning an unexpired invite
|
||||
// findInvite returns the scope (tunnel or group) owning an unexpired invite
|
||||
func (s *stateStore) findInvite(code string) (string, protocol.Invite, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -317,10 +336,10 @@ func capacityOKLocked(st *tunnelState, inv *protocol.Invite, ip string) bool {
|
||||
}
|
||||
|
||||
// checkInvite validates that ip may use the invite right now
|
||||
func (s *stateStore) checkInvite(tunnelID, code, ip string) error {
|
||||
func (s *stateStore) checkInvite(scope, code, ip string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return errInviteNotFound
|
||||
}
|
||||
@@ -349,10 +368,10 @@ func (s *stateStore) recordUseLocked(st *tunnelState, code, name, ip string) {
|
||||
}
|
||||
|
||||
// useInviteAndGrant records the use and stores the lease atomically; returns the lease
|
||||
func (s *stateStore) useInviteAndGrant(tunnelID, code, name, ip string, tunnelExpires *time.Time) (leaseInfo, error) {
|
||||
func (s *stateStore) useInviteAndGrant(scope, code, name, ip string, scopeExpires *time.Time) (leaseInfo, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return leaseInfo{}, errInviteNotFound
|
||||
}
|
||||
@@ -363,19 +382,19 @@ func (s *stateStore) useInviteAndGrant(tunnelID, code, name, ip string, tunnelEx
|
||||
if !capacityOKLocked(st, inv, ip) {
|
||||
return leaseInfo{}, errInviteUsedUp
|
||||
}
|
||||
st = s.tunnelLocked(tunnelID)
|
||||
st = s.scopeLocked(scope)
|
||||
s.recordUseLocked(st, code, name, ip)
|
||||
l := newLease(name, code, inv.LeaseDuration, tunnelExpires, time.Now())
|
||||
l := newLease(name, code, inv.LeaseDuration, scopeExpires, time.Now())
|
||||
st.Leases[ip] = l
|
||||
s.saveLocked()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// revokeInvite removes the invite and the leases it granted; returns the affected IPs
|
||||
func (s *stateStore) revokeInvite(tunnelID, code string) []string {
|
||||
func (s *stateStore) revokeInvite(scope, code string) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -396,12 +415,12 @@ func (s *stateStore) revokeInvite(tunnelID, code string) []string {
|
||||
return ips
|
||||
}
|
||||
|
||||
func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
|
||||
func (s *stateStore) inviteList(scope string) []protocol.Invite {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
var out []protocol.Invite
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
if st := s.data[scope]; st != nil {
|
||||
for _, inv := range st.Invites {
|
||||
if now.Before(inv.ExpiresAt) {
|
||||
c := *inv
|
||||
@@ -417,10 +436,10 @@ func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
|
||||
// ---- approval requests ----
|
||||
|
||||
// addRequest files a pending request (re-using an existing pending one from the same IP)
|
||||
func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.AccessRequest, error) {
|
||||
func (s *stateStore) addRequest(scope, code, name, ip string) (protocol.AccessRequest, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return protocol.AccessRequest{}, errInviteNotFound
|
||||
}
|
||||
@@ -428,7 +447,7 @@ func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.Acces
|
||||
if !ok || !time.Now().Before(inv.ExpiresAt) {
|
||||
return protocol.AccessRequest{}, errInviteNotFound
|
||||
}
|
||||
st = s.tunnelLocked(tunnelID)
|
||||
st = s.scopeLocked(scope)
|
||||
for _, r := range st.Requests {
|
||||
if r.IP == ip && r.Status == "pending" {
|
||||
r.Name = name
|
||||
@@ -440,13 +459,17 @@ func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.Acces
|
||||
return protocol.AccessRequest{}, errInviteUsedUp
|
||||
}
|
||||
r := &protocol.AccessRequest{
|
||||
ID: randomCode(16),
|
||||
TunnelID: tunnelID,
|
||||
Name: name,
|
||||
IP: ip,
|
||||
Invite: code,
|
||||
At: time.Now(),
|
||||
Status: "pending",
|
||||
ID: randomCode(16),
|
||||
Name: name,
|
||||
IP: ip,
|
||||
Invite: code,
|
||||
At: time.Now(),
|
||||
Status: "pending",
|
||||
}
|
||||
if id, isGroup := splitScope(scope); isGroup {
|
||||
r.GroupID = id
|
||||
} else {
|
||||
r.TunnelID = id
|
||||
}
|
||||
st.Requests[r.ID] = r
|
||||
s.saveLocked()
|
||||
@@ -467,11 +490,11 @@ func (s *stateStore) request(id string) (protocol.AccessRequest, bool) {
|
||||
|
||||
// decide marks a pending request; on approval it records the invite use and stores the lease,
|
||||
// lasting as long as the originating invite specifies (capped by the tunnel's own TTL).
|
||||
func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *time.Time) (protocol.AccessRequest, time.Time, error) {
|
||||
func (s *stateStore) decide(scope, id string, approve bool, scopeExpires *time.Time) (protocol.AccessRequest, time.Time, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneRequestsLocked()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return protocol.AccessRequest{}, time.Time{}, errRequestGone
|
||||
}
|
||||
@@ -479,12 +502,12 @@ func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *ti
|
||||
if !ok || r.Status != "pending" {
|
||||
return protocol.AccessRequest{}, time.Time{}, errRequestGone
|
||||
}
|
||||
st = s.tunnelLocked(tunnelID)
|
||||
st = s.scopeLocked(scope)
|
||||
duration := ""
|
||||
if inv, ok := st.Invites[r.Invite]; ok {
|
||||
duration = inv.LeaseDuration
|
||||
}
|
||||
l := newLease(r.Name, r.Invite, duration, tunnelExpires, time.Now())
|
||||
l := newLease(r.Name, r.Invite, duration, scopeExpires, time.Now())
|
||||
exp := l.ExpiresAt
|
||||
r.At = time.Now()
|
||||
if approve {
|
||||
@@ -498,12 +521,12 @@ func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *ti
|
||||
return *r, exp, nil
|
||||
}
|
||||
|
||||
func (s *stateStore) pendingList(tunnelID string) []protocol.AccessRequest {
|
||||
func (s *stateStore) pendingList(scope string) []protocol.AccessRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneRequestsLocked()
|
||||
var out []protocol.AccessRequest
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
if st := s.data[scope]; st != nil {
|
||||
for _, r := range st.Requests {
|
||||
if r.Status == "pending" {
|
||||
out = append(out, *r)
|
||||
@@ -581,7 +604,7 @@ func (s *stateStore) saveLocked() {
|
||||
|
||||
// leaseExpiry computes when a lease granted now ends, per the invite's lease duration,
|
||||
// never outliving the tunnel's own TTL.
|
||||
func leaseExpiry(duration string, tunnelExpires *time.Time, now time.Time) time.Time {
|
||||
func leaseExpiry(duration string, scopeExpires *time.Time, now time.Time) time.Time {
|
||||
var exp time.Time
|
||||
switch duration {
|
||||
case protocol.LeaseDuration1h:
|
||||
@@ -597,8 +620,8 @@ func leaseExpiry(duration string, tunnelExpires *time.Time, now time.Time) time.
|
||||
default:
|
||||
exp = now.Add(2 * time.Hour)
|
||||
}
|
||||
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(exp) {
|
||||
exp = *tunnelExpires
|
||||
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(exp) {
|
||||
exp = *scopeExpires
|
||||
}
|
||||
return exp
|
||||
}
|
||||
+19
-4
@@ -118,13 +118,28 @@ func (t *Tunnel) ID() string {
|
||||
return t.Config.ID
|
||||
}
|
||||
|
||||
func (t *Tunnel) restoreDynamicLeases(leases map[string]time.Time) {
|
||||
if len(leases) == 0 {
|
||||
return
|
||||
// SetDynamic replaces the temporary leases with the given set and disconnects
|
||||
// active clients that are no longer permitted. Returns how many were disconnected.
|
||||
func (t *Tunnel) SetDynamic(leases map[string]time.Time) int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.whitelist.ReplaceDynamic(leases)
|
||||
kicked := 0
|
||||
for id, ac := range t.activeConns {
|
||||
if ok, _ := t.whitelist.IsAllowed(ac.ClientIP); !ok {
|
||||
_ = ac.Conn.Close()
|
||||
delete(t.activeConns, id)
|
||||
kicked++
|
||||
}
|
||||
}
|
||||
return kicked
|
||||
}
|
||||
|
||||
// GroupID returns the tunnel group this tunnel belongs to ("" when none)
|
||||
func (t *Tunnel) GroupID() string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
t.whitelist.RestoreDynamic(leases)
|
||||
return t.Config.GroupID
|
||||
}
|
||||
|
||||
// Name returns the tunnel display name
|
||||
|
||||
+89
-40
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
@@ -58,31 +59,61 @@ func (m *Manager) accessHost(r *http.Request) string {
|
||||
return r.Host
|
||||
}
|
||||
|
||||
// inviteTarget resolves an invite code to its (currently registered) tunnel
|
||||
func (m *Manager) inviteTarget(code string) (*Tunnel, protocol.Invite, string) {
|
||||
// accessScope is what an invite or request grants access to: one tunnel or a whole tunnel group
|
||||
type accessScope struct {
|
||||
key string
|
||||
group bool
|
||||
name string
|
||||
tunnels []protocol.TunnelConfig // 当前开启的通道,按端口排序
|
||||
expires *time.Time
|
||||
}
|
||||
|
||||
// resolveScope looks up the open tunnels of a scope; false when none is open
|
||||
func (m *Manager) resolveScope(scope string) (accessScope, bool) {
|
||||
tunnels := m.scopeTunnels(scope)
|
||||
if len(tunnels) == 0 {
|
||||
return accessScope{}, false
|
||||
}
|
||||
_, isGroup := splitScope(scope)
|
||||
sc := accessScope{key: scope, group: isGroup, expires: scopeExpiresOf(tunnels)}
|
||||
for _, t := range tunnels {
|
||||
sc.tunnels = append(sc.tunnels, t.Snapshot())
|
||||
}
|
||||
sc.name = sc.tunnels[0].Name
|
||||
if isGroup {
|
||||
sc.name = sc.tunnels[0].GroupName
|
||||
if sc.name == "" {
|
||||
sc.name = "通道组"
|
||||
}
|
||||
}
|
||||
return sc, true
|
||||
}
|
||||
|
||||
// inviteTarget resolves an invite code to the tunnel or group it grants access to
|
||||
func (m *Manager) inviteTarget(code string) (accessScope, protocol.Invite, string) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return nil, protocol.Invite{}, "缺少邀请码"
|
||||
return accessScope{}, protocol.Invite{}, "缺少邀请码"
|
||||
}
|
||||
tunnelID, inv, ok := m.state.findInvite(code)
|
||||
scope, inv, ok := m.state.findInvite(code)
|
||||
if !ok {
|
||||
return nil, protocol.Invite{}, "链接无效或已过期,请向管理员索取新的邀请链接"
|
||||
return accessScope{}, protocol.Invite{}, "链接无效或已过期,请向管理员索取新的邀请链接"
|
||||
}
|
||||
t := m.tunnel(tunnelID)
|
||||
if t == nil {
|
||||
return nil, protocol.Invite{}, "对应通道当前未开启,请联系管理员"
|
||||
sc, ok := m.resolveScope(scope)
|
||||
if !ok {
|
||||
return accessScope{}, protocol.Invite{}, "对应通道当前未开启,请联系管理员"
|
||||
}
|
||||
return t, inv, ""
|
||||
return sc, inv, ""
|
||||
}
|
||||
|
||||
func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code string) {
|
||||
ip := clientIPOf(r)
|
||||
t, inv, problem := m.inviteTarget(code)
|
||||
sc, inv, problem := m.inviteTarget(code)
|
||||
if problem == "" && ip == "" {
|
||||
problem = "无法识别客户端 IP"
|
||||
}
|
||||
if problem == "" {
|
||||
if err := m.state.checkInvite(t.ID(), inv.Code, ip); err != nil {
|
||||
if err := m.state.checkInvite(sc.key, inv.Code, ip); err != nil {
|
||||
problem = err.Error()
|
||||
}
|
||||
}
|
||||
@@ -91,7 +122,6 @@ func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code s
|
||||
return
|
||||
}
|
||||
|
||||
cfg := t.Snapshot()
|
||||
action := "确认并获取访问权限"
|
||||
approvalTip := ""
|
||||
if inv.RequireApproval {
|
||||
@@ -107,9 +137,9 @@ func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code s
|
||||
<button type="submit">%s</button>
|
||||
</form>
|
||||
%s`,
|
||||
m.detailRows(r, cfg, ip, durationText(inv.LeaseDuration)),
|
||||
m.detailRows(r, sc, ip, durationText(inv.LeaseDuration)),
|
||||
html.EscapeString(inv.Code), maxNameRunes, action, approvalTip)
|
||||
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+cfg.Name, body)
|
||||
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+sc.name, body)
|
||||
}
|
||||
|
||||
func sanitizeName(s string) string {
|
||||
@@ -130,7 +160,7 @@ func (m *Manager) serveInviteSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ip := clientIPOf(r)
|
||||
name := sanitizeName(r.PostForm.Get("name"))
|
||||
t, inv, problem := m.inviteTarget(r.PostForm.Get("code"))
|
||||
sc, inv, problem := m.inviteTarget(r.PostForm.Get("code"))
|
||||
switch {
|
||||
case problem != "":
|
||||
case ip == "":
|
||||
@@ -143,27 +173,26 @@ func (m *Manager) serveInviteSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg := t.Snapshot()
|
||||
if inv.RequireApproval {
|
||||
req, err := m.state.addRequest(cfg.ID, inv.Code, name, ip)
|
||||
req, err := m.state.addRequest(sc.key, inv.Code, name, ip)
|
||||
if err != nil {
|
||||
m.writePage(w, r, http.StatusConflict, pageError, "无法提交申请", problemHTML(err.Error()))
|
||||
return
|
||||
}
|
||||
log.Printf("[WebAuth] Access request from %s (%s) on tunnel %s awaiting approval", name, ip, cfg.Name)
|
||||
log.Printf("[WebAuth] Access request from %s (%s) on %s awaiting approval", name, ip, sc.name)
|
||||
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(req.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
lease, err := m.state.useInviteAndGrant(cfg.ID, inv.Code, name, ip, cfg.ExpiresAt)
|
||||
lease, err := m.state.useInviteAndGrant(sc.key, inv.Code, name, ip, sc.expires)
|
||||
if err != nil {
|
||||
m.writePage(w, r, http.StatusConflict, pageError, "无法完成授权", problemHTML(err.Error()))
|
||||
return
|
||||
}
|
||||
t.AddDynamicWhitelist(ip, lease.ExpiresAt)
|
||||
log.Printf("[WebAuth] Granted %s (%s) access to tunnel %s (port %d): %s",
|
||||
name, ip, cfg.Name, cfg.ListenPort, leaseValidity(lease))
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "授权成功", m.successBody(r, cfg, ip, lease))
|
||||
m.syncScope(sc.key)
|
||||
log.Printf("[WebAuth] Granted %s (%s) access to %s (%d port(s)): %s",
|
||||
name, ip, sc.name, len(sc.tunnels), leaseValidity(lease))
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "授权成功", m.successBody(r, sc, ip, lease))
|
||||
}
|
||||
|
||||
func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -172,23 +201,21 @@ func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
|
||||
m.writePage(w, r, http.StatusNotFound, pageError, "申请已失效", problemHTML("申请不存在或已过期,请重新打开邀请链接提交"))
|
||||
return
|
||||
}
|
||||
t := m.tunnel(req.TunnelID)
|
||||
sc, open := m.resolveScope(scopeOf(req.TunnelID, req.GroupID))
|
||||
switch {
|
||||
case req.Status == "rejected":
|
||||
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的访问申请"))
|
||||
case t == nil:
|
||||
case !open:
|
||||
m.writePage(w, r, http.StatusOK, pageError, "通道未开启", problemHTML("对应通道当前未开启,请联系管理员"))
|
||||
case req.Status == "approved":
|
||||
cfg := t.Snapshot()
|
||||
lease, _ := m.state.lease(cfg.ID, req.IP)
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, cfg, req.IP, lease))
|
||||
lease, _ := m.state.lease(sc.key, req.IP)
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, sc, req.IP, lease))
|
||||
default:
|
||||
cfg := t.Snapshot()
|
||||
duration := ""
|
||||
if _, inv, ok := m.state.findInvite(req.Invite); ok {
|
||||
duration = inv.LeaseDuration
|
||||
}
|
||||
body := m.detailRows(r, cfg, req.IP, durationText(duration)) +
|
||||
body := m.detailRows(r, sc, req.IP, durationText(duration)) +
|
||||
fmt.Sprintf(`<div class="tip">%s,您的申请已提交,正在等待管理员批准。此页面会自动刷新。</div>`, html.EscapeString(req.Name))
|
||||
w.Header().Set("Refresh", "5")
|
||||
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", body)
|
||||
@@ -211,20 +238,37 @@ func leaseValidity(l leaseInfo) string {
|
||||
return "至 " + l.ExpiresAt.Format("01-02 15:04")
|
||||
}
|
||||
|
||||
func (m *Manager) successBody(r *http.Request, cfg protocol.TunnelConfig, ip string, l leaseInfo) string {
|
||||
return m.detailRows(r, cfg, ip, leaseValidity(l)) +
|
||||
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接上面的访问地址,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。</div>`
|
||||
func (m *Manager) successBody(r *http.Request, sc accessScope, ip string, l leaseInfo) string {
|
||||
where := "上面的访问地址"
|
||||
if sc.group {
|
||||
where = "上面任意一个访问地址"
|
||||
}
|
||||
return m.detailRows(r, sc, ip, leaseValidity(l)) +
|
||||
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。</div>`
|
||||
}
|
||||
|
||||
// detailRows renders the tunnel info table
|
||||
func (m *Manager) detailRows(r *http.Request, cfg protocol.TunnelConfig, ip string, validity string) string {
|
||||
validity = html.EscapeString(validity)
|
||||
return fmt.Sprintf(`
|
||||
<div class="row"><span class="label">目标通道</span><span class="value">%s</span></div>
|
||||
<div class="row"><span class="label">访问地址</span><span class="value mono">%s:%d</span></div>
|
||||
// detailRows renders the info table; a group lists every access address with its target
|
||||
func (m *Manager) detailRows(r *http.Request, sc accessScope, ip string, validity string) string {
|
||||
host := html.EscapeString(m.accessHost(r))
|
||||
var target, addresses string
|
||||
if sc.group {
|
||||
target = fmt.Sprintf(`<div class="row"><span class="label">目标通道组</span><span class="value">%s(%d 个地址)</span></div>`,
|
||||
html.EscapeString(sc.name), len(sc.tunnels))
|
||||
var b strings.Builder
|
||||
for _, t := range sc.tunnels {
|
||||
fmt.Fprintf(&b, `<div class="map"><span class="mono">%s:%d</span><span class="to">%s</span></div>`,
|
||||
host, t.ListenPort, html.EscapeString(t.Target))
|
||||
}
|
||||
addresses = `<div class="row stack"><span class="label">访问地址</span><div class="maps">` + b.String() + `</div></div>`
|
||||
} else {
|
||||
t := sc.tunnels[0]
|
||||
target = fmt.Sprintf(`<div class="row"><span class="label">目标通道</span><span class="value">%s</span></div>`, html.EscapeString(t.Name))
|
||||
addresses = fmt.Sprintf(`<div class="row"><span class="label">访问地址</span><span class="value mono">%s:%d</span></div>`, host, t.ListenPort)
|
||||
}
|
||||
return target + addresses + fmt.Sprintf(`
|
||||
<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>`,
|
||||
html.EscapeString(cfg.Name), html.EscapeString(m.accessHost(r)), cfg.ListenPort, html.EscapeString(ip), validity)
|
||||
html.EscapeString(ip), html.EscapeString(validity))
|
||||
}
|
||||
|
||||
func problemHTML(msg string) string {
|
||||
@@ -284,6 +328,11 @@ func renderPage(kind pageKind, title, body string) string {
|
||||
.label { color:var(--sub); white-space:nowrap; }
|
||||
.value { font-weight:600; text-align:right; word-break:break-all; }
|
||||
.mono { font-family:ui-monospace,Menlo,Monaco,Consolas,monospace; background:var(--code-bg); padding:2px 6px; border-radius:4px; }
|
||||
.row.stack { align-items:flex-start; }
|
||||
.maps { display:flex; flex-direction:column; gap:6px; align-items:flex-end; }
|
||||
.map { display:flex; align-items:center; gap:8px; font-weight:600; }
|
||||
.map .to { color:var(--sub); font-weight:400; font-size:12px; }
|
||||
.map .to::before { content:"→ "; }
|
||||
.highlight { color:var(--accent); }
|
||||
.tip { margin-top:20px; padding:12px 14px; background:var(--code-bg); border-radius:8px; font-size:12px; line-height:1.5; color:var(--sub); }
|
||||
.tip.error { color:#FF3B30; background:rgba(255,59,48,0.1); font-weight:600; text-align:center; }
|
||||
|
||||
Reference in new issue
Block a user