Compare commits

..
9 Commits
Author SHA1 Message Date
DeepFal c71668e82d build(release): 客户端与服务端分开打包,macOS 提供 dmg 安装镜像
- 服务端:portrelay-server-<版本>-linux-{amd64,arm64}.tar.gz(含 deploy/ 安装脚本)
- 客户端:PortRelay-<版本>.dmg(通用版本),以及 Linux / Windows 的 portrelay-agent 压缩包
- make dmg 打包安装镜像;Release 说明按客户端 / 服务端列出下载项
2026-10-14 10:41:38 +08:00
DeepFal dceb59c0ca feat: 授权延长——同事一键申请,管理员批准时决定时长;时长支持 30 分钟与自定义
同事端
- 用过邀请链接的设备再次打开链接时显示自己的授权状态:有效时可申请延长,
  访问结束(到期或通道关闭)后 2 小时内也可以;超过 2 小时或被撤销则提示重新获取邀请
- 「申请延长」只有一个按钮,提交后复用审批状态页,批准后显示新的到期时间
- 用过链接的设备不再自动重新放通,统一走申请延长

网关
- 授权结束后保留 2 小时的记录(通道关闭后仍保留,随状态文件持久化);撤销的不能延长,
  删除通道 / 解散组时一并清除(unregister 带 deleted 标记)
- 新增 extend_lease:有效授权从原到期时间顺延,已结束的从现在起算,不受通道最长开启时长截断,
  并批准该设备的延长申请;指标里带上全部可延长设备与申请(包括已关闭的通道)
- 授权时长支持分钟数("30m"、"90m",5 分钟到 30 天)

Agent / App / 网页面板
- POST .../leases/extend:通道已关闭时先自动开启(组则全组开启),并让通道至少开到授权结束
- 授权管理新增「申请延长」「最近结束 · 2 小时内可延长」,已授权设备可直接「延长」;
  卡片、菜单栏和系统通知提示延长申请,菜单栏可直接选时长批准
- 邀请的可访问时长、通道最长开启时长增加 30 分钟与自定义;网页面板弹窗层级高于侧边抽屉
- 邀请分享文案提示「授权结束后 2 小时内可重新打开此链接申请延长」
2026-10-14 10:11:06 +08:00
DeepFal 9a675ba96e feat: 新建时不再选择通道组,「添加地址」固定加入所在组;占位提示统一措辞
- 「添加通道」去掉通道组下拉:一个地址为独立通道,多个地址自动放进以名称命名的新组,
  只在目标列表下方说明结果
- 组卡片「添加地址」标题为「添加地址到「组名」」,固定加入该组,名称可选(留空按「组名 (主机)」命名),
  按钮为「添加 / 添加 N 个地址」,不再自动弹出邀请
- 通道组选择只保留在编辑已有通道时(不分组 / 新建通道组 / 已有组)
- 占位提示统一为「如:…」并说明格式:IP 或域名、IP 或 CIDR 网段、负责人或团队等
- App 与网页面板一致
2026-10-13 21:31:22 +08:00
DeepFal dfb3a9fd8e build: make install-app 构建并替换 /Applications 中的 App
正在运行的 App 会先正常退出(同时停止后台引擎),装好后按原状态重新打开。
2026-10-13 21:24:48 +08:00
DeepFal 659e69f8f7 feat: 目标改为动态列表、访问端口全自动;菜单栏合并通道组;接入记录可按地址查看
添加通道
- 目标服务改成列表:每行一台主机,多个端口用逗号隔开(也支持 8080-8082、主机范围);
  「添加主机」沿用上一行的端口,粘贴一串 IP / 主机:端口会自动拆成多行
- 新建时不再手动指定对外端口,统一自动分配;预览里列出每个地址分到的端口,
  连通性测试并发进行,结果标在每个地址后面
- 超过一个地址自动放进以名称命名的新通道组(也可选加入已有组),只有一个地址时为不分组;
  编辑通道仍保留端口栏和「新建通道组」
- 网页面板同步:同样的目标列表、自动端口与通道组规则

菜单栏
- 同一个通道组合并为一行「组名(N 个地址)」,子菜单里整组开关、逐个地址开关

连接与授权(通道组)
- 当前在线、历史接入台账增加「按设备 / 按地址」切换:按地址列出组内每个地址
  有几台设备在用、谁访问过;按设备的汇总行显示访问了组内哪些地址,并带上授权姓名
- 网页面板的在线连接同样可以按地址查看
2026-10-13 21:23:25 +08:00
DeepFal 5b355fb759 feat: 通道组并入「添加通道」表单,不再单独新建
- 去掉单独的「新建通道组」入口:添加通道表单的目标可以一次填多个
  (10.0.1.11-13:22、10.0.1.11:8080-8082、逗号分隔),预览每个地址分到的连续端口
- 表单新增「通道组」选项:不分组 / 新建通道组 / 已有组;多个目标自动放进新建的组,
  组名默认与通道名称相同;加入组时名称可不填,按「组名 (主机)」命名
- 闲置关闭、最长时长、限速、访问控制、责任人等设置对每个地址都生效,与单个通道一致
- 编辑通道时可以新建通道组并移入;组卡片上「添加地址」打开同一个表单并选好该组
- 编辑通道组只负责改名、责任人和加入已有通道
- 多个目标的连通性测试并发进行并汇总结果;网页表单补上责任人与限速
- 接口:POST /api/tunnels/batch(dry_run 预览、group_id / new_group),
  PUT /api/tunnels/{id} 支持 new_group;移除 /api/groups/preview 及组接口里的批量建通道
2026-10-13 20:55:57 +08:00
DeepFal e02c4c1184 fix(mac): 拆分长字符串表达式,避免旧版编译器类型推断超时
GitHub Actions 的 macOS runner(较旧的 Xcode)编译菜单签名拼接时报
"unable to type-check this expression in reasonable time"。
2026-10-13 20:22:54 +08:00
DeepFal 3797bef88a feat: 通道组——一次开放多台主机 / 多个端口,共用邀请与授权
- 批量创建:主机支持 IP、主机名、末段范围(10.0.1.11-13)和 主机:端口,
  与端口列表展开为 主机 × 端口,自动分配连续的访问端口,创建前可预览
- 授权以组为单位:一条邀请链接、一次审批即可访问组内全部地址;
  计时从首次连上任一地址开始,撤销一次全组断开;后加入组的通道自动获得组授权
- 网关授权状态按作用范围(通道 / 通道组)存储,通道的动态白名单统一由状态库重新计算,
  通道授权与组授权互不干扰;组内最后一个通道关闭或组被解散时,组的邀请与授权失效
- 闲置联动:组内任一地址在用时整组都不算闲置;支持整组开关
- 已有通道可并入组或移出组;组可改名、追加目标、解散(保留或删除成员)
- App 与网页面板:组卡片、新建 / 编辑通道组、组邀请与“连接与授权”,授权页列出全部地址
- 测试:网关 9 个、Agent 5 个通道组用例
2026-10-13 20:16:43 +08:00
DeepFal 86e159b3f0 PortRelay:把内网服务临时、可控地开放给同事
边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上;
通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。

- portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页)
- portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计)
- mac-native:SwiftUI 菜单栏 App
2026-10-13 16:33:17 +08:00
20 changed files with 2725 additions and 376 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
## 下载 · Downloads
### 客户端 · Client
装在能访问内网服务的电脑上,用来创建通道、邀请同事。
Install it on a machine that can reach your internal services.
| 平台 Platform | 文件 File |
| --- | --- |
| macOS(Apple Silicon / Intel) | [PortRelay-{{VERSION}}.dmg]({{DOWNLOAD}}/PortRelay-{{VERSION}}.dmg) |
| Linux x86_64 | [portrelay-agent-{{VERSION}}-linux-amd64.tar.gz]({{DOWNLOAD}}/portrelay-agent-{{VERSION}}-linux-amd64.tar.gz) |
| Linux ARM64 | [portrelay-agent-{{VERSION}}-linux-arm64.tar.gz]({{DOWNLOAD}}/portrelay-agent-{{VERSION}}-linux-arm64.tar.gz) |
| Windows x86_64 | [portrelay-agent-{{VERSION}}-windows-amd64.zip]({{DOWNLOAD}}/portrelay-agent-{{VERSION}}-windows-amd64.zip) |
macOS 是菜单栏 App:打开 dmg,把 PortRelay 拖进「应用程序」。App 未经 Apple 公证,首次打开请在 Finder 里右键选择「打开」。Linux / Windows 是命令行 Agent,运行后在浏览器里打开管理面板。
On macOS, open the dmg and drag PortRelay to Applications. The app is not notarized, so right-click it and choose Open the first time. On Linux and Windows, run the agent and use the web panel it opens.
### 服务端 · Server (edge gateway)
部署在同事和客户端都能连到的 Linux 机器上。
Deploy it on a Linux host that both teammates and the client can reach.
| 方式 Method | 文件 File |
| --- | --- |
| 二进制 + systemd · x86_64 | [portrelay-server-{{VERSION}}-linux-amd64.tar.gz]({{DOWNLOAD}}/portrelay-server-{{VERSION}}-linux-amd64.tar.gz) |
| 二进制 + systemd · ARM64 | [portrelay-server-{{VERSION}}-linux-arm64.tar.gz]({{DOWNLOAD}}/portrelay-server-{{VERSION}}-linux-arm64.tar.gz) |
| Docker | `{{IMAGE}}:{{VERSION}}` |
解压后运行 `sudo ./deploy/install-server.sh ./portrelay-server`,再次运行即可升级。
Unpack and run `sudo ./deploy/install-server.sh ./portrelay-server`; run it again to upgrade.
校验文件 Checksums:`SHA256SUMS`
+20 -8
View File
@@ -12,7 +12,7 @@ permissions:
jobs:
binaries:
name: Server & agent binaries
name: Server & agent archives
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -31,24 +31,22 @@ jobs:
dist/*.zip
macos-app:
name: macOS app
name: macOS app (dmg)
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build universal app
- name: Build universal dmg
run: |
VERSION=${GITHUB_REF_NAME#v}
case "$VERSION" in [0-9]*) ;; *) VERSION=0.0.0-${GITHUB_SHA::7} ;; esac
UNIVERSAL=1 VERSION=$VERSION ./build-app.sh
mkdir -p dist
ditto -c -k --sequesterRsrc --keepParent PortRelay.app "dist/PortRelay-$VERSION-macos-universal.zip"
UNIVERSAL=1 make dmg VERSION=$VERSION
- uses: actions/upload-artifact@v4
with:
name: macos-app
path: dist/*.zip
path: dist/*.dmg
docker:
name: Docker image
@@ -88,13 +86,27 @@ jobs:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Checksums
run: cd dist && sha256sum * > SHA256SUMS
# 说明 = 标签注释正文(本次更新)+ 下载说明(区分客户端与服务端)+ 自动生成的变更列表
- name: Release notes
run: |
git fetch --force origin "refs/tags/$GITHUB_REF_NAME:refs/tags/$GITHUB_REF_NAME"
body=$(git tag -l --format='%(contents:body)' "$GITHUB_REF_NAME")
{
if [ -n "$body" ]; then printf "## 本次更新 · What's new\n\n%s\n\n" "$body"; fi
sed -e "s|{{VERSION}}|${GITHUB_REF_NAME#v}|g" \
-e "s|{{DOWNLOAD}}|https://github.com/$GITHUB_REPOSITORY/releases/download/$GITHUB_REF_NAME|g" \
-e "s|{{IMAGE}}|ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/portrelay-server|g" \
.github/release-notes.md
} > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: Create release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --generate-notes
run: gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md" --generate-notes
+38 -11
View File
@@ -3,16 +3,20 @@
# make server 交叉编译 Linux 服务端到 dist/
# make agent 编译当前平台的 Agent 到 bin/
# make app 构建 macOS 应用 PortRelay.app(需要 Xcode 命令行工具)
# make release 打包所有平台的服务端 / Agent 到 dist/
# make install-app 构建并替换 /Applications/PortRelay.app(正在运行的会先退出,装好后重新打开)
# make dmg 打包 macOS 安装镜像 dist/PortRelay-<版本>.dmg(UNIVERSAL=1 构建通用版本)
# make release 打包服务端(Linux)与命令行 Agent(Linux / Windows)到 dist/
# make snapshots 离屏渲染 App 界面截图(tools/ui-snapshots/out)
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
GOBUILD := CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)"
PLATFORMS := linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64
# 服务端只发布 Linux 版本;macOS 用户用 App,其余平台用命令行 Agent
SERVER_PLATFORMS := linux/amd64 linux/arm64
AGENT_PLATFORMS := linux/amd64 linux/arm64 windows/amd64
.PHONY: all test fmt server agent app release snapshots clean
.PHONY: all test fmt server agent app install-app dmg release snapshots clean
all: test agent
@@ -34,20 +38,43 @@ agent:
app:
VERSION=$(VERSION) ./build-app.sh
# 正常退出正在运行的 App(会一并停止后台引擎)后替换,装好后按原状态重新打开
install-app: app
@running=; \
if pgrep -f /Applications/PortRelay.app/ >/dev/null; then \
running=1; osascript -e 'quit app "PortRelay"' >/dev/null; \
for i in $$(seq 1 30); do pgrep -f /Applications/PortRelay.app/ >/dev/null || break; sleep 0.5; done; \
if pgrep -f /Applications/PortRelay.app/ >/dev/null; then echo "PortRelay 未能退出,已放弃替换"; exit 1; fi; \
fi; \
rm -rf /Applications/PortRelay.app && ditto PortRelay.app /Applications/PortRelay.app && rm -rf PortRelay.app; \
echo "✓ 已安装 /Applications/PortRelay.app ($(VERSION))"; \
if [ -n "$$running" ]; then open /Applications/PortRelay.app; fi
# 安装镜像:PortRelay.app 和「应用程序」的快捷方式,打开后拖进去即可
dmg: app
@rm -rf build/dmg && mkdir -p build/dmg dist
ditto PortRelay.app build/dmg/PortRelay.app && rm -rf PortRelay.app
ln -s /Applications build/dmg/Applications
hdiutil create -volname PortRelay -srcfolder build/dmg -fs HFS+ -format UDZO -ov dist/PortRelay-$(VERSION).dmg
@rm -rf build/dmg
# 服务端:portrelay-server-<版本>-linux-<架构>.tar.gz(含 deploy/ 安装脚本)
# 客户端:portrelay-agent-<版本>-<系统>-<架构>.tar.gz / .zip
release:
@rm -rf dist && mkdir -p dist
@for p in $(PLATFORMS); do \
os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \
name=portrelay-$(VERSION)-$$os-$$arch; mkdir -p dist/$$name; \
@rm -rf dist/portrelay-* dist/SHA256SUMS && mkdir -p dist
@pack() { \
cmd=$$1; os=$$2; arch=$$3; ext=; [ $$os = windows ] && ext=.exe; \
name=$$cmd-$(VERSION)-$$os-$$arch; mkdir -p dist/$$name; \
echo "==> $$name"; \
GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/portrelay-server$$ext ./cmd/portrelay-server || exit 1; \
GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/portrelay-agent$$ext ./cmd/portrelay-agent || exit 1; \
GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/$$cmd$$ext ./cmd/$$cmd || return 1; \
cp LICENSE README.md README_EN.md dist/$$name/; \
if [ $$os = linux ]; then cp -R deploy dist/$$name/; fi; \
if [ $$cmd = portrelay-server ]; then cp -R deploy dist/$$name/; fi; \
if [ $$os = windows ]; then (cd dist && zip -qr $$name.zip $$name); \
else tar -C dist -czf dist/$$name.tar.gz $$name; fi; \
rm -rf dist/$$name; \
done
}; \
for p in $(SERVER_PLATFORMS); do pack portrelay-server $${p%/*} $${p#*/} || exit 1; done; \
for p in $(AGENT_PLATFORMS); do pack portrelay-agent $${p%/*} $${p#*/} || exit 1; done
@cd dist && shasum -a 256 * > SHA256SUMS && cat SHA256SUMS
snapshots:
+10 -9
View File
@@ -51,11 +51,12 @@ PortRelay 把这件事变成三步:
**访问控制**
- 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。
- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(1 小时、2 小时、8 小时、当天有效,或直到通道关闭),以及是否需要审批。
- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(30 分钟、1 小时、2 小时、8 小时、当天有效、直到通道关闭,或自定义),以及是否需要审批。
- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
- **通道组**:添加通道时目标可以一次填多个(例如 `10.0.1.11-13:22, 10.0.1.11:8080`),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。
- **申请延长**:访问结束后 2 小时内(通道已关闭也可以),同事重新打开原链接点「申请延长」;你在 App、菜单栏或网页面板里选择延长多久即批准,关闭的通道会自动重新开启。你也可以对已授权或刚结束的设备主动延长。
- **通道组**:添加通道时目标是一个列表,每行一台主机、多个端口用逗号隔开(也可以直接粘贴一串 IP),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。
**通道生命周期**
@@ -152,9 +153,9 @@ PortRelay 把这件事变成三步:
**方式 A:二进制 + systemd(推荐)**
```bash
VERSION=1.0.0 # 见 Releases 页面
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-$VERSION-linux-amd64.tar.gz
tar xzf portrelay-$VERSION-linux-amd64.tar.gz && cd portrelay-$VERSION-linux-amd64
VERSION=1.1.0 # 见 Releases 页面;ARM64 机器把 amd64 换成 arm64
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-server-$VERSION-linux-amd64.tar.gz
tar xzf portrelay-server-$VERSION-linux-amd64.tar.gz && cd portrelay-server-$VERSION-linux-amd64
sudo ./deploy/install-server.sh ./portrelay-server
# 安装脚本会生成随机 token,记下来填到 Agent 里
@@ -184,7 +185,7 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
### 2. 运行 Agent
**macOS App**:从 [Releases](https://github.com/DeepFal/PortRelay/releases) 下载 `PortRelay-*-macos-universal.zip`,解压后拖进“应用程序”。
**macOS App**:从 [Releases](https://github.com/DeepFal/PortRelay/releases) 下载 `PortRelay-*.dmg`(Apple Silicon 与 Intel 通用),打开后把 PortRelay 拖进“应用程序”。
> [!NOTE]
> App 未经 Apple 公证。首次打开时,在 Finder 中右键选择“打开”,或者执行 `xattr -dr com.apple.quarantine /Applications/PortRelay.app`。也可以 `make app` 自行构建。
@@ -199,7 +200,7 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
> 举例:Agent 通过 Tailscale 地址 `100.64.0.10` 连网关,同事在办公室局域网里用 `192.168.1.10` 访问。这时“服务器控制地址”填 `100.64.0.10:33000`,“对外访问地址”填 `192.168.1.10`,邀请链接和分享文案都会使用后者。
**Linux / Windows**:下载对应平台的压缩包,直接运行 Agent,在浏览器打开的面板里完成同样的设置。
**Linux / Windows**:下载对应平台的 `portrelay-agent-*` 压缩包,直接运行 Agent,在浏览器打开的面板里完成同样的设置。
```bash
./portrelay-agent # 面板:http://127.0.0.1:33080
@@ -208,11 +209,11 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
### 3. 创建通道并邀请同事
1. 点 **添加通道**,填写名称和目标地址(如 `10.0.1.16:3306`)。可以先“探测”一下本机能否连通;端口会自动推荐。
1. 点 **添加通道**,填写名称和目标主机、端口(如 `10.0.1.16`、`3306`)。可以先测试一下本机能否连通;网关上的访问端口自动分配。
2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,目标填 `10.0.1.11-13:22`,表单会预览端口映射并自动选上「新建通道组」,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。
需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,每行填一台主机(端口用逗号隔开,可以直接粘贴一串 IP),表单会预览端口映射并自动放进新建的通道组,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。组的「连接与授权」可以按设备或按地址查看在线连接和接入记录。
## 配置参考
+10 -9
View File
@@ -54,11 +54,12 @@ PortRelay turns that into three steps:
**Access control**
- Three access modes: **invite-only** (the default and safest), an IP / CIDR allowlist (which can be combined with invites), and open to all.
- Each invite link has its own expiry, device limit, access duration once granted (1 h, 2 h, 8 h, until end of day, or until the tunnel closes) and an optional approval step.
- Each invite link has its own expiry, device limit, access duration once granted (30 min, 1 h, 2 h, 8 h, until end of day, until the tunnel closes, or a custom length) and an optional approval step.
- When approval is required, the app shows a system notification and you approve or deny with one click. The requester's page refreshes on its own.
- **The access clock starts at the first connection**, so a teammate can accept an invite now and use it later.
- When access expires or is revoked, **existing connections are dropped too**. Unauthorized connections are reset (TCP RST) and logged.
- **Tunnel groups**: the add-tunnel form accepts several targets at once (for example `10.0.1.11-13:22, 10.0.1.11:8080`), creates one tunnel per address on a contiguous block of gateway ports and puts them in one group. A group shares one invite link and one set of grants: a teammate is authorized once for the whole group, and one revoke disconnects them everywhere.
- **Extensions.** Within two hours after access ends (even if the tunnel has been closed), a teammate reopens the original link and clicks 申请延长 (Request extension). You approve it by picking a duration in the app, the menu bar or the web panel, and a closed tunnel reopens on its own. You can also extend a running or just-ended grant yourself.
- **Tunnel groups**: the add-tunnel form takes a list of targets, one host per row with comma-separated ports (a pasted list of IPs is split into rows), creates one tunnel per address on a contiguous block of gateway ports and puts them in one group. A group shares one invite link and one set of grants: a teammate is authorized once for the whole group, and one revoke disconnects them everywhere.
**Tunnel lifecycle**
@@ -157,9 +158,9 @@ The gateway must be reachable by **visitors** and by **the agent**. Open TCP `33
**Option A: binary + systemd (recommended)**
```bash
VERSION=1.0.0 # see the Releases page
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-$VERSION-linux-amd64.tar.gz
tar xzf portrelay-$VERSION-linux-amd64.tar.gz && cd portrelay-$VERSION-linux-amd64
VERSION=1.1.0 # see the Releases page; use arm64 instead of amd64 on ARM hosts
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-server-$VERSION-linux-amd64.tar.gz
tar xzf portrelay-server-$VERSION-linux-amd64.tar.gz && cd portrelay-server-$VERSION-linux-amd64
sudo ./deploy/install-server.sh ./portrelay-server
# The installer generates a random token; you will enter it in the agent
@@ -193,7 +194,7 @@ make server # dist/portrelay-server-linux-{amd64,arm64}
### 2. Run the agent
**macOS app.** Download `PortRelay-*-macos-universal.zip` from [Releases](https://github.com/DeepFal/PortRelay/releases), unzip it and move it to Applications.
**macOS app.** Download `PortRelay-*.dmg` (universal: Apple Silicon and Intel) from [Releases](https://github.com/DeepFal/PortRelay/releases), open it and drag PortRelay to Applications.
> [!NOTE]
> The app is not notarized. On first launch, right-click it in Finder and choose Open, or run `xattr -dr com.apple.quarantine /Applications/PortRelay.app`. You can also build it yourself with `make app`.
@@ -208,7 +209,7 @@ Open ⚙️ Settings and fill in:
> Example: the agent reaches the gateway over Tailscale at `100.64.0.10`, while teammates in the office use the LAN address `192.168.1.10`. Set the control address to `100.64.0.10:33000` and the public access address to `192.168.1.10`. Invite links and share messages will use the LAN address.
**Linux / Windows.** Download the archive for your platform, run the agent, and use the web panel that opens in your browser.
**Linux / Windows.** Download the `portrelay-agent-*` archive for your platform, run the agent, and use the web panel that opens in your browser.
```bash
./portrelay-agent # panel at http://127.0.0.1:33080
@@ -217,11 +218,11 @@ Open ⚙️ Settings and fill in:
### 3. Create a tunnel and invite someone
1. Click **添加通道** (Add tunnel) and enter a name and a target such as `10.0.1.16:3306`. You can probe the target from your machine first, and a free port is suggested automatically.
1. Click **添加通道** (Add tunnel) and enter a name, a target host and port such as `10.0.1.16` and `3306`. You can probe the target from your machine first; the gateway port is assigned automatically.
2. Keep the default access mode, **仅授权链接** (invite-only). Click **邀请** (Invite), then choose how long access lasts, how many devices may use the link, and whether approval is required.
3. Send the generated message or QR code. Once the teammate has entered their name, they can connect their database client or SSH to `192.168.1.10:33660`.
To open several hosts at once (a set of servers, say), use the same **添加通道** (Add tunnel) form and enter `10.0.1.11-13:22` as the target. The form previews the port mapping and selects 新建通道组 (New tunnel group); idle close, bandwidth and access settings apply to every address. Then click **邀请整组** (Invite group). To add addresses later, click **添加地址** (Add address) on the group card; an existing tunnel can join a group from its edit form.
To open several hosts at once (a set of servers, say), use the same **添加通道** (Add tunnel) form with one host per row (comma-separated ports; you can paste a list of IPs). The form previews the port mapping and puts the addresses in a new tunnel group; idle close, bandwidth and access settings apply to every address. Then click **邀请整组** (Invite group). To add addresses later, click **添加地址** (Add address) on the group card; an existing tunnel can join a group from its edit form. A group's connections and history can be viewed by device or by address.
## Configuration
+70 -2
View File
@@ -34,6 +34,7 @@ type Agent struct {
latestMetrics protocol.MetricsMessage
regErrors map[string]string // tunnelID -> 服务器返回的注册失败原因
inviteWaiters map[string]chan protocol.InviteRespMessage
extendWaiters map[string]chan protocol.ExtendLeaseRespMessage
controlWriteMu sync.Mutex
controlWriter *bufio.Writer
@@ -57,6 +58,7 @@ func NewAgent(cm *ConfigManager) *Agent {
cm: cm,
regErrors: make(map[string]string),
inviteWaiters: make(map[string]chan protocol.InviteRespMessage),
extendWaiters: make(map[string]chan protocol.ExtendLeaseRespMessage),
usage: make(map[string]*usageState),
idleUnit: time.Minute,
rates: newRateRecorder(),
@@ -199,6 +201,18 @@ func (a *Agent) trackUsage(now time.Time) {
groupBusy[gm.GroupID] = true
}
}
// 等待批准的延长申请:通道(组)保持开启,避免关了又被批准重开
renewing := make(map[string]bool)
for _, r := range metrics.Renewals {
if r.Request == nil {
continue
}
if r.GroupID != "" {
groupBusy[r.GroupID] = true
} else {
renewing[r.TunnelID] = true
}
}
for _, t := range cfg.Tunnels {
if m, ok := byID[t.ID]; ok && t.Enabled && t.GroupID != "" && m.Status == "online" && tunnelInUse(m) {
groupBusy[t.GroupID] = true
@@ -242,7 +256,7 @@ func (a *Agent) trackUsage(now time.Time) {
firstConn = append(firstConn, t.ID)
}
}
u.inUse = tunnelInUse(m) || (t.GroupID != "" && groupBusy[t.GroupID])
u.inUse = tunnelInUse(m) || renewing[t.ID] || (t.GroupID != "" && groupBusy[t.GroupID])
if u.inUse {
u.lastActive = now
}
@@ -520,6 +534,17 @@ func (a *Agent) connectAndRun(cfg Config) error {
delete(a.inviteWaiters, resp.RequestID)
}
a.mu.Unlock()
case protocol.TypeExtendResp:
var resp protocol.ExtendLeaseRespMessage
if json.Unmarshal(line, &resp) != nil {
continue
}
a.mu.Lock()
if ch, ok := a.extendWaiters[resp.RequestID]; ok {
ch <- resp
delete(a.extendWaiters, resp.RequestID)
}
a.mu.Unlock()
case protocol.TypeRegisterResp:
var resp protocol.RegisterRespMessage
if json.Unmarshal(line, &resp) != nil {
@@ -743,14 +768,24 @@ func (a *Agent) wire(t protocol.TunnelConfig) protocol.TunnelConfig {
return t
}
// RemoveTunnel closes a tunnel on the relay server; its devices can still ask for an extension
func (a *Agent) RemoveTunnel(id string) {
a.unregister(id, false)
}
// DeleteTunnel removes a deleted tunnel from the relay server, renewal records included
func (a *Agent) DeleteTunnel(id string) {
a.unregister(id, true)
}
func (a *Agent) unregister(id string, deleted bool) {
a.mu.Lock()
delete(a.regErrors, id)
a.mu.Unlock()
if !a.IsConnected() {
return
}
_ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id})
_ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id, Deleted: deleted})
}
// Scope selects what an invite, request or lease applies to: one tunnel, or a whole group
@@ -792,6 +827,38 @@ func (a *Agent) CreateInvite(scope Scope, note string, validMinutes, maxUses int
}
}
// ExtendLease extends a device's access by minutes (approving its renewal requests) and returns
// the new expiry
func (a *Agent) ExtendLease(scope Scope, ip string, minutes int) (time.Time, error) {
if !a.IsConnected() {
return time.Time{}, fmt.Errorf("未连接中继服务器")
}
reqID := uuid.New().String()
ch := make(chan protocol.ExtendLeaseRespMessage, 1)
a.mu.Lock()
a.extendWaiters[reqID] = ch
a.mu.Unlock()
defer func() {
a.mu.Lock()
delete(a.extendWaiters, reqID)
a.mu.Unlock()
}()
if err := a.sendControl(protocol.ExtendLeaseMessage{
Type: protocol.TypeExtendLease, RequestID: reqID, TunnelID: scope.TunnelID, GroupID: scope.GroupID, IP: ip, Minutes: minutes,
}); err != nil {
return time.Time{}, err
}
select {
case resp := <-ch:
if !resp.Success {
return time.Time{}, fmt.Errorf("%s", resp.Error)
}
return resp.ExpiresAt, nil
case <-time.After(5 * time.Second):
return time.Time{}, fmt.Errorf("服务器未响应,请先把服务端更新到支持延长授权的版本")
}
}
// RevokeInvite invalidates an invite link and the leases it granted
func (a *Agent) RevokeInvite(scope Scope, code string) error {
if !a.IsConnected() {
@@ -859,5 +926,6 @@ func (a *Agent) GetLatestMetrics() protocol.MetricsMessage {
m.Groups = append([]protocol.GroupMetrics(nil), m.Groups...)
m.ActiveConns = append([]protocol.ActiveConnInfo(nil), m.ActiveConns...)
m.SecurityEvents = append([]protocol.SecurityEvent(nil), m.SecurityEvents...)
m.Renewals = append([]protocol.RenewalInfo(nil), m.Renewals...)
return m
}
+106 -1
View File
@@ -132,6 +132,19 @@ type TunnelWithMetrics struct {
EffectiveIdleCloseMinutes int `json:"effective_idle_close_minutes"`
// RateHistory 近 10 分钟流量曲线(仅开启中的通道)
RateHistory *RateSeries `json:"rate_history,omitempty"`
// Renewals 可延长的设备与延长申请(通道关闭后 2 小时内仍有)
Renewals []protocol.RenewalInfo `json:"renewals,omitempty"`
}
// renewalsOf picks the renewals of one tunnel (groupID empty) or one group
func renewalsOf(all []protocol.RenewalInfo, tunnelID, groupID string) []protocol.RenewalInfo {
var out []protocol.RenewalInfo
for _, r := range all {
if (groupID != "" && r.GroupID == groupID) || (groupID == "" && r.GroupID == "" && r.TunnelID == tunnelID) {
out = append(out, r)
}
}
return out
}
// tunnelsWithMetrics merges config, live metrics and registration errors into one consistent view.
@@ -167,6 +180,7 @@ func (s *APIServer) tunnelsWithMetrics(metrics protocol.MetricsMessage) []Tunnel
Metrics: tm,
Activity: s.agent.Activity(t),
EffectiveIdleCloseMinutes: IdleCloseMinutes(t),
Renewals: renewalsOf(metrics.Renewals, t.ID, ""),
}
if t.Enabled {
series := s.agent.RateSeries(t.ID)
@@ -265,7 +279,7 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
return
}
s.agent.ConsumeSessionBytes(tunnelID)
s.agent.RemoveTunnel(tunnelID)
s.agent.DeleteTunnel(tunnelID)
jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
@@ -376,6 +390,7 @@ func shareMessage(inv protocol.Invite, body, link string) string {
} else if inv.MaxUses > 1 {
b.WriteString(fmt.Sprintf("\n此链接最多 %d 台设备使用,请勿转发。", inv.MaxUses))
}
b.WriteString("\n授权结束后 2 小时内,可以重新打开此链接申请延长。")
return b.String()
}
@@ -386,6 +401,7 @@ func isAccessPath(p string) bool {
// handleAccess serves the access endpoints shared by tunnels and groups:
//
// POST leases/revoke {"ip": "..."}(ip 为空表示撤销全部)
// POST leases/extend {"ip": "...", "minutes": 60} 延长授权(同时批准该设备的延长申请)
// POST invites 新建邀请
// DELETE invites/{code} 作废邀请
// GET invites/{code}/share 分享文案
@@ -406,6 +422,26 @@ func (s *APIServer) handleAccess(w http.ResponseWriter, r *http.Request, sc Scop
}
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
case len(rest) == 2 && rest[0] == "leases" && rest[1] == "extend" && r.Method == http.MethodPost:
var req struct {
IP string `json:"ip"`
Minutes int `json:"minutes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.Minutes < 1 || req.Minutes > protocol.MaxLeaseMinutes {
jsonError(w, http.StatusBadRequest, "延长时长须在 1 分钟到 30 天之间")
return
}
exp, err := s.extendAccess(sc, strings.TrimSpace(req.IP), req.Minutes)
if err != nil {
jsonError(w, http.StatusConflict, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]any{"expires_at": exp})
case rest[0] == "invites":
s.handleInvites(w, r, sc, rest[1:])
@@ -428,6 +464,75 @@ func (s *APIServer) handleAccess(w http.ResponseWriter, r *http.Request, sc Scop
}
}
// extendAccess extends a device's access in a tunnel or group. Closed tunnels are reopened first
// (a group reopens all its members), and each is kept open at least until the new expiry.
func (s *APIServer) extendAccess(sc Scope, ip string, minutes int) (time.Time, error) {
if ip == "" {
return time.Time{}, fmt.Errorf("缺少设备 IP")
}
var members []protocol.TunnelConfig
if sc.GroupID != "" {
members = s.cm.GroupMembers(sc.GroupID)
} else if t, ok := s.cm.FindTunnel(sc.TunnelID); ok {
members = []protocol.TunnelConfig{t}
}
if len(members) == 0 {
return time.Time{}, fmt.Errorf("通道不存在或组内没有通道")
}
if !s.canExtend(sc, ip) {
return time.Time{}, fmt.Errorf("这台设备没有可延长的授权(访问结束已超过 2 小时或已被撤销)")
}
on := true
for _, t := range members {
if !t.Enabled {
if _, err := s.setEnabled(t.ID, &on); err != nil {
return time.Time{}, err
}
}
}
exp, err := s.agent.ExtendLease(sc, ip, minutes)
if err != nil {
return time.Time{}, err
}
for _, t := range members {
if updated, changed, err := s.cm.ExtendUntil(t.ID, exp); err == nil && changed {
s.agent.SyncTunnel(updated, true)
}
}
return exp, nil
}
// canExtend reports whether ip has a running lease, or an access ended within two hours, in the scope.
// 先检查再开启通道,避免对没有可延长授权的设备白白打开通道。
func (s *APIServer) canExtend(sc Scope, ip string) bool {
metrics := s.agent.GetLatestMetrics()
for _, r := range renewalsOf(metrics.Renewals, sc.TunnelID, sc.GroupID) {
if r.IP == ip {
return true
}
}
var leases []protocol.DynamicLease
if sc.GroupID != "" {
for _, g := range metrics.Groups {
if g.GroupID == sc.GroupID {
leases = g.DynamicLeases
}
}
} else {
for _, t := range metrics.Tunnels {
if t.TunnelID == sc.TunnelID {
leases = t.DynamicLeases
}
}
}
for _, l := range leases {
if l.IP == ip {
return true
}
}
return false
}
// scopeShare builds the share message for an invite of a tunnel or group
func (s *APIServer) scopeShare(cfg Config, sc Scope, inv protocol.Invite, link string) string {
if sc.GroupID != "" {
+5 -2
View File
@@ -16,6 +16,8 @@ type GroupView struct {
Metrics protocol.GroupMetrics `json:"metrics"`
// RateHistory 开启中成员的近 10 分钟流量之和
RateHistory *RateSeries `json:"rate_history,omitempty"`
// Renewals 可延长的设备与延长申请(组关闭后 2 小时内仍有)
Renewals []protocol.RenewalInfo `json:"renewals,omitempty"`
}
func (s *APIServer) groupsView(metrics protocol.MetricsMessage) []GroupView {
@@ -26,7 +28,8 @@ func (s *APIServer) groupsView(metrics protocol.MetricsMessage) []GroupView {
cfg := s.cm.Get()
out := make([]GroupView, 0, len(cfg.Groups))
for _, g := range cfg.Groups {
v := GroupView{TunnelGroup: g, TunnelIDs: make([]string, 0), Metrics: protocol.GroupMetrics{GroupID: g.ID}}
v := GroupView{TunnelGroup: g, TunnelIDs: make([]string, 0), Metrics: protocol.GroupMetrics{GroupID: g.ID},
Renewals: renewalsOf(metrics.Renewals, "", g.ID)}
if m, ok := shared[g.ID]; ok {
v.Metrics = m
}
@@ -183,7 +186,7 @@ func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
if deleteMembers {
s.agent.ConsumeSessionBytes(t.ID)
s.agent.dropUsage(t.ID)
s.agent.RemoveTunnel(t.ID)
s.agent.DeleteTunnel(t.ID)
} else if t.Enabled {
s.agent.SyncTunnel(t, true)
}
+19
View File
@@ -572,6 +572,25 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
return cloneTunnel(updated), existing.ListenPort, cm.save()
}
// ExtendUntil keeps an open tunnel open at least until t: an approved access extension outlasts the
// tunnel's maximum open time. Returns the tunnel and whether it changed.
func (cm *ConfigManager) ExtendUntil(id string, t time.Time) (protocol.TunnelConfig, bool, error) {
cm.mu.Lock()
defer cm.mu.Unlock()
for i, tc := range cm.cfg.Tunnels {
if tc.ID != id {
continue
}
if !tc.Enabled || tc.ExpiresAt == nil || !tc.ExpiresAt.Before(t) {
return cloneTunnel(tc), false, nil
}
v := t
cm.cfg.Tunnels[i].ExpiresAt = &v
return cloneTunnel(cm.cfg.Tunnels[i]), true, cm.save()
}
return protocol.TunnelConfig{}, false, fmt.Errorf("通道 %s 未找到", id)
}
// RefreshAuthCode generates a new auth code for a tunnel
func (cm *ConfigManager) RefreshAuthCode(id string) (protocol.TunnelConfig, error) {
cm.mu.Lock()
+97
View File
@@ -0,0 +1,97 @@
package client
import (
"encoding/json"
"net/http"
"testing"
"time"
)
func TestExtendReopensClosedTunnel(t *testing.T) {
e := startEnv(t, testToken)
waitFor(t, "agent connected", e.agent.IsConnected)
echo := startEcho(t)
// 最长开启 10 分钟的仅授权链接通道
one := e.addTunnels(t, map[string]any{"name": "db", "target": echo, "duration_minutes": 10, "whitelist": []string{}})
tn := one.Tunnels[0]
waitFor(t, "online", func() bool { return e.tunnel(t, tn.ID).Metrics.Status == "online" })
_, body := e.call(t, "POST", "/api/tunnels/"+tn.ID+"/invites", map[string]any{"lease_duration": "30m"})
var inv struct {
Invite struct{ Code string } `json:"invite"`
}
_ = json.Unmarshal(body, &inv)
grantViaInvite(t, e, inv.Invite.Code, "张三")
if got, err := echoVia(tn.ListenPort, "hi"); err != nil || got != "hi" {
t.Fatalf("granted device cannot connect: %q %v", got, err)
}
// 关闭通道:设备仍可在 2 小时内被延长
e.call(t, "POST", "/api/tunnels/"+tn.ID+"/toggle", map[string]bool{"enabled": false})
waitFor(t, "renewable after close", func() bool {
rs := e.tunnel(t, tn.ID).Renewals
return len(rs) == 1 && rs[0].IP == "127.0.0.1" && rs[0].EndedAt != nil
})
status, body := e.call(t, "POST", "/api/tunnels/"+tn.ID+"/leases/extend", map[string]any{"ip": "127.0.0.1", "minutes": 60})
if status != http.StatusOK {
t.Fatalf("extend: %d %s", status, body)
}
var out struct {
ExpiresAt time.Time `json:"expires_at"`
}
_ = json.Unmarshal(body, &out)
if d := time.Until(out.ExpiresAt); d < 59*time.Minute || d > 61*time.Minute {
t.Fatalf("an ended access is extended from now: %v", d)
}
reopened := e.tunnel(t, tn.ID)
if !reopened.Enabled || reopened.ExpiresAt == nil || reopened.ExpiresAt.Before(out.ExpiresAt) {
t.Fatalf("the tunnel must reopen and stay open until the extended access ends: %+v", reopened.TunnelConfig)
}
waitFor(t, "reachable again", func() bool {
got, err := echoVia(tn.ListenPort, "x")
return err == nil && got == "x"
})
// 没有可延长授权的设备:不打开通道
e.call(t, "POST", "/api/tunnels/"+tn.ID+"/toggle", map[string]bool{"enabled": false})
if status, _ := e.call(t, "POST", "/api/tunnels/"+tn.ID+"/leases/extend", map[string]any{"ip": "10.1.2.3", "minutes": 30}); status != http.StatusConflict {
t.Fatalf("unknown device: %d", status)
}
if e.tunnel(t, tn.ID).Enabled {
t.Fatal("a refused extension must not reopen the tunnel")
}
}
func TestExtendReopensWholeGroup(t *testing.T) {
e := startEnv(t, testToken)
waitFor(t, "agent connected", e.agent.IsConnected)
echo1, echo2 := startEcho(t), startEcho(t)
g := e.addTunnels(t, map[string]any{"name": "订单服务", "target": echo1 + ", " + echo2, "new_group": "订单服务"})
gid := g.Group.ID
waitFor(t, "online", func() bool {
return e.tunnel(t, g.Tunnels[0].ID).Metrics.Status == "online" && e.tunnel(t, g.Tunnels[1].ID).Metrics.Status == "online"
})
_, body := e.call(t, "POST", "/api/groups/"+gid+"/invites", map[string]any{})
var inv struct {
Invite struct{ Code string } `json:"invite"`
}
_ = json.Unmarshal(body, &inv)
grantViaInvite(t, e, inv.Invite.Code, "小王")
e.call(t, "POST", "/api/groups/"+gid+"/toggle", map[string]bool{"enabled": false})
waitFor(t, "group renewable after close", func() bool { return len(e.group(t, gid).Renewals) == 1 })
if status, body := e.call(t, "POST", "/api/groups/"+gid+"/leases/extend", map[string]any{"ip": "127.0.0.1", "minutes": 30}); status != http.StatusOK {
t.Fatalf("extend group: %d %s", status, body)
}
for _, m := range g.Tunnels {
if !e.tunnel(t, m.ID).Enabled {
t.Fatal("extending a group's access reopens every member")
}
}
waitFor(t, "every member reachable", func() bool {
_, err1 := echoVia(g.Tunnels[0].ListenPort, "a")
_, err2 := echoVia(g.Tunnels[1].ListenPort, "b")
return err1 == nil && err2 == nil
})
}
+429 -113
View File
@@ -240,7 +240,7 @@
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(8px);
display: none; align-items: center; justify-content: center; z-index: 1000;
display: none; align-items: center; justify-content: center; z-index: 1002;
}
.modal-overlay.open { display: flex; }
.modal-card {
@@ -329,6 +329,19 @@
.preview-row { font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
.plan-box { margin-top: 8px; padding: 8px 10px; border-radius: 6px; background: rgba(148, 163, 184, 0.08); color: var(--text-muted); font-size: 12px; line-height: 1.7; }
.plan-box .route-endpoint { color: var(--accent, #38bdf8); }
.target-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.target-row input { min-width: 0; }
.target-row .t-ports { flex: 0 0 150px; }
.target-head { display: flex; gap: 8px; font-size: 11px; color: var(--text-muted); margin-bottom: 4px; }
.target-head span:first-child { flex: 1; }
.target-head span:last-child { flex: 0 0 182px; }
.plan-box .ok { color: var(--success); } .plan-box .bad { color: var(--danger); }
.dim-toggle { display: inline-flex; border: 1px solid var(--card-border); border-radius: 6px; overflow: hidden; float: right; }
.dim-toggle button { background: none; border: 0; color: var(--text-muted); font-size: 11px; padding: 2px 10px; cursor: pointer; }
.dim-toggle button.on { background: rgba(56, 189, 248, 0.18); color: var(--text); }
.chip-row { display: flex; gap: 6px; flex-wrap: wrap; }
.chip-btn { background: rgba(148, 163, 184, 0.1); border: 1px solid var(--card-border); color: var(--text); font-size: 12px; padding: 4px 10px; border-radius: 6px; cursor: pointer; }
.chip-btn.on { background: rgba(56, 189, 248, 0.18); border-color: #38bdf8; color: #38bdf8; font-weight: 600; }
.add-member { background: none; border: 0; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 8px 0; }
.add-member:hover { color: var(--text); }
@media (max-width: 860px) { .member-name, .member-meta { display: none; } }
@@ -442,22 +455,25 @@
<input type="hidden" id="tunnelId">
<div class="input-row">
<div class="form-group" style="flex: 1;">
<label>通道名称 / 备注</label>
<input type="text" id="tunnelName" placeholder="例如:MySQL 实习库 或 财务系统 SSH" oninput="schedulePlan()">
<label id="tunnelNameLabel">通道名称</label>
<input type="text" id="tunnelName" placeholder="如:订单数据库" oninput="schedulePlan()">
</div>
<div class="form-group" style="flex: 1;">
<label>责任人(可选)</label>
<input type="text" id="tunnelOwner" placeholder="例如:张三 / 数据组">
<input type="text" id="tunnelOwner" placeholder="负责人或团队,如:数据组">
</div>
</div>
<div class="form-group">
<label id="tunnelTargetLabel">目标地址(中转机 Mac 可直连的内网目标,可填多个)</label>
<div class="input-row">
<input type="text" id="tunnelTarget" required placeholder="例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22" oninput="schedulePlan()">
<button type="button" class="btn-secondary" onclick="probeTarget()">探测测试</button>
<label id="tunnelTargetLabel">目标服务(每行一台主机,多个端口用逗号隔开)</label>
<div class="target-head" id="targetHead"><span>主机</span><span>端口</span></div>
<div id="targetRows"></div>
<div class="input-row" style="justify-content: space-between; align-items: center;">
<button type="button" class="add-member" id="addRowBtn" onclick="addTargetRow()" title="也可以直接粘贴多个地址(每行一个或逗号分隔,可带 :端口),会自动拆成多行">+ 添加主机</button>
<button type="button" class="btn-secondary btn-sm" onclick="probeTarget()">探测测试</button>
</div>
<div class="input-hint" id="probeResult"></div>
<div class="plan-box" id="tunnelPlan" style="display:none;"></div>
<div class="input-hint" id="tunnelAddHint"></div>
</div>
<div class="form-group" id="tunnelPortWrap">
<label>对外监听端口 (范围: 33001 - 34000)</label>
@@ -466,7 +482,7 @@
<button type="button" class="btn-secondary" onclick="autoRecommendPort()">推荐空闲端口</button>
</div>
</div>
<div class="form-group">
<div class="form-group" id="tunnelGroupWrap">
<label>通道组</label>
<div class="input-row">
<select id="tunnelGroup" onchange="onTunnelGroupChange()"></select>
@@ -486,12 +502,14 @@
</div>
<div class="form-group">
<label>最长开启时长(可选,到时无论是否在用都会关闭)</label>
<select id="tunnelMaxDuration">
<select id="tunnelMaxDuration" onchange="onCustomDuration(this, false)">
<option value="0" selected>不限制</option>
<option value="30">30 分钟</option>
<option value="120">2 小时</option>
<option value="480">8 小时</option>
<option value="1440">1 天</option>
<option value="10080">7 天</option>
<option value="custom">自定义…</option>
</select>
</div>
<div class="form-group">
@@ -511,7 +529,7 @@
<option value="specific">固定 IP / 网段 + 授权链接</option>
<option value="all">全部放通 *(不安全)</option>
</select>
<input type="text" id="tunnelWhitelist" style="margin-top: 8px; display: none;" placeholder="例如 192.168.1.100, 192.168.1.0/24(逗号分隔)">
<input type="text" id="tunnelWhitelist" style="margin-top: 8px; display: none;" placeholder="IP 或 CIDR 网段,逗号分隔,如 192.168.1.100, 10.0.0.0/24">
<div class="input-hint" id="accessModeHint"></div>
</div>
@@ -523,6 +541,32 @@
</div>
</div>
<!-- Modal: Extend access(批准延长申请 / 主动延长) -->
<div class="modal-overlay" id="extendModal">
<div class="modal-card" style="width: 420px;">
<div class="modal-header">
<h3 id="extendTitle">延长访问</h3>
<button class="btn-secondary btn-sm" onclick="closeExtendModal()">✕</button>
</div>
<div class="input-hint" id="extendHint" style="margin: -8px 0 14px;"></div>
<div class="form-group">
<label>延长多久</label>
<div class="chip-row" id="extendChips"></div>
<div class="input-row" style="margin-top: 8px;">
<input type="number" id="extendAmount" min="1" placeholder="自定义数量" oninput="updateExtend()">
<select id="extendUnit" style="max-width: 100px;" onchange="updateExtend()">
<option value="1">分钟</option>
<option value="60">小时</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeExtendModal()">取消</button>
<button type="button" class="btn-primary" id="extendConfirm" onclick="confirmExtend()">延长</button>
</div>
</div>
</div>
<!-- Modal: Invite -->
<div class="modal-overlay" id="inviteModal">
<div class="modal-card">
@@ -533,7 +577,7 @@
<div id="inviteForm">
<div class="form-group">
<label>备注(发给谁,可选)</label>
<input type="text" id="inviteNote" placeholder="例如:张三">
<input type="text" id="inviteNote" placeholder="如:张三">
</div>
<div class="form-group">
<label>链接有效期</label>
@@ -553,12 +597,14 @@
</div>
<div class="form-group">
<label>授权后可访问</label>
<select id="inviteLease">
<select id="inviteLease" onchange="onCustomDuration(this, true)">
<option value="30m">30 分钟</option>
<option value="1h">1 小时</option>
<option value="2h" selected>2 小时</option>
<option value="8h">8 小时</option>
<option value="today">当天有效</option>
<option value="tunnel">通道开启期间</option>
<option value="custom">自定义…</option>
</select>
<label style="display:flex; align-items:center; gap:8px; margin-top:10px; font-weight: normal;">
<input type="checkbox" id="inviteApproval" style="width:auto;"> 同事打开链接后需要我批准才放通
@@ -595,11 +641,11 @@
<div class="input-hint" style="margin: -8px 0 14px;">组内地址共用邀请与授权:同事授权一次即可访问整组;组内任一地址在用时,整组都不会闲置关闭。</div>
<div class="form-group">
<label>通道组名称</label>
<input type="text" id="groupName" required placeholder="例如:订单服务集群">
<input type="text" id="groupName" required placeholder="如:订单服务集群">
</div>
<div class="form-group">
<label>责任人(可选)</label>
<input type="text" id="groupOwner" placeholder="例如:张三 / 运维组">
<input type="text" id="groupOwner" placeholder="负责人或团队,如:运维组">
</div>
<div class="form-group" id="groupExistingWrap">
<label>把已有通道加入该组(可选)</label>
@@ -640,7 +686,7 @@
<form onsubmit="saveSettings(event)">
<div class="form-group">
<label>服务器控制地址 (地址:端口)</label>
<input type="text" id="settingServerAddr" required placeholder="例如 gw.example.com:33000">
<input type="text" id="settingServerAddr" required placeholder="地址:端口,如 gw.example.com:33000">
<div class="input-hint">边缘网关的地址与控制端口。局域网、VPN、虚拟组网或公网地址均可,只要本机能连通</div>
</div>
<div class="form-group">
@@ -703,7 +749,30 @@
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), sec = secs % 60;
return h > 0 ? `${h}小时${m}分` : (m > 0 ? `${m}分${sec}秒` : `${sec}秒`);
};
const fmtMinutes = m => m >= 1440 && m % 1440 === 0 ? `${m / 1440} 天` : (m >= 60 && m % 60 === 0 ? `${m / 60} 小时` : `${m} 分钟`);
const fmtMinutes = m => m >= 1440 && m % 1440 === 0 ? `${m / 1440} 天`
: (m >= 60 && m % 60 === 0 ? `${m / 60} 小时` : (m > 60 ? `${Math.floor(m / 60)} 小时 ${m % 60} 分钟` : `${m} 分钟`));
// 自定义时长:输入数量 + 单位(如 90、90m、1.5h),返回分钟数;取消或无效返回 0
function askMinutes() {
const v = (prompt('自定义时长(如 90 表示 90 分钟,3h 表示 3 小时)') || '').trim().toLowerCase();
const m = /^(\d+(?:\.\d+)?)\s*(h|小时|m|分钟)?$/.exec(v);
if (!m) return 0;
const minutes = Math.round(parseFloat(m[1]) * (m[2] === 'h' || m[2] === '小时' ? 60 : 1));
return minutes >= 5 && minutes <= 43200 ? minutes : 0;
}
// 下拉框选「自定义…」:询问时长并加入为一个选项;lease 为 true 时取值形如 "90m"
function onCustomDuration(sel, lease) {
if (sel.value !== 'custom') { sel.dataset.prev = sel.value; return; }
const m = askMinutes();
if (!m) { sel.value = sel.dataset.prev || (lease ? '2h' : '0'); return; }
const value = lease ? `${m}m` : String(m);
if (![...sel.options].some(o => o.value === value)) {
sel.add(new Option(fmtMinutes(m), value), sel.querySelector('option[value="custom"]'));
}
sel.value = value;
sel.dataset.prev = value;
}
const ago = v => (Date.now() - new Date(v)) / 1000;
let totalRateHistory = null;
@@ -766,6 +835,8 @@
let html = '';
if (t.max_bandwidth_mb) html += `<span class="chip purple">限速 ${t.max_bandwidth_mb} MB/s</span>`;
if ((m.pending || []).length) html += `<span class="chip orange" onclick="openDrawer('t:${id}')">🔔 待审批 ${m.pending.length}</span>`;
const asks = (t.renewals || []).filter(r => r.request).length;
if (asks) html += `<span class="chip orange" onclick="openDrawer('t:${id}')">⏱ 申请延长 ${asks}</span>`;
if ((m.dynamic_leases || []).length) html += `<span class="chip green" onclick="openDrawer('t:${id}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`;
return html;
}
@@ -1064,39 +1135,42 @@
}
// 新建代理通道;groupId 非空时默认加入该通道组(通道组卡片上的「添加地址」)
// 组卡片「添加地址」打开时固定加入的通道组;新建时不需要选择通道组
let fixedGroupId = null;
function openCreateModal(groupId) {
document.getElementById('modalTitle').innerText = '新建代理通道';
fixedGroupId = groupId || null;
const g = currentGroups.find(x => x.id === fixedGroupId);
document.getElementById('modalTitle').innerText = fixedGroupId ? `添加地址到「${g ? g.name : '通道组'}」` : '新建代理通道';
document.getElementById('tunnelId').value = '';
document.getElementById('tunnelName').value = '';
document.getElementById('tunnelOwner').value = '';
document.getElementById('tunnelTarget').value = '';
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标,可填多个)';
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22';
document.getElementById('tunnelTargetLabel').innerText = '目标服务(每行一台主机,多个端口用逗号隔开)';
fillTunnelGroupSelect('');
setAccessForm([]);
setLifecycleForm(30, 0, 0);
fillTunnelGroupSelect(groupId || '');
document.getElementById('probeResult').innerText = '';
resetPlan();
autoRecommendPort();
setTargetRows([{ host: '', ports: '' }]);
document.getElementById('tunnelModal').classList.add('open');
document.querySelector('#targetRows .t-host').focus();
}
function editTunnel(id) {
const t = currentTunnels.find(item => item.id === id);
if (!t) return;
fixedGroupId = null;
document.getElementById('modalTitle').innerText = '编辑代理通道';
document.getElementById('tunnelId').value = t.id;
document.getElementById('tunnelName').value = t.name;
document.getElementById('tunnelOwner').value = t.owner || '';
document.getElementById('tunnelTarget').value = t.target;
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标)';
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306';
document.getElementById('tunnelTargetLabel').innerText = '目标服务(内网主机与端口)';
const hp = splitHostPort(t.target) || [t.target, ''];
document.getElementById('tunnelPort').value = t.listen_port;
setAccessForm(t.whitelist || []);
setLifecycleForm(t.effective_idle_close_minutes ?? 30, t.duration_minutes || 0, t.max_bandwidth_mb || 0);
fillTunnelGroupSelect(t.group_id || '');
document.getElementById('probeResult').innerText = '';
resetPlan();
setTargetRows([{ host: hp[0], ports: hp[1] }]);
document.getElementById('tunnelModal').classList.add('open');
}
@@ -1114,6 +1188,96 @@
}
}
// ---- 目标列表:每行一台主机 + 一个或多个端口 ----
const isEditing = () => !!document.getElementById('tunnelId').value;
const listItems = s => s.split(/[\s,,、;;]+/).filter(Boolean);
// "10.0.1.11:22" / "[fd00::1]:22" → [主机, 端口];不带端口(含裸 IPv6)返回 null
function splitHostPort(item) {
const m = /^\[(.+)\]:(.+)$/.exec(item);
if (m) return [m[1], m[2]];
const parts = item.split(':');
return parts.length === 2 && parts[0] && parts[1] ? parts : null;
}
// 粘贴进来的一串目标拆成多行:每项是 "主机" 或 "主机:端口",同一主机的端口并到一行
function parseTargets(text, defaultPorts) {
const out = [];
for (const item of listItems(text)) {
const [host, port] = splitHostPort(item) || [item, ''];
const row = out.find(r => r.host === host);
if (!row) out.push({ host, ports: port || defaultPorts });
else if (port && !listItems(row.ports).includes(port)) row.ports = row.ports ? `${row.ports}, ${port}` : port;
}
return out;
}
// 拼成 "主机:端口, …" 交给服务端展开;还没填完整的行先跳过
function composeTarget(rows) {
const out = [];
for (const r of rows) {
const ports = listItems(r.ports);
for (const h of listItems(r.host)) {
if (splitHostPort(h)) { out.push(h); continue; }
if (!ports.length) continue;
const host = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
ports.forEach(p => out.push(`${host}:${p}`));
}
}
return out.join(', ');
}
function readTargetRows() {
return [...document.querySelectorAll('#targetRows .target-row')].map(r => ({
host: r.querySelector('.t-host').value.trim(), ports: r.querySelector('.t-ports').value.trim()
}));
}
function setTargetRows(rows) {
const edit = isEditing();
document.getElementById('targetRows').innerHTML = rows.map(r => `
<div class="target-row">
<input type="text" class="t-host" value="${escapeHtml(r.host)}" placeholder="${edit ? 'IP 或域名' : 'IP 或域名,支持粘贴多行'}" oninput="schedulePlan()" onpaste="onHostPaste(event)">
<input type="text" class="t-ports" value="${escapeHtml(r.ports)}" placeholder="${edit ? '如 3306' : '如 22, 3306'}" oninput="schedulePlan()">
${edit ? '' : `<button type="button" class="icon-btn" onclick="removeTargetRow(this)" title="删除这一行" ${rows.length === 1 ? 'disabled' : ''}>⊖</button>`}
</div>`).join('');
document.getElementById('addRowBtn').style.display = edit ? 'none' : '';
document.getElementById('targetHead').style.display = edit ? 'none' : 'flex';
schedulePlan();
}
// 新的一行沿用上一行的端口(同一组机器通常开同样的端口)
function addTargetRow() {
const rows = readTargetRows();
rows.push({ host: '', ports: rows.length ? rows[rows.length - 1].ports : '' });
setTargetRows(rows);
const hosts = document.querySelectorAll('#targetRows .t-host');
hosts[hosts.length - 1].focus();
}
function removeTargetRow(btn) {
const all = [...document.querySelectorAll('#targetRows .target-row')];
const rows = readTargetRows();
rows.splice(all.indexOf(btn.closest('.target-row')), 1);
setTargetRows(rows.length ? rows : [{ host: '', ports: '' }]);
}
// 一次粘贴多个主机(或 主机:端口)时拆成多行
function onHostPaste(e) {
if (isEditing()) return;
const text = (e.clipboardData || window.clipboardData).getData('text');
if (listItems(text).length < 2 && !splitHostPort(text.trim())) return;
e.preventDefault();
const all = [...document.querySelectorAll('#targetRows .target-row')];
const idx = all.indexOf(e.target.closest('.target-row'));
const rows = readTargetRows();
const parsed = parseTargets(text, rows[idx].ports);
// 粘贴到空行时替换这一行,否则插在它后面
if (rows[idx].host) rows.splice(idx + 1, 0, ...parsed);
else rows.splice(idx, 1, ...parsed);
setTargetRows(rows);
}
async function probeOne(target) {
const res = await fetch('/api/probe', {
method: 'POST',
@@ -1123,71 +1287,62 @@
return res.json();
}
let probeResults = {};
async function probeTarget() {
const target = document.getElementById('tunnelTarget').value.trim();
if (!target) {
alert('请先输入目标地址');
return;
}
const targets = isBatch() ? tunnelPlan.map(t => t.target) : [target];
const target = composeTarget(readTargetRows());
if (!target) { alert('请先填写目标主机和端口'); return; }
const targets = isEditing() ? [target] : tunnelPlan.map(t => t.target);
if (!targets.length) return;
const hint = document.getElementById('probeResult');
hint.innerText = '探测中...';
hint.style.color = 'var(--text-muted)';
try {
const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
const failed = results.map((r, i) => [targets[i], r]).filter(([, r]) => !r.reachable);
if (targets.length === 1) {
const data = results[0];
hint.innerText = data.reachable ? `✓ 连通性测试通过!延迟: ${data.rtt}` : `✗ 连通失败: ${data.error || '连接超时'}`;
} else if (!failed.length) {
hint.innerText = `✓ 全部 ${targets.length} 个目标连通`;
} else {
const [t, r] = failed[0];
hint.innerText = `✗ ${targets.length - failed.length}/${targets.length} 连通;${t} 连通失败: ${r.error || '连接超时'}` + (failed.length > 1 ? `(共 ${failed.length} 个失败)` : '');
}
hint.style.color = failed.length ? 'var(--danger)' : 'var(--success)';
} catch (e) {
hint.innerText = '探测请求失败';
hint.style.color = 'var(--danger)';
const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
probeResults = {};
targets.forEach((t, i) => { probeResults[t] = results[i]; });
if (isEditing()) {
const r = results[0];
hint.innerText = r.reachable ? `✓ 连通性测试通过!延迟: ${r.rtt}` : `✗ 连通失败: ${r.error || '连接超时'}`;
hint.style.color = r.reachable ? 'var(--success)' : 'var(--danger)';
return;
}
hint.innerText = '';
renderPlan();
}
// ---- 多个目标:预览将创建的通道,并放进通道组 ----
let tunnelPlan = [], tunnelPlanError = null, planTimer = null;
// ---- 预览将创建的通道(含自动分配的访问端口);多个地址自动放进通道组 ----
let tunnelPlan = [], tunnelPlanError = null, planTimer = null, plannedTarget = '';
function resetPlan() {
tunnelPlan = [];
tunnelPlanError = null;
clearTimeout(planTimer);
probeResults = {};
document.getElementById('probeResult').innerText = '';
renderPlan();
}
// 可能填了多个目标(逗号 / 空格分隔,或含范围 "10.0.1.11-13:22"),需要服务端展开
function mayBeMulti() {
if (document.getElementById('tunnelId').value) return false;
const target = document.getElementById('tunnelTarget').value.trim();
return target.split(/[\s,,、;;]+/).filter(Boolean).length > 1 || target.includes('-');
}
function isBatch() {
return mayBeMulti() && tunnelPlan.length > 1;
return !isEditing() && tunnelPlan.length > 1;
}
// 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID
function groupFields() {
const sel = document.getElementById('tunnelGroup').value;
if (sel === '__new__') {
const name = document.getElementById('tunnelNewGroup').value.trim() || document.getElementById('tunnelName').value.trim();
return { group_id: '', new_group: name };
const name = document.getElementById('tunnelName').value.trim();
if (!isEditing()) {
// 新建:「添加地址」固定加入该组;多个地址自动新建以名称命名的组;一个地址不分组
if (fixedGroupId) return { group_id: fixedGroupId, new_group: '' };
return { group_id: '', new_group: isBatch() ? name : '' };
}
return { group_id: sel, new_group: '' };
const sel = document.getElementById('tunnelGroup').value;
if (sel !== '__new__') return { group_id: sel, new_group: '' };
return { group_id: '', new_group: document.getElementById('tunnelNewGroup').value.trim() || name };
}
function tunnelPayload(port, whitelist) {
return {
name: document.getElementById('tunnelName').value.trim(),
owner: document.getElementById('tunnelOwner').value.trim(),
target: document.getElementById('tunnelTarget').value.trim(),
target: composeTarget(readTargetRows()),
listen_port: port,
whitelist,
idle_close_minutes: parseInt(document.getElementById('tunnelIdleClose').value, 10),
@@ -1199,7 +1354,9 @@
function schedulePlan() {
clearTimeout(planTimer);
if (!mayBeMulti()) { tunnelPlan = []; tunnelPlanError = null; renderPlan(); return; }
const target = composeTarget(readTargetRows());
if (target !== plannedTarget) { probeResults = {}; plannedTarget = target; }
if (isEditing() || !target) { tunnelPlan = []; tunnelPlanError = null; renderPlan(); return; }
planTimer = setTimeout(async () => {
try {
const res = await fetch('/api/tunnels/batch', {
@@ -1222,28 +1379,37 @@
}
function renderPlan() {
const batch = isBatch();
const box = document.getElementById('tunnelPlan');
document.getElementById('tunnelPortWrap').style.display = batch ? 'none' : 'block';
// 多个目标必须放进同一个通道组
const sel = document.getElementById('tunnelGroup');
const none = sel.querySelector('option[value=""]');
if (none) none.disabled = batch;
if (batch && sel.value === '') { sel.value = '__new__'; onTunnelGroupChange(true); }
const batch = isBatch(), edit = isEditing();
document.getElementById('tunnelPortWrap').style.display = edit ? 'block' : 'none';
document.getElementById('tunnelGroupWrap').style.display = edit ? 'block' : 'none';
updateGroupHint();
document.getElementById('tunnelSaveBtn').innerText = batch ? `创建 ${tunnelPlan.length} 个通道` : '保存通道';
if (mayBeMulti() && tunnelPlanError) {
const n = tunnelPlan.length;
document.getElementById('tunnelSaveBtn').innerText = fixedGroupId ? (batch ? `添加 ${n} 个地址` : '添加')
: (batch ? `创建 ${n} 个通道` : '保存通道');
const box = document.getElementById('tunnelPlan');
if (tunnelPlanError) {
box.style.display = 'block';
box.style.color = 'var(--danger)';
box.innerText = tunnelPlanError;
return;
}
if (!batch) { box.style.display = 'none'; box.innerHTML = ''; return; }
const shown = tunnelPlan.slice(0, 6);
if (!tunnelPlan.length) { box.style.display = 'none'; box.innerHTML = ''; return; }
const shown = tunnelPlan.slice(0, 8);
const tested = tunnelPlan.filter(m => probeResults[m.target]);
const failed = tested.filter(m => !probeResults[m.target].reachable);
const status = m => {
const r = probeResults[m.target];
if (!r) return '';
return r.reachable ? ` <span class="ok">✓ ${escapeHtml(r.rtt)}</span>` : ` <span class="bad" title="${escapeHtml(r.error || '连接超时')}">✗ 不通</span>`;
};
let caption = tunnelPlan.length === 1 ? '访问端口自动分配' : '每个地址一个通道,访问端口自动连续分配';
if (tested.length === tunnelPlan.length && !failed.length) caption = tunnelPlan.length === 1 ? '目标连接成功' : `全部 ${tunnelPlan.length} 个目标连接成功`;
if (failed.length) caption = `<span class="bad">${failed.length} 个目标连不上,鼠标停在 ✗ 上可查看原因</span>`;
box.style.display = 'block';
box.style.color = '';
box.innerHTML = shown.map(m => `<div class="preview-row"><span class="route-endpoint">${escapeHtml(publicHost)}:${m.listen_port}</span> <span class="route-arrow">➔</span> <span class="route-target">${escapeHtml(m.target)}</span></div>`).join('')
+ `<div>${tunnelPlan.length > shown.length ? '… ' : ''}共 ${tunnelPlan.length} 个地址,每个地址一个通道,访问端口自动连续分配</div>`;
box.innerHTML = shown.map(m => `<div class="preview-row"><span class="route-endpoint">${escapeHtml(publicHost)}:${m.listen_port}</span> <span class="route-arrow">➔</span> <span class="route-target">${escapeHtml(m.target)}</span>${status(m)}</div>`).join('')
+ `<div>${tunnelPlan.length > shown.length ? `… 共 ${tunnelPlan.length} 个地址,` : ''}${caption}</div>`;
}
function fillTunnelGroupSelect(selected) {
@@ -1252,28 +1418,38 @@
(currentGroups.length ? '<option disabled>──────────</option>' : '') +
currentGroups.map(g => `<option value="${escapeHtml(g.id)}" ${g.id === selected ? 'selected' : ''}>${escapeHtml(g.name)}</option>`).join('');
document.getElementById('tunnelNewGroup').value = '';
onTunnelGroupChange(true);
updateGroupHint();
}
function updateGroupHint() {
const sel = document.getElementById('tunnelGroup').value;
const isNew = sel === '__new__';
const name = document.getElementById('tunnelName').value.trim();
const nameInput = document.getElementById('tunnelName');
const label = document.getElementById('tunnelNameLabel');
if (!isEditing()) {
// 新建:不需要选择通道组,只说明结果
const g = fixedGroupId ? ((currentGroups.find(x => x.id === fixedGroupId) || {}).name || '通道组') : null;
let hint = '';
if (g) hint = `新地址加入通道组「${g}」,自动沿用组的邀请和授权,已授权的同事无需重新授权`;
else if (isBatch()) hint = `${tunnelPlan.length} 个地址将放进通道组${name ? `「${name}」` : '(以上面填写的名称命名)'}:共用邀请和授权,同事授权一次即可访问全部地址`;
document.getElementById('tunnelAddHint').innerText = hint;
label.innerText = g ? '通道名称(可选)' : (isBatch() ? '通道组名称' : '通道名称');
nameInput.placeholder = g ? `留空则命名为「${g} (主机)」` : (isBatch() ? '如:订单服务集群' : '如:订单数据库');
return;
}
document.getElementById('tunnelAddHint').innerText = '';
label.innerText = '通道名称';
nameInput.placeholder = '如:订单数据库';
const sel = document.getElementById('tunnelGroup').value;
const newInput = document.getElementById('tunnelNewGroup');
newInput.style.display = isNew ? 'block' : 'none';
newInput.placeholder = name ? `组名,默认「${name}」` : '通道组名称';
document.getElementById('tunnelGroupHint').innerText = isBatch()
? '多个地址会放进同一个通道组:共用邀请和授权,同事授权一次即可访问全部地址'
newInput.style.display = sel === '__new__' ? 'block' : 'none';
newInput.placeholder = name ? `通道组名称,留空则用「${name}」` : '通道组名称';
document.getElementById('tunnelGroupHint').innerText = sel === '__new__' ? '新建通道组并移入;之后可在组卡片的「编辑」里把其它通道加入'
: (sel ? '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址' : '');
// 放进通道组时名称可以不填,按组名命名
const g = isNew ? newInput.value.trim() : (currentGroups.find(x => x.id === sel) || {}).name;
document.getElementById('tunnelName').placeholder = (!document.getElementById('tunnelId').value && g)
? `可不填,默认「${g} (主机)」` : '例如:MySQL 实习库 或 财务系统 SSH';
}
function onTunnelGroupChange(skipPlan) {
function onTunnelGroupChange() {
updateGroupHint();
if (!skipPlan) schedulePlan();
schedulePlan();
}
// 闲置自动关闭 / 最长开启时长;旧配置中不在选项里的值临时加入下拉框
@@ -1309,11 +1485,22 @@
const id = document.getElementById('tunnelId').value;
const batch = isBatch();
const sel = document.getElementById('tunnelGroup').value;
const port = parseInt(document.getElementById('tunnelPort').value, 10);
if (!batch && !(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; }
if (mayBeMulti() && tunnelPlanError) { alert(tunnelPlanError); return; }
if (!document.getElementById('tunnelName').value.trim() && (id || !sel)) { alert('请填写通道名称'); return; }
if (sel === '__new__' && !groupFields().new_group) { alert('请填写通道组名称'); return; }
// 加入已有组时组的授权自动覆盖新地址,不需要再发邀请
const joinsExisting = !id && !!fixedGroupId;
const name = document.getElementById('tunnelName').value.trim();
const target = composeTarget(readTargetRows());
let port = 0;
if (id) {
port = parseInt(document.getElementById('tunnelPort').value, 10);
if (!(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; }
if (!target) { alert('请填写目标主机和端口'); return; }
if (target.includes(',')) { alert('一个通道只对应一个目标端口;要开放更多端口,请用「新建代理通道」'); return; }
} else {
if (tunnelPlanError) { alert(tunnelPlanError); return; }
if (!tunnelPlan.length) { alert('请填写目标主机和端口'); return; }
}
if (!name && (id || !joinsExisting)) { alert(batch ? '请填写通道组名称' : '请填写通道名称'); return; }
if (id && sel === '__new__' && !groupFields().new_group) { alert('请填写通道组名称'); return; }
const mode = document.getElementById('tunnelAccessMode').value;
let whitelist = [];
if (mode === 'all') whitelist = ['*'];
@@ -1322,10 +1509,10 @@
if (whitelist.length === 0) { alert('请填写允许访问的 IP 或网段,或选择“仅授权链接”'); return; }
if (whitelist.includes('*')) { alert('如需放通全部,请选择“全部放通 *”'); return; }
}
const payload = tunnelPayload(batch ? 0 : port, whitelist);
const payload = tunnelPayload(port, whitelist);
try {
// 新建统一走 /api/tunnels/batch:单个目标、多个目标、新建或加入通道组都在这一个表单里
// 新建统一走 /api/tunnels/batch:一个或多个地址、新建或加入通道组都在这一个表单里,访问端口自动分配
const res = await fetch(id ? `/api/tunnels/${encodeURIComponent(id)}` : '/api/tunnels/batch', {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1339,7 +1526,6 @@
closeTunnelModal();
await fetchTunnels();
// 新建后直接生成邀请:新建的通道组邀请整组;加入已有组时组的授权自动覆盖新地址,不用再邀请
const joinsExisting = sel !== '' && sel !== '__new__';
if (!id && mode !== 'all' && !joinsExisting) {
const key = data.group ? 'g:' + data.group.id : 't:' + data.tunnels[0].id;
setTimeout(() => openInviteModal(key, false), 800);
@@ -1394,19 +1580,19 @@
}
function scopeInfo(key) {
const s = parseScope(key);
let name, members, m;
let name, members, m, renewals;
if (s.group) {
const g = currentGroups.find(x => x.id === s.id);
if (!g) return null;
name = g.name; members = groupMembers(g); m = g.metrics || {};
name = g.name; members = groupMembers(g); m = g.metrics || {}; renewals = g.renewals;
} else {
const t = currentTunnels.find(x => x.id === s.id);
if (!t) return null;
name = t.name; members = [t]; m = t.metrics || {};
name = t.name; members = [t]; m = t.metrics || {}; renewals = t.renewals;
}
return {
group: s.group, name, members, enabled: members.some(t => t.enabled),
leases: m.dynamic_leases || [], invites: m.invites || [], pending: m.pending || [],
leases: m.dynamic_leases || [], invites: m.invites || [], pending: m.pending || [], renewals: renewals || [],
};
}
@@ -1429,7 +1615,10 @@
const cardStyle = 'background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; display:flex; justify-content:space-between; align-items:center; gap: 10px;';
const pad2 = n => String(n).padStart(2, '0');
const leaseTitle = d => ({ '1h': '1 小时', '8h': '8 小时', today: '当天有效', tunnel: '通道开启期间' }[d] || '2 小时');
const leaseTitle = d => ({ '1h': '1 小时', '8h': '8 小时', today: '当天有效', tunnel: '通道开启期间' }[d]
|| (/^\d+m$/.test(d || '') ? fmtMinutes(parseInt(d, 10)) : '2 小时'));
const fmtClock = v => { const d = new Date(v); const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
return d.toDateString() === new Date().toDateString() ? hm : `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${hm}`; };
const fmtDT = v => {
const d = new Date(v);
const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
@@ -1439,9 +1628,31 @@
function renderLeases(key) {
const info = scopeInfo(key);
if (!info) return { pending: '', auth: '' };
const { leases, pending, invites } = info;
const { leases, pending, invites, renewals } = info;
const k = escapeHtml(key);
let html = '';
const closedTag = info.enabled ? '' : ' <span style="font-size: 11px; color: var(--text-muted);">· 通道已关闭,批准后自动开启</span>';
const extendBtn = (r, asking) => `<button class="${asking ? 'btn-primary' : 'btn-secondary'} btn-sm" onclick='openExtendModal(${escapeHtml(JSON.stringify(key))}, ${escapeHtml(JSON.stringify(r.ip))}, ${escapeHtml(JSON.stringify(r.name || ''))}, ${asking})'>${asking ? '批准延长…' : '延长…'}</button>`;
const renewDetail = r => [
r.request ? `申请于 ${fmtClock(r.request.at)}` : '',
r.ended_at ? `访问已于 ${fmtClock(r.ended_at)} 结束` : '',
r.renew_before ? `${fmtClock(r.renew_before)} 前可延长` : '',
!r.ended_at ? '当前仍有效,批准后在原到期时间上顺延' : '',
].filter(Boolean).join(' · ');
const renewCard = r => `
<div style="${cardStyle}">
<div><b>${escapeHtml(r.name || '未署名')}</b> <code style="color:#38bdf8;">${escapeHtml(r.ip)}</code>${closedTag}
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${renewDetail(r)}</div></div>
<div class="drawer-row-actions">
${r.request ? `<button class="btn-secondary btn-sm" onclick="decideRequest('${k}', '${escapeHtml(r.request.id)}', false)">拒绝</button>` : ''}
${extendBtn(r, !!r.request)}
</div>
</div>`;
const asks = renewals.filter(r => r.request), ended = renewals.filter(r => !r.request && r.ended_at);
if (asks.length) {
html += `<b style="font-size:13px; display:block; margin: 4px 0 10px; color:#fbbf24;">申请延长 (${asks.length})</b>`;
html += asks.map(renewCard).join('');
}
if (pending.length) {
html += `<b style="font-size:13px; display:block; margin: 4px 0 10px; color:#fbbf24;">待审批 (${pending.length})</b>`;
@@ -1479,10 +1690,18 @@
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${l.awaiting_connect
? `已授权,尚未连接 · 首次连接后可访问 ${leaseTitle(l.duration)} · 请在 ${fmtDT(l.expires_at)} 前连接`
: `${l.started_at ? '首次连接 ' + fmtDT(l.started_at) + ' · ' : ''}有效期至 ${fmtDT(l.expires_at)} · 剩余约 ${mins} 分钟`}</div></div>
<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '${escapeHtml(l.ip)}')">撤销</button>
<div class="drawer-row-actions">
${l.awaiting_connect ? '' : extendBtn(l, false)}
<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '${escapeHtml(l.ip)}')">撤销</button>
</div>
</div>`;
}).join('');
if (ended.length) {
html += `<b style="font-size:13px; display:block; margin: 8px 0 10px;">最近结束 · 2 小时内可延长 (${ended.length})</b>`;
html += ended.map(renewCard).join('');
}
html += `<b style="font-size:13px; display:block; margin: 8px 0 10px;">有效的邀请链接 (${invites.length})</b>`;
html += invites.length === 0
? '<div style="color: var(--text-muted); font-size: 12px; margin-bottom: 14px;">暂无有效的邀请链接</div>'
@@ -1588,6 +1807,63 @@
document.getElementById('inviteResult').style.display = 'block';
}
// ---- 延长访问:批准延长申请或主动延长,确定时长后立即生效;通道已关闭时会自动重新开启 ----
let extendTarget = null;
const EXTEND_OPTIONS = [[30, '30 分钟'], [60, '1 小时'], [120, '2 小时'], [480, '8 小时']];
function openExtendModal(key, ip, name, asking) {
const info = scopeInfo(key);
extendTarget = { key, ip };
document.getElementById('extendTitle').innerText = `${asking ? '批准延长' : '延长访问'} · ${name || ip}`;
document.getElementById('extendHint').innerText = (info && !info.enabled ? '通道当前已关闭,延长后会自动重新开启。' : '')
+ '访问还没结束时从原到期时间顺延;已结束时从现在开始计算。';
document.getElementById('extendChips').innerHTML = EXTEND_OPTIONS.map(([m, t]) =>
`<button type="button" class="chip-btn" data-m="${m}" onclick="setExtendMinutes(${m})">${t}</button>`).join('');
setExtendMinutes(60);
document.getElementById('extendModal').classList.add('open');
}
function closeExtendModal() {
document.getElementById('extendModal').classList.remove('open');
extendTarget = null;
}
function setExtendMinutes(m) {
const hours = m >= 60 && m % 60 === 0;
document.getElementById('extendAmount').value = hours ? m / 60 : m;
document.getElementById('extendUnit').value = hours ? '60' : '1';
updateExtend();
}
function extendMinutes() {
const n = parseInt(document.getElementById('extendAmount').value, 10);
const m = n * parseInt(document.getElementById('extendUnit').value, 10);
return n > 0 && m <= 43200 ? m : 0;
}
function updateExtend() {
const m = extendMinutes();
document.querySelectorAll('#extendChips .chip-btn').forEach(b => b.classList.toggle('on', +b.dataset.m === m));
const btn = document.getElementById('extendConfirm');
btn.disabled = !m;
btn.innerText = m ? `延长 ${fmtMinutes(m)}` : '请输入 1 分钟到 30 天';
}
async function confirmExtend() {
const m = extendMinutes();
if (!extendTarget || !m) return;
const btn = document.getElementById('extendConfirm');
btn.disabled = true;
btn.innerText = '处理中…';
const res = await fetch(`${scopeBase(extendTarget.key)}/leases/extend`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip: extendTarget.ip, minutes: m })
});
if (!res.ok) { alert('延长失败: ' + await apiError(res)); updateExtend(); return; }
closeExtendModal();
await fetchTunnels();
}
async function revokeLease(key, ip) {
if (!confirm(ip ? `撤销 ${ip} 的访问权限?该设备的连接会被立即断开。` : '撤销全部已授权设备?')) return;
const res = await fetch(`${scopeBase(key)}/leases/revoke`, {
@@ -1598,6 +1874,14 @@
if (!res.ok) alert('撤销失败: ' + await apiError(res));
}
// 通道组的在线连接可以按设备(来源 IP)或按组内地址查看
let drawerByAddress = false;
function setDrawerDimension(v) {
drawerByAddress = v;
renderDrawerClients();
}
const hostOfAddr = a => { const m = /^\[(.+)\]:\d+$/.exec(a) || /^([^:]+):\d+$/.exec(a); return m ? m[1] : a; };
function renderDrawerClients() {
if (!drawerScope) return;
const info = scopeInfo(drawerScope);
@@ -1616,15 +1900,45 @@
// 同一 IP 的连接合并展示
const groups = {};
list.forEach(c => {
const m = /^\[(.+)\]:\d+$/.exec(c.client_ip) || /^([^:]+):\d+$/.exec(c.client_ip);
const ip = m ? m[1] : c.client_ip;
const ip = hostOfAddr(c.client_ip);
(groups[ip] = groups[ip] || []).push(c);
});
const leaseNames = new Map(info.leases.map(l => [l.ip, l.name || '']));
const sum = (arr, k) => arr.reduce((a, c) => a + (c[k] || 0), 0);
const ips = Object.keys(groups).sort((x, y) => groups[y].length - groups[x].length || x.localeCompare(y));
const byAddress = info.group && drawerByAddress;
const inUse = new Set(list.map(c => c.tunnel_id)).size;
const toggle = info.group ? `<span class="dim-toggle"><button type="button" class="${byAddress ? '' : 'on'}" onclick="setDrawerDimension(false)">按设备</button><button type="button" class="${byAddress ? 'on' : ''}" onclick="setDrawerDimension(true)">按地址</button></span>` : '';
const header = `<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (${byAddress ? `${inUse}/${info.members.length} 个地址在用` : `${ips.length} 台 · ${list.length} 连接`})${toggle}</b>`;
container.innerHTML = parts.pending + `<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (${ips.length} 台 · ${list.length} 连接)</b>` + ips.map(ip => {
if (byAddress) {
container.innerHTML = parts.pending + header + info.members.map(t => {
const conns = list.filter(c => c.tunnel_id === t.id);
const byIp = {};
conns.forEach(c => { const ip = hostOfAddr(c.client_ip); (byIp[ip] = byIp[ip] || []).push(c); });
const devs = Object.keys(byIp).sort();
const kill = cs => escapeHtml(JSON.stringify(cs.map(c => c.conn_id)));
return `
<div style="background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; ${conns.length ? '' : 'opacity: 0.55;'}">
<div style="display: flex; justify-content: space-between; align-items: center; gap: 8px;">
<div style="font-size: 13px;">
<code style="color: #38bdf8; font-weight: 600;">${escapeHtml(publicHost)}:${t.listen_port}</code> <span class="route-arrow">➔</span> <code>${escapeHtml(t.target)}</code>
<span style="font-size: 11px; color: var(--text-muted); margin-left: 6px;">${conns.length ? `${devs.length} 台设备 · ${conns.length} 个连接` : (t.enabled ? '无连接' : '已关闭')}</span>
</div>
${conns.length ? `<button class="btn-danger btn-sm" onclick='killConns(${kill(conns)})'>全部断开</button>` : ''}
</div>
${devs.map(ip => `
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--text-muted); padding: 6px 0 0;">
<span><code style="color: var(--text);">${escapeHtml(ip)}</code>${leaseNames.get(ip) ? ` <span style="color: #34d399;">${escapeHtml(leaseNames.get(ip))}</span>` : ''}
· ${byIp[ip].length} 个连接 · ↑ ${formatRate(sum(byIp[ip], 'rate_in'))} / ↓ ${formatRate(sum(byIp[ip], 'rate_out'))}</span>
<button class="btn-secondary btn-sm" onclick='killConns(${kill(byIp[ip])})'>断开</button>
</div>`).join('')}
</div>`;
}).join('') + parts.auth;
return;
}
container.innerHTML = parts.pending + header + ips.map(ip => {
const conns = groups[ip];
const first = conns.map(c => new Date(c.connected_at)).sort((x, y) => x - y)[0];
const ids = conns.map(c => c.conn_id);
@@ -1708,6 +2022,8 @@
}
const m = g.metrics || {}, key = escapeHtml('g:' + g.id);
if ((m.pending || []).length) parts.push(`<span class="chip orange" onclick="openDrawer('${key}')">🔔 待审批 ${m.pending.length}</span>`);
const asks = (g.renewals || []).filter(r => r.request).length;
if (asks) parts.push(`<span class="chip orange" onclick="openDrawer('${key}')">⏱ 申请延长 ${asks}</span>`);
if ((m.dynamic_leases || []).length) parts.push(`<span class="chip green" onclick="openDrawer('${key}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`);
return parts.join('');
}
+106 -12
View File
@@ -1,6 +1,11 @@
package protocol
import "time"
import (
"fmt"
"strconv"
"strings"
"time"
)
// Message types
const (
@@ -18,6 +23,8 @@ const (
TypeRevokeInvite = "revoke_invite"
TypeDecideRequest = "decide_request"
TypeDeleteGroup = "delete_group"
TypeExtendLease = "extend_lease"
TypeExtendResp = "extend_lease_resp"
TypeMetrics = "metrics_update"
TypePing = "ping"
TypePong = "pong"
@@ -94,6 +101,8 @@ type RegisterRespMessage struct {
type UnregisterMessage struct {
Type string `json:"type"`
TunnelID string `json:"tunnel_id"`
// Deleted 为 true 表示通道被删除:连同可延长的授权记录一起清除;否则只是关闭
Deleted bool `json:"deleted,omitempty"`
}
// UnregisterRespMessage response to unregister
@@ -130,7 +139,8 @@ type DeleteGroupMessage struct {
GroupID string `json:"group_id"`
}
// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久
// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久。
// 除下列取值外,也可以是分钟数,如 "30m"、"90m"(见 LeaseMinutes)。
const (
LeaseDuration1h = "1h"
LeaseDuration2h = "2h"
@@ -139,32 +149,75 @@ const (
LeaseDurationTunnel = "tunnel" // 通道开启期间有效
)
// 自定义授权时长的范围(分钟)
const (
MinLeaseMinutes = 5
MaxLeaseMinutes = 30 * 24 * 60
)
// LeaseMinutes returns how many minutes a fixed-length lease duration lasts ("" means 2 hours);
// false for "today", "tunnel" and invalid values
func LeaseMinutes(d string) (int, bool) {
switch d {
case "", LeaseDuration2h:
return 120, true
case LeaseDuration1h:
return 60, true
case LeaseDuration8h:
return 480, true
}
if !strings.HasSuffix(d, "m") {
return 0, false
}
n, err := strconv.Atoi(strings.TrimSuffix(d, "m"))
if err != nil || n < MinLeaseMinutes || n > MaxLeaseMinutes {
return 0, false
}
return n, true
}
// LeaseDurationOf is the lease duration value for a number of minutes
func LeaseDurationOf(minutes int) string {
return fmt.Sprintf("%dm", minutes)
}
// ValidLeaseDuration reports whether d is a supported lease duration ("" means default 2h)
func ValidLeaseDuration(d string) bool {
switch d {
case "", LeaseDuration1h, LeaseDuration2h, LeaseDuration8h, LeaseDurationToday, LeaseDurationTunnel:
if d == LeaseDurationToday || d == LeaseDurationTunnel {
return true
}
return false
_, ok := LeaseMinutes(d)
return ok
}
// DescribeMinutes renders a duration in minutes: "30 分钟"、"2 小时"、"1 小时 30 分钟"、"3 天"
func DescribeMinutes(n int) string {
switch {
case n >= 1440 && n%1440 == 0:
return fmt.Sprintf("%d 天", n/1440)
case n >= 60 && n%60 == 0:
return fmt.Sprintf("%d 小时", n/60)
case n > 60:
return fmt.Sprintf("%d 小时 %d 分钟", n/60, n%60)
default:
return fmt.Sprintf("%d 分钟", n)
}
}
// DescribeLeaseDuration is the human-readable lease duration
func DescribeLeaseDuration(d string) string {
switch d {
case LeaseDuration1h:
return "1 小时"
case LeaseDuration8h:
return "8 小时"
case LeaseDurationToday:
return "当天有效"
case LeaseDurationTunnel:
return "通道开启期间有效"
default:
return "2 小时"
}
if n, ok := LeaseMinutes(d); ok {
return DescribeMinutes(n)
}
return "2 小时"
}
// DynamicLease is a temporary whitelist entry granted through an invite link
type DynamicLease struct {
IP string `json:"ip"`
ExpiresAt time.Time `json:"expires_at"`
@@ -208,6 +261,45 @@ type AccessRequest struct {
Invite string `json:"invite,omitempty"`
At time.Time `json:"at"`
Status string `json:"status"` // pending / approved / rejected
// Renewal 为 true 表示授权已结束(或即将结束)的设备申请延长;由管理员批准时决定延长多久
Renewal bool `json:"renewal,omitempty"`
}
// RenewalInfo is a device whose access can still be extended: it ended less than two hours ago
// (even if its tunnel has been closed since), or it asked to extend an access that is still running.
type RenewalInfo struct {
TunnelID string `json:"tunnel_id,omitempty"`
GroupID string `json:"group_id,omitempty"`
IP string `json:"ip"`
Name string `json:"name,omitempty"`
Invite string `json:"invite,omitempty"`
// EndedAt 访问结束的时间(授权到期或通道关闭);授权仍有效时为空
EndedAt *time.Time `json:"ended_at,omitempty"`
// RenewBefore 可以延长的截止时间(结束后 2 小时)
RenewBefore *time.Time `json:"renew_before,omitempty"`
// Request 该设备待处理的延长申请
Request *AccessRequest `json:"request,omitempty"`
}
// ExtendLeaseMessage extends a device's access by Minutes: a running lease from its expiry, an
// ended one (within two hours) from now. It approves the pending renewal requests of that device,
// and is not capped by the tunnel's own maximum open time.
type ExtendLeaseMessage struct {
Type string `json:"type"`
RequestID string `json:"request_id"` // 用于匹配 ExtendLeaseRespMessage
TunnelID string `json:"tunnel_id"`
GroupID string `json:"group_id,omitempty"`
IP string `json:"ip"`
Minutes int `json:"minutes"`
}
// ExtendLeaseRespMessage answers ExtendLeaseMessage with the new expiry
type ExtendLeaseRespMessage struct {
Type string `json:"type"`
RequestID string `json:"request_id"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
}
// CreateInviteMessage asks the server to create an invite link
@@ -321,6 +413,8 @@ type MetricsMessage struct {
Groups []GroupMetrics `json:"groups,omitempty"`
ActiveConns []ActiveConnInfo `json:"active_conns"`
SecurityEvents []SecurityEvent `json:"security_events,omitempty"`
// Renewals 可延长的设备与延长申请,包括已关闭的通道
Renewals []RenewalInfo `json:"renewals,omitempty"`
}
// StreamHandshake sent as first line when a data stream is opened
Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 144 KiB

File diff suppressed because it is too large. Load diff
+45 -12
View File
@@ -240,7 +240,7 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
case protocol.TypeUnregister:
var msg protocol.UnregisterMessage
if json.Unmarshal(raw, &msg) == nil {
m.unregisterTunnel(as, msg.TunnelID)
m.unregisterTunnel(as, msg.TunnelID, msg.Deleted)
as.send(protocol.UnregisterRespMessage{Type: protocol.TypeUnregisterResp, TunnelID: msg.TunnelID, Success: true})
}
@@ -274,6 +274,12 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
m.decideRequest(scopeOf(msg.TunnelID, msg.GroupID), msg.RequestID, msg.Approve)
}
case protocol.TypeExtendLease:
var msg protocol.ExtendLeaseMessage
if json.Unmarshal(raw, &msg) == nil {
as.send(m.extendLease(msg))
}
case protocol.TypeDeleteGroup:
var msg protocol.DeleteGroupMessage
if json.Unmarshal(raw, &msg) == nil && msg.GroupID != "" {
@@ -349,14 +355,19 @@ func (m *Manager) registerTunnel(as *agentSession, cfg protocol.TunnelConfig) pr
return registerResp(cfg, nil)
}
func (m *Manager) unregisterTunnel(as *agentSession, tunnelID string) {
func (m *Manager) unregisterTunnel(as *agentSession, tunnelID string, deleted bool) {
m.mu.Lock()
defer m.mu.Unlock()
if m.active != as {
return
}
// 主动关闭/删除通道时,放通、邀请链接与待审批申请一并失效(断线重连不会走到这里)
m.state.forgetScope(tunnelID)
// 主动关闭通道时,放通、邀请链接与待审批申请一并失效,已授权的设备 2 小时内仍可申请延长;
// 删除通道则全部清除(断线重连不会走到这里)
if deleted {
m.state.dropScope(tunnelID)
} else {
m.state.closeScope(tunnelID)
}
if t, exists := m.tunnels[tunnelID]; exists {
t.Close()
delete(m.portMap, t.ListenPort())
@@ -501,8 +512,12 @@ func scopeExpiresOf(tunnels []*Tunnel) *time.Time {
// 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 {
m.state.setName(t.ID(), t.Name())
leases := m.state.leaseExpiries(t.ID())
if gid := t.GroupID(); gid != "" {
if name := t.Snapshot().GroupName; name != "" {
m.state.setName(groupScope(gid), name)
}
for ip, exp := range m.state.leaseExpiries(groupScope(gid)) {
if cur, ok := leases[ip]; !ok || exp.After(cur) {
leases[ip] = exp
@@ -521,19 +536,19 @@ func (m *Manager) syncScope(scope string) int {
return kicked
}
// forgetGroupIfEmptyLocked drops a group's shared access state once none of its tunnels is open.
// Caller must hold m.mu.
// forgetGroupIfEmptyLocked closes a group's shared access state once none of its tunnels is open
// (ended devices can still ask for an extension). Caller must hold m.mu.
func (m *Manager) forgetGroupIfEmptyLocked(groupID string) {
if groupID == "" || len(m.groupMembersLocked(groupID)) > 0 {
return
}
m.state.forgetScope(groupScope(groupID))
m.state.closeScope(groupScope(groupID))
}
// deleteGroup drops a dissolved group's invites, leases and requests
// deleteGroup drops a dissolved group's invites, leases, requests and renewal records
func (m *Manager) deleteGroup(groupID string) {
scope := groupScope(groupID)
m.state.forgetScope(scope)
m.state.dropScope(scope)
kicked := m.syncScope(scope)
log.Printf("[WebAuth] Tunnel group %s dissolved, disconnected %d client(s)", groupID, kicked)
}
@@ -569,9 +584,6 @@ func (m *Manager) revokeInvite(scope, code string) {
func (m *Manager) decideRequest(scope, requestID string, approve bool) {
tunnels := m.scopeTunnels(scope)
if len(tunnels) == 0 {
return
}
r, _, err := m.state.decide(scope, requestID, approve, scopeExpiresOf(tunnels))
if err != nil {
return
@@ -582,6 +594,26 @@ func (m *Manager) decideRequest(scope, requestID string, approve bool) {
log.Printf("[WebAuth] Request from %s (%s) on %s: %s", r.Name, r.IP, m.scopeName(scope), r.Status)
}
// extendLease extends a device's access (approving its renewal requests); the scope may be
// closed — the Agent reopens it, and registering re-applies the lease.
func (m *Manager) extendLease(msg protocol.ExtendLeaseMessage) protocol.ExtendLeaseRespMessage {
resp := protocol.ExtendLeaseRespMessage{Type: protocol.TypeExtendResp, RequestID: msg.RequestID}
scope := scopeOf(msg.TunnelID, msg.GroupID)
if msg.Minutes < 1 || msg.Minutes > protocol.MaxLeaseMinutes || msg.IP == "" {
resp.Error = "延长时长须在 1 分钟到 30 天之间"
return resp
}
exp, err := m.state.extendLease(scope, msg.IP, msg.Minutes)
if err != nil {
resp.Error = err.Error()
return resp
}
m.syncScope(scope)
resp.Success, resp.ExpiresAt = true, exp
log.Printf("[WebAuth] Extended %s on %s by %s, until %s", msg.IP, m.scopeName(scope), protocol.DescribeMinutes(msg.Minutes), exp.Format("01-02 15:04"))
return resp
}
// onClientConnect starts the countdown of a device's lease on its first connection.
// 组授权在首次连上组内任意一个通道时开始计时。
func (m *Manager) onClientConnect(tunnelID, ip string) {
@@ -678,6 +710,7 @@ func (m *Manager) metricsLoop(as *agentSession) {
Groups: groupMetrics,
ActiveConns: allActiveConns,
SecurityEvents: secCopy,
Renewals: m.state.renewalList(),
})
}
}
+3 -2
View File
@@ -575,8 +575,9 @@ func TestRevokeInviteRemovesItsLeases(t *testing.T) {
if hit(cfg.ListenPort) == "OK" {
t.Fatal("leases of revoked invite still active")
}
if status, _ := httpGet(t, addr, "/i/"+inv.Code, nil); status != 404 {
t.Fatal("revoked invite still usable")
// 用过它的设备看到「授权已撤销」,不能申请延长
if status, body := httpGet(t, addr, "/i/"+inv.Code, nil); status != 403 || !strings.Contains(body, "授权已撤销") {
t.Fatalf("revoked invite still usable: %d", status)
}
}
+245
View File
@@ -0,0 +1,245 @@
package server
import (
"encoding/json"
"io"
"net/url"
"strings"
"testing"
"time"
"github.com/DeepFal/PortRelay/core/protocol"
)
// linkOnly registers a tunnel that only invited devices can reach
func linkOnly(t *testing.T, a *testAgent, id string) protocol.TunnelConfig {
t.Helper()
cfg := tunnelCfg(id, freePort(t))
cfg.Whitelist = nil
if resp := a.register(t, protocol.TypeRegisterTunnel, cfg); !resp.Success {
t.Fatal(resp.Error)
}
return cfg
}
// grantVia uses an invite from 127.0.0.1 and connects once so the countdown starts
func grantVia(t *testing.T, m *Manager, addr string, cfg protocol.TunnelConfig, code string) {
t.Helper()
if status, _, body := postForm(t, addr, code, "张三"); status != 200 {
t.Fatalf("grant: %d %s", status, body)
}
if hit(cfg.ListenPort) != "OK" {
t.Fatal("granted device cannot connect")
}
waitFor(t, func() bool { l := m.state.leaseList(cfg.ID); return len(l) == 1 && !l[0].AwaitingConnect })
}
// expire moves a lease's end into the past by ago
func expire(m *Manager, scope, ip string, ago time.Duration) {
m.state.mu.Lock()
l := m.state.data[scope].Leases[ip]
l.ExpiresAt = time.Now().Add(-ago)
m.state.data[scope].Leases[ip] = l
m.state.mu.Unlock()
m.syncScope(scope)
}
// renew presses「申请延长」and returns the status page path
func renew(t *testing.T, addr, code string) string {
t.Helper()
resp, err := noRedirect.PostForm("http://"+addr+"/renew", url.Values{"code": {code}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 303 || !strings.HasPrefix(resp.Header.Get("Location"), "/auth/status?id=") {
t.Fatalf("renew: %d %s", resp.StatusCode, b)
}
return resp.Header.Get("Location")
}
func (a *testAgent) extend(t *testing.T, tunnelID, ip string, minutes int) protocol.ExtendLeaseRespMessage {
t.Helper()
a.send(t, protocol.ExtendLeaseMessage{Type: protocol.TypeExtendLease, RequestID: "e1", TunnelID: tunnelID, IP: ip, Minutes: minutes})
var resp protocol.ExtendLeaseRespMessage
_ = json.Unmarshal(a.next(t), &resp)
return resp
}
func TestRenewAfterExpiry(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := linkOnly(t, a, "a")
inv := a.createInviteWith(t, "a", 1, "30m")
grantVia(t, m, addr, cfg, inv.Code)
// 授权有效时再打开链接:显示剩余时间与「申请延长」,不会重新授权
if _, body := httpGet(t, addr, "/i/"+inv.Code, nil); !strings.Contains(body, "访问已授权") || !strings.Contains(body, "申请延长") {
t.Fatalf("running access page: %s", body)
}
expire(m, "a", "127.0.0.1", time.Minute)
waitFor(t, func() bool { return hit(cfg.ListenPort) != "OK" })
if _, body := httpGet(t, addr, "/i/"+inv.Code, nil); !strings.Contains(body, "访问已结束") || !strings.Contains(body, "申请延长") {
t.Fatalf("ended access page: %s", body)
}
// 用过链接的设备不能再直接授权
if status, _, _ := postForm(t, addr, inv.Code, "张三"); status != 409 {
t.Fatalf("a returning device must not be re-granted directly, got %d", status)
}
status := renew(t, addr, inv.Code)
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "等待管理员批准") {
t.Fatalf("pending renewal page: %s", body)
}
if again := renew(t, addr, inv.Code); again != status {
t.Fatal("one device should have one pending renewal")
}
rl := m.state.renewalList()
if len(rl) != 1 || rl[0].Request == nil || rl[0].EndedAt == nil || rl[0].TunnelID != "a" || rl[0].Name != "张三" {
t.Fatalf("renewal list: %+v", rl)
}
if p := m.state.pendingList("a"); len(p) != 0 {
t.Fatalf("renewals are not first-time requests: %+v", p)
}
resp := a.extend(t, "a", "127.0.0.1", 45)
if !resp.Success || time.Until(resp.ExpiresAt) < 44*time.Minute || time.Until(resp.ExpiresAt) > 46*time.Minute {
t.Fatalf("extend an ended lease from now: %+v", resp)
}
if hit(cfg.ListenPort) != "OK" {
t.Fatal("extended device cannot connect")
}
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "延长已批准") {
t.Fatalf("approved renewal page: %s", body)
}
if rl := m.state.renewalList(); len(rl) != 0 {
t.Fatalf("nothing left to renew: %+v", rl)
}
}
func TestRenewAfterTunnelClosed(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := linkOnly(t, a, "a")
inv := a.createInvite(t, "a", 1)
grantVia(t, m, addr, cfg, inv.Code)
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "a"})
a.next(t)
_, body := httpGet(t, addr, "/i/"+inv.Code, nil)
if !strings.Contains(body, "访问已结束") || !strings.Contains(body, "通道当前已关闭") || !strings.Contains(body, "目标通道") {
t.Fatalf("closed tunnel page: %s", body)
}
status := renew(t, addr, inv.Code)
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "等待管理员批准") || !strings.Contains(body, "自动重新开启") {
t.Fatalf("pending renewal on a closed tunnel: %s", body)
}
// 拒绝也不需要通道开启
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: "a", RequestID: m.state.renewalList()[0].Request.ID, Approve: false})
waitFor(t, func() bool {
_, b := httpGet(t, addr, status, nil)
return strings.Contains(b, "拒绝了您的延长申请")
})
// 再申请一次,批准后 Agent 重新开启通道,授权随注册生效
status = renew(t, addr, inv.Code)
if resp := a.extend(t, "a", "127.0.0.1", 60); !resp.Success {
t.Fatalf("extend while closed: %s", resp.Error)
}
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "正在开启通道") {
t.Fatalf("approved while closed: %s", body)
}
if resp := a.register(t, protocol.TypeRegisterTunnel, cfg); !resp.Success {
t.Fatal(resp.Error)
}
if hit(cfg.ListenPort) != "OK" {
t.Fatal("the reopened tunnel must admit the extended device")
}
}
func TestRenewWindowRevokeAndDelete(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := linkOnly(t, a, "a")
inv := a.createInvite(t, "a", 0)
grantVia(t, m, addr, cfg, inv.Code)
// 结束超过 2 小时:只能重新邀请
expire(m, "a", "127.0.0.1", renewWindow+time.Minute)
if _, body := httpGet(t, addr, "/i/"+inv.Code, nil); !strings.Contains(body, "超过 2 小时") {
t.Fatalf("expired window page: %s", body)
}
if resp := a.extend(t, "a", "127.0.0.1", 30); resp.Success {
t.Fatal("cannot extend after the renewal window")
}
// 撤销的授权不能延长
grantTestLease(m, "a", "10.9.9.9", time.Now().Add(time.Hour))
a.send(t, protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, TunnelID: "a", IP: "10.9.9.9"})
waitFor(t, func() bool { return len(m.state.leaseList("a")) == 0 })
if resp := a.extend(t, "a", "10.9.9.9", 30); resp.Success || !strings.Contains(resp.Error, "撤销") {
t.Fatalf("revoked access must not be extended: %+v", resp)
}
// 删除通道:连同记录一起清除
grantTestLease(m, "a", "10.9.9.8", time.Now().Add(time.Hour))
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "a", Deleted: true})
a.next(t)
if rl := m.state.renewalList(); len(rl) != 0 {
t.Fatalf("a deleted tunnel leaves nothing to renew: %+v", rl)
}
if resp := a.extend(t, "a", "10.9.9.8", 30); resp.Success {
t.Fatal("cannot extend on a deleted tunnel")
}
}
func TestExtendRunningLeaseIsNotCappedByTunnelTTL(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := tunnelCfg("a", freePort(t))
cfg.Whitelist = nil
end := time.Now().Add(10 * time.Minute)
cfg.ExpiresAt = &end
a.register(t, protocol.TypeRegisterTunnel, cfg)
inv := a.createInvite(t, "a", 1)
grantVia(t, m, addr, cfg, inv.Code)
before := m.state.leaseList("a")[0].ExpiresAt
if before.After(end.Add(time.Second)) {
t.Fatalf("a regular grant is capped by the tunnel TTL: %v > %v", before, end)
}
resp := a.extend(t, "a", "127.0.0.1", 60)
if !resp.Success || !resp.ExpiresAt.Equal(before.Add(time.Hour)) {
t.Fatalf("a running lease extends from its expiry: %+v (was %v)", resp, before)
}
}
func TestCustomLeaseDurations(t *testing.T) {
for d, want := range map[string]int{"30m": 30, "90m": 90, "1h": 60, "": 120, "8h": 480} {
if n, ok := protocol.LeaseMinutes(d); !ok || n != want {
t.Errorf("LeaseMinutes(%q) = %d, %v", d, n, ok)
}
}
for _, bad := range []string{"3m", "abc", "90", "100000m"} {
if protocol.ValidLeaseDuration(bad) {
t.Errorf("%q should be invalid", bad)
}
}
if got := protocol.DescribeLeaseDuration("90m"); got != "1 小时 30 分钟" {
t.Errorf("describe 90m: %q", got)
}
now := time.Now()
if got := leaseExpiry("45m", nil, now); !got.Equal(now.Add(45 * time.Minute)) {
t.Errorf("leaseExpiry 45m: %v", got.Sub(now))
}
}
+353 -19
View File
@@ -25,10 +25,17 @@ const (
leaseStartWindow = 24 * time.Hour
)
// renewWindow 访问结束(授权到期或通道关闭)后多久内还能申请延长;之后清除记录,需要重新邀请
var renewWindow = 2 * time.Hour
var (
errInviteNotFound = errors.New("邀请链接无效或已过期")
errInviteUsedUp = errors.New("该邀请链接的可用设备数已用完,请向管理员索取新的链接")
errRequestGone = errors.New("申请不存在或已过期")
errAlreadyUsed = errors.New("这台设备已经用过该邀请链接,请重新打开链接申请延长")
errNothingToRenew = errors.New("访问已结束超过 2 小时,请联系管理员获取新的邀请链接")
errRevoked = errors.New("授权已被管理员撤销,如需继续使用请联系管理员")
errNotStarted = errors.New("授权尚未开始计时(首次连接后才开始),无需延长")
)
type leaseInfo struct {
@@ -89,10 +96,27 @@ func (s *stateStore) lease(scope, ip string) (leaseInfo, bool) {
return leaseInfo{}, false
}
// leaseRecord remembers a device whose access ended, so it can ask for an extension
type leaseRecord struct {
EndedAt time.Time `json:"ended_at"`
Name string `json:"name,omitempty"`
Invite string `json:"invite,omitempty"`
// Revoked 为 true 表示被管理员撤销,不能申请延长
Revoked bool `json:"revoked,omitempty"`
}
func (r leaseRecord) renewable(now time.Time) bool {
return !r.Revoked && now.Before(r.EndedAt.Add(renewWindow))
}
type tunnelState struct {
Leases map[string]leaseInfo `json:"leases,omitempty"` // ip -> lease
Invites map[string]*protocol.Invite `json:"invites,omitempty"` // code -> invite
Requests map[string]*protocol.AccessRequest `json:"requests,omitempty"` // id -> request
// Ended 访问已结束的设备(ip -> 记录),结束后 renewWindow 内可申请延长;通道关闭后仍保留
Ended map[string]leaseRecord `json:"ended,omitempty"`
// Name 通道或组最近的名称,通道关闭后用于申请延长页面
Name string `json:"name,omitempty"`
}
// 授权状态的作用范围:单个通道(键为通道 ID,与旧数据兼容)或整个通道组(键为 "group:<组ID>")
@@ -177,7 +201,7 @@ func (m *Manager) EnableLeasePersistence(path string) error {
// looksLikeState distinguishes the new format from the legacy one (both are JSON objects of objects)
func looksLikeState(d map[string]*tunnelState) bool {
for _, st := range d {
if st == nil || (st.Leases == nil && st.Invites == nil && st.Requests == nil) {
if st == nil || (st.Leases == nil && st.Invites == nil && st.Requests == nil && st.Ended == nil) {
return false
}
}
@@ -199,9 +223,21 @@ func (s *stateStore) scopeLocked(id string) *tunnelState {
if st.Requests == nil {
st.Requests = make(map[string]*protocol.AccessRequest)
}
if st.Ended == nil {
st.Ended = make(map[string]leaseRecord)
}
return st
}
// endLeaseLocked turns a lease into an ended record (renewable unless revoked)
func endLeaseLocked(st *tunnelState, ip string, l leaseInfo, at time.Time, revoked bool) {
if st.Ended == nil {
st.Ended = make(map[string]leaseRecord)
}
st.Ended[ip] = leaseRecord{EndedAt: at, Name: l.Name, Invite: l.Invite, Revoked: revoked}
delete(st.Leases, ip)
}
func randomCode(n int) string {
const chars = "23456789abcdefghjkmnpqrstuvwxyz"
b := make([]byte, n)
@@ -225,13 +261,25 @@ func (s *stateStore) removeLease(scope, ip string) int {
if st == nil {
return 0
}
// 被撤销的授权不能再申请延长
now := time.Now()
n := 0
if ip == "" {
n = len(st.Leases)
st.Leases = make(map[string]leaseInfo)
} else if _, ok := st.Leases[ip]; ok {
n = 1
delete(st.Leases, ip)
for lip, l := range st.Leases {
if ip == "" || lip == ip {
endLeaseLocked(st, lip, l, now, true)
n++
}
}
for lip, r := range st.Ended {
if ip == "" || lip == ip {
r.Revoked = true
st.Ended[lip] = r
}
}
for id, r := range st.Requests {
if r.Renewal && r.Status == "pending" && (ip == "" || r.IP == ip) {
delete(st.Requests, id)
}
}
s.saveLocked()
return n
@@ -271,8 +319,54 @@ func (s *stateStore) leaseList(scope string) []protocol.DynamicLease {
return out
}
// forgetScope drops all access state of a tunnel or group (explicitly closed, deleted or dissolved)
func (s *stateStore) forgetScope(scope string) {
// closeScope runs when a tunnel, or the last open tunnel of a group, is closed: invites and
// first-time requests become invalid, running leases end — but ended devices are remembered for
// renewWindow so they can still ask for an extension.
func (s *stateStore) closeScope(scope string) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[scope]
if st == nil {
return
}
now := time.Now()
for ip, l := range st.Leases {
end := now
if l.ExpiresAt.Before(now) {
end = l.ExpiresAt
}
endLeaseLocked(st, ip, l, end, false)
}
st.Invites = nil
for id, r := range st.Requests {
if !r.Renewal && r.Status == "pending" {
delete(st.Requests, id)
}
}
s.saveLocked()
}
// scopeDisplayName is the last known name of a scope (for pages shown after it closed)
func (s *stateStore) scopeDisplayName(scope string) string {
s.mu.Lock()
defer s.mu.Unlock()
if st := s.data[scope]; st != nil {
return st.Name
}
return ""
}
// setName records the display name of a scope that has access state
func (s *stateStore) setName(scope, name string) {
s.mu.Lock()
defer s.mu.Unlock()
if st := s.data[scope]; st != nil && name != "" && st.Name != name {
st.Name = name
}
}
// dropScope drops all access state of a deleted tunnel or a dissolved group, renewal records included
func (s *stateStore) dropScope(scope string) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.data[scope]; ok {
@@ -379,6 +473,13 @@ func (s *stateStore) useInviteAndGrant(scope, code, name, ip string, scopeExpire
if !ok || !time.Now().Before(inv.ExpiresAt) {
return leaseInfo{}, errInviteNotFound
}
// 已授权的设备再次提交不重新计时;用过链接、授权已结束的设备要申请延长
if l, ok := st.Leases[ip]; ok && time.Now().Before(l.ExpiresAt) {
return l, nil
}
if usedByLocked(inv, ip) {
return leaseInfo{}, errAlreadyUsed
}
if !capacityOKLocked(st, inv, ip) {
return leaseInfo{}, errInviteUsedUp
}
@@ -386,10 +487,20 @@ func (s *stateStore) useInviteAndGrant(scope, code, name, ip string, scopeExpire
s.recordUseLocked(st, code, name, ip)
l := newLease(name, code, inv.LeaseDuration, scopeExpires, time.Now())
st.Leases[ip] = l
delete(st.Ended, ip)
s.saveLocked()
return l, nil
}
func usedByLocked(inv *protocol.Invite, ip string) bool {
for _, u := range inv.Uses {
if u.IP == ip {
return true
}
}
return false
}
// revokeInvite removes the invite and the leases it granted; returns the affected IPs
func (s *stateStore) revokeInvite(scope, code string) []string {
s.mu.Lock()
@@ -400,10 +511,17 @@ func (s *stateStore) revokeInvite(scope, code string) []string {
}
delete(st.Invites, code)
var ips []string
now := time.Now()
for ip, l := range st.Leases {
if l.Invite == code {
ips = append(ips, ip)
delete(st.Leases, ip)
endLeaseLocked(st, ip, l, now, true)
}
}
for ip, r := range st.Ended {
if r.Invite == code {
r.Revoked = true
st.Ended[ip] = r
}
}
for id, r := range st.Requests {
@@ -449,12 +567,15 @@ func (s *stateStore) addRequest(scope, code, name, ip string) (protocol.AccessRe
}
st = s.scopeLocked(scope)
for _, r := range st.Requests {
if r.IP == ip && r.Status == "pending" {
if r.IP == ip && r.Status == "pending" && !r.Renewal {
r.Name = name
s.saveLocked()
return *r, nil
}
}
if usedByLocked(inv, ip) {
return protocol.AccessRequest{}, errAlreadyUsed
}
if !capacityOKLocked(st, inv, ip) {
return protocol.AccessRequest{}, errInviteUsedUp
}
@@ -507,6 +628,24 @@ func (s *stateStore) decide(scope, id string, approve bool, scopeExpires *time.T
if inv, ok := st.Invites[r.Invite]; ok {
duration = inv.LeaseDuration
}
if r.Renewal {
// 延长申请通常由 extendLease 按管理员选择的时长批准;这里按邀请的时长(默认 2 小时)处理
if !approve {
r.Status, r.At = "rejected", time.Now()
s.saveLocked()
return *r, time.Time{}, nil
}
minutes, ok := protocol.LeaseMinutes(duration)
if !ok {
minutes = 120
}
exp, err := s.extendLocked(st, r.IP, minutes, time.Now())
if err != nil {
return protocol.AccessRequest{}, time.Time{}, err
}
s.saveLocked()
return *r, exp, nil
}
l := newLease(r.Name, r.Invite, duration, scopeExpires, time.Now())
exp := l.ExpiresAt
r.At = time.Now()
@@ -514,6 +653,7 @@ func (s *stateStore) decide(scope, id string, approve bool, scopeExpires *time.T
r.Status = "approved"
s.recordUseLocked(st, r.Invite, r.Name, r.IP)
st.Leases[r.IP] = l
delete(st.Ended, r.IP)
} else {
r.Status = "rejected"
}
@@ -528,7 +668,7 @@ func (s *stateStore) pendingList(scope string) []protocol.AccessRequest {
var out []protocol.AccessRequest
if st := s.data[scope]; st != nil {
for _, r := range st.Requests {
if r.Status == "pending" {
if r.Status == "pending" && !r.Renewal {
out = append(out, *r)
}
}
@@ -537,6 +677,195 @@ func (s *stateStore) pendingList(scope string) []protocol.AccessRequest {
return out
}
// ---- renewals ----
// deviceAccess is what a device that opens an invite link already has in the invite's scope
type deviceAccess struct {
scope string
name string // 通道或组的名称(通道关闭后为最近一次的名称)
lease *leaseInfo
ended *leaseRecord
pending *protocol.AccessRequest // 待处理的延长申请
// usedBefore 用过该链接,但访问已结束超过 renewWindow
usedBefore bool
}
// deviceAccess looks up ip's access in the scope of invite code, also after the invite or the
// tunnel is gone; false when the device never used this invite (a new visitor)
func (s *stateStore) deviceAccess(code, ip string) (deviceAccess, bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked()
now := time.Now()
for scope, st := range s.data {
inv := st.Invites[code]
l, hasLease := st.Leases[ip]
rec, hasRec := st.Ended[ip]
mine := (inv != nil && usedByLocked(inv, ip)) || (hasLease && l.Invite == code) || (hasRec && rec.Invite == code)
if !mine {
continue
}
da := deviceAccess{scope: scope, name: st.Name}
switch {
case hasLease && now.Before(l.ExpiresAt):
da.lease = &l
case hasRec && (rec.Revoked || rec.renewable(now)):
da.ended = &rec
default:
da.usedBefore = true
}
for _, r := range st.Requests {
if r.IP == ip && r.Renewal && r.Status == "pending" {
c := *r
da.pending = &c
}
}
return da, true
}
return deviceAccess{}, false
}
// addRenewal files (or re-uses) a renewal request for ip: its lease must be running, or have
// ended less than renewWindow ago without being revoked
func (s *stateStore) addRenewal(scope, ip string) (protocol.AccessRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked()
st := s.data[scope]
if st == nil {
return protocol.AccessRequest{}, errNothingToRenew
}
now := time.Now()
var name, invite string
if l, ok := st.Leases[ip]; ok && now.Before(l.ExpiresAt) {
if l.Deferred {
return protocol.AccessRequest{}, errNotStarted
}
name, invite = l.Name, l.Invite
} else if rec, ok := st.Ended[ip]; ok && rec.Revoked {
return protocol.AccessRequest{}, errRevoked
} else if ok && rec.renewable(now) {
name, invite = rec.Name, rec.Invite
} else {
return protocol.AccessRequest{}, errNothingToRenew
}
st = s.scopeLocked(scope)
for _, r := range st.Requests {
if r.IP == ip && r.Renewal && r.Status == "pending" {
return *r, nil
}
}
r := &protocol.AccessRequest{ID: randomCode(16), Name: name, IP: ip, Invite: invite, At: now, Status: "pending", Renewal: true}
if id, isGroup := splitScope(scope); isGroup {
r.GroupID = id
} else {
r.TunnelID = id
}
st.Requests[r.ID] = r
s.saveLocked()
return *r, nil
}
// extendLease extends ip's access in scope by minutes and approves its pending renewal requests.
// 不受通道最长开启时长限制:管理员批准的时长优先。
func (s *stateStore) extendLease(scope, ip string, minutes int) (time.Time, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked()
st := s.data[scope]
if st == nil {
return time.Time{}, errNothingToRenew
}
exp, err := s.extendLocked(s.scopeLocked(scope), ip, minutes, time.Now())
if err != nil {
return time.Time{}, err
}
s.saveLocked()
return exp, nil
}
// extendLocked: a running lease is extended from its expiry (one not yet started starts now),
// an ended one is restored from now. Caller must hold s.mu and save.
func (s *stateStore) extendLocked(st *tunnelState, ip string, minutes int, now time.Time) (time.Time, error) {
d := time.Duration(minutes) * time.Minute
if l, ok := st.Leases[ip]; ok && now.Before(l.ExpiresAt) {
if l.Deferred {
l.Deferred, l.StartedAt, l.ExpiresAt = false, &now, now.Add(d)
} else {
l.ExpiresAt = l.ExpiresAt.Add(d)
}
st.Leases[ip] = l
} else if rec, ok := st.Ended[ip]; ok && rec.Revoked {
return time.Time{}, errRevoked
} else if ok && rec.renewable(now) {
st.Leases[ip] = leaseInfo{Name: rec.Name, Invite: rec.Invite, Duration: protocol.LeaseDurationOf(minutes), StartedAt: &now, ExpiresAt: now.Add(d)}
delete(st.Ended, ip)
} else {
return time.Time{}, errNothingToRenew
}
for _, r := range st.Requests {
if r.IP == ip && r.Renewal && r.Status == "pending" {
r.Status, r.At = "approved", now
}
}
return st.Leases[ip].ExpiresAt, nil
}
// renewalList reports, across all scopes (closed ones included), the devices that can still be
// extended and the pending renewal requests
func (s *stateStore) renewalList() []protocol.RenewalInfo {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked()
now := time.Now()
var out []protocol.RenewalInfo
for scope, st := range s.data {
id, isGroup := splitScope(scope)
base := protocol.RenewalInfo{TunnelID: id}
if isGroup {
base = protocol.RenewalInfo{GroupID: id}
}
byIP := make(map[string]int)
for ip, rec := range st.Ended {
if !rec.renewable(now) {
continue
}
info := base
end, before := rec.EndedAt, rec.EndedAt.Add(renewWindow)
info.IP, info.Name, info.Invite, info.EndedAt, info.RenewBefore = ip, rec.Name, rec.Invite, &end, &before
byIP[ip] = len(out)
out = append(out, info)
}
for _, r := range st.Requests {
if !r.Renewal || r.Status != "pending" {
continue
}
c := *r
if i, ok := byIP[r.IP]; ok {
out[i].Request = &c
continue
}
info := base
info.IP, info.Name, info.Invite, info.Request = r.IP, r.Name, r.Invite, &c
out = append(out, info)
}
}
sort.Slice(out, func(i, j int) bool {
if (out[i].Request != nil) != (out[j].Request != nil) {
return out[i].Request != nil
}
ti, tj := now, now
if out[i].EndedAt != nil {
ti = *out[i].EndedAt
}
if out[j].EndedAt != nil {
tj = *out[j].EndedAt
}
return ti.After(tj)
})
return out
}
// ---- housekeeping ----
func (s *stateStore) pruneRequestsLocked() {
@@ -560,7 +889,12 @@ func (s *stateStore) pruneLocked() {
}
for ip, l := range st.Leases {
if !now.Before(l.ExpiresAt) {
delete(st.Leases, ip)
endLeaseLocked(st, ip, l, l.ExpiresAt, false)
}
}
for ip, r := range st.Ended {
if !now.Before(r.EndedAt.Add(renewWindow)) {
delete(st.Ended, ip)
}
}
for code, inv := range st.Invites {
@@ -568,7 +902,7 @@ func (s *stateStore) pruneLocked() {
delete(st.Invites, code)
}
}
if len(st.Leases) == 0 && len(st.Invites) == 0 && len(st.Requests) == 0 {
if len(st.Leases) == 0 && len(st.Invites) == 0 && len(st.Requests) == 0 && len(st.Ended) == 0 {
delete(s.data, id)
}
}
@@ -607,10 +941,6 @@ func (s *stateStore) saveLocked() {
func leaseExpiry(duration string, scopeExpires *time.Time, now time.Time) time.Time {
var exp time.Time
switch duration {
case protocol.LeaseDuration1h:
exp = now.Add(time.Hour)
case protocol.LeaseDuration8h:
exp = now.Add(8 * time.Hour)
case protocol.LeaseDurationToday:
y, mo, d := now.Date()
exp = time.Date(y, mo, d, 23, 59, 59, 0, now.Location())
@@ -618,7 +948,11 @@ func leaseExpiry(duration string, scopeExpires *time.Time, now time.Time) time.T
// 通道开启期间有效:通道被主动关闭时放通会被清除;设一个上限避免永久放通
exp = now.Add(30 * 24 * time.Hour)
default:
exp = now.Add(2 * time.Hour)
minutes, ok := protocol.LeaseMinutes(duration)
if !ok {
minutes = 120
}
exp = now.Add(time.Duration(minutes) * time.Minute)
}
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(exp) {
exp = *scopeExpires
+113 -2
View File
@@ -31,6 +31,8 @@ func (m *Manager) handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
m.serveInviteForm(w, r, r.URL.Query().Get("code"))
case path == "/auth/status":
m.serveRequestStatus(w, r)
case path == "/renew" && r.Method == http.MethodPost:
m.serveRenewSubmit(w, r)
default:
http.NotFound(w, r)
}
@@ -108,6 +110,13 @@ func (m *Manager) inviteTarget(code string) (accessScope, protocol.Invite, strin
func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code string) {
ip := clientIPOf(r)
// 用过这个链接的设备:显示它的授权状态,到期前后都可以申请延长(链接或通道失效后也一样)
if ip != "" {
if da, ok := m.state.deviceAccess(strings.TrimSpace(code), ip); ok {
m.serveDeviceAccess(w, r, strings.TrimSpace(code), ip, da)
return
}
}
sc, inv, problem := m.inviteTarget(code)
if problem == "" && ip == "" {
problem = "无法识别客户端 IP"
@@ -142,6 +151,88 @@ func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code s
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+sc.name, body)
}
// serveDeviceAccess shows a returning device its access and, when it can, the renewal button
func (m *Manager) serveDeviceAccess(w http.ResponseWriter, r *http.Request, code, ip string, da deviceAccess) {
if da.pending != nil {
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(da.pending.ID), http.StatusSeeOther)
return
}
sc, open := m.resolveScope(da.scope)
rows := func(validity string) string {
if open {
return m.detailRows(r, sc, ip, validity)
}
return closedRows(da, ip, validity)
}
renewForm := fmt.Sprintf(`
<form method="post" action="/renew" class="form">
<input type="hidden" name="code" value="%s">
<button type="submit">申请延长</button>
</form>`, html.EscapeString(code))
switch {
case da.lease != nil && da.lease.Deferred && open:
m.writePage(w, r, http.StatusOK, pageSuccess, "已获得访问权限", m.successBody(r, sc, ip, *da.lease))
case da.lease != nil && da.lease.Deferred:
m.writePage(w, r, http.StatusOK, pageSuccess, "已获得访问权限", rows(leaseValidity(*da.lease)))
case da.lease != nil:
left := time.Until(da.lease.ExpiresAt).Round(time.Minute)
body := rows(fmt.Sprintf("至 %s(剩余 %s)", da.lease.ExpiresAt.Format("01-02 15:04"), protocol.DescribeMinutes(max(1, int(left/time.Minute))))) +
`<div class="tip">需要更多时间时可以申请延长,管理员批准后在原到期时间上顺延。</div>` + renewForm
m.writePage(w, r, http.StatusOK, pageSuccess, "访问已授权", body)
case da.ended != nil && da.ended.Revoked:
m.writePage(w, r, http.StatusForbidden, pageError, "授权已撤销", problemHTML(errRevoked.Error()))
case da.ended != nil:
closedTip := ""
if !open {
closedTip = "通道当前已关闭,批准后会自动重新开启。"
}
body := rows("已于 "+da.ended.EndedAt.Format("01-02 15:04")+" 结束") +
fmt.Sprintf(`<div class="tip">可在 %s 前申请延长,管理员批准并确定时长后即可继续使用。%s</div>`,
da.ended.EndedAt.Add(renewWindow).Format("15:04"), closedTip) + renewForm
m.writePage(w, r, http.StatusOK, pageForm, "访问已结束", body)
default:
m.writePage(w, r, http.StatusGone, pageError, "访问已结束", problemHTML(errNothingToRenew.Error()))
}
}
// closedRows describes a scope whose tunnels are all closed (only its last known name is left)
func closedRows(da deviceAccess, ip, validity string) string {
label := "目标通道"
if _, isGroup := splitScope(da.scope); isGroup {
label = "目标通道组"
}
name := da.name
if name == "" {
name = "(已关闭)"
}
return fmt.Sprintf(`
<div class="row"><span class="label">%s</span><span class="value">%s</span></div>
<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>`,
label, html.EscapeString(name), html.EscapeString(ip), html.EscapeString(validity))
}
func (m *Manager) serveRenewSubmit(w http.ResponseWriter, r *http.Request) {
r.Body = io.NopCloser(io.LimitReader(r.Body, 8<<10))
if err := r.ParseForm(); err != nil {
m.writePage(w, r, http.StatusBadRequest, pageError, "请求无效", problemHTML("表单内容无法解析"))
return
}
ip := clientIPOf(r)
da, ok := m.state.deviceAccess(strings.TrimSpace(r.PostForm.Get("code")), ip)
if ip == "" || !ok {
m.writePage(w, r, http.StatusNotFound, pageError, "无法申请延长", problemHTML("没有找到这台设备的授权,请联系管理员获取新的邀请链接"))
return
}
req, err := m.state.addRenewal(da.scope, ip)
if err != nil {
m.writePage(w, r, http.StatusConflict, pageError, "无法申请延长", problemHTML(err.Error()))
return
}
log.Printf("[WebAuth] %s (%s) asked to extend access to %s", req.Name, ip, da.name)
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(req.ID), http.StatusSeeOther)
}
func sanitizeName(s string) string {
s = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
@@ -201,15 +292,35 @@ func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
m.writePage(w, r, http.StatusNotFound, pageError, "申请已失效", problemHTML("申请不存在或已过期,请重新打开邀请链接提交"))
return
}
sc, open := m.resolveScope(scopeOf(req.TunnelID, req.GroupID))
scope := scopeOf(req.TunnelID, req.GroupID)
sc, open := m.resolveScope(scope)
switch {
case req.Status == "rejected" && req.Renewal:
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的延长申请"))
case req.Status == "rejected":
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的访问申请"))
case req.Renewal && req.Status == "approved" && !open:
// 批准后 Agent 正在重新开启通道
w.Header().Set("Refresh", "3")
m.writePage(w, r, http.StatusOK, pagePending, "已批准,正在开启通道", `<div class="tip">管理员已批准延长,通道正在重新开启,此页面会自动刷新。</div>`)
case req.Renewal && !open:
da := deviceAccess{scope: scope, name: m.state.scopeDisplayName(scope)}
w.Header().Set("Refresh", "5")
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", closedRows(da, req.IP, "等待批准")+
fmt.Sprintf(`<div class="tip">%s,您的延长申请已提交,管理员批准时会确定延长多久;通道当前已关闭,批准后会自动重新开启。此页面会自动刷新。</div>`, html.EscapeString(req.Name)))
case !open:
m.writePage(w, r, http.StatusOK, pageError, "通道未开启", problemHTML("对应通道当前未开启,请联系管理员"))
case req.Status == "approved" && req.Renewal:
lease, _ := m.state.lease(sc.key, req.IP)
m.writePage(w, r, http.StatusOK, pageSuccess, "延长已批准", m.detailRows(r, sc, req.IP, leaseValidity(lease))+
`<div class="tip">管理员已批准延长,现在可以继续使用。到期后 2 小时内还可以重新打开邀请链接申请延长。</div>`)
case req.Status == "approved":
lease, _ := m.state.lease(sc.key, req.IP)
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, sc, req.IP, lease))
case req.Renewal:
w.Header().Set("Refresh", "5")
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", m.detailRows(r, sc, req.IP, "等待批准")+
fmt.Sprintf(`<div class="tip">%s,您的延长申请已提交,管理员批准时会确定延长多久。此页面会自动刷新。</div>`, html.EscapeString(req.Name)))
default:
duration := ""
if _, inv, ok := m.state.findInvite(req.Invite); ok {
@@ -244,7 +355,7 @@ func (m *Manager) successBody(r *http.Request, sc accessScope, ip string, l leas
where = "上面任意一个访问地址"
}
return m.detailRows(r, sc, ip, leaseValidity(l)) +
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。</div>`
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后 2 小时内,可以重新打开这个邀请链接申请延长。</div>`
}
// detailRows renders the info table; a group lists every access address with its target
+28 -8
View File
@@ -189,7 +189,10 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
[
{"id":"h1","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51000","connected_at":"\(t(-5000))","disconnected_at":"\(t(-4000))","duration_sec":1000,"bytes_in":1288490188,"bytes_out":5368709},
{"id":"h2","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51001","connected_at":"\(t(-3000))","disconnected_at":"\(t(-2990))","duration_sec":10,"bytes_in":2048,"bytes_out":4096},
{"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1}
{"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1},
{"id":"h4","tunnel_id":"g1a","tunnel_name":"订单服务 (10.0.3.11)","port":33670,"client_ip":"192.168.1.42:50100","connected_at":"\(t(-7000))","disconnected_at":"\(t(-6400))","duration_sec":600,"bytes_in":820000,"bytes_out":5200000},
{"id":"h5","tunnel_id":"g1b","tunnel_name":"订单服务 (10.0.3.12)","port":33671,"client_ip":"192.168.1.42:50120","connected_at":"\(t(-6800))","disconnected_at":"\(t(-6000))","duration_sec":800,"bytes_in":420000,"bytes_out":3100000},
{"id":"h6","tunnel_id":"g1a","tunnel_name":"订单服务 (10.0.3.11)","port":33670,"client_ip":"192.168.1.66:41022","connected_at":"\(t(-90000))","disconnected_at":"\(t(-88200))","duration_sec":1800,"bytes_in":1200000,"bytes_out":9400000}
]
""")
vm.totalActiveConns = 8; vm.totalHistoricalConns = 1656; vm.totalRateIn = totalIn; vm.totalRateOut = totalOut
@@ -241,8 +244,8 @@ MainActor.assumeIsolated {
render("01b-main-min", CGSize(width: 920, height: 640), ContentView(vm: vm))
render("04-clients-history", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 1, isPresented: yes))
render("05-clients-auth", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 2, isPresented: yes))
render("06-edit-new", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: nil, isPresented: yes))
render("07-edit-existing", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: vm.tunnels[1], isPresented: yes))
render("06-edit-new", CGSize(width: 520, height: 760), TunnelEditSheet(vm: vm, tunnel: nil, isPresented: yes))
render("07-edit-existing", CGSize(width: 520, height: 760), TunnelEditSheet(vm: vm, tunnel: vm.tunnels[1], isPresented: yes))
render("08-invite-form", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: false, isPresented: yes))
render("09-invite-result", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: true, isPresented: yes))
render("10-settings", CGSize(width: 400, height: 320), SettingsSheet(vm: vm, isPresented: yes))
@@ -256,7 +259,7 @@ MainActor.assumeIsolated {
render("readme-invite", CGSize(width: 560, height: 250), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes))
render("readme-clients", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 0, isPresented: yes))
render("readme-access", CGSize(width: 760, height: 490), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 2, isPresented: yes))
render("readme-edit", CGSize(width: 490, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
render("readme-edit", CGSize(width: 520, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
let g = demo.groups[0]
render("group-card", CGSize(width: 980, height: 330), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
@@ -264,14 +267,31 @@ MainActor.assumeIsolated {
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
render("group-card-narrow", CGSize(width: 720, height: 360), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
render("group-add-batch", CGSize(width: 490, height: 860), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes,
draftName: "订单服务", draftTarget: "10.0.3.11-13:22, 10.0.3.11-13:8080"))
render("group-add-member", CGSize(width: 490, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, groupId: g.id))
render("group-add-batch", CGSize(width: 520, height: 900), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes,
draftName: "订单服务", draftRows: [("10.0.3.11", "22, 8080"), ("10.0.3.12", "22, 8080"), ("10.0.3.13", "22")]))
render("group-add-single", CGSize(width: 520, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes,
draftName: "MySQL 实习库", draftRows: [("10.0.1.16", "3306")]))
render("group-add-member", CGSize(width: 520, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, groupId: g.id))
render("group-sheet-edit", CGSize(width: 520, height: 420), GroupEditSheet(vm: demo, group: g, isPresented: yes))
render("group-invite", CGSize(width: 560, height: 300), InviteSheet(vm: demo, scope: .group(g.id), autoGenerate: true, isPresented: yes))
render("group-access", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 2, isPresented: yes))
render("group-online", CGSize(width: 760, height: 400), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 0, isPresented: yes))
render("group-edit-member", CGSize(width: 490, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes))
render("group-online-addr", CGSize(width: 760, height: 460), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 0, byAddress: true, isPresented: yes))
render("group-history", CGSize(width: 760, height: 400), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 1, isPresented: yes))
render("group-history-addr", CGSize(width: 760, height: 520), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 1, byAddress: true, isPresented: yes))
render("group-edit-member", CGSize(width: 520, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes))
// 授权延长:申请延长、最近结束、已授权设备的「延长」
let rvm = makeDemoVM()
rvm.tunnels[0].renewals = decode([RenewalItem].self, """
[{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.35","name":"李四","invite":"ab1","ended_at":"\(t(-1200))","renew_before":"\(t(6000))",
"request":{"id":"rn1","tunnel_id":"\(rvm.tunnels[0].id)","name":"李四","ip":"192.168.1.35","invite":"ab1","at":"\(t(-60))","status":"pending","renewal":true}},
{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.36","name":"王五","invite":"ab2","ended_at":"\(t(-3000))","renew_before":"\(t(4200))"}]
""")
render("renew-access", CGSize(width: 760, height: 620), ClientDetailsSheet(vm: rvm, tunnel: rvm.tunnels[0], initialTab: 2, isPresented: yes))
render("renew-card", CGSize(width: 980, height: 220), TunnelCardView(tunnel: rvm.tunnels[0], vm: rvm, onEdit: {}, onViewClients: {}, onViewLeases: {}, onInvite: {}).padding(20))
render("renew-invite-form", CGSize(width: 600, height: 400), InviteSheet(vm: rvm, tunnel: rvm.tunnels[0], autoGenerate: false, isPresented: yes))
render("renew-custom", CGSize(width: 300, height: 90), CustomMinutesForm(actionTitle: "延长") { _ in })
// README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
if only.hasPrefix("gif") {