Compare commits

..
12 Commits
Author SHA1 Message Date
DeepFal a9e6ca7080 docs: 授权延长在 README 里单独成节,主界面与动图带上延长申请
- 「申请延长」单独成节:同事端 ④⑤⑥ 与 App 的连接与授权截图放在一起,
  写明卡片提示、点「批准延长」选时长、最近结束的设备也可直接延长
- 演示数据:MySQL 通道带一条延长申请和一台最近结束的设备,主界面、动图、
  授权管理与网页面板展示同一组数据,卡片上能看到「申请延长 1」
2026-10-14 15:00:08 +08:00
DeepFal 3272e66247 docs: README 跟上授权延长与通道组,截图和动图改为一条命令重新生成
README
- 申请延长写清规则:授权有效期间也可申请,有效的从原到期时间顺延、已结束的从批准时起算,
  被撤销的不能延长;批准延长时通道会开到授权结束(与最长开启时长的关系)
- 修正通道组「一起开关」与「可以单独开关」的矛盾:闲置按整组计算,每个地址仍可单独开关
- 时长选项用界面上的「通道开启期间」;快速开始按实际流程写(保存后自动弹出邀请设置)
- 新增同事端申请延长的三张截图;流程图补上 30 分钟、自定义与 2 小时内可申请延长

截图与动图
- 授权管理、编辑通道、邀请文案、授权成功页、网页面板(含通道组)更新为当前界面
- 离屏渲染时控件按激活状态绘制,开关不再显示成灰色
- 新增 make readme-images:App 离屏渲染、动图合成、授权页与网页面板的无头 Chrome 截图一次完成,
  写入 docs/images;授权页 HTML 由服务端测试用模拟状态生成,网页面板用本机模拟 API

界面
- 计时文字第二个单位补零(27分05秒),卡片上的徽标不再随秒数左右跳动;App 与网页面板一致
- 待审批与延长申请的时间格式统一为不带秒;App 预览的分享文案补上延长提示
2026-10-14 14:51:07 +08:00
DeepFal 6f2a1472b6 ci(release): 只下载安装包 artifact,避免 docker 构建记录导致发布失败 2026-10-14 10:57:53 +08:00
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
28 changed files with 506 additions and 84 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`
+26 -9
View File
@@ -12,7 +12,7 @@ permissions:
jobs: jobs:
binaries: binaries:
name: Server & agent binaries name: Server & agent archives
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -31,24 +31,22 @@ jobs:
dist/*.zip dist/*.zip
macos-app: macos-app:
name: macOS app name: macOS app (dmg)
runs-on: macos-15 runs-on: macos-15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version-file: go.mod
- name: Build universal app - name: Build universal dmg
run: | run: |
VERSION=${GITHUB_REF_NAME#v} VERSION=${GITHUB_REF_NAME#v}
case "$VERSION" in [0-9]*) ;; *) VERSION=0.0.0-${GITHUB_SHA::7} ;; esac case "$VERSION" in [0-9]*) ;; *) VERSION=0.0.0-${GITHUB_SHA::7} ;; esac
UNIVERSAL=1 VERSION=$VERSION ./build-app.sh UNIVERSAL=1 make dmg VERSION=$VERSION
mkdir -p dist
ditto -c -k --sequesterRsrc --keepParent PortRelay.app "dist/PortRelay-$VERSION-macos-universal.zip"
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: macos-app name: macos-app
path: dist/*.zip path: dist/*.dmg
docker: docker:
name: Docker image name: Docker image
@@ -88,13 +86,32 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
steps: steps:
- uses: actions/checkout@v4
# 只取安装包;docker 任务还会上传构建记录,不能一并下载
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
name: binaries
path: dist
- uses: actions/download-artifact@v4
with:
name: macos-app
path: dist path: dist
merge-multiple: true
- name: Checksums - name: Checksums
run: cd dist && sha256sum * > SHA256SUMS 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 - name: Create release
env: env:
GH_TOKEN: ${{ github.token }} 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
+29 -11
View File
@@ -4,16 +4,20 @@
# make agent 编译当前平台的 Agent 到 bin/ # make agent 编译当前平台的 Agent 到 bin/
# make app 构建 macOS 应用 PortRelay.app(需要 Xcode 命令行工具) # make app 构建 macOS 应用 PortRelay.app(需要 Xcode 命令行工具)
# make install-app 构建并替换 /Applications/PortRelay.app(正在运行的会先退出,装好后重新打开) # make install-app 构建并替换 /Applications/PortRelay.app(正在运行的会先退出,装好后重新打开)
# make release 打包所有平台的服务端 / Agent 到 dist/ # make dmg 打包 macOS 安装镜像 dist/PortRelay-<版本>.dmg(UNIVERSAL=1 构建通用版本)
# make release 打包服务端(Linux)与命令行 Agent(Linux / Windows)到 dist/
# make snapshots 离屏渲染 App 界面截图(tools/ui-snapshots/out) # make snapshots 离屏渲染 App 界面截图(tools/ui-snapshots/out)
# make readme-images 重新生成 README 的全部截图和动图到 docs/images(需要 Google Chrome)
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo dev) VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION) LDFLAGS := -s -w -X main.version=$(VERSION)
GOBUILD := CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" 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 install-app release snapshots clean .PHONY: all test fmt server agent app install-app dmg release snapshots readme-images clean
all: test agent all: test agent
@@ -47,24 +51,38 @@ install-app: app
echo "✓ 已安装 /Applications/PortRelay.app ($(VERSION))"; \ echo "✓ 已安装 /Applications/PortRelay.app ($(VERSION))"; \
if [ -n "$$running" ]; then open /Applications/PortRelay.app; fi 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: release:
@rm -rf dist && mkdir -p dist @rm -rf dist/portrelay-* dist/SHA256SUMS && mkdir -p dist
@for p in $(PLATFORMS); do \ @pack() { \
os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \ cmd=$$1; os=$$2; arch=$$3; ext=; [ $$os = windows ] && ext=.exe; \
name=portrelay-$(VERSION)-$$os-$$arch; mkdir -p dist/$$name; \ name=$$cmd-$(VERSION)-$$os-$$arch; mkdir -p dist/$$name; \
echo "==> $$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/$$cmd$$ext ./cmd/$$cmd || return 1; \
GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/portrelay-agent$$ext ./cmd/portrelay-agent || exit 1; \
cp LICENSE README.md README_EN.md dist/$$name/; \ 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); \ if [ $$os = windows ]; then (cd dist && zip -qr $$name.zip $$name); \
else tar -C dist -czf dist/$$name.tar.gz $$name; fi; \ else tar -C dist -czf dist/$$name.tar.gz $$name; fi; \
rm -rf dist/$$name; \ 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 @cd dist && shasum -a 256 * > SHA256SUMS && cat SHA256SUMS
snapshots: snapshots:
tools/ui-snapshots/render.sh tools/ui-snapshots/render.sh
readme-images:
tools/ui-snapshots/readme.sh
clean: clean:
rm -rf bin build dist PortRelay.app mac-native/PortRelay tools/ui-snapshots/.build tools/ui-snapshots/out rm -rf bin build dist PortRelay.app mac-native/PortRelay tools/ui-snapshots/.build tools/ui-snapshots/out
+31 -20
View File
@@ -51,18 +51,18 @@ PortRelay 把这件事变成三步:
**访问控制** **访问控制**
- 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。 - 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。
- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(30 分钟、1 小时、2 小时、8 小时、当天有效、直到通道关闭,或自定义),以及是否需要审批。 - 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(30 分钟、1 小时、2 小时、8 小时、当天有效、通道开启期间,或自定义),以及是否需要审批。
- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。 - 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。 - **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。 - 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
- **申请延长**:访问结束后 2 小时内(通道已关闭也可以),同事重新打开原链接点「申请延长」;你在 App、菜单栏或网页面板里选择延长多久即批准,关闭的通道会自动重新开启。你也可以对已授权或刚结束的设备主动延长。 - **申请延长**:同事重新打开原来的邀请链接,点「申请延长」,你在 App、菜单栏或网页面板里选好延长多久即可批准。授权还没到期时,从原到期时间往后顺延;已经结束的从批准时算起,结束后 2 小时内都能申请,通道关了也没关系,批准后会自动重新开启。被撤销的授权不能延长。你也可以主动给已授权或刚结束的设备延长。
- **通道组**:添加通道时目标是一个列表,每行一台主机、多个端口用逗号隔开(也可以直接粘贴一串 IP),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。 - **通道组**:添加通道时目标是一个列表,每行一台主机、多个端口用逗号隔开(也可以直接粘贴一串 IP),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。
**通道生命周期** **通道生命周期**
- **闲置自动关闭**:没有连接、没有有效授权、没有待处理申请,也没有还能使用的邀请链接时开始计时,默认 30 分钟后关闭。 - **闲置自动关闭**:没有连接、没有有效授权、没有待处理申请,也没有还能使用的邀请链接时开始计时,默认 30 分钟后关闭。
- 可选的**最长开启时长**,到点无论是否在用都关闭。 - 可选的**最长开启时长**,到点无论是否在用都关闭。批准延长时,如果新的授权到期时间更晚,通道会开到授权结束。
- 通道组内任一地址在用,整组都不算闲置,一起开关、一起关闭。 - 通道组的闲置按整组计算:任一地址在用,整组都不会因闲置关闭,到时也是整组一起关。每个地址仍然可以单独开关。
- 记录首次连接时间、实际连接时长和每次关闭的原因。 - 记录首次连接时间、实际连接时长和每次关闭的原因。
- 编辑通道不会打断现有连接;更换端口时先绑定新端口再释放旧端口。 - 编辑通道不会打断现有连接;更换端口时先绑定新端口再释放旧端口。
@@ -82,7 +82,7 @@ PortRelay 把这件事变成三步:
<p align="center"> <p align="center">
<img src="docs/images/live-traffic.gif" alt="通道卡片与实时流量" width="100%"> <img src="docs/images/live-traffic.gif" alt="通道卡片与实时流量" width="100%">
<br><sub>通道卡片:访问地址、授权状态、实际连接时长,以及近 10 分钟流量(动图为加速演示)</sub> <br><sub>通道卡片:访问地址、待处理的访问与延长申请、实际连接时长,以及近 10 分钟流量(动图为加速演示)</sub>
</p> </p>
<picture> <picture>
@@ -104,18 +104,28 @@ PortRelay 把这件事变成三步:
</tr> </tr>
</table> </table>
**申请延长**:授权快到期或刚结束时,同事再打开同一个链接,就能看到自己的授权状态并申请延长,不用你重新发邀请。
<table>
<tr>
<td align="center"><img src="docs/images/auth-renew.png" alt="申请延长" width="100%"><br><sub>④ 再次打开链接,申请延长</sub></td>
<td align="center"><img src="docs/images/auth-renew-pending.png" alt="等待批准延长" width="100%"><br><sub>⑤ 等待管理员选择时长</sub></td>
<td align="center"><img src="docs/images/auth-renew-ok.png" alt="延长已批准" width="100%"><br><sub>⑥ 延长已批准</sub></td>
</tr>
</table>
你这边,通道卡片上会出现「申请延长」。打开「连接与授权」,延长申请排在最前面,点「批准延长」选好时长即可,同事的页面随即显示新的到期时间。这里还列出待审批的访问申请、已授权设备(在线状态与剩余时间)、最近结束的授权和仍然有效的邀请链接,刚结束的设备也可以直接延长。
<p align="center">
<img src="docs/images/app-access.png" alt="连接与授权:延长申请、待审批、已授权设备、最近结束" width="80%">
</p>
**通道组**:组卡片汇总整组的状态与流量,下面逐行列出每个访问地址,可以单独开关、单独邀请或移出组。 **通道组**:组卡片汇总整组的状态与流量,下面逐行列出每个访问地址,可以单独开关、单独邀请或移出组。
<p align="center"> <p align="center">
<img src="docs/images/app-group.png" alt="通道组" width="100%"> <img src="docs/images/app-group.png" alt="通道组" width="100%">
</p> </p>
**连接与授权管理**:待审批的申请、已授权设备(在线状态与剩余时间),以及仍然有效的邀请链接,都在同一处管理。
<p align="center">
<img src="docs/images/app-access.png" alt="授权管理" width="80%">
</p>
<details> <details>
<summary><b>网页面板与更多截图</b></summary> <summary><b>网页面板与更多截图</b></summary>
<br> <br>
@@ -153,9 +163,9 @@ PortRelay 把这件事变成三步:
**方式 A:二进制 + systemd(推荐)** **方式 A:二进制 + systemd(推荐)**
```bash ```bash
VERSION=1.0.0 # 见 Releases 页面 VERSION=1.1.0 # 见 Releases 页面;ARM64 机器把 amd64 换成 arm64
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-$VERSION-linux-amd64.tar.gz curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-server-$VERSION-linux-amd64.tar.gz
tar xzf portrelay-$VERSION-linux-amd64.tar.gz && cd portrelay-$VERSION-linux-amd64 tar xzf portrelay-server-$VERSION-linux-amd64.tar.gz && cd portrelay-server-$VERSION-linux-amd64
sudo ./deploy/install-server.sh ./portrelay-server sudo ./deploy/install-server.sh ./portrelay-server
# 安装脚本会生成随机 token,记下来填到 Agent 里 # 安装脚本会生成随机 token,记下来填到 Agent 里
@@ -185,7 +195,7 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
### 2. 运行 Agent ### 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] > [!NOTE]
> App 未经 Apple 公证。首次打开时,在 Finder 中右键选择“打开”,或者执行 `xattr -dr com.apple.quarantine /Applications/PortRelay.app`。也可以 `make app` 自行构建。 > App 未经 Apple 公证。首次打开时,在 Finder 中右键选择“打开”,或者执行 `xattr -dr com.apple.quarantine /Applications/PortRelay.app`。也可以 `make app` 自行构建。
@@ -200,7 +210,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`,邀请链接和分享文案都会使用后者。 > 举例: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 ```bash
./portrelay-agent # 面板:http://127.0.0.1:33080 ./portrelay-agent # 面板:http://127.0.0.1:33080
@@ -210,10 +220,10 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
### 3. 创建通道并邀请同事 ### 3. 创建通道并邀请同事
1. 点 **添加通道**,填写名称和目标主机、端口(如 `10.0.1.16`、`3306`)。可以先测试一下本机能否连通;网关上的访问端口自动分配。 1. 点 **添加通道**,填写名称和目标主机、端口(如 `10.0.1.16`、`3306`)。可以先测试一下本机能否连通;网关上的访问端口自动分配。
2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。 2. 访问方式保持默认的 **仅授权链接**。保存后会直接弹出邀请设置,选择授权后可访问时长、可用设备数,以及是否需要审批。以后要再发邀请,点通道卡片上的 **邀请**。
3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。 3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,每行填一台主机(端口用逗号隔开,可以直接粘贴一串 IP),表单会预览端口映射并自动放进新建的通道组,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。组的「连接与授权」可以按设备或按地址查看在线连接和接入记录。 需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,每行填一台主机(端口用逗号隔开,可以直接粘贴一串 IP),表单会预览端口映射并自动放进新建的通道组,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后弹出的邀请对整组有效。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。组的「连接与授权」可以按设备或按地址查看在线连接和接入记录。
## 配置参考 ## 配置参考
@@ -299,6 +309,7 @@ make agent # 编译当前平台的 Agent 到 bin/
make app # 构建 PortRelay.app(UNIVERSAL=1 ./build-app.sh 构建通用二进制) make app # 构建 PortRelay.app(UNIVERSAL=1 ./build-app.sh 构建通用二进制)
make release # 打包全部平台的网关与 Agent 到 dist/ make release # 打包全部平台的网关与 Agent 到 dist/
make snapshots # 离屏渲染 App 各界面截图到 tools/ui-snapshots/out/ make snapshots # 离屏渲染 App 各界面截图到 tools/ui-snapshots/out/
make readme-images # 重新生成 README 的全部截图和动图到 docs/images/
``` ```
```text ```text
@@ -309,10 +320,10 @@ client/ Agent:配置、隧道、本地 API、闲置关闭、流
core/ 协议消息、ACL、流量计量与限速 core/ 协议消息、ACL、流量计量与限速
mac-native/ SwiftUI 菜单栏 App mac-native/ SwiftUI 菜单栏 App
deploy/ systemd、Docker Compose 部署示例与安装脚本 deploy/ systemd、Docker Compose 部署示例与安装脚本
tools/ui-snapshots/ 离屏截图工具与 README 动图生成(mkgif) tools/ui-snapshots/ README 截图与动图:App 离屏渲染、网页截图、GIF 合成
``` ```
README 里的截图和动图都用模拟数据生成:`tools/ui-snapshots/render.sh readme` 渲染截图,`FRAMES=40 tools/ui-snapshots/render.sh gif` 渲染逐帧画面,再用 `go run ./tools/ui-snapshots/mkgif` 合成 GIF。 README 里的截图和动图都使用模拟数据,改了界面后运行 `make readme-images` 即可全部重新生成(需要 Google Chrome)。App 界面离屏渲染,动图由逐帧画面合成,授权页和网页面板用无头 Chrome 截图。
欢迎提交 Issue 和 Pull Request。提交前请先运行 `make test`。 欢迎提交 Issue 和 Pull Request。提交前请先运行 `make test`。
+31 -23
View File
@@ -54,18 +54,18 @@ PortRelay turns that into three steps:
**Access control** **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. - 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 (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. - 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, while the tunnel stays open, 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. - 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. - **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. - When access expires or is revoked, **existing connections are dropped too**. Unauthorized connections are reset (TCP RST) and logged.
- **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. - **Extensions.** A teammate reopens the original invite link and clicks 申请延长 (Request extension); you approve by picking a duration in the app, the menu bar or the web panel. A grant that is still running is extended from its current expiry. One that has ended starts again from the moment you approve; that works for up to two hours after it ended, even if the tunnel has been closed, and the tunnel reopens on approval. Revoked grants cannot be extended. 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 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** **Tunnel lifecycle**
- **Idle auto-close.** The idle timer starts when a tunnel has no connections, no active grants, no pending requests and no invite links with uses left. By default the tunnel closes after 30 minutes of idle time. - **Idle auto-close.** The idle timer starts when a tunnel has no connections, no active grants, no pending requests and no invite links with uses left. By default the tunnel closes after 30 minutes of idle time.
- An optional **maximum open time** closes the tunnel at a fixed point, whether or not it is in use. - An optional **maximum open time** closes the tunnel at a fixed point, whether or not it is in use. If you approve an extension that runs past that point, the tunnel stays open until the grant ends.
- While any address in a group is in use, the whole group counts as in use; members are switched on and off together. - Idle time is counted per group: while any address is in use, none of them is closed for being idle, and when the group does go idle it closes as a whole. Each address can still be switched on or off on its own.
- PortRelay records the first-connection time, the actual connected time and why each tunnel was closed. - PortRelay records the first-connection time, the actual connected time and why each tunnel was closed.
- Editing a tunnel keeps existing connections. Moving a tunnel binds the new port before releasing the old one. - Editing a tunnel keeps existing connections. Moving a tunnel binds the new port before releasing the old one.
@@ -85,7 +85,7 @@ PortRelay turns that into three steps:
<p align="center"> <p align="center">
<img src="docs/images/live-traffic.gif" alt="Tunnel card with live traffic" width="100%"> <img src="docs/images/live-traffic.gif" alt="Tunnel card with live traffic" width="100%">
<br><sub>A tunnel card: access address, grants, connected time and the last 10 minutes of traffic (sped up)</sub> <br><sub>A tunnel card: access address, pending access and extension requests, connected time and the last 10 minutes of traffic (sped up)</sub>
</p> </p>
<picture> <picture>
@@ -107,18 +107,28 @@ PortRelay turns that into three steps:
</tr> </tr>
</table> </table>
**Extensions.** When access is about to run out or has just ended, the teammate opens the same link again, sees where their access stands and asks for more time. You don't need to send a new invite.
<table>
<tr>
<td align="center"><img src="docs/images/auth-renew.png" alt="Request an extension" width="100%"><br><sub>④ Reopen the link and request an extension</sub></td>
<td align="center"><img src="docs/images/auth-renew-pending.png" alt="Waiting for the extension" width="100%"><br><sub>⑤ Wait while you choose a duration</sub></td>
<td align="center"><img src="docs/images/auth-renew-ok.png" alt="Extension approved" width="100%"><br><sub>⑥ Extension approved</sub></td>
</tr>
</table>
On your side, the tunnel card shows 申请延长 (Extension requested). Open 连接与授权 (Connections & access): extension requests are listed first, and you approve one by clicking 批准延长 (Approve) and picking a duration. The teammate's page then shows the new expiry. The same view lists pending access requests, authorized devices (with online state and remaining time), recently ended grants and active invite links, and a device whose access just ended can be extended from there too.
<p align="center">
<img src="docs/images/app-access.png" alt="Access management: extension requests, pending requests, authorized devices, recently ended" width="80%">
</p>
**Tunnel groups.** A group card sums up the state and traffic of the whole group and lists every access address. Each address can be switched, invited or moved out on its own. **Tunnel groups.** A group card sums up the state and traffic of the whole group and lists every access address. Each address can be switched, invited or moved out on its own.
<p align="center"> <p align="center">
<img src="docs/images/app-group.png" alt="Tunnel group" width="100%"> <img src="docs/images/app-group.png" alt="Tunnel group" width="100%">
</p> </p>
**Access management.** Pending requests, authorized devices (with online state and remaining time) and active invite links are all managed in one place.
<p align="center">
<img src="docs/images/app-access.png" alt="Access management" width="80%">
</p>
<details> <details>
<summary><b>Web panel and more screenshots</b></summary> <summary><b>Web panel and more screenshots</b></summary>
<br> <br>
@@ -158,9 +168,9 @@ The gateway must be reachable by **visitors** and by **the agent**. Open TCP `33
**Option A: binary + systemd (recommended)** **Option A: binary + systemd (recommended)**
```bash ```bash
VERSION=1.0.0 # see the Releases page 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-$VERSION-linux-amd64.tar.gz curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-server-$VERSION-linux-amd64.tar.gz
tar xzf portrelay-$VERSION-linux-amd64.tar.gz && cd portrelay-$VERSION-linux-amd64 tar xzf portrelay-server-$VERSION-linux-amd64.tar.gz && cd portrelay-server-$VERSION-linux-amd64
sudo ./deploy/install-server.sh ./portrelay-server sudo ./deploy/install-server.sh ./portrelay-server
# The installer generates a random token; you will enter it in the agent # The installer generates a random token; you will enter it in the agent
@@ -194,7 +204,7 @@ make server # dist/portrelay-server-linux-{amd64,arm64}
### 2. Run the agent ### 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] > [!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`. > 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`.
@@ -209,7 +219,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. > 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 ```bash
./portrelay-agent # panel at http://127.0.0.1:33080 ./portrelay-agent # panel at http://127.0.0.1:33080
@@ -219,10 +229,10 @@ Open ⚙️ Settings and fill in:
### 3. Create a tunnel and invite someone ### 3. Create a tunnel and invite someone
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. 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. 2. Keep the default access mode, **仅授权链接** (invite-only). After you save, the invite form opens: choose how long access lasts, how many devices may use the link, and whether approval is required. To send another invite later, click **邀请** (Invite) on the tunnel card.
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`. 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 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. 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. The invite form that opens after creation covers the whole 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 ## Configuration
@@ -315,6 +325,7 @@ make agent # build the agent for this platform into bin/
make app # build PortRelay.app (UNIVERSAL=1 ./build-app.sh for a universal binary) make app # build PortRelay.app (UNIVERSAL=1 ./build-app.sh for a universal binary)
make release # package gateway + agent for all platforms into dist/ make release # package gateway + agent for all platforms into dist/
make snapshots # render app screens offscreen into tools/ui-snapshots/out/ make snapshots # render app screens offscreen into tools/ui-snapshots/out/
make readme-images # regenerate every README screenshot and animation in docs/images/
``` ```
```text ```text
@@ -325,13 +336,10 @@ client/ agent: config, tunnels, local API, idle close, traffic st
core/ protocol messages, ACL, traffic metering and rate limiting core/ protocol messages, ACL, traffic metering and rate limiting
mac-native/ SwiftUI menu-bar app mac-native/ SwiftUI menu-bar app
deploy/ systemd and Docker Compose examples, install script deploy/ systemd and Docker Compose examples, install script
tools/ui-snapshots/ offscreen screenshot renderer and README GIF builder (mkgif) tools/ui-snapshots/ README images: offscreen app rendering, web screenshots, GIF encoding
``` ```
All screenshots and animations in this README come from mock data: All screenshots and animations in this README use mock data. After a UI change, run `make readme-images` to regenerate them (Google Chrome required). App screens are rendered offscreen, the animation is encoded from rendered frames, and the invite pages and web panel are captured with headless Chrome.
- `tools/ui-snapshots/render.sh readme` renders the screenshots;
- `FRAMES=40 tools/ui-snapshots/render.sh gif` renders the animation frames;
- `go run ./tools/ui-snapshots/mkgif` encodes the frames as a GIF.
Issues and pull requests are welcome. Please run `make test` before submitting. Issues and pull requests are welcome. Please run `make test` before submitting.
+2 -1
View File
@@ -747,7 +747,8 @@
const fmtSecs = secs => { const fmtSecs = secs => {
secs = Math.max(0, Math.floor(secs || 0)); secs = Math.max(0, Math.floor(secs || 0));
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), sec = secs % 60; 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 pad = n => String(n).padStart(2, '0');
return h > 0 ? `${h}小时${pad(m)}分` : (m > 0 ? `${m}分${pad(sec)}秒` : `${sec}秒`);
}; };
const fmtMinutes = m => m >= 1440 && m % 1440 === 0 ? `${m / 1440} 天` 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} 分钟`)); : (m >= 60 && m % 60 === 0 ? `${m / 60} 小时` : (m > 60 ? `${Math.floor(m / 60)} 小时 ${m % 60} 分钟` : `${m} 分钟`));
Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+4 -4
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 210" width="960" height="210" role="img" aria-label="邀请流程:生成邀请链接、同事打开链接填写姓名、可选的管理员审批、首次连接开始计时、到期自动断开"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 210" width="960" height="210" role="img" aria-label="邀请流程:生成邀请链接、同事打开链接填写姓名、可选的管理员审批、首次连接开始计时、到期自动断开,结束后 2 小时内可申请延长">
<style> <style>
svg { svg {
--bg: #f8fafc; --track: #e2e8f0; --ring: #cbd5e1; --base: #ffffff; --text: #0f172a; --sub: #64748b; --accent: #2563eb; --on: #ffffff; --bg: #f8fafc; --track: #e2e8f0; --ring: #cbd5e1; --base: #ffffff; --text: #0f172a; --sub: #64748b; --accent: #2563eb; --on: #ffffff;
@@ -68,8 +68,8 @@
<text class="numon" x="665" y="67.5" text-anchor="middle">4</text> <text class="numon" x="665" y="67.5" text-anchor="middle">4</text>
</g> </g>
<text class="title" x="665" y="116" text-anchor="middle">首次连接起计时</text> <text class="title" x="665" y="116" text-anchor="middle">首次连接起计时</text>
<text class="sub" x="665" y="140" text-anchor="middle">2 小时 / 8 小时 / 当天</text> <text class="sub" x="665" y="140" text-anchor="middle">30 分钟 / 2 小时 / 当天</text>
<text class="sub" x="665" y="160" text-anchor="middle">或直到通道关闭</text> <text class="sub" x="665" y="160" text-anchor="middle">也可以自定义时长</text>
<circle class="ring" cx="850" cy="62" r="20"/> <circle class="ring" cx="850" cy="62" r="20"/>
<text class="num" x="850" y="67.5" text-anchor="middle">5</text> <text class="num" x="850" y="67.5" text-anchor="middle">5</text>
<g class="on5"> <g class="on5">
@@ -78,5 +78,5 @@
</g> </g>
<text class="title" x="850" y="116" text-anchor="middle">到期自动断开</text> <text class="title" x="850" y="116" text-anchor="middle">到期自动断开</text>
<text class="sub" x="850" y="140" text-anchor="middle">已建立的连接一并断开</text> <text class="sub" x="850" y="140" text-anchor="middle">已建立的连接一并断开</text>
<text class="sub" x="850" y="160" text-anchor="middle">闲置的通道自动关闭</text> <text class="sub" x="850" y="160" text-anchor="middle">2 小时内可申请延长</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 560 KiB

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 711 KiB

After

Width:  |  Height:  |  Size: 1010 KiB

+5 -3
View File
@@ -397,10 +397,11 @@ func formatSecondsToDuration(_ totalSec: Int64) -> String {
let hours = totalSec / 3600 let hours = totalSec / 3600
let mins = (totalSec % 3600) / 60 let mins = (totalSec % 3600) / 60
let secs = totalSec % 60 let secs = totalSec % 60
// 第二个单位补零,计时跳动时文字宽度不变
if hours > 0 { if hours > 0 {
return "\(hours)小时\(mins)分" return "\(hours)小时" + String(format: "%02d分", mins)
} else if mins > 0 { } else if mins > 0 {
return "\(mins)分\(secs)秒" return "\(mins)分" + String(format: "%02d秒", secs)
} else { } else {
return "\(secs)秒" return "\(secs)秒"
} }
@@ -1557,6 +1558,7 @@ class PortRelayViewModel: ObservableObject {
lines.append("请在 \(fmt.string(from: deadline)) 前用浏览器打开下面的链接,\(action)(访问有效期:\(leaseDurationTitle(leaseDuration))):") lines.append("请在 \(fmt.string(from: deadline)) 前用浏览器打开下面的链接,\(action)(访问有效期:\(leaseDurationTitle(leaseDuration))):")
lines.append(link) lines.append(link)
lines.append("此链接仅限 1 台设备使用,请勿转发。") lines.append("此链接仅限 1 台设备使用,请勿转发。")
lines.append("授权结束后 2 小时内,可以重新打开此链接申请延长。")
let text = lines.joined(separator: "\n") let text = lines.joined(separator: "\n")
return (InviteShareResp(invite: InviteItem(code: "k7m2xq9fpa", note: note, created_at: "", expires_at: "", max_uses: maxUses, lease_duration: leaseDuration, require_approval: requireApproval, uses: nil), link: link, share_text: text), nil) return (InviteShareResp(invite: InviteItem(code: "k7m2xq9fpa", note: note, created_at: "", expires_at: "", max_uses: maxUses, lease_duration: leaseDuration, require_approval: requireApproval, uses: nil), link: link, share_text: text), nil)
} }
@@ -3702,7 +3704,7 @@ struct ClientDetailsSheet: View {
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
} }
Text(verbatim: "申请于 \(formatDateTimeShort(r.at)) · 批准后可访问 \(leaseDurationTitle(invites.first { $0.code == r.invite }?.lease_duration))") Text(verbatim: "申请于 \(formatClock(r.at)) · 批准后可访问 \(leaseDurationTitle(invites.first { $0.code == r.invite }?.lease_duration))")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
+85
View File
@@ -0,0 +1,85 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DeepFal/PortRelay/core/acl"
"github.com/DeepFal/PortRelay/core/protocol"
"github.com/DeepFal/PortRelay/core/traffic"
)
// TestReadmePages 用模拟状态生成 README 截图用的授权页 HTML(tools/ui-snapshots/web.sh 调用),平时跳过。
// 通道只放在内存里,不监听端口;访问者 IP 直接写在请求上。
func TestReadmePages(t *testing.T) {
out := os.Getenv("PORTRELAY_README_PAGES")
if out == "" {
t.Skip("PORTRELAY_README_PAGES not set")
}
m := NewManager(33000, 34000, testToken)
m.PublicHost = "192.168.1.10"
cfg := protocol.TunnelConfig{ID: "mysql", Name: "MySQL 数据库", Target: "10.0.1.16:3306", ListenPort: 33660, Enabled: true}
m.mu.Lock()
m.tunnels[cfg.ID] = &Tunnel{Config: cfg, whitelist: acl.NewWhitelist(nil), activeConns: map[string]*ActiveClient{},
meter: traffic.NewMeter(), closed: make(chan struct{})}
m.mu.Unlock()
do := func(method, path, ip string, form url.Values) *httptest.ResponseRecorder {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
r := httptest.NewRequest(method, "http://192.168.1.10:33000"+path, body)
if form != nil {
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
r.RemoteAddr = ip + ":52000"
w := httptest.NewRecorder()
m.handleHTTPRequest(w, r)
return w
}
follow := func(w *httptest.ResponseRecorder, ip string) *httptest.ResponseRecorder {
if loc := w.Header().Get("Location"); loc != "" {
return do(http.MethodGet, loc, ip, nil)
}
return w
}
save := func(name string, w *httptest.ResponseRecorder) {
t.Helper()
if w.Code != http.StatusOK {
t.Fatalf("%s: %d %s", name, w.Code, w.Body)
}
if err := os.WriteFile(filepath.Join(out, name+".html"), w.Body.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
// ① 打开邀请链接 → ③ 授权成功
inv := m.state.createInvite(cfg.ID, "张三", 24*time.Hour, 1, "2h", false)
save("auth-form", do(http.MethodGet, "/i/"+inv.Code, "192.168.1.21", nil))
save("auth-ok", do(http.MethodPost, "/auth", "192.168.1.21", url.Values{"code": {inv.Code}, "name": {"张三"}}))
// ② 需要审批
ask := m.state.createInvite(cfg.ID, "测试组", 24*time.Hour, 0, "today", true)
save("auth-pending", follow(do(http.MethodPost, "/auth", "192.168.1.88", url.Values{"code": {ask.Code}, "name": {"李四"}}), "192.168.1.88"))
// ④ ⑤ ⑥ 两小时前开始使用、20 分钟前到期,再打开链接申请延长,管理员批准 2 小时
ip := "192.168.1.47"
rn := m.state.createInvite(cfg.ID, "赵六", 24*time.Hour, 1, "2h", false)
do(http.MethodPost, "/auth", ip, url.Values{"code": {rn.Code}, "name": {"赵六"}})
m.state.startLease(cfg.ID, ip, nil, time.Now().Add(-140*time.Minute))
save("auth-renew", do(http.MethodGet, "/i/"+rn.Code, ip, nil))
status := do(http.MethodPost, "/renew", ip, url.Values{"code": {rn.Code}})
save("auth-renew-pending", follow(status, ip))
if resp := m.extendLease(protocol.ExtendLeaseMessage{TunnelID: cfg.ID, IP: ip, Minutes: 120}); !resp.Success {
t.Fatal(resp.Error)
}
save("auth-renew-ok", follow(status, ip))
}
+14 -13
View File
@@ -163,6 +163,12 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
{"code":"gx2","note":"测试组","created_at":"\(t(-100))","expires_at":"\(t(86000))","max_uses":3,"lease_duration":"today","require_approval":true}]}}] {"code":"gx2","note":"测试组","created_at":"\(t(-100))","expires_at":"\(t(86000))","max_uses":3,"lease_duration":"today","require_approval":true}]}}]
""") """)
vm.groups[0].rate_history = RateSeriesItem(end: ga.end, step: 5, up: zip(ga.up, gb.up).map { $0 + $1 }, down: zip(ga.down, gb.down).map { $0 + $1 }) vm.groups[0].rate_history = RateSeriesItem(end: ga.end, step: 5, up: zip(ga.up, gb.up).map { $0 + $1 }, down: zip(ga.down, gb.down).map { $0 + $1 })
// 授权延长:赵六的访问 20 分钟前结束,刚申请延长;王五结束不到 2 小时,还可以延长
vm.tunnels[0].renewals = decode([RenewalItem].self, """
[{"tunnel_id":"t1","ip":"192.168.1.47","name":"赵六","invite":"ab1","ended_at":"\(t(-1200))","renew_before":"\(t(6000))",
"request":{"id":"rn1","tunnel_id":"t1","name":"赵六","ip":"192.168.1.47","invite":"ab1","at":"\(t(-60))","status":"pending","renewal":true}},
{"tunnel_id":"t1","ip":"192.168.1.36","name":"王五","invite":"ab2","ended_at":"\(t(-3000))","renew_before":"\(t(4200))"}]
""")
// 卡片与顶部统计的实时速率取曲线最后一个点 // 卡片与顶部统计的实时速率取曲线最后一个点
var totalIn: Int64 = 0, totalOut: Int64 = 0 var totalIn: Int64 = 0, totalOut: Int64 = 0
for i in [0, 1, 3, 4] { for i in [0, 1, 3, 4] {
@@ -205,6 +211,8 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
final class KeyWindow: NSWindow { final class KeyWindow: NSWindow {
override var isKeyWindow: Bool { true } override var isKeyWindow: Bool { true }
override var isMainWindow: Bool { true } override var isMainWindow: Bool { true }
/// 渲染进程不会被激活,AppKit 控件(开关、分段控件)按这个私有属性决定是否画成灰色
@objc(hasKeyAppearance) func alwaysHasKeyAppearance() -> Bool { true }
} }
@MainActor @MainActor
@@ -256,12 +264,11 @@ MainActor.assumeIsolated {
let demo = makeDemoVM() let demo = makeDemoVM()
render("readme-main-light", CGSize(width: 980, height: 1180), ContentView(vm: demo)) render("readme-main-light", CGSize(width: 980, height: 1180), ContentView(vm: demo))
render("readme-main-dark", CGSize(width: 980, height: 1180), dark: true, ContentView(vm: demo)) render("readme-main-dark", CGSize(width: 980, height: 1180), dark: true, ContentView(vm: demo))
render("readme-invite", CGSize(width: 560, height: 250), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes)) render("readme-invite", CGSize(width: 600, height: 270), 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-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: 520, height: 570), 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] 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, render("readme-group", 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)) onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
render("group-card-dark", CGSize(width: 980, height: 330), dark: true, GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo, render("group-card-dark", CGSize(width: 980, height: 330), dark: true, 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)) onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
@@ -282,15 +289,9 @@ MainActor.assumeIsolated {
render("group-edit-member", CGSize(width: 520, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes)) render("group-edit-member", CGSize(width: 520, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes))
// 授权延长:申请延长、最近结束、已授权设备的「延长」 // 授权延长:申请延长、最近结束、已授权设备的「延长」
let rvm = makeDemoVM() render("readme-access", CGSize(width: 760, height: 800), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 2, isPresented: yes))
rvm.tunnels[0].renewals = decode([RenewalItem].self, """ render("renew-card", CGSize(width: 980, height: 220), TunnelCardView(tunnel: demo.tunnels[0], vm: demo, onEdit: {}, onViewClients: {}, onViewLeases: {}, onInvite: {}).padding(20))
[{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.35","name":"李四","invite":"ab1","ended_at":"\(t(-1200))","renew_before":"\(t(6000))", render("renew-invite-form", CGSize(width: 600, height: 400), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: false, isPresented: yes))
"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 }) render("renew-custom", CGSize(width: 300, height: 90), CustomMinutesForm(actionTitle: "延长") { _ in })
// README 动图逐帧:卡片随时间推移(每帧 = 5 秒) // README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# 重新生成 README 用到的全部截图和动图(模拟数据),写入 docs/images。
# 用法: tools/ui-snapshots/readme.sh(即 make readme-images)
# 需要 Xcode 命令行工具和 Google Chrome。
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$DIR/../.." && pwd)"
IMG="$ROOT/docs/images"
# App 界面:readme-xxx.png → app-xxx.png
"$DIR/render.sh" readme
for f in "$DIR"/out/readme-*.png; do
name="$(basename "$f")"
cp "$f" "$IMG/app-${name#readme-}"
done
# 通道卡片动图:40 帧,每帧相当于 5 秒
FRAMES=40 "$DIR/render.sh" gif
(cd "$ROOT" && go run ./tools/ui-snapshots/mkgif -o "$IMG/live-traffic.gif" "$DIR"/out/gif-*.png)
# 授权页与网页面板
"$DIR/web.sh"
cp "$DIR"/out/web/*.png "$IMG/"
echo "✓ 已更新 docs/images"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# 用无头 Chrome 截取 README 里的授权页和网页面板(模拟数据)。
# 用法: tools/ui-snapshots/web.sh 输出: tools/ui-snapshots/out/web/*.png
# 需要 Google Chrome,路径可用 CHROME 环境变量指定。
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$DIR/../.." && pwd)"
BUILD="$DIR/.build/web"
OUT="$DIR/out/web"
CHROME="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
[ -x "$CHROME" ] || { echo "找不到 Chrome:$CHROME(可用 CHROME=... 指定)" >&2; exit 1; }
rm -rf "$BUILD" "$OUT"
mkdir -p "$BUILD" "$OUT"
# shot URL 宽 高 输出:按 2 倍像素截图,使用临时的浏览器配置
shot() {
"$CHROME" --headless=new --disable-gpu --hide-scrollbars --no-first-run --no-default-browser-check \
--blink-settings=preferredColorScheme=1 --force-device-scale-factor=2 --window-size="$2,$3" --timeout=3000 \
--user-data-dir="$BUILD/chrome" --screenshot="$4" "$1" >/dev/null 2>&1
}
# 授权页:服务端用模拟状态生成 HTML。无头 Chrome 的窗口最窄约 500pt,
# 所以把页面放进居中的 390pt 宽 iframe(手机宽度),截图后从中间裁出 iframe 部分
(cd "$ROOT" && PORTRELAY_README_PAGES="$BUILD" go test ./server -run '^TestReadmePages$' -count=1 >/dev/null)
for page in "$BUILD"/auth-*.html; do
name="$(basename "$page" .html)"
printf '<!DOCTYPE html><body style="margin:0;background:#F5F5F7"><iframe src="%s" width="390" height="610" style="border:0;display:block;margin:0 auto"></iframe>' \
"$(basename "$page")" >"$BUILD/frame-$name.html"
shot "file://$BUILD/frame-$name.html" 520 610 "$OUT/$name.png"
sips -c 1220 780 "$OUT/$name.png" >/dev/null
echo "captured $name"
done
# 网页面板:本机模拟 Agent API,只监听回环地址
(cd "$ROOT" && go build -o "$BUILD/webpanel" ./tools/ui-snapshots/webpanel)
"$BUILD/webpanel" "$ROOT/cmd/portrelay-agent/web/index.html" >"$BUILD/webpanel.url" &
pid=$!
trap 'kill $pid 2>/dev/null' EXIT
for _ in $(seq 50); do [ -s "$BUILD/webpanel.url" ] && break; sleep 0.1; done
shot "$(head -1 "$BUILD/webpanel.url")" 1280 1124 "$OUT/web-panel.png"
echo "captured web-panel"
+181
View File
@@ -0,0 +1,181 @@
// webpanel 用模拟数据提供 Agent 网页面板,用于 README 截图(tools/ui-snapshots/web.sh 调用)。
//
// go run ./tools/ui-snapshots/webpanel cmd/portrelay-agent/web/index.html
//
// 只监听 127.0.0.1 的随机端口,启动后在标准输出打印面板地址。
// 数据与 tools/ui-snapshots/main.swift 的 makeDemoVM 保持一致,App 截图和网页截图展示的是同一组通道。
package main
import (
"encoding/json"
"fmt"
"log"
"math"
"net"
"net/http"
"os"
"time"
)
var base = time.Now()
func at(sec float64) string {
return base.Add(time.Duration(sec * float64(time.Second))).Format(time.RFC3339Nano)
}
// demoRate 与 main.swift 的同名函数相同:几段查询突发叠加平稳传输
func demoRate(k int, seed, level, burst float64) int64 {
x := float64(k)
v := level * (0.55 + 0.25*math.Sin(x/9+seed) + 0.2*math.Sin(x/3.7+seed*2))
for c := -60; c <= 600; c += 21 {
h := 0.35 + 0.65*math.Abs(math.Sin(float64(c)*0.731+seed))
v += burst * h * math.Exp(-math.Pow((x-float64(c)-7*math.Sin(float64(c)+seed))/3.2, 2))
}
return int64(math.Max(0, v))
}
type series struct {
End int64 `json:"end"`
Step int `json:"step"`
Up []int64 `json:"up"`
Down []int64 `json:"down"`
}
func demoSeries(seed, level, burst, upScale float64, points int) series {
s := series{End: base.Unix(), Step: 5, Up: make([]int64, 120), Down: make([]int64, 120)}
for i := 0; i < points; i++ {
s.Down[i] = demoRate(i, seed, level, burst)
s.Up[i] = demoRate(i, seed+1.7, level*upScale, burst*upScale)
}
return s
}
func sum(a, b series) series {
s := series{End: a.End, Step: a.Step}
for i := range a.Up {
s.Up = append(s.Up, a.Up[i]+b.Up[i])
s.Down = append(s.Down, a.Down[i]+b.Down[i])
}
return s
}
func obj(format string, args ...any) map[string]any {
var m map[string]any
if err := json.Unmarshal([]byte(fmt.Sprintf(format, args...)), &m); err != nil {
log.Fatal(err)
}
return m
}
func member(id, host string, port, conns int, firstAgo float64, connectedSec int, bytesIn, bytesOut int64) map[string]any {
first := ""
if firstAgo != 0 {
first = fmt.Sprintf(`"first_connected_at":%q,`, at(firstAgo))
}
return obj(`{"id":%q,"name":"订单服务 (%s)","target":"%s:22","listen_port":%d,"whitelist":[],"enabled":true,"group_id":"g1","group_name":"订单服务",
"owner":"运维组","started_at":%q,"effective_idle_close_minutes":30,"idle_close_minutes":30,%s"connected_sec":%d,
"activity":{"in_use":true,"connected_sec":%d},
"metrics":{"tunnel_id":%q,"status":"online","active_conns":%d,"total_conns":%d,"bytes_in":%d,"bytes_out":%d,"blocked_attempts":0}}`,
id, host, host, port, at(-1800), first, connectedSec, connectedSec, id, conns, conns*5, bytesIn, bytesOut)
}
func main() {
if len(os.Args) != 2 {
log.Fatal("usage: webpanel path/to/index.html")
}
page, err := os.ReadFile(os.Args[1])
if err != nil {
log.Fatal(err)
}
tunnels := []map[string]any{
obj(`{"id":"t1","name":"MySQL 数据库","target":"10.0.1.16:3306","listen_port":33660,"whitelist":[],"enabled":true,
"owner":"数据组","duration_minutes":240,"started_at":%q,"expires_at":%q,"historical_bytes_in":1288490188,"historical_bytes_out":53687091200,
"idle_close_minutes":30,"effective_idle_close_minutes":30,"first_connected_at":%q,"connected_sec":1520,
"activity":{"in_use":true,"connected_sec":1525},
"metrics":{"tunnel_id":"t1","status":"online","active_conns":3,"total_conns":1287,"bytes_in":245039104,"bytes_out":8703180,"blocked_attempts":12,
"dynamic_leases":[{"ip":"192.168.1.21","expires_at":%q,"name":"张三","invite":"a1","duration":"2h","started_at":%q},
{"ip":"192.168.1.35","expires_at":%q,"name":"王小明","invite":"a2","duration":"8h","awaiting_connect":true}],
"pending":[{"id":"r1","tunnel_id":"t1","name":"李四","ip":"192.168.1.88","invite":"a3","at":%q,"status":"pending"}]},
"renewals":[{"tunnel_id":"t1","ip":"192.168.1.47","name":"赵六","invite":"ab1","ended_at":%q,"renew_before":%q,
"request":{"id":"rn1","tunnel_id":"t1","name":"赵六","ip":"192.168.1.47","invite":"ab1","at":%q,"status":"pending","renewal":true}},
{"tunnel_id":"t1","ip":"192.168.1.36","name":"王五","invite":"ab2","ended_at":%q,"renew_before":%q}]}`,
at(-3725), at(10675), at(-3300), at(7000), at(-200), at(80000), at(-60), at(-1200), at(6000), at(-60), at(-3000), at(4200)),
member("g1a", "10.0.3.11", 33670, 1, -1500, 1320, 3203180, 18039104),
member("g1b", "10.0.3.12", 33671, 2, -1400, 1100, 2203180, 12039104),
member("g1c", "10.0.3.13", 33672, 0, 0, 0, 0, 0),
obj(`{"id":"t2","name":"Grafana 监控面板","target":"10.0.2.30:3000","listen_port":33661,"whitelist":["192.168.1.0/24"],"enabled":true,
"owner":"运维组","duration_minutes":0,"started_at":%q,"effective_idle_close_minutes":0,"idle_close_minutes":0,"first_connected_at":%q,"connected_sec":36120,
"activity":{"in_use":true,"connected_sec":36120},
"metrics":{"tunnel_id":"t2","status":"online","active_conns":2,"total_conns":342,"bytes_in":52039104,"bytes_out":2703180,"blocked_attempts":0}}`,
at(-50000), at(-49000)),
obj(`{"id":"t3","name":"SSH 跳板机(测试环境)","target":"10.0.8.3:22","listen_port":33662,"whitelist":[],"enabled":true,
"owner":"运维组","duration_minutes":0,"started_at":%q,"effective_idle_close_minutes":30,"idle_close_minutes":30,"first_connected_at":%q,"connected_sec":640,
"activity":{"in_use":false,"idle_since":%q,"auto_close_at":%q,"connected_sec":640},
"metrics":{"tunnel_id":"t3","status":"online","active_conns":0,"total_conns":4,"bytes_in":1983000,"bytes_out":204000,"blocked_attempts":0}}`,
at(-2100), at(-2000), at(-720), at(1080)),
obj(`{"id":"t4","name":"Redis 缓存","target":"10.0.1.20:6379","listen_port":33663,"whitelist":[],"enabled":false,"last_duration_sec":1905,"duration_minutes":0,
"effective_idle_close_minutes":30,"last_connected_sec":742,"close_reason":"闲置 30 分钟自动关闭","historical_bytes_in":3145728,"historical_bytes_out":524288,
"metrics":{"tunnel_id":"t4","status":"disabled"}}`),
}
rates := map[string]series{
"t1": demoSeries(0.4, 26000, 150000, 0.08, 120),
"t2": demoSeries(2.1, 9000, 30000, 0.15, 120),
"t3": demoSeries(6, 1500, 9000, 0.2, 30), // 前一段时间有流量,之后闲置
"g1a": demoSeries(3.3, 4000, 26000, 0.3, 120),
"g1b": demoSeries(4.6, 4000, 26000, 0.3, 120),
"g1c": demoSeries(0, 0, 0, 0, 0),
}
for _, t := range tunnels {
if s, ok := rates[t["id"].(string)]; ok {
t["rate_history"] = s
m := t["metrics"].(map[string]any)
m["rate_in"], m["rate_out"] = s.Up[len(s.Up)-1], s.Down[len(s.Down)-1]
}
}
groups := []map[string]any{obj(`{"id":"g1","name":"订单服务","owner":"运维组","created_at":%q,"tunnel_ids":["g1a","g1b","g1c"],
"metrics":{"group_id":"g1",
"dynamic_leases":[{"ip":"192.168.1.42","expires_at":%q,"name":"小王","invite":"gx1","duration":"8h","started_at":%q}],
"pending":[{"id":"gr1","tunnel_id":"","group_id":"g1","name":"小李","ip":"192.168.1.57","invite":"gx2","at":%q,"status":"pending"}]}}`,
at(-1800), at(27000), at(-1500), at(-40))}
groups[0]["rate_history"] = sum(rates["g1a"], rates["g1b"])
status := map[string]any{
"connected": true, "server_addr": "192.168.1.10:33000", "public_host": "192.168.1.10",
"invite_base": "http://192.168.1.10:33000/i/", "total_rate_history": sum(rates["t1"], rates["t2"]),
"port_range": "33001 - 34000", "timestamp": base,
}
stream := map[string]any{"metrics": map[string]any{"active_conns": []any{}, "security_events": []any{}}, "tunnels": tunnels, "groups": groups}
for k, v := range status {
stream[k] = v
}
serveJSON := func(v any) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
}
mux := http.NewServeMux()
mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(page)
})
mux.HandleFunc("/api/status", serveJSON(status))
mux.HandleFunc("/api/tunnels", serveJSON(tunnels))
mux.HandleFunc("/api/groups", serveJSON(groups))
mux.HandleFunc("/api/stream", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
b, _ := json.Marshal(stream)
fmt.Fprintf(w, "data: %s\n\n", b)
w.(http.Flusher).Flush()
<-r.Context().Done()
})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
fmt.Printf("http://%s/\n", ln.Addr())
log.Fatal(http.Serve(ln, mux))
}