feat: 通道组——一次开放多台主机 / 多个端口,共用邀请与授权

- 批量创建:主机支持 IP、主机名、末段范围(10.0.1.11-13)和 主机:端口,
  与端口列表展开为 主机 × 端口,自动分配连续的访问端口,创建前可预览
- 授权以组为单位:一条邀请链接、一次审批即可访问组内全部地址;
  计时从首次连上任一地址开始,撤销一次全组断开;后加入组的通道自动获得组授权
- 网关授权状态按作用范围(通道 / 通道组)存储,通道的动态白名单统一由状态库重新计算,
  通道授权与组授权互不干扰;组内最后一个通道关闭或组被解散时,组的邀请与授权失效
- 闲置联动:组内任一地址在用时整组都不算闲置;支持整组开关
- 已有通道可并入组或移出组;组可改名、追加目标、解散(保留或删除成员)
- App 与网页面板:组卡片、新建 / 编辑通道组、组邀请与“连接与授权”,授权页列出全部地址
- 测试:网关 9 个、Agent 5 个通道组用例
This commit is contained in:
DeepFal committed 2026-10-13 20:16:43 +08:00
1 parent 86e159b3f0
commit 3797bef88a
22 files changed
+3894 -471

No files matched your search

+259
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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; }