Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9e6ca7080 | ||
|
|
3272e66247 | ||
|
|
6f2a1472b6 | ||
|
|
c71668e82d | ||
|
|
dceb59c0ca | ||
|
|
9a675ba96e | ||
|
|
dfb3a9fd8e | ||
|
|
659e69f8f7 | ||
|
|
5b355fb759 | ||
|
|
e02c4c1184 | ||
|
|
3797bef88a |
No files matched your search
@@ -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`
|
||||
@@ -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,32 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# 只取安装包;docker 任务还会上传构建记录,不能一并下载
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: binaries
|
||||
path: dist
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-app
|
||||
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
|
||||
@@ -3,16 +3,21 @@
|
||||
# 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)
|
||||
# make readme-images 重新生成 README 的全部截图和动图到 docs/images(需要 Google Chrome)
|
||||
|
||||
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 readme-images clean
|
||||
|
||||
all: test agent
|
||||
|
||||
@@ -34,24 +39,50 @@ 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:
|
||||
tools/ui-snapshots/render.sh
|
||||
|
||||
readme-images:
|
||||
tools/ui-snapshots/readme.sh
|
||||
|
||||
clean:
|
||||
rm -rf bin build dist PortRelay.app mac-native/PortRelay tools/ui-snapshots/.build tools/ui-snapshots/out
|
||||
@@ -51,15 +51,18 @@ PortRelay 把这件事变成三步:
|
||||
**访问控制**
|
||||
|
||||
- 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。
|
||||
- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(1 小时、2 小时、8 小时、当天有效,或直到通道关闭),以及是否需要审批。
|
||||
- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(30 分钟、1 小时、2 小时、8 小时、当天有效、通道开启期间,或自定义),以及是否需要审批。
|
||||
- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
|
||||
- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
|
||||
- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
|
||||
- **申请延长**:同事重新打开原来的邀请链接,点「申请延长」,你在 App、菜单栏或网页面板里选好延长多久即可批准。授权还没到期时,从原到期时间往后顺延;已经结束的从批准时算起,结束后 2 小时内都能申请,通道关了也没关系,批准后会自动重新开启。被撤销的授权不能延长。你也可以主动给已授权或刚结束的设备延长。
|
||||
- **通道组**:添加通道时目标是一个列表,每行一台主机、多个端口用逗号隔开(也可以直接粘贴一串 IP),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。
|
||||
|
||||
**通道生命周期**
|
||||
|
||||
- **闲置自动关闭**:没有连接、没有有效授权、没有待处理申请,也没有还能使用的邀请链接时开始计时,默认 30 分钟后关闭。
|
||||
- 可选的**最长开启时长**,到点无论是否在用都关闭。
|
||||
- 可选的**最长开启时长**,到点无论是否在用都关闭。批准延长时,如果新的授权到期时间更晚,通道会开到授权结束。
|
||||
- 通道组的闲置按整组计算:任一地址在用,整组都不会因闲置关闭,到时也是整组一起关。每个地址仍然可以单独开关。
|
||||
- 记录首次连接时间、实际连接时长和每次关闭的原因。
|
||||
- 编辑通道不会打断现有连接;更换端口时先绑定新端口再释放旧端口。
|
||||
|
||||
@@ -79,7 +82,7 @@ PortRelay 把这件事变成三步:
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/live-traffic.gif" alt="通道卡片与实时流量" width="100%">
|
||||
<br><sub>通道卡片:访问地址、授权状态、实际连接时长,以及近 10 分钟流量(动图为加速演示)</sub>
|
||||
<br><sub>通道卡片:访问地址、待处理的访问与延长申请、实际连接时长,以及近 10 分钟流量(动图为加速演示)</sub>
|
||||
</p>
|
||||
|
||||
<picture>
|
||||
@@ -101,10 +104,26 @@ PortRelay 把这件事变成三步:
|
||||
</tr>
|
||||
</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%">
|
||||
<img src="docs/images/app-access.png" alt="连接与授权:延长申请、待审批、已授权设备、最近结束" width="80%">
|
||||
</p>
|
||||
|
||||
**通道组**:组卡片汇总整组的状态与流量,下面逐行列出每个访问地址,可以单独开关、单独邀请或移出组。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/app-group.png" alt="通道组" width="100%">
|
||||
</p>
|
||||
|
||||
<details>
|
||||
@@ -144,9 +163,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 里
|
||||
@@ -176,7 +195,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` 自行构建。
|
||||
@@ -191,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`,邀请链接和分享文案都会使用后者。
|
||||
|
||||
**Linux / Windows**:下载对应平台的压缩包,直接运行 Agent,在浏览器打开的面板里完成同样的设置。
|
||||
**Linux / Windows**:下载对应平台的 `portrelay-agent-*` 压缩包,直接运行 Agent,在浏览器打开的面板里完成同样的设置。
|
||||
|
||||
```bash
|
||||
./portrelay-agent # 面板:http://127.0.0.1:33080
|
||||
@@ -200,10 +219,12 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
|
||||
|
||||
### 3. 创建通道并邀请同事
|
||||
|
||||
1. 点 **添加通道**,填写名称和目标地址(如 `10.0.1.16:3306`)。可以先“探测”一下本机能否连通;端口会自动推荐。
|
||||
2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
|
||||
1. 点 **添加通道**,填写名称和目标主机、端口(如 `10.0.1.16`、`3306`)。可以先测试一下本机能否连通;网关上的访问端口自动分配。
|
||||
2. 访问方式保持默认的 **仅授权链接**。保存后会直接弹出邀请设置,选择授权后可访问时长、可用设备数,以及是否需要审批。以后要再发邀请,点通道卡片上的 **邀请**。
|
||||
3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
|
||||
|
||||
需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,每行填一台主机(端口用逗号隔开,可以直接粘贴一串 IP),表单会预览端口映射并自动放进新建的通道组,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后弹出的邀请对整组有效。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。组的「连接与授权」可以按设备或按地址查看在线连接和接入记录。
|
||||
|
||||
## 配置参考
|
||||
|
||||
**网关 `portrelay-server`**
|
||||
@@ -288,6 +309,7 @@ make agent # 编译当前平台的 Agent 到 bin/
|
||||
make app # 构建 PortRelay.app(UNIVERSAL=1 ./build-app.sh 构建通用二进制)
|
||||
make release # 打包全部平台的网关与 Agent 到 dist/
|
||||
make snapshots # 离屏渲染 App 各界面截图到 tools/ui-snapshots/out/
|
||||
make readme-images # 重新生成 README 的全部截图和动图到 docs/images/
|
||||
```
|
||||
|
||||
```text
|
||||
@@ -298,10 +320,10 @@ client/ Agent:配置、隧道、本地 API、闲置关闭、流
|
||||
core/ 协议消息、ACL、流量计量与限速
|
||||
mac-native/ SwiftUI 菜单栏 App
|
||||
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`。
|
||||
|
||||
|
||||
@@ -54,15 +54,18 @@ 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, 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.
|
||||
- **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.
|
||||
- **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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Editing a tunnel keeps existing connections. Moving a tunnel binds the new port before releasing the old one.
|
||||
|
||||
@@ -82,7 +85,7 @@ PortRelay turns that into three steps:
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
<picture>
|
||||
@@ -104,10 +107,26 @@ PortRelay turns that into three steps:
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**Access management.** Pending requests, authorized devices (with online state and remaining time) and active invite links are all managed in one place.
|
||||
**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" width="80%">
|
||||
<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.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/app-group.png" alt="Tunnel group" width="100%">
|
||||
</p>
|
||||
|
||||
<details>
|
||||
@@ -149,9 +168,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
|
||||
@@ -185,7 +204,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`.
|
||||
@@ -200,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.
|
||||
|
||||
**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
|
||||
@@ -209,10 +228,12 @@ 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.
|
||||
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.
|
||||
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). 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`.
|
||||
|
||||
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
|
||||
|
||||
**Gateway: `portrelay-server`**
|
||||
@@ -304,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 release # package gateway + agent for all platforms into dist/
|
||||
make snapshots # render app screens offscreen into tools/ui-snapshots/out/
|
||||
make readme-images # regenerate every README screenshot and animation in docs/images/
|
||||
```
|
||||
|
||||
```text
|
||||
@@ -314,13 +336,10 @@ client/ agent: config, tunnels, local API, idle close, traffic st
|
||||
core/ protocol messages, ACL, traffic metering and rate limiting
|
||||
mac-native/ SwiftUI menu-bar app
|
||||
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:
|
||||
- `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.
|
||||
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.
|
||||
|
||||
Issues and pull requests are welcome. Please run `make test` before submitting.
|
||||
|
||||
|
||||
@@ -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(),
|
||||
@@ -166,10 +168,14 @@ type TunnelActivity struct {
|
||||
|
||||
// inUse: 有在线连接、有效授权(含尚未首次连接的)、待审批申请或仍可使用的邀请
|
||||
func tunnelInUse(m protocol.TunnelMetrics) bool {
|
||||
if m.ActiveConns > 0 || len(m.DynamicLeases) > 0 || len(m.Pending) > 0 {
|
||||
return m.ActiveConns > 0 || accessInUse(m.DynamicLeases, m.Pending, m.Invites)
|
||||
}
|
||||
|
||||
func accessInUse(leases []protocol.DynamicLease, pending []protocol.AccessRequest, invites []protocol.Invite) bool {
|
||||
if len(leases) > 0 || len(pending) > 0 {
|
||||
return true
|
||||
}
|
||||
for _, inv := range m.Invites {
|
||||
for _, inv := range invites {
|
||||
if inv.MaxUses <= 0 || len(inv.Uses) < inv.MaxUses {
|
||||
return true
|
||||
}
|
||||
@@ -188,6 +194,30 @@ func (a *Agent) trackUsage(now time.Time) {
|
||||
for _, m := range metrics.Tunnels {
|
||||
byID[m.TunnelID] = m
|
||||
}
|
||||
// 通道组整体在用:组内任一通道在用,或组的邀请 / 授权 / 申请仍有效。组内通道一起计闲置、一起关闭。
|
||||
groupBusy := make(map[string]bool)
|
||||
for _, gm := range metrics.Groups {
|
||||
if accessInUse(gm.DynamicLeases, gm.Pending, gm.Invites) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
type closing struct {
|
||||
id, reason string
|
||||
@@ -226,7 +256,7 @@ func (a *Agent) trackUsage(now time.Time) {
|
||||
firstConn = append(firstConn, t.ID)
|
||||
}
|
||||
}
|
||||
u.inUse = tunnelInUse(m)
|
||||
u.inUse = tunnelInUse(m) || renewing[t.ID] || (t.GroupID != "" && groupBusy[t.GroupID])
|
||||
if u.inUse {
|
||||
u.lastActive = now
|
||||
}
|
||||
@@ -292,6 +322,20 @@ func (a *Agent) RateSeries(id string) RateSeries {
|
||||
return a.rates.series(id, time.Now())
|
||||
}
|
||||
|
||||
// GroupRateSeries sums the rate curves of several tunnels (a tunnel group)
|
||||
func (a *Agent) GroupRateSeries(ids []string) RateSeries {
|
||||
now := time.Now()
|
||||
sum := (&rateHistory{}).series(now)
|
||||
for _, id := range ids {
|
||||
s := a.rates.series(id, now)
|
||||
for k := range sum.Up {
|
||||
sum.Up[k] += s.Up[k]
|
||||
sum.Down[k] += s.Down[k]
|
||||
}
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// Activity returns the live usage of an enabled tunnel (nil when not tracked yet)
|
||||
func (a *Agent) Activity(t protocol.TunnelConfig) *TunnelActivity {
|
||||
a.usageMu.Lock()
|
||||
@@ -448,7 +492,7 @@ func (a *Agent) connectAndRun(cfg Config) error {
|
||||
for _, t := range a.cm.Get().Tunnels {
|
||||
if t.Enabled {
|
||||
log.Printf("[Agent] 注册通道: %s (:%d -> %s) [责任人: %s]", t.Name, t.ListenPort, t.Target, t.Owner)
|
||||
_ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: t})
|
||||
_ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: a.wire(t)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,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 {
|
||||
@@ -696,24 +751,51 @@ func (a *Agent) SyncTunnel(t protocol.TunnelConfig, edited bool) {
|
||||
return
|
||||
}
|
||||
if edited {
|
||||
_ = a.sendControl(protocol.UpdateTunnelMessage{Type: protocol.TypeUpdateTunnel, Tunnel: t})
|
||||
_ = a.sendControl(protocol.UpdateTunnelMessage{Type: protocol.TypeUpdateTunnel, Tunnel: a.wire(t)})
|
||||
} else {
|
||||
_ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: t})
|
||||
_ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: a.wire(t)})
|
||||
}
|
||||
}
|
||||
|
||||
// wire fills in what the relay server needs besides the stored config (the group's display name)
|
||||
func (a *Agent) wire(t protocol.TunnelConfig) protocol.TunnelConfig {
|
||||
t.GroupName = ""
|
||||
if t.GroupID != "" {
|
||||
if g, ok := a.cm.FindGroup(t.GroupID); ok {
|
||||
t.GroupName = g.Name
|
||||
}
|
||||
}
|
||||
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
|
||||
type Scope struct {
|
||||
TunnelID string
|
||||
GroupID string
|
||||
}
|
||||
|
||||
// CreateInvite asks the relay server for a new invite link and waits for the result
|
||||
func (a *Agent) CreateInvite(tunnelID, note string, validMinutes, maxUses int, leaseDuration string, requireApproval bool) (protocol.Invite, error) {
|
||||
func (a *Agent) CreateInvite(scope Scope, note string, validMinutes, maxUses int, leaseDuration string, requireApproval bool) (protocol.Invite, error) {
|
||||
if !a.IsConnected() {
|
||||
return protocol.Invite{}, fmt.Errorf("未连接中继服务器")
|
||||
}
|
||||
@@ -729,7 +811,7 @@ func (a *Agent) CreateInvite(tunnelID, note string, validMinutes, maxUses int, l
|
||||
}()
|
||||
|
||||
if err := a.sendControl(protocol.CreateInviteMessage{
|
||||
Type: protocol.TypeCreateInvite, RequestID: reqID, TunnelID: tunnelID,
|
||||
Type: protocol.TypeCreateInvite, RequestID: reqID, TunnelID: scope.TunnelID, GroupID: scope.GroupID,
|
||||
Note: note, ValidMinutes: validMinutes, MaxUses: maxUses, LeaseDuration: leaseDuration, RequireApproval: requireApproval,
|
||||
}); err != nil {
|
||||
return protocol.Invite{}, err
|
||||
@@ -745,28 +827,68 @@ func (a *Agent) CreateInvite(tunnelID, note string, validMinutes, maxUses int, l
|
||||
}
|
||||
}
|
||||
|
||||
// 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(tunnelID, code string) error {
|
||||
func (a *Agent) RevokeInvite(scope Scope, code string) error {
|
||||
if !a.IsConnected() {
|
||||
return fmt.Errorf("未连接中继服务器")
|
||||
}
|
||||
return a.sendControl(protocol.RevokeInviteMessage{Type: protocol.TypeRevokeInvite, TunnelID: tunnelID, Code: code})
|
||||
return a.sendControl(protocol.RevokeInviteMessage{Type: protocol.TypeRevokeInvite, TunnelID: scope.TunnelID, GroupID: scope.GroupID, Code: code})
|
||||
}
|
||||
|
||||
// DecideRequest approves or rejects a pending access request
|
||||
func (a *Agent) DecideRequest(tunnelID, requestID string, approve bool) error {
|
||||
func (a *Agent) DecideRequest(scope Scope, requestID string, approve bool) error {
|
||||
if !a.IsConnected() {
|
||||
return fmt.Errorf("未连接中继服务器")
|
||||
}
|
||||
return a.sendControl(protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: tunnelID, RequestID: requestID, Approve: approve})
|
||||
return a.sendControl(protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: scope.TunnelID, GroupID: scope.GroupID, RequestID: requestID, Approve: approve})
|
||||
}
|
||||
|
||||
// RevokeLease removes a temporary whitelist lease on the relay server (all leases when ip is empty)
|
||||
func (a *Agent) RevokeLease(tunnelID, ip string) error {
|
||||
func (a *Agent) RevokeLease(scope Scope, ip string) error {
|
||||
if !a.IsConnected() {
|
||||
return fmt.Errorf("未连接中继服务器")
|
||||
}
|
||||
return a.sendControl(protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, TunnelID: tunnelID, IP: ip})
|
||||
return a.sendControl(protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, TunnelID: scope.TunnelID, GroupID: scope.GroupID, IP: ip})
|
||||
}
|
||||
|
||||
// DeleteGroup tells the relay server to drop a dissolved group's invites and leases
|
||||
func (a *Agent) DeleteGroup(groupID string) {
|
||||
if !a.IsConnected() {
|
||||
return
|
||||
}
|
||||
_ = a.sendControl(protocol.DeleteGroupMessage{Type: protocol.TypeDeleteGroup, GroupID: groupID})
|
||||
}
|
||||
|
||||
func (a *Agent) KillConn(connID string) {
|
||||
@@ -801,7 +923,9 @@ func (a *Agent) GetLatestMetrics() protocol.MetricsMessage {
|
||||
defer a.mu.RUnlock()
|
||||
m := a.latestMetrics
|
||||
m.Tunnels = append([]protocol.TunnelMetrics(nil), m.Tunnels...)
|
||||
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
|
||||
}
|
||||
@@ -42,6 +42,8 @@ func (s *APIServer) Handler() http.Handler {
|
||||
mux.HandleFunc("/api/status", s.handleStatus)
|
||||
mux.HandleFunc("/api/tunnels", s.handleTunnels)
|
||||
mux.HandleFunc("/api/tunnels/", s.handleTunnelItem)
|
||||
mux.HandleFunc("/api/groups", s.handleGroups)
|
||||
mux.HandleFunc("/api/groups/", s.handleGroupItem)
|
||||
mux.HandleFunc("/api/probe", s.handleProbe)
|
||||
mux.HandleFunc("/api/recommend-port", s.handleRecommendPort)
|
||||
mux.HandleFunc("/api/connections", s.handleConnections)
|
||||
@@ -130,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.
|
||||
@@ -165,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)
|
||||
@@ -207,52 +223,22 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
tunnelID := parts[2]
|
||||
|
||||
if tunnelID == "batch" && len(parts) == 3 && r.Method == http.MethodPost {
|
||||
s.handleTunnelBatch(w, r)
|
||||
return
|
||||
}
|
||||
if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost {
|
||||
s.handleToggle(w, r, tunnelID)
|
||||
return
|
||||
}
|
||||
|
||||
// POST /api/tunnels/{id}/leases/revoke {"ip": "..."};ip 为空表示撤销该通道全部临时放通
|
||||
if len(parts) == 5 && parts[3] == "leases" && parts[4] == "revoke" && r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
// 授权相关:leases/revoke、invites、requests(与通道组共用同一套处理)
|
||||
if len(parts) >= 4 && isAccessPath(parts[3]) {
|
||||
if _, ok := s.cm.FindTunnel(tunnelID); !ok {
|
||||
jsonError(w, http.StatusNotFound, "通道未找到")
|
||||
return
|
||||
}
|
||||
if err := s.agent.RevokeLease(tunnelID, strings.TrimSpace(req.IP)); err != nil {
|
||||
jsonError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
return
|
||||
}
|
||||
|
||||
// 邀请链接:POST 新建;DELETE /invites/{code} 作废;GET /invites/{code}/share 获取分享文案
|
||||
if len(parts) >= 4 && parts[3] == "invites" {
|
||||
s.handleInvites(w, r, tunnelID, parts[4:])
|
||||
return
|
||||
}
|
||||
|
||||
// POST /api/tunnels/{id}/requests/{rid} {"approve": true|false}
|
||||
if len(parts) == 5 && parts[3] == "requests" && r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Approve bool `json:"approve"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := s.agent.DecideRequest(tunnelID, parts[4], req.Approve); err != nil {
|
||||
jsonError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
s.handleAccess(w, r, Scope{TunnelID: tunnelID}, parts[3:])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -293,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:
|
||||
@@ -303,44 +289,59 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleToggle 支持 {"enabled": true|false} 指定目标状态(幂等,防止重复点击来回翻转);不带 body 时取反
|
||||
func (s *APIServer) handleToggle(w http.ResponseWriter, r *http.Request, tunnelID string) {
|
||||
want, err := decodeToggle(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
t, err := s.setEnabled(tunnelID, want)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// decodeToggle reads {"enabled": bool}; nil means "flip"
|
||||
func decodeToggle(r *http.Request) (*bool, error) {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return req.Enabled, nil
|
||||
}
|
||||
|
||||
// setEnabled turns a tunnel on or off (want nil flips it), merging session traffic and syncing the server
|
||||
func (s *APIServer) setEnabled(tunnelID string, want *bool) (protocol.TunnelConfig, error) {
|
||||
current, err := s.cm.CurrentEnabled(tunnelID)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
return protocol.TunnelConfig{}, err
|
||||
}
|
||||
want := !current
|
||||
if req.Enabled != nil {
|
||||
want = *req.Enabled
|
||||
target := !current
|
||||
if want != nil {
|
||||
target = *want
|
||||
}
|
||||
|
||||
var sessIn, sessOut int64
|
||||
if !want && current {
|
||||
if !target && current {
|
||||
s.agent.FlushUsage(tunnelID)
|
||||
}
|
||||
if want != current {
|
||||
if target != current {
|
||||
// 关闭时把本次会话流量并入历史;开启时清掉可能残留的旧指标
|
||||
sessIn, sessOut = s.agent.ConsumeSessionBytes(tunnelID)
|
||||
}
|
||||
t, changed, err := s.cm.SetTunnelEnabled(tunnelID, want, sessIn, sessOut)
|
||||
t, changed, err := s.cm.SetTunnelEnabled(tunnelID, target, sessIn, sessOut)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
return t, err
|
||||
}
|
||||
if changed {
|
||||
s.agent.dropUsage(tunnelID)
|
||||
s.agent.SyncTunnel(t, false)
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, t)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// inviteBase is the URL prefix of invite links: http://<对外访问地址>:<控制端口>/i/
|
||||
@@ -358,13 +359,26 @@ func (s *APIServer) inviteBase(cfg Config) string {
|
||||
|
||||
// shareText is the ready-to-send message (e.g. for WeChat) for an invite
|
||||
func shareText(cfg Config, t protocol.TunnelConfig, inv protocol.Invite, link string) string {
|
||||
return shareMessage(inv, fmt.Sprintf("通道:%s\n访问地址:%s:%d", t.Name, cfg.PublicHostOrDefault(), t.ListenPort), link)
|
||||
}
|
||||
|
||||
// groupShareText lists every open address of a group with the target behind it
|
||||
func groupShareText(cfg Config, g TunnelGroup, members []protocol.TunnelConfig, inv protocol.Invite, link string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "通道组:%s(%d 个地址)\n访问地址:", g.Name, len(members))
|
||||
for _, t := range members {
|
||||
fmt.Fprintf(&b, "\n %s:%d → %s", cfg.PublicHostOrDefault(), t.ListenPort, t.Target)
|
||||
}
|
||||
return shareMessage(inv, b.String(), link)
|
||||
}
|
||||
|
||||
func shareMessage(inv protocol.Invite, body, link string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("【PortRelay 访问邀请】")
|
||||
if inv.Note != "" {
|
||||
b.WriteString("(给 " + inv.Note + ")")
|
||||
}
|
||||
b.WriteString("\n通道:" + t.Name)
|
||||
b.WriteString(fmt.Sprintf("\n访问地址:%s:%d", cfg.PublicHostOrDefault(), t.ListenPort))
|
||||
b.WriteString("\n" + body)
|
||||
action := "填写姓名后即可访问"
|
||||
if inv.RequireApproval {
|
||||
action = "填写姓名提交申请,管理员批准后即可访问"
|
||||
@@ -376,15 +390,198 @@ func shareText(cfg Config, t protocol.TunnelConfig, inv protocol.Invite, link st
|
||||
} else if inv.MaxUses > 1 {
|
||||
b.WriteString(fmt.Sprintf("\n此链接最多 %d 台设备使用,请勿转发。", inv.MaxUses))
|
||||
}
|
||||
b.WriteString("\n授权结束后 2 小时内,可以重新打开此链接申请延长。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnelID string, rest []string) {
|
||||
t, ok := s.cm.FindTunnel(tunnelID)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, "通道未找到")
|
||||
func isAccessPath(p string) bool {
|
||||
return p == "leases" || p == "invites" || p == "requests"
|
||||
}
|
||||
|
||||
// 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 分享文案
|
||||
// POST requests/{rid} {"approve": true|false}
|
||||
func (s *APIServer) handleAccess(w http.ResponseWriter, r *http.Request, sc Scope, rest []string) {
|
||||
switch {
|
||||
case len(rest) == 2 && rest[0] == "leases" && rest[1] == "revoke" && r.Method == http.MethodPost:
|
||||
var req struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := s.agent.RevokeLease(sc, strings.TrimSpace(req.IP)); err != nil {
|
||||
jsonError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
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:])
|
||||
|
||||
case len(rest) == 2 && rest[0] == "requests" && r.Method == http.MethodPost:
|
||||
var req struct {
|
||||
Approve bool `json:"approve"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := s.agent.DecideRequest(sc, rest[1], req.Approve); err != nil {
|
||||
jsonError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
g, _ := s.cm.FindGroup(sc.GroupID)
|
||||
var open []protocol.TunnelConfig
|
||||
for _, t := range s.cm.GroupMembers(sc.GroupID) {
|
||||
if t.Enabled {
|
||||
open = append(open, t)
|
||||
}
|
||||
}
|
||||
return groupShareText(cfg, g, open, inv, link)
|
||||
}
|
||||
t, _ := s.cm.FindTunnel(sc.TunnelID)
|
||||
return shareText(cfg, t, inv, link)
|
||||
}
|
||||
|
||||
// scopeOpen reports whether the tunnel, or at least one tunnel of the group, is enabled
|
||||
func (s *APIServer) scopeOpen(sc Scope) bool {
|
||||
if sc.GroupID != "" {
|
||||
for _, t := range s.cm.GroupMembers(sc.GroupID) {
|
||||
if t.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
t, ok := s.cm.FindTunnel(sc.TunnelID)
|
||||
return ok && t.Enabled
|
||||
}
|
||||
|
||||
// scopeInvites returns the live invites of a tunnel or group
|
||||
func scopeInvites(m protocol.MetricsMessage, sc Scope) []protocol.Invite {
|
||||
if sc.GroupID != "" {
|
||||
for _, g := range m.Groups {
|
||||
if g.GroupID == sc.GroupID {
|
||||
return g.Invites
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, t := range m.Tunnels {
|
||||
if t.TunnelID == sc.TunnelID {
|
||||
return t.Invites
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, sc Scope, rest []string) {
|
||||
cfg := s.cm.Get()
|
||||
|
||||
switch {
|
||||
@@ -415,11 +612,15 @@ func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnel
|
||||
if req.LeaseDuration == "" {
|
||||
req.LeaseDuration = protocol.LeaseDuration2h
|
||||
}
|
||||
if !t.Enabled {
|
||||
jsonError(w, http.StatusConflict, "通道未开启,请先开启通道再生成邀请链接")
|
||||
if !s.scopeOpen(sc) {
|
||||
msg := "通道未开启,请先开启通道再生成邀请链接"
|
||||
if sc.GroupID != "" {
|
||||
msg = "通道组内没有开启的通道,请先开启再生成邀请链接"
|
||||
}
|
||||
jsonError(w, http.StatusConflict, msg)
|
||||
return
|
||||
}
|
||||
inv, err := s.agent.CreateInvite(tunnelID, strings.TrimSpace(req.Note), req.ValidMinutes, maxUses, req.LeaseDuration, req.RequireApproval)
|
||||
inv, err := s.agent.CreateInvite(sc, strings.TrimSpace(req.Note), req.ValidMinutes, maxUses, req.LeaseDuration, req.RequireApproval)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
@@ -428,29 +629,24 @@ func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnel
|
||||
jsonResponse(w, http.StatusCreated, map[string]any{
|
||||
"invite": inv,
|
||||
"link": link,
|
||||
"share_text": shareText(cfg, t, inv, link),
|
||||
"share_text": s.scopeShare(cfg, sc, inv, link),
|
||||
})
|
||||
|
||||
case len(rest) == 1 && r.Method == http.MethodDelete:
|
||||
if err := s.agent.RevokeInvite(tunnelID, rest[0]); err != nil {
|
||||
if err := s.agent.RevokeInvite(sc, rest[0]); err != nil {
|
||||
jsonError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
|
||||
case len(rest) == 2 && rest[1] == "share" && r.Method == http.MethodGet:
|
||||
for _, m := range s.agent.GetLatestMetrics().Tunnels {
|
||||
if m.TunnelID != tunnelID {
|
||||
continue
|
||||
}
|
||||
for _, inv := range m.Invites {
|
||||
for _, inv := range scopeInvites(s.agent.GetLatestMetrics(), sc) {
|
||||
if inv.Code == rest[0] {
|
||||
link := s.inviteBase(cfg) + inv.Code
|
||||
jsonResponse(w, http.StatusOK, map[string]any{"invite": inv, "link": link, "share_text": shareText(cfg, t, inv, link)})
|
||||
jsonResponse(w, http.StatusOK, map[string]any{"invite": inv, "link": link, "share_text": s.scopeShare(cfg, sc, inv, link)})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
jsonError(w, http.StatusNotFound, "邀请链接不存在或已过期")
|
||||
|
||||
default:
|
||||
@@ -504,10 +700,10 @@ func (s *APIServer) handleRecommendPort(w http.ResponseWriter, r *http.Request)
|
||||
// handleConnections returns live client connections (optionally for one tunnel) and recent security events
|
||||
func (s *APIServer) handleConnections(w http.ResponseWriter, r *http.Request) {
|
||||
metrics := s.agent.GetLatestMetrics()
|
||||
tunnelID := r.URL.Query().Get("tunnel_id")
|
||||
match := s.tunnelFilter(r)
|
||||
conns := make([]protocol.ActiveConnInfo, 0, len(metrics.ActiveConns))
|
||||
for _, c := range metrics.ActiveConns {
|
||||
if tunnelID == "" || c.TunnelID == tunnelID {
|
||||
if match(c.TunnelID) {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
}
|
||||
@@ -537,8 +733,29 @@ func (s *APIServer) handleKillConn(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *APIServer) handleClientHistory(w http.ResponseWriter, r *http.Request) {
|
||||
tunnelID := r.URL.Query().Get("tunnel_id")
|
||||
jsonResponse(w, http.StatusOK, s.cm.GetClientHistory(tunnelID))
|
||||
match := s.tunnelFilter(r)
|
||||
out := make([]protocol.ClientHistoryRecord, 0)
|
||||
for _, rec := range s.cm.GetClientHistory("") {
|
||||
if match(rec.TunnelID) {
|
||||
out = append(out, rec)
|
||||
}
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// tunnelFilter matches tunnels selected by ?tunnel_id= or ?group_id= (everything when neither is set)
|
||||
func (s *APIServer) tunnelFilter(r *http.Request) func(id string) bool {
|
||||
if gid := r.URL.Query().Get("group_id"); gid != "" {
|
||||
ids := make(map[string]bool)
|
||||
for _, t := range s.cm.GroupMembers(gid) {
|
||||
ids[t.ID] = true
|
||||
}
|
||||
return func(id string) bool { return ids[id] }
|
||||
}
|
||||
if tid := r.URL.Query().Get("tunnel_id"); tid != "" {
|
||||
return func(id string) bool { return id == tid }
|
||||
}
|
||||
return func(string) bool { return true }
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -595,6 +812,7 @@ func (s *APIServer) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
payload := s.statusPayload()
|
||||
payload["metrics"] = metrics
|
||||
payload["tunnels"] = s.tunnelsWithMetrics(metrics)
|
||||
payload["groups"] = s.groupsView(metrics)
|
||||
data, _ := json.Marshal(payload)
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
)
|
||||
|
||||
// GroupView joins a tunnel group with its members and live shared access state
|
||||
type GroupView struct {
|
||||
TunnelGroup
|
||||
TunnelIDs []string `json:"tunnel_ids"` // 成员,按监听端口排序
|
||||
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 {
|
||||
shared := make(map[string]protocol.GroupMetrics, len(metrics.Groups))
|
||||
for _, g := range metrics.Groups {
|
||||
shared[g.GroupID] = g
|
||||
}
|
||||
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},
|
||||
Renewals: renewalsOf(metrics.Renewals, "", g.ID)}
|
||||
if m, ok := shared[g.ID]; ok {
|
||||
v.Metrics = m
|
||||
}
|
||||
var open []string
|
||||
for _, t := range s.cm.GroupMembers(g.ID) {
|
||||
v.TunnelIDs = append(v.TunnelIDs, t.ID)
|
||||
if t.Enabled {
|
||||
open = append(open, t.ID)
|
||||
}
|
||||
}
|
||||
if len(open) > 0 {
|
||||
series := s.agent.GroupRateSeries(open)
|
||||
v.RateHistory = &series
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// groupRequest renames a group or moves existing tunnels into it
|
||||
type groupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
// TunnelIDs 加入该组的已有通道
|
||||
TunnelIDs []string `json:"tunnel_ids"`
|
||||
}
|
||||
|
||||
func (req groupRequest) spec() GroupSpec {
|
||||
return GroupSpec{Name: req.Name, Owner: req.Owner, MoveIDs: req.TunnelIDs}
|
||||
}
|
||||
|
||||
func decodeGroupRequest(r *http.Request) (groupRequest, error) {
|
||||
var req groupRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
return req, err
|
||||
}
|
||||
|
||||
// syncChanged pushes members whose group changed to the relay server
|
||||
func (s *APIServer) syncChanged(changed []protocol.TunnelConfig) {
|
||||
for _, t := range changed {
|
||||
if t.Enabled {
|
||||
s.agent.SyncTunnel(t, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleGroups: GET 列表;POST 用已有通道组成通道组(新通道在添加通道表单里创建,见 handleTunnelBatch)
|
||||
func (s *APIServer) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, s.groupsView(s.agent.GetLatestMetrics()))
|
||||
case http.MethodPost:
|
||||
req, err := decodeGroupRequest(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
g, changed, err := s.cm.SaveGroup("", req.spec())
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.syncChanged(changed)
|
||||
jsonResponse(w, http.StatusCreated, map[string]any{"group": g})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleGroupItem:
|
||||
//
|
||||
// PUT /api/groups/{id} 改名 / 责任人,并可把已有通道加入该组
|
||||
// DELETE /api/groups/{id}?delete_members=1 解散(默认保留成员为独立通道)
|
||||
// POST /api/groups/{id}/toggle {"enabled": bool} 整组开关
|
||||
// ... /api/groups/{id}/leases|invites|requests 与单个通道相同的授权接口
|
||||
func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
if len(parts) < 3 || parts[2] == "" {
|
||||
jsonError(w, http.StatusBadRequest, "Missing group ID")
|
||||
return
|
||||
}
|
||||
id := parts[2]
|
||||
g, ok := s.cm.FindGroup(id)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, "通道组不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) >= 4 && isAccessPath(parts[3]) {
|
||||
s.handleAccess(w, r, Scope{GroupID: id}, parts[3:])
|
||||
return
|
||||
}
|
||||
if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost {
|
||||
want, err := decodeToggle(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if want == nil {
|
||||
// 不指定时:有任一成员开启则整组关闭,否则整组开启
|
||||
on := false
|
||||
for _, t := range s.cm.GroupMembers(id) {
|
||||
on = on || t.Enabled
|
||||
}
|
||||
flip := !on
|
||||
want = &flip
|
||||
}
|
||||
out := make([]protocol.TunnelConfig, 0)
|
||||
for _, t := range s.cm.GroupMembers(id) {
|
||||
updated, err := s.setEnabled(t.ID, want)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
out = append(out, updated)
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
if len(parts) != 3 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
req, err := decodeGroupRequest(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
req.Name = g.Name
|
||||
}
|
||||
updated, changed, err := s.cm.SaveGroup(id, req.spec())
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.syncChanged(changed)
|
||||
jsonResponse(w, http.StatusOK, map[string]any{"group": updated})
|
||||
|
||||
case http.MethodDelete:
|
||||
deleteMembers := r.URL.Query().Get("delete_members") == "1"
|
||||
members, err := s.cm.RemoveGroup(id, deleteMembers)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
for _, t := range members {
|
||||
if deleteMembers {
|
||||
s.agent.ConsumeSessionBytes(t.ID)
|
||||
s.agent.dropUsage(t.ID)
|
||||
s.agent.DeleteTunnel(t.ID)
|
||||
} else if t.Enabled {
|
||||
s.agent.SyncTunnel(t, true)
|
||||
}
|
||||
}
|
||||
s.agent.DeleteGroup(id)
|
||||
jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTunnelBatch: POST /api/tunnels/batch —— 添加通道表单的提交,可以一次创建单个或多个通道。
|
||||
// 请求体是通道设置,另加:
|
||||
//
|
||||
// target 可以写多个目标,如 "10.0.1.11-13:22, 10.0.2.5:3306"(见 ExpandTargets)
|
||||
// group_id 加入已有通道组;new_group 新建通道组。多个目标必须放进通道组
|
||||
// dry_run 只预览将创建的通道(名称、目标、分配的端口),不保存
|
||||
//
|
||||
// 返回 {"group": 加入的通道组或 null, "tunnels": [...]}
|
||||
func (s *APIServer) handleTunnelBatch(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
protocol.TunnelConfig
|
||||
NewGroup string `json:"new_group"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Invalid JSON payload")
|
||||
return
|
||||
}
|
||||
group, created, err := s.cm.CreateTunnels(NewTunnels{Template: req.TunnelConfig, NewGroup: req.NewGroup}, req.DryRun)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if !req.DryRun {
|
||||
status = http.StatusCreated
|
||||
for _, t := range created {
|
||||
s.agent.SyncTunnel(t, false)
|
||||
}
|
||||
}
|
||||
jsonResponse(w, status, map[string]any{"group": group, "tunnels": created})
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxBatchTargets 一次批量创建的目标上限,避免误操作占满端口池
|
||||
const maxBatchTargets = 64
|
||||
|
||||
var (
|
||||
listSeparators = regexp.MustCompile(`[\s,,、;;]+`)
|
||||
shortRange = regexp.MustCompile(`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.)(\d{1,3})-(\d{1,3})$`)
|
||||
fullRange = regexp.MustCompile(`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})-(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$`)
|
||||
hostnameRe = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9._-]*[A-Za-z0-9_])?$`)
|
||||
)
|
||||
|
||||
func splitList(s string) []string {
|
||||
var out []string
|
||||
for _, item := range listSeparators.Split(strings.TrimSpace(s), -1) {
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parsePorts accepts "22, 8080-8082"
|
||||
func parsePorts(s string) ([]int, error) {
|
||||
var out []int
|
||||
for _, item := range splitList(s) {
|
||||
lo, hi := item, item
|
||||
if i := strings.Index(item, "-"); i > 0 {
|
||||
lo, hi = item[:i], item[i+1:]
|
||||
}
|
||||
a, err1 := strconv.Atoi(lo)
|
||||
b, err2 := strconv.Atoi(hi)
|
||||
if err1 != nil || err2 != nil || a <= 0 || b > 65535 || a > b {
|
||||
return nil, fmt.Errorf("无效的端口 %q", item)
|
||||
}
|
||||
if b-a >= maxBatchTargets {
|
||||
return nil, fmt.Errorf("端口范围 %q 过大", item)
|
||||
}
|
||||
for p := a; p <= b; p++ {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// expandIPv4Range expands "10.0.1.11-13" or "10.0.1.11-10.0.1.13"
|
||||
func expandIPv4Range(item string) ([]string, bool, error) {
|
||||
var from, to net.IP
|
||||
if m := shortRange.FindStringSubmatch(item); m != nil {
|
||||
from, to = net.ParseIP(m[1]+m[2]).To4(), net.ParseIP(m[1]+m[3]).To4()
|
||||
} else if m := fullRange.FindStringSubmatch(item); m != nil {
|
||||
from, to = net.ParseIP(m[1]).To4(), net.ParseIP(m[2]).To4()
|
||||
} else {
|
||||
return nil, false, nil
|
||||
}
|
||||
if from == nil || to == nil {
|
||||
return nil, true, fmt.Errorf("无效的地址范围 %q", item)
|
||||
}
|
||||
a := uint32(from[0])<<24 | uint32(from[1])<<16 | uint32(from[2])<<8 | uint32(from[3])
|
||||
b := uint32(to[0])<<24 | uint32(to[1])<<16 | uint32(to[2])<<8 | uint32(to[3])
|
||||
if a > b {
|
||||
return nil, true, fmt.Errorf("地址范围 %q 起止颠倒", item)
|
||||
}
|
||||
if b-a >= maxBatchTargets {
|
||||
return nil, true, fmt.Errorf("地址范围 %q 过大", item)
|
||||
}
|
||||
var out []string
|
||||
for v := a; v <= b; v++ {
|
||||
out = append(out, net.IPv4(byte(v>>24), byte(v>>16), byte(v>>8), byte(v)).String())
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
func validHost(h string) bool {
|
||||
return net.ParseIP(h) != nil || hostnameRe.MatchString(h)
|
||||
}
|
||||
|
||||
// hostsOf expands one host item: an IPv4 range ("10.0.1.11-13") or a single IP / hostname
|
||||
func hostsOf(item string) ([]string, error) {
|
||||
hosts, isRange, err := expandIPv4Range(item)
|
||||
if err != nil || isRange {
|
||||
return hosts, err
|
||||
}
|
||||
if !validHost(item) {
|
||||
return nil, fmt.Errorf("无效的主机 %q", item)
|
||||
}
|
||||
return []string{item}, nil
|
||||
}
|
||||
|
||||
// ExpandTargets turns host and port lists into "host:port" targets, host by host.
|
||||
//
|
||||
// hosts 用逗号、空格或换行分隔,每一项可以是 IP、主机名、IPv4 范围("10.0.1.11-13" 或
|
||||
// "10.0.1.11-10.0.1.13"),也可以直接写成 "主机:端口"(主机和端口都可以是范围,如
|
||||
// "10.0.1.11-13:22"、"10.0.1.11:8080-8082");ports 形如 "22, 8080-8082",作用于没有写端口
|
||||
// 的主机。结果去重并保持输入顺序。
|
||||
func ExpandTargets(hosts, ports string) ([]string, error) {
|
||||
portList, err := parsePorts(ports)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
add := func(t string) error {
|
||||
if !seen[t] {
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
if len(out) > maxBatchTargets {
|
||||
return fmt.Errorf("一次最多添加 %d 个目标", maxBatchTargets)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
items := splitList(hosts)
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("请填写至少一个目标主机")
|
||||
}
|
||||
for _, item := range items {
|
||||
itemHosts, itemPorts := item, portList
|
||||
// 自带端口的完整目标:"10.0.1.11:22"、"10.0.1.11-13:22"、"[fd00::1]:22"
|
||||
if h, p, err := net.SplitHostPort(item); err == nil {
|
||||
if itemPorts, err = parsePorts(p); err != nil || len(itemPorts) == 0 {
|
||||
return nil, fmt.Errorf("无效的目标 %q", item)
|
||||
}
|
||||
itemHosts = h
|
||||
}
|
||||
hostList, err := hostsOf(itemHosts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(itemPorts) == 0 {
|
||||
return nil, fmt.Errorf("%q 缺少端口,请写成 主机:端口", item)
|
||||
}
|
||||
for _, h := range hostList {
|
||||
for _, p := range itemPorts {
|
||||
if err := add(net.JoinHostPort(h, strconv.Itoa(p))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// memberNames names batch-created tunnels after the group: "组名 (主机)" when every target
|
||||
// shares one port, otherwise "组名 (主机:端口)".
|
||||
func memberNames(group string, targets []string) []string {
|
||||
ports := make(map[string]bool)
|
||||
for _, t := range targets {
|
||||
if _, p, err := net.SplitHostPort(t); err == nil {
|
||||
ports[p] = true
|
||||
}
|
||||
}
|
||||
names := make([]string, len(targets))
|
||||
for i, t := range targets {
|
||||
label := t
|
||||
if len(ports) == 1 {
|
||||
if h, _, err := net.SplitHostPort(t); err == nil {
|
||||
label = h
|
||||
}
|
||||
}
|
||||
names[i] = fmt.Sprintf("%s (%s)", group, label)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// allocatePortsLocked picks n free listen ports, preferring one contiguous block so the mapping
|
||||
// is easy to read. Caller must hold cm.mu.
|
||||
func (cm *ConfigManager) allocatePortsLocked(n int) ([]int, error) {
|
||||
used := make(map[int]bool)
|
||||
for _, t := range cm.cfg.Tunnels {
|
||||
used[t.ListenPort] = true
|
||||
}
|
||||
// 与单个通道的推荐顺序一致:先 33660 往上,再从端口池开头补
|
||||
var order []int
|
||||
for p := 33660; p <= PortMax; p++ {
|
||||
order = append(order, p)
|
||||
}
|
||||
for p := PortMin; p < 33660; p++ {
|
||||
order = append(order, p)
|
||||
}
|
||||
free := func(p int) bool { return p != ControlPort && !used[p] }
|
||||
|
||||
for i := range order {
|
||||
ok := true
|
||||
for k := 0; k < n; k++ {
|
||||
if i+k >= len(order) || !free(order[i+k]) || (k > 0 && order[i+k] != order[i+k-1]+1) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return order[i : i+n], nil
|
||||
}
|
||||
}
|
||||
var out []int
|
||||
for _, p := range order {
|
||||
if free(p) {
|
||||
out = append(out, p)
|
||||
if len(out) == n {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("端口池剩余 %d 个空闲端口,不够分配 %d 个", len(out), n)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -49,12 +50,21 @@ func generateAuthCode() string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TunnelGroup 通道组:组内通道共用邀请、授权与审批,闲置计时联动
|
||||
type TunnelGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Config represents persistent client settings
|
||||
type Config struct {
|
||||
ServerAddr string `json:"server_addr"` // 中继服务器控制地址 (host:port)
|
||||
Token string `json:"token"`
|
||||
PublicHost string `json:"public_host,omitempty"` // 访问者连接通道时使用的网关地址
|
||||
Tunnels []protocol.TunnelConfig `json:"tunnels"`
|
||||
Groups []TunnelGroup `json:"groups,omitempty"`
|
||||
ClientHistory []protocol.ClientHistoryRecord `json:"client_history,omitempty"`
|
||||
}
|
||||
|
||||
@@ -208,6 +218,7 @@ func (cm *ConfigManager) Get() Config {
|
||||
for i, t := range cm.cfg.Tunnels {
|
||||
out.Tunnels[i] = cloneTunnel(t)
|
||||
}
|
||||
out.Groups = append([]TunnelGroup(nil), cm.cfg.Groups...)
|
||||
out.ClientHistory = append([]protocol.ClientHistoryRecord(nil), cm.cfg.ClientHistory...)
|
||||
return out
|
||||
}
|
||||
@@ -316,9 +327,24 @@ func normalizeAndValidate(t *protocol.TunnelConfig) error {
|
||||
// 放通时长与是否审批已改为在每个邀请上设置,通道级字段不再使用
|
||||
t.LeaseDuration = ""
|
||||
t.RequireApproval = false
|
||||
// 组名只在同步到服务端时填写,配置里以组 ID 为准
|
||||
t.GroupName = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupExistsLocked reports whether a group ID is known ("" means no group). Caller must hold cm.mu.
|
||||
func (cm *ConfigManager) groupExistsLocked(id string) bool {
|
||||
if id == "" {
|
||||
return true
|
||||
}
|
||||
for _, g := range cm.cfg.Groups {
|
||||
if g.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// checkPortLocked returns an error if another tunnel already uses the port. Caller must hold cm.mu.
|
||||
func (cm *ConfigManager) checkPortLocked(id string, port int) error {
|
||||
for _, existing := range cm.cfg.Tunnels {
|
||||
@@ -338,30 +364,131 @@ func (cm *ConfigManager) AddTunnel(t protocol.TunnelConfig) (protocol.TunnelConf
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if err := cm.checkPortLocked("", t.ListenPort); err != nil {
|
||||
return t, err
|
||||
}
|
||||
if !cm.groupExistsLocked(t.GroupID) {
|
||||
return t, fmt.Errorf("通道组不存在")
|
||||
}
|
||||
prepareNew(&t, time.Now())
|
||||
cm.cfg.Tunnels = append(cm.cfg.Tunnels, t)
|
||||
return cloneTunnel(t), cm.save()
|
||||
}
|
||||
|
||||
// prepareNew resets the program-maintained fields of a tunnel being created and opens it
|
||||
func prepareNew(t *protocol.TunnelConfig, now time.Time) {
|
||||
t.ID = uuid.New().String()
|
||||
t.AuthCode = generateAuthCode()
|
||||
t.HistoricalBytesIn, t.HistoricalBytesOut, t.LastDurationSec = 0, 0, 0
|
||||
t.CloseReason, t.FirstConnectedAt, t.ConnectedSec, t.LastConnectedSec = "", nil, 0, 0
|
||||
t.DynamicWhitelist = nil
|
||||
if t.IdleCloseMinutes == nil {
|
||||
v := DefaultIdleCloseMinutes
|
||||
t.IdleCloseMinutes = &v
|
||||
}
|
||||
t.DynamicWhitelist = nil
|
||||
if err := cm.checkPortLocked(t.ID, t.ListenPort); err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
t.Enabled = true
|
||||
now := time.Now()
|
||||
t.StartedAt = &now
|
||||
start := now
|
||||
t.StartedAt = &start
|
||||
t.ExpiresAt = nil
|
||||
if t.DurationMinutes > 0 {
|
||||
exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
|
||||
t.ExpiresAt = &exp
|
||||
}
|
||||
}
|
||||
|
||||
cm.cfg.Tunnels = append(cm.cfg.Tunnels, t)
|
||||
return cloneTunnel(t), cm.save()
|
||||
// NewTunnels is one submission of the add-tunnel form
|
||||
type NewTunnels struct {
|
||||
// Template 通道设置。Target 可以写多个目标(见 ExpandTargets);ListenPort 只用于单个目标,
|
||||
// 多个目标自动分配一段连续端口;GroupID 表示加入已有通道组。
|
||||
Template protocol.TunnelConfig
|
||||
// NewGroup 新建一个通道组并把这些通道放进去
|
||||
NewGroup string
|
||||
}
|
||||
|
||||
// CreateTunnels adds the tunnels of one add-tunnel form. Several targets become one tunnel each,
|
||||
// named "名称 (主机)", and must go into a group; a group member without a name is named after the
|
||||
// group. With dryRun nothing is saved and the result is a preview.
|
||||
// Returns the group the tunnels joined (nil when ungrouped) and the created tunnels.
|
||||
func (cm *ConfigManager) CreateTunnels(req NewTunnels, dryRun bool) (*TunnelGroup, []protocol.TunnelConfig, error) {
|
||||
tpl := req.Template
|
||||
targets, err := ExpandTargets(tpl.Target, "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
newGroup := strings.TrimSpace(req.NewGroup)
|
||||
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
var group *TunnelGroup
|
||||
switch {
|
||||
case newGroup != "":
|
||||
if err := cm.checkGroupNameLocked("", newGroup); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
group = &TunnelGroup{ID: uuid.New().String(), Name: newGroup, Owner: strings.TrimSpace(tpl.Owner), CreatedAt: time.Now()}
|
||||
case tpl.GroupID != "":
|
||||
g, ok := cm.findGroupLocked(tpl.GroupID)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("通道组不存在")
|
||||
}
|
||||
group = &g
|
||||
case len(targets) > 1 && !dryRun:
|
||||
return nil, nil, fmt.Errorf("多个目标需要放进同一个通道组,请选择或新建通道组")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(tpl.Name)
|
||||
base := name
|
||||
if base == "" && group != nil {
|
||||
base = group.Name
|
||||
}
|
||||
if base == "" && dryRun {
|
||||
base = "通道"
|
||||
}
|
||||
names := []string{base}
|
||||
if len(targets) > 1 || (name == "" && group != nil) {
|
||||
names = memberNames(base, targets)
|
||||
}
|
||||
|
||||
var ports []int
|
||||
if len(targets) == 1 && tpl.ListenPort > 0 {
|
||||
if err := cm.checkPortLocked("", tpl.ListenPort); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ports = []int{tpl.ListenPort}
|
||||
} else if ports, err = cm.allocatePortsLocked(len(targets)); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
created := make([]protocol.TunnelConfig, 0, len(targets))
|
||||
for i, target := range targets {
|
||||
t := cloneTunnel(tpl)
|
||||
t.Name, t.Target, t.ListenPort, t.GroupID = names[i], target, ports[i], ""
|
||||
if group != nil {
|
||||
t.GroupID = group.ID
|
||||
}
|
||||
if err := normalizeAndValidate(&t); err != nil {
|
||||
if len(targets) > 1 {
|
||||
err = fmt.Errorf("%s:%w", target, err)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
prepareNew(&t, now)
|
||||
created = append(created, t)
|
||||
}
|
||||
if dryRun {
|
||||
return group, created, nil
|
||||
}
|
||||
if newGroup != "" {
|
||||
cm.cfg.Groups = append(cm.cfg.Groups, *group)
|
||||
}
|
||||
cm.cfg.Tunnels = append(cm.cfg.Tunnels, created...)
|
||||
out := make([]protocol.TunnelConfig, len(created))
|
||||
for i, t := range created {
|
||||
out[i] = cloneTunnel(t)
|
||||
}
|
||||
return group, out, cm.save()
|
||||
}
|
||||
|
||||
// PatchTunnel applies a partial JSON update: only fields present in patch change.
|
||||
@@ -387,6 +514,11 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
|
||||
if err := json.Unmarshal(patch, &updated); err != nil {
|
||||
return existing, existing.ListenPort, fmt.Errorf("Invalid JSON")
|
||||
}
|
||||
// new_group:新建一个通道组并把该通道移进去
|
||||
var extra struct {
|
||||
NewGroup string `json:"new_group"`
|
||||
}
|
||||
_ = json.Unmarshal(patch, &extra)
|
||||
|
||||
// 受保护字段
|
||||
updated.ID = existing.ID
|
||||
@@ -409,6 +541,16 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
|
||||
if err := cm.checkPortLocked(id, updated.ListenPort); err != nil {
|
||||
return existing, existing.ListenPort, err
|
||||
}
|
||||
var newGroup *TunnelGroup
|
||||
if name := strings.TrimSpace(extra.NewGroup); name != "" {
|
||||
if err := cm.checkGroupNameLocked("", name); err != nil {
|
||||
return existing, existing.ListenPort, err
|
||||
}
|
||||
newGroup = &TunnelGroup{ID: uuid.New().String(), Name: name, Owner: updated.Owner, CreatedAt: time.Now()}
|
||||
updated.GroupID = newGroup.ID
|
||||
} else if !cm.groupExistsLocked(updated.GroupID) {
|
||||
return existing, existing.ListenPort, fmt.Errorf("通道组不存在")
|
||||
}
|
||||
|
||||
// 运行中修改时长:从本次开启时间重新计算到期时间
|
||||
if updated.Enabled && updated.DurationMinutes != existing.DurationMinutes {
|
||||
@@ -423,10 +565,32 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
|
||||
}
|
||||
}
|
||||
|
||||
if newGroup != nil {
|
||||
cm.cfg.Groups = append(cm.cfg.Groups, *newGroup)
|
||||
}
|
||||
cm.cfg.Tunnels[idx] = updated
|
||||
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()
|
||||
@@ -700,3 +864,168 @@ func (cm *ConfigManager) RecommendPort() int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- 通道组 ----
|
||||
|
||||
// FindGroup returns the group with the given ID
|
||||
func (cm *ConfigManager) FindGroup(id string) (TunnelGroup, bool) {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.findGroupLocked(id)
|
||||
}
|
||||
|
||||
func (cm *ConfigManager) findGroupLocked(id string) (TunnelGroup, bool) {
|
||||
for _, g := range cm.cfg.Groups {
|
||||
if g.ID == id {
|
||||
return g, true
|
||||
}
|
||||
}
|
||||
return TunnelGroup{}, false
|
||||
}
|
||||
|
||||
// checkGroupNameLocked validates the name for group id ("" for a new group). Caller must hold cm.mu.
|
||||
func (cm *ConfigManager) checkGroupNameLocked(id, name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("请填写通道组名称")
|
||||
}
|
||||
if len([]rune(name)) > 64 {
|
||||
return fmt.Errorf("通道组名称不能超过 64 个字")
|
||||
}
|
||||
for _, g := range cm.cfg.Groups {
|
||||
if g.ID != id && g.Name == name {
|
||||
return fmt.Errorf("已有同名的通道组「%s」", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GroupMembers returns the tunnels of a group ordered by listen port
|
||||
func (cm *ConfigManager) GroupMembers(id string) []protocol.TunnelConfig {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.groupMembersLocked(id)
|
||||
}
|
||||
|
||||
func (cm *ConfigManager) groupMembersLocked(id string) []protocol.TunnelConfig {
|
||||
var out []protocol.TunnelConfig
|
||||
for _, t := range cm.cfg.Tunnels {
|
||||
if id != "" && t.GroupID == id {
|
||||
out = append(out, cloneTunnel(t))
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ListenPort < out[j].ListenPort })
|
||||
return out
|
||||
}
|
||||
|
||||
// GroupSpec describes a group's name and owner, and existing tunnels to move into it.
|
||||
// 新通道统一在添加通道表单里创建(见 CreateTunnels)。
|
||||
type GroupSpec struct {
|
||||
Name string
|
||||
Owner string
|
||||
MoveIDs []string
|
||||
}
|
||||
|
||||
// SaveGroup creates a group from existing tunnels (id == "") or renames / adds members to one.
|
||||
// Returns the group and the members whose config changed (moved in, or the group was renamed)
|
||||
// — the caller pushes them to the relay server.
|
||||
func (cm *ConfigManager) SaveGroup(id string, spec GroupSpec) (TunnelGroup, []protocol.TunnelConfig, error) {
|
||||
spec.Name = strings.TrimSpace(spec.Name)
|
||||
spec.Owner = strings.TrimSpace(spec.Owner)
|
||||
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if err := cm.checkGroupNameLocked(id, spec.Name); err != nil {
|
||||
return TunnelGroup{}, nil, err
|
||||
}
|
||||
gi := -1
|
||||
for i, g := range cm.cfg.Groups {
|
||||
if id != "" && g.ID == id {
|
||||
gi = i
|
||||
}
|
||||
}
|
||||
if id != "" && gi < 0 {
|
||||
return TunnelGroup{}, nil, fmt.Errorf("通道组不存在")
|
||||
}
|
||||
if id == "" && len(spec.MoveIDs) == 0 {
|
||||
return TunnelGroup{}, nil, fmt.Errorf("请选择要放进该组的通道")
|
||||
}
|
||||
|
||||
group := TunnelGroup{ID: id, Name: spec.Name, Owner: spec.Owner, CreatedAt: time.Now()}
|
||||
if gi >= 0 {
|
||||
group.CreatedAt = cm.cfg.Groups[gi].CreatedAt
|
||||
} else {
|
||||
group.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
move := make(map[string]bool)
|
||||
for _, mid := range spec.MoveIDs {
|
||||
found := false
|
||||
for _, t := range cm.cfg.Tunnels {
|
||||
if t.ID != mid {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if t.GroupID != "" && t.GroupID != group.ID {
|
||||
return TunnelGroup{}, nil, fmt.Errorf("「%s」已属于其它通道组,请先将其移出", t.Name)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return TunnelGroup{}, nil, fmt.Errorf("通道 %s 未找到", mid)
|
||||
}
|
||||
move[mid] = true
|
||||
}
|
||||
|
||||
renamed := gi >= 0 && cm.cfg.Groups[gi].Name != group.Name
|
||||
var changed []protocol.TunnelConfig
|
||||
for i, t := range cm.cfg.Tunnels {
|
||||
switch {
|
||||
case move[t.ID] && t.GroupID != group.ID:
|
||||
cm.cfg.Tunnels[i].GroupID = group.ID
|
||||
changed = append(changed, cloneTunnel(cm.cfg.Tunnels[i]))
|
||||
case renamed && t.GroupID == group.ID:
|
||||
changed = append(changed, cloneTunnel(t))
|
||||
}
|
||||
}
|
||||
if gi >= 0 {
|
||||
cm.cfg.Groups[gi] = group
|
||||
} else {
|
||||
cm.cfg.Groups = append(cm.cfg.Groups, group)
|
||||
}
|
||||
return group, changed, cm.save()
|
||||
}
|
||||
|
||||
// RemoveGroup dissolves a group. With deleteMembers its tunnels are deleted too, otherwise they
|
||||
// stay as standalone tunnels. Returns the affected members (as they are after the change).
|
||||
func (cm *ConfigManager) RemoveGroup(id string, deleteMembers bool) ([]protocol.TunnelConfig, error) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
groups := make([]TunnelGroup, 0, len(cm.cfg.Groups))
|
||||
found := false
|
||||
for _, g := range cm.cfg.Groups {
|
||||
if g.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("通道组不存在")
|
||||
}
|
||||
var affected []protocol.TunnelConfig
|
||||
kept := make([]protocol.TunnelConfig, 0, len(cm.cfg.Tunnels))
|
||||
for _, t := range cm.cfg.Tunnels {
|
||||
if t.GroupID != id {
|
||||
kept = append(kept, t)
|
||||
continue
|
||||
}
|
||||
t.GroupID = ""
|
||||
affected = append(affected, cloneTunnel(t))
|
||||
if !deleteMembers {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
cm.cfg.Groups = groups
|
||||
cm.cfg.Tunnels = kept
|
||||
return affected, cm.save()
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
)
|
||||
|
||||
func TestExpandTargets(t *testing.T) {
|
||||
cases := []struct {
|
||||
hosts, ports string
|
||||
want []string
|
||||
}{
|
||||
{"10.0.1.11-13", "22", []string{"10.0.1.11:22", "10.0.1.12:22", "10.0.1.13:22"}},
|
||||
{"10.0.1.11, 10.0.1.12", "22, 8080", []string{"10.0.1.11:22", "10.0.1.11:8080", "10.0.1.12:22", "10.0.1.12:8080"}},
|
||||
{"10.0.1.11-10.0.1.12", "8080-8081", []string{"10.0.1.11:8080", "10.0.1.11:8081", "10.0.1.12:8080", "10.0.1.12:8081"}},
|
||||
{"db.internal:3306\n10.0.1.5", "22", []string{"db.internal:3306", "10.0.1.5:22"}},
|
||||
{"10.0.1.1,10.0.1.2、10.0.1.1", "22", []string{"10.0.1.1:22", "10.0.1.2:22"}},
|
||||
{"fd00::1", "22", []string{"[fd00::1]:22"}},
|
||||
{"10.0.2.9:5432", "", []string{"10.0.2.9:5432"}},
|
||||
{"10.0.1.11-13:22", "", []string{"10.0.1.11:22", "10.0.1.12:22", "10.0.1.13:22"}},
|
||||
{"10.0.1.11:8080-8081, db_1.internal:3306", "", []string{"10.0.1.11:8080", "10.0.1.11:8081", "db_1.internal:3306"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ExpandTargets(c.hosts, c.ports)
|
||||
if err != nil || !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("ExpandTargets(%q, %q) = %v, %v; want %v", c.hosts, c.ports, got, err, c.want)
|
||||
}
|
||||
}
|
||||
for _, bad := range [][2]string{
|
||||
{"", "22"},
|
||||
{"10.0.1.11", ""},
|
||||
{"bad host!", "22"},
|
||||
{"10.0.1.1-200", "22"},
|
||||
{"10.0.1.13-11", "22"},
|
||||
{"10.0.1.11", "0"},
|
||||
{"10.0.1.11", "70000"},
|
||||
{"10.0.1.1-40", "22, 23"},
|
||||
{"10.0.1.11-13", ""},
|
||||
{"10.0.1.11-300:22", ""},
|
||||
{"10.0.1.11:0", ""},
|
||||
} {
|
||||
if got, err := ExpandTargets(bad[0], bad[1]); err == nil {
|
||||
t.Errorf("ExpandTargets(%q, %q) should fail, got %v", bad[0], bad[1], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberNames(t *testing.T) {
|
||||
if got := memberNames("订单服务", []string{"10.0.1.11:22", "10.0.1.12:22"}); got[0] != "订单服务 (10.0.1.11)" {
|
||||
t.Errorf("single port should name by host: %v", got)
|
||||
}
|
||||
if got := memberNames("订单服务", []string{"10.0.1.11:22", "10.0.1.11:8080"}); got[1] != "订单服务 (10.0.1.11:8080)" {
|
||||
t.Errorf("several ports should name by host:port: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocatePortsPrefersContiguousBlock(t *testing.T) {
|
||||
cm := newTestConfig(t, "127.0.0.1:1", testToken)
|
||||
for _, p := range []int{33660, 33662} {
|
||||
if _, err := cm.AddTunnel(protocol.TunnelConfig{Name: fmt.Sprint(p), Target: "10.0.0.1:22", ListenPort: p}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cm.mu.Lock()
|
||||
three, err3 := cm.allocatePortsLocked(3)
|
||||
one, err1 := cm.allocatePortsLocked(1)
|
||||
cm.mu.Unlock()
|
||||
if err3 != nil || !reflect.DeepEqual(three, []int{33663, 33664, 33665}) {
|
||||
t.Fatalf("3 ports: %v %v", three, err3)
|
||||
}
|
||||
if err1 != nil || !reflect.DeepEqual(one, []int{33661}) {
|
||||
t.Fatalf("1 port: %v %v", one, err1)
|
||||
}
|
||||
}
|
||||
|
||||
type batchResp struct {
|
||||
Group *TunnelGroup `json:"group"`
|
||||
Tunnels []protocol.TunnelConfig `json:"tunnels"`
|
||||
}
|
||||
|
||||
// addTunnels submits the add-tunnel form
|
||||
func (e *env) addTunnels(t *testing.T, body map[string]any) batchResp {
|
||||
t.Helper()
|
||||
status, raw := e.call(t, "POST", "/api/tunnels/batch", body)
|
||||
want := http.StatusCreated
|
||||
if body["dry_run"] == true {
|
||||
want = http.StatusOK
|
||||
}
|
||||
if status != want {
|
||||
t.Fatalf("add tunnels %v: %d %s", body, status, raw)
|
||||
}
|
||||
var out batchResp
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *env) group(t *testing.T, id string) GroupView {
|
||||
t.Helper()
|
||||
_, body := e.call(t, "GET", "/api/groups", nil)
|
||||
var list []GroupView
|
||||
_ = json.Unmarshal(body, &list)
|
||||
for _, g := range list {
|
||||
if g.ID == id {
|
||||
return g
|
||||
}
|
||||
}
|
||||
t.Fatalf("group %s not found", id)
|
||||
return GroupView{}
|
||||
}
|
||||
|
||||
func grantViaInvite(t *testing.T, e *env, code, name string) {
|
||||
t.Helper()
|
||||
resp, err := http.PostForm(fmt.Sprintf("http://%s/auth", e.addr), url.Values{"code": {code}, "name": {name}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("grant: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupEndToEnd(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": "订单服务", "owner": "运维组", "target": echo1 + ", " + echo2, "new_group": "订单服务",
|
||||
"whitelist": []string{}, "idle_close_minutes": 60, "duration_minutes": 480, "max_bandwidth_mb": 5,
|
||||
})
|
||||
if g.Group == nil || g.Group.Name != "订单服务" || g.Group.Owner != "运维组" {
|
||||
t.Fatalf("new group: %+v", g.Group)
|
||||
}
|
||||
if len(g.Tunnels) != 2 || g.Tunnels[1].ListenPort != g.Tunnels[0].ListenPort+1 {
|
||||
t.Fatalf("members should get a contiguous port block: %+v", g.Tunnels)
|
||||
}
|
||||
m1, m2 := g.Tunnels[0], g.Tunnels[1]
|
||||
for _, m := range g.Tunnels {
|
||||
if m.GroupID != g.Group.ID || m.Owner != "运维组" || len(m.Whitelist) != 0 || !strings.HasPrefix(m.Name, "订单服务 (") {
|
||||
t.Fatalf("member not set up as a link-only group member: %+v", m)
|
||||
}
|
||||
// 表单里的每一项设置都用于每个成员
|
||||
if *m.IdleCloseMinutes != 60 || m.DurationMinutes != 480 || m.MaxBandwidthMB != 5 || m.ExpiresAt == nil {
|
||||
t.Fatalf("member lost form settings: %+v", m)
|
||||
}
|
||||
}
|
||||
waitFor(t, "members online", func() bool {
|
||||
return e.tunnel(t, m1.ID).Metrics.Status == "online" && e.tunnel(t, m2.ID).Metrics.Status == "online"
|
||||
})
|
||||
if _, err := echoVia(m1.ListenPort, "x"); err == nil {
|
||||
t.Fatal("group members are link-only by default")
|
||||
}
|
||||
|
||||
status, body := e.call(t, "POST", "/api/groups/"+g.Group.ID+"/invites", map[string]any{"note": "小王", "lease_duration": "8h"})
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("group invite: %d %s", status, body)
|
||||
}
|
||||
var inv struct {
|
||||
Invite protocol.Invite `json:"invite"`
|
||||
ShareText string `json:"share_text"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &inv)
|
||||
for _, want := range []string{"通道组:订单服务(2 个地址)", fmt.Sprintf("127.0.0.1:%d → %s", m1.ListenPort, echo1), fmt.Sprintf("127.0.0.1:%d → %s", m2.ListenPort, echo2), "8 小时"} {
|
||||
if !strings.Contains(inv.ShareText, want) {
|
||||
t.Errorf("share text missing %q:\n%s", want, inv.ShareText)
|
||||
}
|
||||
}
|
||||
waitFor(t, "group invite listed", func() bool { return len(e.group(t, g.Group.ID).Metrics.Invites) == 1 })
|
||||
if status, _ := e.call(t, "GET", "/api/groups/"+g.Group.ID+"/invites/"+inv.Invite.Code+"/share", nil); status != http.StatusOK {
|
||||
t.Fatalf("share of existing group invite: %d", status)
|
||||
}
|
||||
|
||||
grantViaInvite(t, e, inv.Invite.Code, "小王")
|
||||
for _, m := range g.Tunnels {
|
||||
if got, err := echoVia(m.ListenPort, "hi"); err != nil || got != "hi" {
|
||||
t.Fatalf("member %d not reachable after group grant: %q %v", m.ListenPort, got, err)
|
||||
}
|
||||
}
|
||||
waitFor(t, "group lease started", func() bool {
|
||||
l := e.group(t, g.Group.ID).Metrics.DynamicLeases
|
||||
return len(l) == 1 && l[0].Name == "小王" && !l[0].AwaitingConnect
|
||||
})
|
||||
if gv := e.group(t, g.Group.ID); !reflect.DeepEqual(gv.TunnelIDs, []string{m1.ID, m2.ID}) || gv.RateHistory == nil {
|
||||
t.Fatalf("group view: %+v", gv)
|
||||
}
|
||||
|
||||
if status, _ := e.call(t, "POST", "/api/groups/"+g.Group.ID+"/leases/revoke", map[string]string{}); status != http.StatusOK {
|
||||
t.Fatal("revoke group leases failed")
|
||||
}
|
||||
waitFor(t, "group access revoked", func() bool {
|
||||
_, err1 := echoVia(m1.ListenPort, "x")
|
||||
_, err2 := echoVia(m2.ListenPort, "x")
|
||||
return err1 != nil && err2 != nil
|
||||
})
|
||||
|
||||
// 整组关闭 / 开启
|
||||
e.call(t, "POST", "/api/groups/"+g.Group.ID+"/toggle", map[string]bool{"enabled": false})
|
||||
if e.tunnel(t, m1.ID).Enabled || e.tunnel(t, m2.ID).Enabled {
|
||||
t.Fatal("group toggle should close every member")
|
||||
}
|
||||
e.call(t, "POST", "/api/groups/"+g.Group.ID+"/toggle", map[string]bool{"enabled": true})
|
||||
if !e.tunnel(t, m1.ID).Enabled || !e.tunnel(t, m2.ID).Enabled {
|
||||
t.Fatal("group toggle should open every member")
|
||||
}
|
||||
|
||||
// 解散:成员保留为独立通道
|
||||
if status, _ := e.call(t, "DELETE", "/api/groups/"+g.Group.ID, nil); status != http.StatusOK {
|
||||
t.Fatal("dissolve failed")
|
||||
}
|
||||
if e.tunnel(t, m1.ID).GroupID != "" || len(e.cm.Get().Groups) != 0 {
|
||||
t.Fatal("dissolved group should leave standalone tunnels")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupFromExistingTunnelsRenameAndMoveOut(t *testing.T) {
|
||||
e := startEnv(t, testToken)
|
||||
waitFor(t, "agent connected", e.agent.IsConnected)
|
||||
var ids []string
|
||||
var ports []int
|
||||
for i := 0; i < 2; i++ {
|
||||
port := freeRelayPort(t, ports...)
|
||||
_, body := e.call(t, "POST", "/api/tunnels", map[string]any{"name": fmt.Sprint("t", i), "target": "127.0.0.1:1", "listen_port": port})
|
||||
var tc protocol.TunnelConfig
|
||||
_ = json.Unmarshal(body, &tc)
|
||||
ids, ports = append(ids, tc.ID), append(ports, port)
|
||||
}
|
||||
|
||||
status, body := e.call(t, "POST", "/api/groups", map[string]any{"name": "支付服务", "tunnel_ids": ids})
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("group from existing tunnels: %d %s", status, body)
|
||||
}
|
||||
var g batchResp
|
||||
_ = json.Unmarshal(body, &g)
|
||||
if e.tunnel(t, ids[0]).GroupID != g.Group.ID || e.tunnel(t, ids[1]).GroupID != g.Group.ID {
|
||||
t.Fatal("existing tunnels should join the group")
|
||||
}
|
||||
_, body = e.call(t, "POST", "/api/groups/"+g.Group.ID+"/invites", map[string]any{})
|
||||
var inv struct {
|
||||
Invite protocol.Invite `json:"invite"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &inv)
|
||||
page := func() string {
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/i/%s", e.addr, inv.Invite.Code))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b)
|
||||
}
|
||||
if p := page(); !strings.Contains(p, "支付服务(2 个地址)") {
|
||||
t.Fatalf("invite page should list the group: %s", p)
|
||||
}
|
||||
|
||||
// 改名会同步到服务端
|
||||
if status, body := e.call(t, "PUT", "/api/groups/"+g.Group.ID, map[string]any{"name": "支付服务后端"}); status != http.StatusOK {
|
||||
t.Fatalf("rename: %d %s", status, body)
|
||||
}
|
||||
waitFor(t, "new group name on the invite page", func() bool { return strings.Contains(page(), "支付服务后端(2 个地址)") })
|
||||
|
||||
// 移出组;不能加入不存在的组
|
||||
if status, _ := e.call(t, "PUT", "/api/tunnels/"+ids[0], map[string]any{"group_id": ""}); status != http.StatusOK {
|
||||
t.Fatal("move out failed")
|
||||
}
|
||||
waitFor(t, "invite page lists the remaining member", func() bool { return strings.Contains(page(), "(1 个地址)") })
|
||||
if status, _ := e.call(t, "PUT", "/api/tunnels/"+ids[0], map[string]any{"group_id": "nope"}); status != http.StatusBadRequest {
|
||||
t.Fatal("joining an unknown group must fail")
|
||||
}
|
||||
|
||||
// 一个通道只能属于一个组;组名不能重复
|
||||
if status, _ := e.call(t, "POST", "/api/groups", map[string]any{"name": "其它", "tunnel_ids": []string{ids[1]}}); status != http.StatusBadRequest {
|
||||
t.Fatal("a tunnel cannot be in two groups")
|
||||
}
|
||||
if status, _ := e.call(t, "POST", "/api/groups", map[string]any{"name": "支付服务后端", "tunnel_ids": []string{ids[0]}}); status != http.StatusBadRequest {
|
||||
t.Fatal("duplicate group names must be rejected")
|
||||
}
|
||||
|
||||
// 解散并删除成员
|
||||
if status, _ := e.call(t, "DELETE", "/api/groups/"+g.Group.ID+"?delete_members=1", nil); status != http.StatusOK {
|
||||
t.Fatal("delete group with members failed")
|
||||
}
|
||||
if _, ok := e.cm.FindTunnel(ids[1]); ok {
|
||||
t.Fatal("members should be deleted with the group")
|
||||
}
|
||||
if _, ok := e.cm.FindTunnel(ids[0]); !ok {
|
||||
t.Fatal("the tunnel moved out earlier must remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTunnelFormPreviewAndRules(t *testing.T) {
|
||||
e := startEnv(t, testToken)
|
||||
|
||||
// 预览:不保存,展开目标并预分配连续端口
|
||||
pv := e.addTunnels(t, map[string]any{"target": "10.0.1.11-12:22", "dry_run": true})
|
||||
if len(pv.Tunnels) != 2 || pv.Tunnels[0].Target != "10.0.1.11:22" || pv.Tunnels[1].ListenPort != pv.Tunnels[0].ListenPort+1 {
|
||||
t.Fatalf("preview: %+v", pv.Tunnels)
|
||||
}
|
||||
if len(e.cm.Get().Tunnels) != 0 {
|
||||
t.Fatal("preview must not create tunnels")
|
||||
}
|
||||
|
||||
// 多个目标必须放进通道组
|
||||
if status, _ := e.call(t, "POST", "/api/tunnels/batch", map[string]any{"name": "x", "target": "10.0.1.11-12:22"}); status != http.StatusBadRequest {
|
||||
t.Fatal("several targets without a group must be rejected")
|
||||
}
|
||||
|
||||
// 单个目标:名称和端口按填写的来,不分组
|
||||
port := freeRelayPort(t)
|
||||
one := e.addTunnels(t, map[string]any{"name": "MySQL", "target": "10.0.2.9:3306", "listen_port": port})
|
||||
if one.Group != nil || len(one.Tunnels) != 1 || one.Tunnels[0].Name != "MySQL" || one.Tunnels[0].ListenPort != port {
|
||||
t.Fatalf("single tunnel: %+v", one)
|
||||
}
|
||||
|
||||
// 加入已有组且不填名称:按组名命名
|
||||
grp := e.addTunnels(t, map[string]any{"name": "订单服务", "target": "10.0.1.11:22", "new_group": "订单服务"})
|
||||
if grp.Tunnels[0].Name != "订单服务" {
|
||||
t.Fatalf("a named single tunnel keeps its name: %+v", grp.Tunnels[0])
|
||||
}
|
||||
more := e.addTunnels(t, map[string]any{"target": "10.0.1.12-13:22", "group_id": grp.Group.ID})
|
||||
if more.Group.ID != grp.Group.ID || more.Tunnels[0].Name != "订单服务 (10.0.1.12)" || more.Tunnels[0].GroupID != grp.Group.ID {
|
||||
t.Fatalf("members added to an existing group: %+v", more)
|
||||
}
|
||||
if status, _ := e.call(t, "POST", "/api/tunnels/batch", map[string]any{"name": "y", "target": "10.0.3.1:22", "new_group": "订单服务"}); status != http.StatusBadRequest {
|
||||
t.Fatal("duplicate group names must be rejected")
|
||||
}
|
||||
|
||||
// 编辑通道时新建通道组
|
||||
status, body := e.call(t, "PUT", "/api/tunnels/"+one.Tunnels[0].ID, map[string]any{"new_group": "数据库"})
|
||||
var moved protocol.TunnelConfig
|
||||
_ = json.Unmarshal(body, &moved)
|
||||
groups := e.cm.Get().Groups
|
||||
if status != http.StatusOK || len(groups) != 2 || groups[1].Name != "数据库" || moved.GroupID != groups[1].ID {
|
||||
t.Fatalf("edit into a new group: %d %s %+v", status, body, groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupIdleCloseIsLinked(t *testing.T) {
|
||||
e := startEnvWith(t, testToken, func(a *Agent) { a.idleUnit = 3 * time.Second })
|
||||
waitFor(t, "agent connected", e.agent.IsConnected)
|
||||
g := e.addTunnels(t, map[string]any{"name": "g", "target": "127.0.0.1:1, 127.0.0.1:2", "new_group": "g", "idle_close_minutes": 1})
|
||||
a, b := g.Tunnels[0], g.Tunnels[1]
|
||||
waitFor(t, "members online", func() bool {
|
||||
return e.tunnel(t, a.ID).Metrics.Status == "online" && e.tunnel(t, b.ID).Metrics.Status == "online"
|
||||
})
|
||||
|
||||
// 只给成员 a 发邀请:b 也算在用,不会单独闲置关闭
|
||||
_, ib := e.call(t, "POST", "/api/tunnels/"+a.ID+"/invites", map[string]any{})
|
||||
var ir struct {
|
||||
Invite protocol.Invite `json:"invite"`
|
||||
}
|
||||
_ = json.Unmarshal(ib, &ir)
|
||||
time.Sleep(6 * time.Second)
|
||||
if !e.tunnel(t, b.ID).Enabled {
|
||||
t.Fatal("a member must stay open while another member is in use")
|
||||
}
|
||||
e.call(t, "DELETE", "/api/tunnels/"+a.ID+"/invites/"+ir.Invite.Code, nil)
|
||||
waitFor(t, "both members closed together", func() bool { return !e.tunnel(t, a.ID).Enabled && !e.tunnel(t, b.ID).Enabled })
|
||||
|
||||
// 组邀请同样让整组保持开启
|
||||
e.call(t, "POST", "/api/groups/"+g.Group.ID+"/toggle", map[string]bool{"enabled": true})
|
||||
waitFor(t, "members online again", func() bool {
|
||||
return e.tunnel(t, a.ID).Metrics.Status == "online" && e.tunnel(t, b.ID).Metrics.Status == "online"
|
||||
})
|
||||
_, gb := e.call(t, "POST", "/api/groups/"+g.Group.ID+"/invites", map[string]any{})
|
||||
var gr struct {
|
||||
Invite protocol.Invite `json:"invite"`
|
||||
}
|
||||
_ = json.Unmarshal(gb, &gr)
|
||||
time.Sleep(6 * time.Second)
|
||||
if !e.tunnel(t, a.ID).Enabled || !e.tunnel(t, b.ID).Enabled {
|
||||
t.Fatal("an unused group invite keeps the whole group open")
|
||||
}
|
||||
e.call(t, "DELETE", "/api/groups/"+g.Group.ID+"/invites/"+gr.Invite.Code, nil)
|
||||
waitFor(t, "group closed after its invite was revoked", func() bool { return !e.tunnel(t, a.ID).Enabled && !e.tunnel(t, b.ID).Enabled })
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -96,6 +96,18 @@ func (w *Whitelist) RestoreDynamic(leases map[string]time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceDynamic sets the dynamic leases to exactly the given set (the access state store is the source of truth)
|
||||
func (w *Whitelist) ReplaceDynamic(leases map[string]time.Time) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.dynamic = make(map[string]time.Time, len(leases))
|
||||
for ip, exp := range leases {
|
||||
if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil {
|
||||
w.dynamic[parsed.String()] = exp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveDynamic revokes the lease for ip (all leases when ip is empty) and returns how many were removed
|
||||
func (w *Whitelist) RemoveDynamic(ip string) int {
|
||||
w.mu.Lock()
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message types
|
||||
const (
|
||||
@@ -17,6 +22,9 @@ const (
|
||||
TypeInviteResp = "invite_resp"
|
||||
TypeRevokeInvite = "revoke_invite"
|
||||
TypeDecideRequest = "decide_request"
|
||||
TypeDeleteGroup = "delete_group"
|
||||
TypeExtendLease = "extend_lease"
|
||||
TypeExtendResp = "extend_lease_resp"
|
||||
TypeMetrics = "metrics_update"
|
||||
TypePing = "ping"
|
||||
TypePong = "pong"
|
||||
@@ -68,6 +76,10 @@ type TunnelConfig struct {
|
||||
Notes string `json:"notes,omitempty"`
|
||||
LeaseDuration string `json:"lease_duration,omitempty"` // 已废弃:放通时长改为在每个邀请上设置,保留字段仅为兼容旧配置
|
||||
RequireApproval bool `json:"require_approval,omitempty"` // 已废弃:是否审批改为在每个邀请上设置,保留字段仅为兼容旧配置
|
||||
// GroupID 所属通道组:组内通道共用邀请、授权与审批,闲置计时联动
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
// GroupName 由 Agent 同步到服务端时填写,用于授权页展示(不持久化)
|
||||
GroupName string `json:"group_name,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterTunnelMessage registers a tunnel on server
|
||||
@@ -89,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
|
||||
@@ -110,14 +124,23 @@ type KillConnMessage struct {
|
||||
ConnID string `json:"conn_id"`
|
||||
}
|
||||
|
||||
// RevokeLeaseMessage removes a temporary (web-auth) whitelist lease; empty IP revokes all leases of the tunnel
|
||||
// RevokeLeaseMessage removes a temporary (web-auth) whitelist lease; empty IP revokes all leases of the scope.
|
||||
// 设置 GroupID 时作用于整个通道组的授权(TunnelID 忽略),下同。
|
||||
type RevokeLeaseMessage struct {
|
||||
Type string `json:"type"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
}
|
||||
|
||||
// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久
|
||||
// DeleteGroupMessage drops the invites, leases and requests of a dissolved tunnel group
|
||||
type DeleteGroupMessage struct {
|
||||
Type string `json:"type"`
|
||||
GroupID string `json:"group_id"`
|
||||
}
|
||||
|
||||
// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久。
|
||||
// 除下列取值外,也可以是分钟数,如 "30m"、"90m"(见 LeaseMinutes)。
|
||||
const (
|
||||
LeaseDuration1h = "1h"
|
||||
LeaseDuration2h = "2h"
|
||||
@@ -126,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"`
|
||||
@@ -185,15 +251,55 @@ type Invite struct {
|
||||
Uses []InviteUse `json:"uses,omitempty"`
|
||||
}
|
||||
|
||||
// AccessRequest is an access application on a tunnel that requires approval
|
||||
// AccessRequest is an access application on a tunnel (or tunnel group) that requires approval
|
||||
type AccessRequest struct {
|
||||
ID string `json:"id"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
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
|
||||
@@ -201,6 +307,7 @@ type CreateInviteMessage struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
ValidMinutes int `json:"valid_minutes"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
@@ -223,6 +330,7 @@ type InviteRespMessage struct {
|
||||
type RevokeInviteMessage struct {
|
||||
Type string `json:"type"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
@@ -230,6 +338,7 @@ type RevokeInviteMessage struct {
|
||||
type DecideRequestMessage struct {
|
||||
Type string `json:"type"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
RequestID string `json:"request_id"`
|
||||
Approve bool `json:"approve"`
|
||||
}
|
||||
@@ -279,6 +388,14 @@ type TunnelMetrics struct {
|
||||
Pending []AccessRequest `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
// GroupMetrics carries the access state shared by all tunnels of a group
|
||||
type GroupMetrics struct {
|
||||
GroupID string `json:"group_id"`
|
||||
DynamicLeases []DynamicLease `json:"dynamic_leases,omitempty"`
|
||||
Invites []Invite `json:"invites,omitempty"`
|
||||
Pending []AccessRequest `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
// SecurityEvent records blocked or flagged connection attempts
|
||||
type SecurityEvent struct {
|
||||
ID string `json:"id"`
|
||||
@@ -293,8 +410,11 @@ type SecurityEvent struct {
|
||||
type MetricsMessage struct {
|
||||
Type string `json:"type"`
|
||||
Tunnels []TunnelMetrics `json:"tunnels"`
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 657 KiB |
|
Before Width: | Height: | Size: 426 KiB After Width: | Height: | Size: 749 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 89 KiB |
@@ -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>
|
||||
svg {
|
||||
--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>
|
||||
</g>
|
||||
<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="160" text-anchor="middle">或直到通道关闭</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>
|
||||
<circle class="ring" cx="850" cy="62" r="20"/>
|
||||
<text class="num" x="850" y="67.5" text-anchor="middle">5</text>
|
||||
<g class="on5">
|
||||
@@ -78,5 +78,5 @@
|
||||
</g>
|
||||
<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="160" text-anchor="middle">闲置的通道自动关闭</text>
|
||||
<text class="sub" x="850" y="160" text-anchor="middle">2 小时内可申请延长</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 560 KiB After Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 711 KiB After Width: | Height: | Size: 1010 KiB |
@@ -0,0 +1,259 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
)
|
||||
|
||||
// groupMember is a link-only tunnel that belongs to group "g"
|
||||
func groupMember(id string, port int, target string) protocol.TunnelConfig {
|
||||
cfg := tunnelCfg(id, port)
|
||||
cfg.Whitelist = nil
|
||||
cfg.Target = target
|
||||
cfg.GroupID = "g"
|
||||
cfg.GroupName = "订单服务"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (a *testAgent) createGroupInvite(t *testing.T, groupID string, lease string, approval bool) protocol.Invite {
|
||||
t.Helper()
|
||||
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "g1", GroupID: groupID, ValidMinutes: 60, MaxUses: 1, LeaseDuration: lease, RequireApproval: approval})
|
||||
var resp protocol.InviteRespMessage
|
||||
_ = json.Unmarshal(a.next(t), &resp)
|
||||
if !resp.Success {
|
||||
t.Fatalf("create group invite failed: %s", resp.Error)
|
||||
}
|
||||
return resp.Invite
|
||||
}
|
||||
|
||||
// setupGroup registers two members of group "g" plus one ungrouped link-only tunnel
|
||||
func setupGroup(t *testing.T) (*Manager, *testAgent, string, [3]int) {
|
||||
t.Helper()
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
ports := [3]int{freePort(t), freePort(t), freePort(t)}
|
||||
a.register(t, protocol.TypeRegisterTunnel, groupMember("m1", ports[0], "10.0.1.11:22"))
|
||||
a.register(t, protocol.TypeRegisterTunnel, groupMember("m2", ports[1], "10.0.1.12:22"))
|
||||
other := tunnelCfg("other", ports[2])
|
||||
other.Whitelist = nil
|
||||
a.register(t, protocol.TypeRegisterTunnel, other)
|
||||
return m, a, addr, ports
|
||||
}
|
||||
|
||||
// holdConn opens a client connection that stays up until the server closes it
|
||||
func holdConn(t *testing.T, port int) net.Conn {
|
||||
t.Helper()
|
||||
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { c.Close() })
|
||||
buf := make([]byte, 2)
|
||||
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, err := io.ReadFull(c, buf); err != nil {
|
||||
t.Fatalf("connection to %d should be allowed: %v", port, err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func expectClosed(t *testing.T, c net.Conn) {
|
||||
t.Helper()
|
||||
_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
if _, err := c.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("connection should have been closed")
|
||||
} else if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
t.Fatal("connection still open after access was revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupInviteGrantsEveryMember(t *testing.T) {
|
||||
m, a, addr, p := setupGroup(t)
|
||||
if hit(p[0]) == "OK" {
|
||||
t.Fatal("link-only member must be closed before any grant")
|
||||
}
|
||||
inv := a.createGroupInvite(t, "g", "", false)
|
||||
|
||||
status, body := httpGet(t, addr, "/i/"+inv.Code, nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("invite page: %d %s", status, body)
|
||||
}
|
||||
for _, want := range []string{"目标通道组", "订单服务(2 个地址)", fmt.Sprintf("127.0.0.1:%d", p[0]), fmt.Sprintf("127.0.0.1:%d", p[1]), "10.0.1.11:22", "10.0.1.12:22"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("invite page lacks %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
if status, _, body := postForm(t, addr, inv.Code, "张三"); status != 200 || !strings.Contains(body, "任意一个访问地址") {
|
||||
t.Fatalf("grant failed: %d %s", status, body)
|
||||
}
|
||||
if hit(p[0]) != "OK" || hit(p[1]) != "OK" {
|
||||
t.Fatal("a group grant must open every member")
|
||||
}
|
||||
if hit(p[2]) == "OK" {
|
||||
t.Fatal("a group grant must not open tunnels outside the group")
|
||||
}
|
||||
if l := m.state.leaseList(groupScope("g")); len(l) != 1 || l[0].Name != "张三" {
|
||||
t.Fatalf("group lease not recorded: %+v", l)
|
||||
}
|
||||
if l := m.state.leaseList("m1"); len(l) != 0 {
|
||||
t.Fatalf("group grant must not create per-tunnel leases: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupLeaseStartsOnFirstConnectionToAnyMember(t *testing.T) {
|
||||
m, a, addr, p := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", protocol.LeaseDuration2h, false)
|
||||
postForm(t, addr, inv.Code, "张三")
|
||||
if l := m.state.leaseList(groupScope("g")); len(l) != 1 || !l[0].AwaitingConnect {
|
||||
t.Fatalf("lease should wait for the first connection: %+v", l)
|
||||
}
|
||||
|
||||
if hit(p[1]) != "OK" {
|
||||
t.Fatal("member 2 should be reachable")
|
||||
}
|
||||
waitFor(t, func() bool {
|
||||
l := m.state.leaseList(groupScope("g"))
|
||||
return len(l) == 1 && !l[0].AwaitingConnect
|
||||
})
|
||||
exp := m.state.leaseList(groupScope("g"))[0].ExpiresAt
|
||||
if d := time.Until(exp); d < 119*time.Minute || d > 121*time.Minute {
|
||||
t.Fatalf("countdown should be 2h from first connection, got %v", d)
|
||||
}
|
||||
// 其它成员的放通随之更新为同一到期时间
|
||||
if got := m.tunnel("m1").dynamicLeases()["127.0.0.1"]; !got.Equal(exp) {
|
||||
t.Fatalf("member 1 expiry %v, want %v", got, exp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupRevokeDisconnectsAllMembers(t *testing.T) {
|
||||
_, a, addr, p := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", "", false)
|
||||
postForm(t, addr, inv.Code, "张三")
|
||||
c1, c2 := holdConn(t, p[0]), holdConn(t, p[1])
|
||||
|
||||
a.send(t, protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, GroupID: "g"})
|
||||
expectClosed(t, c1)
|
||||
expectClosed(t, c2)
|
||||
if hit(p[0]) == "OK" || hit(p[1]) == "OK" {
|
||||
t.Fatal("members still open after revoking the group grant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupApprovalFlow(t *testing.T) {
|
||||
m, a, addr, p := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", "", true)
|
||||
|
||||
status, loc, _ := postForm(t, addr, inv.Code, "李四")
|
||||
if status != 303 || !strings.HasPrefix(loc, "/auth/status?id=") {
|
||||
t.Fatalf("approval invite should redirect to the status page: %d %q", status, loc)
|
||||
}
|
||||
pending := m.state.pendingList(groupScope("g"))
|
||||
if len(pending) != 1 || pending[0].GroupID != "g" || pending[0].TunnelID != "" {
|
||||
t.Fatalf("pending request should belong to the group: %+v", pending)
|
||||
}
|
||||
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "等待管理员批准") || !strings.Contains(body, "10.0.1.12:22") {
|
||||
t.Fatalf("pending page: %s", body)
|
||||
}
|
||||
if hit(p[0]) == "OK" {
|
||||
t.Fatal("must stay closed until approved")
|
||||
}
|
||||
|
||||
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, GroupID: "g", RequestID: pending[0].ID, Approve: true})
|
||||
waitFor(t, func() bool { return hit(p[0]) == "OK" && hit(p[1]) == "OK" })
|
||||
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "申请已批准") {
|
||||
t.Fatalf("status page after approval: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupStateDroppedWhenLastMemberCloses(t *testing.T) {
|
||||
_, a, addr, _ := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", "", false)
|
||||
|
||||
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "m1"})
|
||||
a.next(t)
|
||||
if status, body := httpGet(t, addr, "/i/"+inv.Code, nil); status != 200 || !strings.Contains(body, "(1 个地址)") {
|
||||
t.Fatalf("invite should still work with one open member: %d", status)
|
||||
}
|
||||
|
||||
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "m2"})
|
||||
a.next(t)
|
||||
if status, _ := httpGet(t, addr, "/i/"+inv.Code, nil); status != 404 {
|
||||
t.Fatalf("invite must be gone once every member is closed, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMembershipChangesFollowAccess(t *testing.T) {
|
||||
_, a, addr, p := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", "", false)
|
||||
postForm(t, addr, inv.Code, "张三")
|
||||
c2 := holdConn(t, p[1])
|
||||
|
||||
// 移出组:组授权不再适用,现有连接断开
|
||||
out := groupMember("m2", p[1], "10.0.1.12:22")
|
||||
out.GroupID, out.GroupName = "", ""
|
||||
if resp := a.register(t, protocol.TypeUpdateTunnel, out); !resp.Success {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
expectClosed(t, c2)
|
||||
if hit(p[1]) == "OK" || hit(p[0]) != "OK" {
|
||||
t.Fatal("only remaining members keep the group grant")
|
||||
}
|
||||
|
||||
// 后加入组的通道自动获得组授权
|
||||
in := groupMember("other", p[2], "10.0.1.13:22")
|
||||
if resp := a.register(t, protocol.TypeUpdateTunnel, in); !resp.Success {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
if hit(p[2]) != "OK" {
|
||||
t.Fatal("a tunnel joining the group should get the group grant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelAndGroupLeasesAreIndependent(t *testing.T) {
|
||||
m, _, _, p := setupGroup(t)
|
||||
exp := time.Now().Add(time.Hour)
|
||||
grantTestLease(m, "m1", "127.0.0.1", exp)
|
||||
grantTestLease(m, groupScope("g"), "127.0.0.1", exp)
|
||||
|
||||
m.revokeLease(groupScope("g"), "127.0.0.1")
|
||||
if hit(p[0]) != "OK" {
|
||||
t.Fatal("the per-tunnel grant must survive revoking the group grant")
|
||||
}
|
||||
if hit(p[1]) == "OK" {
|
||||
t.Fatal("member without its own grant must close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGroupDropsSharedAccess(t *testing.T) {
|
||||
m, a, addr, p := setupGroup(t)
|
||||
inv := a.createGroupInvite(t, "g", "", false)
|
||||
postForm(t, addr, inv.Code, "张三")
|
||||
|
||||
a.send(t, protocol.DeleteGroupMessage{Type: protocol.TypeDeleteGroup, GroupID: "g"})
|
||||
waitFor(t, func() bool { return hit(p[0]) != "OK" })
|
||||
if _, _, ok := m.state.findInvite(inv.Code); ok {
|
||||
t.Fatal("invites of a dissolved group must be dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupInviteNeedsAnOpenMember(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "x", GroupID: "empty", ValidMinutes: 60, MaxUses: 1})
|
||||
var resp protocol.InviteRespMessage
|
||||
_ = json.Unmarshal(a.next(t), &resp)
|
||||
if resp.Success || !strings.Contains(resp.Error, "通道组") {
|
||||
t.Fatalf("invite for a group without open tunnels should fail: %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -239,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})
|
||||
}
|
||||
|
||||
@@ -252,7 +253,7 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
|
||||
case protocol.TypeRevokeLease:
|
||||
var msg protocol.RevokeLeaseMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.revokeLease(msg.TunnelID, msg.IP)
|
||||
m.revokeLease(scopeOf(msg.TunnelID, msg.GroupID), msg.IP)
|
||||
}
|
||||
|
||||
case protocol.TypeCreateInvite:
|
||||
@@ -264,13 +265,25 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
|
||||
case protocol.TypeRevokeInvite:
|
||||
var msg protocol.RevokeInviteMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.revokeInvite(msg.TunnelID, msg.Code)
|
||||
m.revokeInvite(scopeOf(msg.TunnelID, msg.GroupID), msg.Code)
|
||||
}
|
||||
|
||||
case protocol.TypeDecideRequest:
|
||||
var msg protocol.DecideRequestMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.decideRequest(msg.TunnelID, msg.RequestID, msg.Approve)
|
||||
m.decideRequest(scopeOf(msg.TunnelID, msg.GroupID), msg.RequestID, msg.Approve)
|
||||
}
|
||||
|
||||
case protocol.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 != "" {
|
||||
m.deleteGroup(msg.GroupID)
|
||||
}
|
||||
|
||||
case protocol.TypeKillConn:
|
||||
@@ -330,30 +343,38 @@ func (m *Manager) registerTunnel(as *agentSession, cfg protocol.TunnelConfig) pr
|
||||
if err != nil {
|
||||
return registerResp(cfg, err)
|
||||
}
|
||||
if old == nil {
|
||||
// 服务端重启或 Agent 重连后,恢复该通道尚未过期的临时放通
|
||||
t.restoreDynamicLeases(m.state.leaseExpiries(cfg.ID))
|
||||
}
|
||||
m.tunnels[cfg.ID] = t
|
||||
m.portMap[cfg.ListenPort] = cfg.ID
|
||||
// 服务端重启或 Agent 重连后,按授权状态恢复该通道(及其所在组)尚未过期的临时放通
|
||||
m.syncAccess(t)
|
||||
if old != nil && old.GroupID() != cfg.GroupID {
|
||||
m.forgetGroupIfEmptyLocked(old.GroupID())
|
||||
}
|
||||
|
||||
log.Printf("[Server] Registered tunnel '%s' listening on 0.0.0.0:%d", cfg.Name, cfg.ListenPort)
|
||||
return registerResp(cfg, nil)
|
||||
}
|
||||
|
||||
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.forgetTunnel(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())
|
||||
delete(m.tunnels, tunnelID)
|
||||
log.Printf("[Server] Unregistered tunnel '%s' (port %d freed)", t.Name(), t.ListenPort())
|
||||
// 组内最后一个开启的通道关闭后,组的邀请与授权一并失效(与单个通道关闭的语义一致)
|
||||
m.forgetGroupIfEmptyLocked(t.GroupID())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,8 +388,13 @@ func (m *Manager) updateTunnel(as *agentSession, cfg protocol.TunnelConfig) prot
|
||||
}
|
||||
defer m.mu.Unlock()
|
||||
|
||||
prevGroup := t.GroupID()
|
||||
if t.ListenPort() == cfg.ListenPort {
|
||||
t.UpdateConfig(cfg)
|
||||
m.syncAccess(t)
|
||||
if prevGroup != cfg.GroupID {
|
||||
m.forgetGroupIfEmptyLocked(prevGroup)
|
||||
}
|
||||
log.Printf("[Server] Updated tunnel '%s' whitelist/metadata", cfg.Name)
|
||||
return registerResp(cfg, nil)
|
||||
}
|
||||
@@ -384,6 +410,10 @@ func (m *Manager) updateTunnel(as *agentSession, cfg protocol.TunnelConfig) prot
|
||||
delete(m.portMap, t.ListenPort())
|
||||
m.tunnels[cfg.ID] = nt
|
||||
m.portMap[cfg.ListenPort] = cfg.ID
|
||||
m.syncAccess(nt)
|
||||
if prevGroup != cfg.GroupID {
|
||||
m.forgetGroupIfEmptyLocked(prevGroup)
|
||||
}
|
||||
log.Printf("[Server] Moved tunnel '%s' from port %d to %d", cfg.Name, t.ListenPort(), cfg.ListenPort)
|
||||
return registerResp(cfg, nil)
|
||||
}
|
||||
@@ -399,21 +429,15 @@ func (m *Manager) killConn(connID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) revokeLease(tunnelID, ip string) {
|
||||
m.mu.RLock()
|
||||
t := m.tunnels[tunnelID]
|
||||
m.mu.RUnlock()
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
revoked, kicked := t.RevokeDynamic(ip)
|
||||
m.state.removeLease(tunnelID, ip)
|
||||
func (m *Manager) revokeLease(scope, ip string) {
|
||||
revoked := m.state.removeLease(scope, ip)
|
||||
kicked := m.syncScope(scope)
|
||||
target := ip
|
||||
if target == "" {
|
||||
target = "all"
|
||||
}
|
||||
log.Printf("[WebAuth] Revoked %d temporary lease(s) (%s) on tunnel %s, disconnected %d client(s)",
|
||||
revoked, target, t.Name(), kicked)
|
||||
log.Printf("[WebAuth] Revoked %d temporary lease(s) (%s) on %s, disconnected %d client(s)",
|
||||
revoked, target, m.scopeName(scope), kicked)
|
||||
}
|
||||
|
||||
func (m *Manager) tunnel(id string) *Tunnel {
|
||||
@@ -422,11 +446,121 @@ func (m *Manager) tunnel(id string) *Tunnel {
|
||||
return m.tunnels[id]
|
||||
}
|
||||
|
||||
// ---- 作用范围:单个通道或通道组 ----
|
||||
|
||||
// groupMembersLocked returns the open tunnels of a group ordered by port. Caller must hold m.mu.
|
||||
func (m *Manager) groupMembersLocked(groupID string) []*Tunnel {
|
||||
if groupID == "" {
|
||||
return nil
|
||||
}
|
||||
var out []*Tunnel
|
||||
for _, t := range m.tunnels {
|
||||
if t.GroupID() == groupID {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ListenPort() < out[j].ListenPort() })
|
||||
return out
|
||||
}
|
||||
|
||||
// scopeTunnels returns the open tunnels an access scope applies to
|
||||
func (m *Manager) scopeTunnels(scope string) []*Tunnel {
|
||||
id, isGroup := splitScope(scope)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if isGroup {
|
||||
return m.groupMembersLocked(id)
|
||||
}
|
||||
if t := m.tunnels[id]; t != nil {
|
||||
return []*Tunnel{t}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scopeName is a display name for logs and pages
|
||||
func (m *Manager) scopeName(scope string) string {
|
||||
tunnels := m.scopeTunnels(scope)
|
||||
if len(tunnels) == 0 {
|
||||
return scope
|
||||
}
|
||||
if _, isGroup := splitScope(scope); isGroup {
|
||||
if name := tunnels[0].Snapshot().GroupName; name != "" {
|
||||
return name
|
||||
}
|
||||
return "通道组"
|
||||
}
|
||||
return tunnels[0].Name()
|
||||
}
|
||||
|
||||
// scopeExpiresOf caps leases by how long the scope stays open: a tunnel's own TTL, or for a
|
||||
// group the TTL of its longest-lived member (nil when any member has no TTL)
|
||||
func scopeExpiresOf(tunnels []*Tunnel) *time.Time {
|
||||
var latest *time.Time
|
||||
for _, t := range tunnels {
|
||||
exp := t.Snapshot().ExpiresAt
|
||||
if exp == nil {
|
||||
return nil
|
||||
}
|
||||
if latest == nil || exp.After(*latest) {
|
||||
v := *exp
|
||||
latest = &v
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
// syncAccess makes a tunnel's temporary allowlist match the access state — its own leases plus
|
||||
// those of its group — and disconnects clients that are no longer permitted.
|
||||
func (m *Manager) syncAccess(t *Tunnel) int {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
return t.SetDynamic(leases)
|
||||
}
|
||||
|
||||
// syncScope re-applies the access state to every open tunnel of a scope
|
||||
func (m *Manager) syncScope(scope string) int {
|
||||
kicked := 0
|
||||
for _, t := range m.scopeTunnels(scope) {
|
||||
kicked += m.syncAccess(t)
|
||||
}
|
||||
return kicked
|
||||
}
|
||||
|
||||
// forgetGroupIfEmptyLocked 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.closeScope(groupScope(groupID))
|
||||
}
|
||||
|
||||
// deleteGroup drops a dissolved group's invites, leases, requests and renewal records
|
||||
func (m *Manager) deleteGroup(groupID string) {
|
||||
scope := groupScope(groupID)
|
||||
m.state.dropScope(scope)
|
||||
kicked := m.syncScope(scope)
|
||||
log.Printf("[WebAuth] Tunnel group %s dissolved, disconnected %d client(s)", groupID, kicked)
|
||||
}
|
||||
|
||||
func (m *Manager) createInvite(msg protocol.CreateInviteMessage) protocol.InviteRespMessage {
|
||||
resp := protocol.InviteRespMessage{Type: protocol.TypeInviteResp, RequestID: msg.RequestID}
|
||||
t := m.tunnel(msg.TunnelID)
|
||||
scope := scopeOf(msg.TunnelID, msg.GroupID)
|
||||
open := len(m.scopeTunnels(scope)) > 0
|
||||
switch {
|
||||
case t == nil:
|
||||
case !open && msg.GroupID != "":
|
||||
resp.Error = "通道组内没有开启的通道,无法生成邀请链接"
|
||||
case !open:
|
||||
resp.Error = "通道未开启,无法生成邀请链接"
|
||||
case msg.ValidMinutes <= 0 || msg.ValidMinutes > 30*24*60:
|
||||
resp.Error = "邀请有效期须在 1 分钟到 30 天之间"
|
||||
@@ -435,50 +569,70 @@ func (m *Manager) createInvite(msg protocol.CreateInviteMessage) protocol.Invite
|
||||
case !protocol.ValidLeaseDuration(msg.LeaseDuration):
|
||||
resp.Error = "无效的授权时长"
|
||||
default:
|
||||
resp.Invite = m.state.createInvite(msg.TunnelID, truncateRunes(msg.Note, 64), time.Duration(msg.ValidMinutes)*time.Minute, msg.MaxUses, msg.LeaseDuration, msg.RequireApproval)
|
||||
resp.Invite = m.state.createInvite(scope, truncateRunes(msg.Note, 64), time.Duration(msg.ValidMinutes)*time.Minute, msg.MaxUses, msg.LeaseDuration, msg.RequireApproval)
|
||||
resp.Success = true
|
||||
log.Printf("[WebAuth] Created invite for tunnel %s (valid %dm, max %d device(s))", t.Name(), msg.ValidMinutes, msg.MaxUses)
|
||||
log.Printf("[WebAuth] Created invite for %s (valid %dm, max %d device(s))", m.scopeName(scope), msg.ValidMinutes, msg.MaxUses)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (m *Manager) revokeInvite(tunnelID, code string) {
|
||||
ips := m.state.revokeInvite(tunnelID, code)
|
||||
kicked := 0
|
||||
if t := m.tunnel(tunnelID); t != nil {
|
||||
for _, ip := range ips {
|
||||
_, k := t.RevokeDynamic(ip)
|
||||
kicked += k
|
||||
}
|
||||
}
|
||||
log.Printf("[WebAuth] Revoked invite on tunnel %s: %d lease(s) removed, %d client(s) disconnected", tunnelID, len(ips), kicked)
|
||||
func (m *Manager) revokeInvite(scope, code string) {
|
||||
ips := m.state.revokeInvite(scope, code)
|
||||
kicked := m.syncScope(scope)
|
||||
log.Printf("[WebAuth] Revoked invite on %s: %d lease(s) removed, %d client(s) disconnected", m.scopeName(scope), len(ips), kicked)
|
||||
}
|
||||
|
||||
func (m *Manager) decideRequest(tunnelID, requestID string, approve bool) {
|
||||
t := m.tunnel(tunnelID)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
r, exp, err := m.state.decide(tunnelID, requestID, approve, t.Snapshot().ExpiresAt)
|
||||
func (m *Manager) decideRequest(scope, requestID string, approve bool) {
|
||||
tunnels := m.scopeTunnels(scope)
|
||||
r, _, err := m.state.decide(scope, requestID, approve, scopeExpiresOf(tunnels))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if approve {
|
||||
t.AddDynamicWhitelist(r.IP, exp)
|
||||
m.syncScope(scope)
|
||||
}
|
||||
log.Printf("[WebAuth] Request from %s (%s) on tunnel %s: %s", r.Name, r.IP, t.Name(), r.Status)
|
||||
log.Printf("[WebAuth] Request from %s (%s) on %s: %s", r.Name, r.IP, m.scopeName(scope), r.Status)
|
||||
}
|
||||
|
||||
// onClientConnect starts the countdown of a device's lease on its first connection
|
||||
// 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) {
|
||||
t := m.tunnel(tunnelID)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
if exp, started := m.state.startLease(tunnelID, ip, t.Snapshot().ExpiresAt, time.Now()); started {
|
||||
t.AddDynamicWhitelist(ip, exp)
|
||||
now := time.Now()
|
||||
if exp, started := m.state.startLease(t.ID(), ip, t.Snapshot().ExpiresAt, now); started {
|
||||
m.syncAccess(t)
|
||||
log.Printf("[WebAuth] %s connected for the first time on tunnel %s, access valid until %s", ip, t.Name(), exp.Format("01-02 15:04"))
|
||||
}
|
||||
if gid := t.GroupID(); gid != "" {
|
||||
scope := groupScope(gid)
|
||||
if exp, started := m.state.startLease(scope, ip, scopeExpiresOf(m.scopeTunnels(scope)), now); started {
|
||||
m.syncScope(scope)
|
||||
log.Printf("[WebAuth] %s connected for the first time in group %s, access valid until %s", ip, m.scopeName(scope), exp.Format("01-02 15:04"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
@@ -516,7 +670,11 @@ func (m *Manager) metricsLoop(as *agentSession) {
|
||||
}
|
||||
tunnelMetricsList := make([]protocol.TunnelMetrics, 0, len(m.tunnels))
|
||||
allActiveConns := make([]protocol.ActiveConnInfo, 0)
|
||||
groupIDs := make(map[string]bool)
|
||||
for _, t := range m.tunnels {
|
||||
if gid := t.GroupID(); gid != "" {
|
||||
groupIDs[gid] = true
|
||||
}
|
||||
// 授权到期(或白名单变更)后,已建立的连接也要断开,“授权后可访问 X”才名副其实
|
||||
for _, addr := range t.EnforceAccess() {
|
||||
log.Printf("[Tunnel %d] access expired, disconnected %s", t.ListenPort(), addr)
|
||||
@@ -529,6 +687,16 @@ func (m *Manager) metricsLoop(as *agentSession) {
|
||||
tunnelMetricsList = append(tunnelMetricsList, tm)
|
||||
allActiveConns = append(allActiveConns, conns...)
|
||||
}
|
||||
groupMetrics := make([]protocol.GroupMetrics, 0, len(groupIDs))
|
||||
for gid := range groupIDs {
|
||||
scope := groupScope(gid)
|
||||
groupMetrics = append(groupMetrics, protocol.GroupMetrics{
|
||||
GroupID: gid,
|
||||
DynamicLeases: m.state.leaseList(scope),
|
||||
Invites: m.state.inviteList(scope),
|
||||
Pending: m.state.pendingList(scope),
|
||||
})
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.secEventsMu.Lock()
|
||||
@@ -539,8 +707,10 @@ func (m *Manager) metricsLoop(as *agentSession) {
|
||||
as.send(protocol.MetricsMessage{
|
||||
Type: protocol.TypeMetrics,
|
||||
Tunnels: tunnelMetricsList,
|
||||
Groups: groupMetrics,
|
||||
ActiveConns: allActiveConns,
|
||||
SecurityEvents: secCopy,
|
||||
Renewals: m.state.renewalList(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +326,14 @@ func TestStaleSessionTeardownKeepsNewSessionTunnels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// grantTestLease stores a running lease in the access state and applies it to the scope's tunnels
|
||||
func grantTestLease(m *Manager, scope, ip string, exp time.Time) {
|
||||
m.state.mu.Lock()
|
||||
m.state.scopeLocked(scope).Leases[ip] = leaseInfo{ExpiresAt: exp}
|
||||
m.state.mu.Unlock()
|
||||
m.syncScope(scope)
|
||||
}
|
||||
|
||||
func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
@@ -339,7 +347,7 @@ func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
|
||||
m.mu.RLock()
|
||||
tun := m.tunnels["a"]
|
||||
m.mu.RUnlock()
|
||||
tun.AddDynamicWhitelist("10.1.2.3", time.Now().Add(time.Hour))
|
||||
grantTestLease(m, "a", "10.1.2.3", time.Now().Add(time.Hour))
|
||||
|
||||
cfg.Name = "renamed"
|
||||
cfg.Whitelist = []string{"127.0.0.1"}
|
||||
@@ -567,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,10 +632,7 @@ func TestExpiredLeaseDisconnectsActiveConnection(t *testing.T) {
|
||||
cfg.Whitelist = nil
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
|
||||
m.mu.RLock()
|
||||
tun := m.tunnels["a"]
|
||||
m.mu.RUnlock()
|
||||
tun.AddDynamicWhitelist("127.0.0.1", time.Now().Add(1500*time.Millisecond))
|
||||
grantTestLease(m, "a", "127.0.0.1", time.Now().Add(1500*time.Millisecond))
|
||||
|
||||
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.ListenPort))
|
||||
if err != nil {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -24,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 {
|
||||
@@ -42,27 +50,27 @@ type leaseInfo struct {
|
||||
|
||||
// newLease creates a lease whose countdown starts at the device's first connection.
|
||||
// Leases valid for the whole tunnel lifetime start immediately.
|
||||
func newLease(name, invite, duration string, tunnelExpires *time.Time, now time.Time) leaseInfo {
|
||||
func newLease(name, invite, duration string, scopeExpires *time.Time, now time.Time) leaseInfo {
|
||||
l := leaseInfo{Name: name, Invite: invite, Duration: duration}
|
||||
if duration == protocol.LeaseDurationTunnel {
|
||||
l.StartedAt = &now
|
||||
l.ExpiresAt = leaseExpiry(duration, tunnelExpires, now)
|
||||
l.ExpiresAt = leaseExpiry(duration, scopeExpires, now)
|
||||
return l
|
||||
}
|
||||
l.Deferred = true
|
||||
l.ExpiresAt = now.Add(leaseStartWindow)
|
||||
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(l.ExpiresAt) {
|
||||
l.ExpiresAt = *tunnelExpires
|
||||
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(l.ExpiresAt) {
|
||||
l.ExpiresAt = *scopeExpires
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// startLease starts the countdown of a deferred lease on the device's first connection.
|
||||
// Returns the new expiry and true when the lease was started now.
|
||||
func (s *stateStore) startLease(tunnelID, ip string, tunnelExpires *time.Time, now time.Time) (time.Time, bool) {
|
||||
func (s *stateStore) startLease(scope, ip string, scopeExpires *time.Time, now time.Time) (time.Time, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -72,34 +80,72 @@ func (s *stateStore) startLease(tunnelID, ip string, tunnelExpires *time.Time, n
|
||||
}
|
||||
l.Deferred = false
|
||||
l.StartedAt = &now
|
||||
l.ExpiresAt = leaseExpiry(l.Duration, tunnelExpires, now)
|
||||
l.ExpiresAt = leaseExpiry(l.Duration, scopeExpires, now)
|
||||
st.Leases[ip] = l
|
||||
s.saveLocked()
|
||||
return l.ExpiresAt, true
|
||||
}
|
||||
|
||||
func (s *stateStore) lease(tunnelID, ip string) (leaseInfo, bool) {
|
||||
func (s *stateStore) lease(scope, ip string) (leaseInfo, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
if st := s.data[scope]; st != nil {
|
||||
l, ok := st.Leases[ip]
|
||||
return l, ok && time.Now().Before(l.ExpiresAt)
|
||||
}
|
||||
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"`
|
||||
}
|
||||
|
||||
// stateStore keeps per-tunnel access state (leases, invites, approval requests).
|
||||
// 授权状态的作用范围:单个通道(键为通道 ID,与旧数据兼容)或整个通道组(键为 "group:<组ID>")
|
||||
const groupScopePrefix = "group:"
|
||||
|
||||
func groupScope(groupID string) string { return groupScopePrefix + groupID }
|
||||
|
||||
// scopeOf returns the state key of a tunnel, or of its group when groupID is set
|
||||
func scopeOf(tunnelID, groupID string) string {
|
||||
if groupID != "" {
|
||||
return groupScope(groupID)
|
||||
}
|
||||
return tunnelID
|
||||
}
|
||||
|
||||
// splitScope returns the tunnel or group ID of a scope and whether it is a group scope
|
||||
func splitScope(scope string) (id string, isGroup bool) {
|
||||
if strings.HasPrefix(scope, groupScopePrefix) {
|
||||
return strings.TrimPrefix(scope, groupScopePrefix), true
|
||||
}
|
||||
return scope, false
|
||||
}
|
||||
|
||||
// stateStore keeps per-scope access state (leases, invites, approval requests).
|
||||
// It lives in memory and is optionally persisted to a file so it survives restarts.
|
||||
type stateStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
data map[string]*tunnelState // tunnelID -> state
|
||||
data map[string]*tunnelState // scope(通道 ID 或 group:<组ID>)-> state
|
||||
}
|
||||
|
||||
func newStateStore() *stateStore {
|
||||
@@ -116,7 +162,7 @@ func (m *Manager) EnableLeasePersistence(path string) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
if err := json.Unmarshal(raw, &data); err != nil || !looksLikeState(data) {
|
||||
// 兼容旧格式:tunnelID -> ip -> 到期时间
|
||||
// 兼容旧格式:通道 ID -> ip -> 到期时间
|
||||
var legacy map[string]map[string]time.Time
|
||||
if json.Unmarshal(raw, &legacy) == nil {
|
||||
data = make(map[string]*tunnelState)
|
||||
@@ -155,14 +201,14 @@ 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
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *stateStore) tunnelLocked(id string) *tunnelState {
|
||||
func (s *stateStore) scopeLocked(id string) *tunnelState {
|
||||
st := s.data[id]
|
||||
if st == nil {
|
||||
st = &tunnelState{}
|
||||
@@ -177,9 +223,21 @@ func (s *stateStore) tunnelLocked(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)
|
||||
@@ -195,35 +253,44 @@ func randomCode(n int) string {
|
||||
|
||||
// ---- leases ----
|
||||
|
||||
func (s *stateStore) grantLease(tunnelID, ip string, exp time.Time, name, invite string) {
|
||||
// removeLease removes one lease, or all leases of the scope when ip is empty; returns how many were removed
|
||||
func (s *stateStore) removeLease(scope, ip string) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.tunnelLocked(tunnelID).Leases[ip] = leaseInfo{ExpiresAt: exp, Name: name, Invite: invite}
|
||||
s.saveLocked()
|
||||
}
|
||||
|
||||
// removeLease removes one lease, or all leases of the tunnel when ip is empty
|
||||
func (s *stateStore) removeLease(tunnelID, ip string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return
|
||||
return 0
|
||||
}
|
||||
// 被撤销的授权不能再申请延长
|
||||
now := time.Now()
|
||||
n := 0
|
||||
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)
|
||||
}
|
||||
if ip == "" {
|
||||
st.Leases = make(map[string]leaseInfo)
|
||||
} else {
|
||||
delete(st.Leases, ip)
|
||||
}
|
||||
s.saveLocked()
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *stateStore) leaseExpiries(tunnelID string) map[string]time.Time {
|
||||
func (s *stateStore) leaseExpiries(scope string) map[string]time.Time {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
out := make(map[string]time.Time)
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
if st := s.data[scope]; st != nil {
|
||||
for ip, l := range st.Leases {
|
||||
if now.Before(l.ExpiresAt) {
|
||||
out[ip] = l.ExpiresAt
|
||||
@@ -233,12 +300,12 @@ func (s *stateStore) leaseExpiries(tunnelID string) map[string]time.Time {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
|
||||
func (s *stateStore) leaseList(scope string) []protocol.DynamicLease {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
var out []protocol.DynamicLease
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
if st := s.data[scope]; st != nil {
|
||||
for ip, l := range st.Leases {
|
||||
if now.Before(l.ExpiresAt) {
|
||||
out = append(out, protocol.DynamicLease{
|
||||
@@ -252,19 +319,65 @@ func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
|
||||
return out
|
||||
}
|
||||
|
||||
// forgetTunnel drops all access state of a tunnel (explicitly closed or deleted)
|
||||
func (s *stateStore) forgetTunnel(tunnelID string) {
|
||||
// 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()
|
||||
if _, ok := s.data[tunnelID]; ok {
|
||||
delete(s.data, tunnelID)
|
||||
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 {
|
||||
delete(s.data, scope)
|
||||
s.saveLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- invites ----
|
||||
|
||||
func (s *stateStore) createInvite(tunnelID, note string, valid time.Duration, maxUses int, leaseDuration string, requireApproval bool) protocol.Invite {
|
||||
func (s *stateStore) createInvite(scope, note string, valid time.Duration, maxUses int, leaseDuration string, requireApproval bool) protocol.Invite {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
@@ -280,12 +393,12 @@ func (s *stateStore) createInvite(tunnelID, note string, valid time.Duration, ma
|
||||
LeaseDuration: leaseDuration,
|
||||
RequireApproval: requireApproval,
|
||||
}
|
||||
s.tunnelLocked(tunnelID).Invites[inv.Code] = inv
|
||||
s.scopeLocked(scope).Invites[inv.Code] = inv
|
||||
s.saveLocked()
|
||||
return *inv
|
||||
}
|
||||
|
||||
// findInvite returns the tunnel owning an unexpired invite
|
||||
// findInvite returns the scope (tunnel or group) owning an unexpired invite
|
||||
func (s *stateStore) findInvite(code string) (string, protocol.Invite, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -317,10 +430,10 @@ func capacityOKLocked(st *tunnelState, inv *protocol.Invite, ip string) bool {
|
||||
}
|
||||
|
||||
// checkInvite validates that ip may use the invite right now
|
||||
func (s *stateStore) checkInvite(tunnelID, code, ip string) error {
|
||||
func (s *stateStore) checkInvite(scope, code, ip string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return errInviteNotFound
|
||||
}
|
||||
@@ -349,10 +462,10 @@ func (s *stateStore) recordUseLocked(st *tunnelState, code, name, ip string) {
|
||||
}
|
||||
|
||||
// useInviteAndGrant records the use and stores the lease atomically; returns the lease
|
||||
func (s *stateStore) useInviteAndGrant(tunnelID, code, name, ip string, tunnelExpires *time.Time) (leaseInfo, error) {
|
||||
func (s *stateStore) useInviteAndGrant(scope, code, name, ip string, scopeExpires *time.Time) (leaseInfo, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return leaseInfo{}, errInviteNotFound
|
||||
}
|
||||
@@ -360,31 +473,55 @@ func (s *stateStore) useInviteAndGrant(tunnelID, code, name, ip string, tunnelEx
|
||||
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
|
||||
}
|
||||
st = s.tunnelLocked(tunnelID)
|
||||
st = s.scopeLocked(scope)
|
||||
s.recordUseLocked(st, code, name, ip)
|
||||
l := newLease(name, code, inv.LeaseDuration, tunnelExpires, time.Now())
|
||||
l := newLease(name, code, inv.LeaseDuration, scopeExpires, time.Now())
|
||||
st.Leases[ip] = l
|
||||
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(tunnelID, code string) []string {
|
||||
func (s *stateStore) revokeInvite(scope, code string) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
@@ -396,12 +533,12 @@ func (s *stateStore) revokeInvite(tunnelID, code string) []string {
|
||||
return ips
|
||||
}
|
||||
|
||||
func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
|
||||
func (s *stateStore) inviteList(scope string) []protocol.Invite {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
var out []protocol.Invite
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
if st := s.data[scope]; st != nil {
|
||||
for _, inv := range st.Invites {
|
||||
if now.Before(inv.ExpiresAt) {
|
||||
c := *inv
|
||||
@@ -417,10 +554,10 @@ func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
|
||||
// ---- approval requests ----
|
||||
|
||||
// addRequest files a pending request (re-using an existing pending one from the same IP)
|
||||
func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.AccessRequest, error) {
|
||||
func (s *stateStore) addRequest(scope, code, name, ip string) (protocol.AccessRequest, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return protocol.AccessRequest{}, errInviteNotFound
|
||||
}
|
||||
@@ -428,26 +565,33 @@ func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.Acces
|
||||
if !ok || !time.Now().Before(inv.ExpiresAt) {
|
||||
return protocol.AccessRequest{}, errInviteNotFound
|
||||
}
|
||||
st = s.tunnelLocked(tunnelID)
|
||||
st = s.scopeLocked(scope)
|
||||
for _, r := range st.Requests {
|
||||
if r.IP == ip && r.Status == "pending" {
|
||||
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
|
||||
}
|
||||
r := &protocol.AccessRequest{
|
||||
ID: randomCode(16),
|
||||
TunnelID: tunnelID,
|
||||
Name: name,
|
||||
IP: ip,
|
||||
Invite: code,
|
||||
At: time.Now(),
|
||||
Status: "pending",
|
||||
}
|
||||
if id, isGroup := splitScope(scope); isGroup {
|
||||
r.GroupID = id
|
||||
} else {
|
||||
r.TunnelID = id
|
||||
}
|
||||
st.Requests[r.ID] = r
|
||||
s.saveLocked()
|
||||
return *r, nil
|
||||
@@ -467,11 +611,11 @@ func (s *stateStore) request(id string) (protocol.AccessRequest, bool) {
|
||||
|
||||
// decide marks a pending request; on approval it records the invite use and stores the lease,
|
||||
// lasting as long as the originating invite specifies (capped by the tunnel's own TTL).
|
||||
func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *time.Time) (protocol.AccessRequest, time.Time, error) {
|
||||
func (s *stateStore) decide(scope, id string, approve bool, scopeExpires *time.Time) (protocol.AccessRequest, time.Time, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneRequestsLocked()
|
||||
st := s.data[tunnelID]
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return protocol.AccessRequest{}, time.Time{}, errRequestGone
|
||||
}
|
||||
@@ -479,18 +623,37 @@ func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *ti
|
||||
if !ok || r.Status != "pending" {
|
||||
return protocol.AccessRequest{}, time.Time{}, errRequestGone
|
||||
}
|
||||
st = s.tunnelLocked(tunnelID)
|
||||
st = s.scopeLocked(scope)
|
||||
duration := ""
|
||||
if inv, ok := st.Invites[r.Invite]; ok {
|
||||
duration = inv.LeaseDuration
|
||||
}
|
||||
l := newLease(r.Name, r.Invite, duration, tunnelExpires, time.Now())
|
||||
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()
|
||||
if approve {
|
||||
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"
|
||||
}
|
||||
@@ -498,14 +661,14 @@ func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *ti
|
||||
return *r, exp, nil
|
||||
}
|
||||
|
||||
func (s *stateStore) pendingList(tunnelID string) []protocol.AccessRequest {
|
||||
func (s *stateStore) pendingList(scope string) []protocol.AccessRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneRequestsLocked()
|
||||
var out []protocol.AccessRequest
|
||||
if st := s.data[tunnelID]; st != nil {
|
||||
if st := s.data[scope]; st != nil {
|
||||
for _, r := range st.Requests {
|
||||
if r.Status == "pending" {
|
||||
if r.Status == "pending" && !r.Renewal {
|
||||
out = append(out, *r)
|
||||
}
|
||||
}
|
||||
@@ -514,6 +677,195 @@ func (s *stateStore) pendingList(tunnelID 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() {
|
||||
@@ -537,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 {
|
||||
@@ -545,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)
|
||||
}
|
||||
}
|
||||
@@ -581,13 +938,9 @@ func (s *stateStore) saveLocked() {
|
||||
|
||||
// leaseExpiry computes when a lease granted now ends, per the invite's lease duration,
|
||||
// never outliving the tunnel's own TTL.
|
||||
func leaseExpiry(duration string, tunnelExpires *time.Time, now time.Time) time.Time {
|
||||
func leaseExpiry(duration string, scopeExpires *time.Time, now time.Time) time.Time {
|
||||
var exp time.Time
|
||||
switch duration {
|
||||
case protocol.LeaseDuration1h:
|
||||
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())
|
||||
@@ -595,10 +948,14 @@ func leaseExpiry(duration string, tunnelExpires *time.Time, now time.Time) time.
|
||||
// 通道开启期间有效:通道被主动关闭时放通会被清除;设一个上限避免永久放通
|
||||
exp = now.Add(30 * 24 * time.Hour)
|
||||
default:
|
||||
exp = now.Add(2 * time.Hour)
|
||||
minutes, ok := protocol.LeaseMinutes(duration)
|
||||
if !ok {
|
||||
minutes = 120
|
||||
}
|
||||
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(exp) {
|
||||
exp = *tunnelExpires
|
||||
exp = now.Add(time.Duration(minutes) * time.Minute)
|
||||
}
|
||||
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(exp) {
|
||||
exp = *scopeExpires
|
||||
}
|
||||
return exp
|
||||
}
|
||||
@@ -118,13 +118,28 @@ func (t *Tunnel) ID() string {
|
||||
return t.Config.ID
|
||||
}
|
||||
|
||||
func (t *Tunnel) restoreDynamicLeases(leases map[string]time.Time) {
|
||||
if len(leases) == 0 {
|
||||
return
|
||||
// SetDynamic replaces the temporary leases with the given set and disconnects
|
||||
// active clients that are no longer permitted. Returns how many were disconnected.
|
||||
func (t *Tunnel) SetDynamic(leases map[string]time.Time) int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.whitelist.ReplaceDynamic(leases)
|
||||
kicked := 0
|
||||
for id, ac := range t.activeConns {
|
||||
if ok, _ := t.whitelist.IsAllowed(ac.ClientIP); !ok {
|
||||
_ = ac.Conn.Close()
|
||||
delete(t.activeConns, id)
|
||||
kicked++
|
||||
}
|
||||
}
|
||||
return kicked
|
||||
}
|
||||
|
||||
// GroupID returns the tunnel group this tunnel belongs to ("" when none)
|
||||
func (t *Tunnel) GroupID() string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
t.whitelist.RestoreDynamic(leases)
|
||||
return t.Config.GroupID
|
||||
}
|
||||
|
||||
// Name returns the tunnel display name
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
@@ -30,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)
|
||||
}
|
||||
@@ -58,31 +61,68 @@ func (m *Manager) accessHost(r *http.Request) string {
|
||||
return r.Host
|
||||
}
|
||||
|
||||
// inviteTarget resolves an invite code to its (currently registered) tunnel
|
||||
func (m *Manager) inviteTarget(code string) (*Tunnel, protocol.Invite, string) {
|
||||
// accessScope is what an invite or request grants access to: one tunnel or a whole tunnel group
|
||||
type accessScope struct {
|
||||
key string
|
||||
group bool
|
||||
name string
|
||||
tunnels []protocol.TunnelConfig // 当前开启的通道,按端口排序
|
||||
expires *time.Time
|
||||
}
|
||||
|
||||
// resolveScope looks up the open tunnels of a scope; false when none is open
|
||||
func (m *Manager) resolveScope(scope string) (accessScope, bool) {
|
||||
tunnels := m.scopeTunnels(scope)
|
||||
if len(tunnels) == 0 {
|
||||
return accessScope{}, false
|
||||
}
|
||||
_, isGroup := splitScope(scope)
|
||||
sc := accessScope{key: scope, group: isGroup, expires: scopeExpiresOf(tunnels)}
|
||||
for _, t := range tunnels {
|
||||
sc.tunnels = append(sc.tunnels, t.Snapshot())
|
||||
}
|
||||
sc.name = sc.tunnels[0].Name
|
||||
if isGroup {
|
||||
sc.name = sc.tunnels[0].GroupName
|
||||
if sc.name == "" {
|
||||
sc.name = "通道组"
|
||||
}
|
||||
}
|
||||
return sc, true
|
||||
}
|
||||
|
||||
// inviteTarget resolves an invite code to the tunnel or group it grants access to
|
||||
func (m *Manager) inviteTarget(code string) (accessScope, protocol.Invite, string) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return nil, protocol.Invite{}, "缺少邀请码"
|
||||
return accessScope{}, protocol.Invite{}, "缺少邀请码"
|
||||
}
|
||||
tunnelID, inv, ok := m.state.findInvite(code)
|
||||
scope, inv, ok := m.state.findInvite(code)
|
||||
if !ok {
|
||||
return nil, protocol.Invite{}, "链接无效或已过期,请向管理员索取新的邀请链接"
|
||||
return accessScope{}, protocol.Invite{}, "链接无效或已过期,请向管理员索取新的邀请链接"
|
||||
}
|
||||
t := m.tunnel(tunnelID)
|
||||
if t == nil {
|
||||
return nil, protocol.Invite{}, "对应通道当前未开启,请联系管理员"
|
||||
sc, ok := m.resolveScope(scope)
|
||||
if !ok {
|
||||
return accessScope{}, protocol.Invite{}, "对应通道当前未开启,请联系管理员"
|
||||
}
|
||||
return t, inv, ""
|
||||
return sc, inv, ""
|
||||
}
|
||||
|
||||
func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code string) {
|
||||
ip := clientIPOf(r)
|
||||
t, inv, problem := m.inviteTarget(code)
|
||||
// 用过这个链接的设备:显示它的授权状态,到期前后都可以申请延长(链接或通道失效后也一样)
|
||||
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"
|
||||
}
|
||||
if problem == "" {
|
||||
if err := m.state.checkInvite(t.ID(), inv.Code, ip); err != nil {
|
||||
if err := m.state.checkInvite(sc.key, inv.Code, ip); err != nil {
|
||||
problem = err.Error()
|
||||
}
|
||||
}
|
||||
@@ -91,7 +131,6 @@ func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code s
|
||||
return
|
||||
}
|
||||
|
||||
cfg := t.Snapshot()
|
||||
action := "确认并获取访问权限"
|
||||
approvalTip := ""
|
||||
if inv.RequireApproval {
|
||||
@@ -107,9 +146,91 @@ func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code s
|
||||
<button type="submit">%s</button>
|
||||
</form>
|
||||
%s`,
|
||||
m.detailRows(r, cfg, ip, durationText(inv.LeaseDuration)),
|
||||
m.detailRows(r, sc, ip, durationText(inv.LeaseDuration)),
|
||||
html.EscapeString(inv.Code), maxNameRunes, action, approvalTip)
|
||||
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+cfg.Name, body)
|
||||
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+sc.name, body)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -130,7 +251,7 @@ func (m *Manager) serveInviteSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ip := clientIPOf(r)
|
||||
name := sanitizeName(r.PostForm.Get("name"))
|
||||
t, inv, problem := m.inviteTarget(r.PostForm.Get("code"))
|
||||
sc, inv, problem := m.inviteTarget(r.PostForm.Get("code"))
|
||||
switch {
|
||||
case problem != "":
|
||||
case ip == "":
|
||||
@@ -143,27 +264,26 @@ func (m *Manager) serveInviteSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg := t.Snapshot()
|
||||
if inv.RequireApproval {
|
||||
req, err := m.state.addRequest(cfg.ID, inv.Code, name, ip)
|
||||
req, err := m.state.addRequest(sc.key, inv.Code, name, ip)
|
||||
if err != nil {
|
||||
m.writePage(w, r, http.StatusConflict, pageError, "无法提交申请", problemHTML(err.Error()))
|
||||
return
|
||||
}
|
||||
log.Printf("[WebAuth] Access request from %s (%s) on tunnel %s awaiting approval", name, ip, cfg.Name)
|
||||
log.Printf("[WebAuth] Access request from %s (%s) on %s awaiting approval", name, ip, sc.name)
|
||||
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(req.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
lease, err := m.state.useInviteAndGrant(cfg.ID, inv.Code, name, ip, cfg.ExpiresAt)
|
||||
lease, err := m.state.useInviteAndGrant(sc.key, inv.Code, name, ip, sc.expires)
|
||||
if err != nil {
|
||||
m.writePage(w, r, http.StatusConflict, pageError, "无法完成授权", problemHTML(err.Error()))
|
||||
return
|
||||
}
|
||||
t.AddDynamicWhitelist(ip, lease.ExpiresAt)
|
||||
log.Printf("[WebAuth] Granted %s (%s) access to tunnel %s (port %d): %s",
|
||||
name, ip, cfg.Name, cfg.ListenPort, leaseValidity(lease))
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "授权成功", m.successBody(r, cfg, ip, lease))
|
||||
m.syncScope(sc.key)
|
||||
log.Printf("[WebAuth] Granted %s (%s) access to %s (%d port(s)): %s",
|
||||
name, ip, sc.name, len(sc.tunnels), leaseValidity(lease))
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "授权成功", m.successBody(r, sc, ip, lease))
|
||||
}
|
||||
|
||||
func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -172,23 +292,41 @@ func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
|
||||
m.writePage(w, r, http.StatusNotFound, pageError, "申请已失效", problemHTML("申请不存在或已过期,请重新打开邀请链接提交"))
|
||||
return
|
||||
}
|
||||
t := m.tunnel(req.TunnelID)
|
||||
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 t == nil:
|
||||
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":
|
||||
cfg := t.Snapshot()
|
||||
lease, _ := m.state.lease(cfg.ID, req.IP)
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, cfg, req.IP, lease))
|
||||
lease, _ := m.state.lease(sc.key, req.IP)
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, sc, req.IP, lease))
|
||||
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:
|
||||
cfg := t.Snapshot()
|
||||
duration := ""
|
||||
if _, inv, ok := m.state.findInvite(req.Invite); ok {
|
||||
duration = inv.LeaseDuration
|
||||
}
|
||||
body := m.detailRows(r, cfg, req.IP, durationText(duration)) +
|
||||
body := m.detailRows(r, sc, req.IP, durationText(duration)) +
|
||||
fmt.Sprintf(`<div class="tip">%s,您的申请已提交,正在等待管理员批准。此页面会自动刷新。</div>`, html.EscapeString(req.Name))
|
||||
w.Header().Set("Refresh", "5")
|
||||
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", body)
|
||||
@@ -211,20 +349,37 @@ func leaseValidity(l leaseInfo) string {
|
||||
return "至 " + l.ExpiresAt.Format("01-02 15:04")
|
||||
}
|
||||
|
||||
func (m *Manager) successBody(r *http.Request, cfg protocol.TunnelConfig, ip string, l leaseInfo) string {
|
||||
return m.detailRows(r, cfg, ip, leaseValidity(l)) +
|
||||
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接上面的访问地址,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。</div>`
|
||||
func (m *Manager) successBody(r *http.Request, sc accessScope, ip string, l leaseInfo) string {
|
||||
where := "上面的访问地址"
|
||||
if sc.group {
|
||||
where = "上面任意一个访问地址"
|
||||
}
|
||||
return m.detailRows(r, sc, ip, leaseValidity(l)) +
|
||||
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后 2 小时内,可以重新打开这个邀请链接申请延长。</div>`
|
||||
}
|
||||
|
||||
// detailRows renders the tunnel info table
|
||||
func (m *Manager) detailRows(r *http.Request, cfg protocol.TunnelConfig, ip string, validity string) string {
|
||||
validity = html.EscapeString(validity)
|
||||
return fmt.Sprintf(`
|
||||
<div class="row"><span class="label">目标通道</span><span class="value">%s</span></div>
|
||||
<div class="row"><span class="label">访问地址</span><span class="value mono">%s:%d</span></div>
|
||||
// detailRows renders the info table; a group lists every access address with its target
|
||||
func (m *Manager) detailRows(r *http.Request, sc accessScope, ip string, validity string) string {
|
||||
host := html.EscapeString(m.accessHost(r))
|
||||
var target, addresses string
|
||||
if sc.group {
|
||||
target = fmt.Sprintf(`<div class="row"><span class="label">目标通道组</span><span class="value">%s(%d 个地址)</span></div>`,
|
||||
html.EscapeString(sc.name), len(sc.tunnels))
|
||||
var b strings.Builder
|
||||
for _, t := range sc.tunnels {
|
||||
fmt.Fprintf(&b, `<div class="map"><span class="mono">%s:%d</span><span class="to">%s</span></div>`,
|
||||
host, t.ListenPort, html.EscapeString(t.Target))
|
||||
}
|
||||
addresses = `<div class="row stack"><span class="label">访问地址</span><div class="maps">` + b.String() + `</div></div>`
|
||||
} else {
|
||||
t := sc.tunnels[0]
|
||||
target = fmt.Sprintf(`<div class="row"><span class="label">目标通道</span><span class="value">%s</span></div>`, html.EscapeString(t.Name))
|
||||
addresses = fmt.Sprintf(`<div class="row"><span class="label">访问地址</span><span class="value mono">%s:%d</span></div>`, host, t.ListenPort)
|
||||
}
|
||||
return target + addresses + fmt.Sprintf(`
|
||||
<div class="row"><span class="label">您的设备 IP</span><span class="value mono">%s</span></div>
|
||||
<div class="row"><span class="label">访问有效期</span><span class="value highlight">%s</span></div>`,
|
||||
html.EscapeString(cfg.Name), html.EscapeString(m.accessHost(r)), cfg.ListenPort, html.EscapeString(ip), validity)
|
||||
html.EscapeString(ip), html.EscapeString(validity))
|
||||
}
|
||||
|
||||
func problemHTML(msg string) string {
|
||||
@@ -284,6 +439,11 @@ func renderPage(kind pageKind, title, body string) string {
|
||||
.label { color:var(--sub); white-space:nowrap; }
|
||||
.value { font-weight:600; text-align:right; word-break:break-all; }
|
||||
.mono { font-family:ui-monospace,Menlo,Monaco,Consolas,monospace; background:var(--code-bg); padding:2px 6px; border-radius:4px; }
|
||||
.row.stack { align-items:flex-start; }
|
||||
.maps { display:flex; flex-direction:column; gap:6px; align-items:flex-end; }
|
||||
.map { display:flex; align-items:center; gap:8px; font-weight:600; }
|
||||
.map .to { color:var(--sub); font-weight:400; font-size:12px; }
|
||||
.map .to::before { content:"→ "; }
|
||||
.highlight { color:var(--accent); }
|
||||
.tip { margin-top:20px; padding:12px 14px; background:var(--code-bg); border-radius:8px; font-size:12px; line-height:1.5; color:var(--sub); }
|
||||
.tip.error { color:#FF3B30; background:rgba(255,59,48,0.1); font-weight:600; text-align:center; }
|
||||
|
||||
@@ -127,6 +127,18 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
|
||||
"owner":"运维组","duration_minutes":0,"started_at":"\(t(-2100))","effective_idle_close_minutes":30,"idle_close_minutes":30,"first_connected_at":"\(t(-2000))","connected_sec":640,
|
||||
"activity":{"in_use":false,"idle_since":"\(t(-720))","auto_close_at":"\(t(1080))","connected_sec":640},
|
||||
"metrics":{"tunnel_id":"t3","status":"online","active_conns":0,"total_conns":4,"bytes_in":1983000,"bytes_out":204000,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
|
||||
{"id":"g1a","name":"订单服务 (10.0.3.11)","target":"10.0.3.11:22","listen_port":33670,"whitelist":[],"enabled":true,"group_id":"g1",
|
||||
"owner":"运维组","started_at":"\(t(-1800))","effective_idle_close_minutes":30,"idle_close_minutes":30,"first_connected_at":"\(t(-1500))","connected_sec":\(1320 + Int(dt)),
|
||||
"activity":{"in_use":true,"connected_sec":\(1320 + Int(dt))},
|
||||
"metrics":{"tunnel_id":"g1a","status":"online","active_conns":1,"total_conns":9,"bytes_in":3203180,"bytes_out":18039104,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
|
||||
{"id":"g1b","name":"订单服务 (10.0.3.12)","target":"10.0.3.12:22","listen_port":33671,"whitelist":[],"enabled":true,"group_id":"g1",
|
||||
"owner":"运维组","started_at":"\(t(-1800))","effective_idle_close_minutes":30,"idle_close_minutes":30,"first_connected_at":"\(t(-1400))","connected_sec":\(1100 + Int(dt)),
|
||||
"activity":{"in_use":true,"connected_sec":\(1100 + Int(dt))},
|
||||
"metrics":{"tunnel_id":"g1b","status":"online","active_conns":2,"total_conns":14,"bytes_in":2203180,"bytes_out":12039104,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
|
||||
{"id":"g1c","name":"订单服务 (10.0.3.13)","target":"10.0.3.13:22","listen_port":33672,"whitelist":[],"enabled":true,"group_id":"g1",
|
||||
"owner":"运维组","started_at":"\(t(-1800))","effective_idle_close_minutes":30,"idle_close_minutes":30,"connected_sec":0,
|
||||
"activity":{"in_use":true,"connected_sec":0},
|
||||
"metrics":{"tunnel_id":"g1c","status":"online","active_conns":0,"total_conns":0,"bytes_in":0,"bytes_out":0,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
|
||||
{"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}
|
||||
]
|
||||
@@ -136,9 +148,30 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
|
||||
vm.tunnels[2].rate_history = RateSeriesItem(end: Int64(Date().timeIntervalSince1970), step: 5,
|
||||
up: (0..<120).map { $0 < 30 ? demoRate($0, seed: 5, base: 300, burst: 2000) : 0 },
|
||||
down: (0..<120).map { $0 < 30 ? demoRate($0, seed: 6, base: 1500, burst: 9000) : 0 })
|
||||
for (i, seed) in [(3, 3.3), (4, 4.6)] {
|
||||
vm.tunnels[i].rate_history = demoSeries(tick: tick, seed: seed, base: 4000, burst: 26000, upScale: 0.3)
|
||||
}
|
||||
vm.tunnels[5].rate_history = RateSeriesItem(end: Int64(Date().timeIntervalSince1970), step: 5,
|
||||
up: Array(repeating: 0, count: 120), down: Array(repeating: 0, count: 120))
|
||||
let ga = vm.tunnels[3].rate_history!, gb = vm.tunnels[4].rate_history!
|
||||
vm.groups = decode([GroupItem].self, """
|
||||
[{"id":"g1","name":"订单服务","owner":"运维组","created_at":"\(t(-1800))","tunnel_ids":["g1a","g1b","g1c"],
|
||||
"metrics":{"group_id":"g1",
|
||||
"dynamic_leases":[{"ip":"192.168.1.42","expires_at":"\(t(27000))","name":"小王","invite":"gx1","duration":"8h","started_at":"\(t(-1500))"}],
|
||||
"pending":[{"id":"gr1","tunnel_id":"","group_id":"g1","name":"小李","ip":"192.168.1.57","invite":"gx2","at":"\(t(-40))","status":"pending"}],
|
||||
"invites":[{"code":"gx1","note":"小王","created_at":"\(t(-1700))","expires_at":"\(t(84000))","max_uses":1,"lease_duration":"8h","uses":[{"name":"小王","ip":"192.168.1.42","at":"\(t(-1600))"}]},
|
||||
{"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 })
|
||||
// 授权延长:赵六的访问 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
|
||||
for i in 0..<2 {
|
||||
for i in [0, 1, 3, 4] {
|
||||
let h = vm.tunnels[i].rate_history!
|
||||
vm.tunnels[i].metrics?.rate_in = h.up.last ?? 0
|
||||
vm.tunnels[i].metrics?.rate_out = h.down.last ?? 0
|
||||
@@ -152,6 +185,9 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
|
||||
{"conn_id":"c2","tunnel_id":"t1","client_ip":"192.168.1.21:50395","connected_at":"\(t(-577))","bytes_in":406528,"bytes_out":3984588,"rate_in":1126,"rate_out":29798},
|
||||
{"conn_id":"c3","tunnel_id":"t1","client_ip":"192.168.1.21:53023","connected_at":"\(t(-526))","bytes_in":244213760,"bytes_out":633856,"rate_in":0,"rate_out":0},
|
||||
{"conn_id":"c4","tunnel_id":"t2","client_ip":"192.168.1.52:61001","connected_at":"\(t(-3000))","bytes_in":120000,"bytes_out":8800000,"rate_in":120,"rate_out":9100},
|
||||
{"conn_id":"c6","tunnel_id":"g1a","client_ip":"192.168.1.42:50211","connected_at":"\(t(-900))","bytes_in":320000,"bytes_out":1800000,"rate_in":900,"rate_out":5100},
|
||||
{"conn_id":"c7","tunnel_id":"g1b","client_ip":"192.168.1.42:50388","connected_at":"\(t(-600))","bytes_in":220000,"bytes_out":1200000,"rate_in":700,"rate_out":3900},
|
||||
{"conn_id":"c8","tunnel_id":"g1b","client_ip":"192.168.1.42:50390","connected_at":"\(t(-300))","bytes_in":20000,"bytes_out":90000,"rate_in":0,"rate_out":0},
|
||||
{"conn_id":"c5","tunnel_id":"t2","client_ip":"192.168.1.60:61201","connected_at":"\(t(-300))","bytes_in":42000,"bytes_out":1800000,"rate_in":80,"rate_out":3100}
|
||||
]
|
||||
""")
|
||||
@@ -159,10 +195,13 @@ 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 = 5; vm.totalHistoricalConns = 1633; vm.totalRateIn = totalIn; vm.totalRateOut = totalOut
|
||||
vm.totalActiveConns = 8; vm.totalHistoricalConns = 1656; vm.totalRateIn = totalIn; vm.totalRateOut = totalOut
|
||||
vm.totalTransferred = 60000000000; vm.totalBlockedAttempts = 12
|
||||
vm.runDiagnostics()
|
||||
return vm
|
||||
@@ -172,6 +211,8 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
|
||||
final class KeyWindow: NSWindow {
|
||||
override var isKeyWindow: Bool { true }
|
||||
override var isMainWindow: Bool { true }
|
||||
/// 渲染进程不会被激活,AppKit 控件(开关、分段控件)按这个私有属性决定是否画成灰色
|
||||
@objc(hasKeyAppearance) func alwaysHasKeyAppearance() -> Bool { true }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -211,8 +252,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))
|
||||
@@ -221,12 +262,37 @@ MainActor.assumeIsolated {
|
||||
|
||||
// README 截图
|
||||
let demo = makeDemoVM()
|
||||
render("readme-main-light", CGSize(width: 980, height: 880), ContentView(vm: demo))
|
||||
render("readme-main-dark", CGSize(width: 980, height: 880), 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-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-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-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: 570), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
|
||||
let g = demo.groups[0]
|
||||
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))
|
||||
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))
|
||||
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: 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-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))
|
||||
|
||||
// 授权延长:申请延长、最近结束、已授权设备的「延长」
|
||||
render("readme-access", CGSize(width: 760, height: 800), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 2, isPresented: yes))
|
||||
render("renew-card", CGSize(width: 980, height: 220), TunnelCardView(tunnel: demo.tunnels[0], vm: demo, onEdit: {}, onViewClients: {}, onViewLeases: {}, onInvite: {}).padding(20))
|
||||
render("renew-invite-form", CGSize(width: 600, height: 400), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: false, isPresented: yes))
|
||||
render("renew-custom", CGSize(width: 300, height: 90), CustomMinutesForm(actionTitle: "延长") { _ in })
|
||||
|
||||
// README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
|
||||
if only.hasPrefix("gif") {
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
}
|
||||