Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:
|
jobs:
|
||||||
binaries:
|
binaries:
|
||||||
name: Server & agent binaries
|
name: Server & agent archives
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -31,24 +31,22 @@ jobs:
|
|||||||
dist/*.zip
|
dist/*.zip
|
||||||
|
|
||||||
macos-app:
|
macos-app:
|
||||||
name: macOS app
|
name: macOS app (dmg)
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
- name: Build universal app
|
- name: Build universal dmg
|
||||||
run: |
|
run: |
|
||||||
VERSION=${GITHUB_REF_NAME#v}
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
case "$VERSION" in [0-9]*) ;; *) VERSION=0.0.0-${GITHUB_SHA::7} ;; esac
|
case "$VERSION" in [0-9]*) ;; *) VERSION=0.0.0-${GITHUB_SHA::7} ;; esac
|
||||||
UNIVERSAL=1 VERSION=$VERSION ./build-app.sh
|
UNIVERSAL=1 make dmg VERSION=$VERSION
|
||||||
mkdir -p dist
|
|
||||||
ditto -c -k --sequesterRsrc --keepParent PortRelay.app "dist/PortRelay-$VERSION-macos-universal.zip"
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos-app
|
name: macos-app
|
||||||
path: dist/*.zip
|
path: dist/*.dmg
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
name: Docker image
|
name: Docker image
|
||||||
@@ -88,13 +86,27 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
steps:
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: dist
|
path: dist
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: cd dist && sha256sum * > SHA256SUMS
|
run: cd dist && sha256sum * > SHA256SUMS
|
||||||
|
# 说明 = 标签注释正文(本次更新)+ 下载说明(区分客户端与服务端)+ 自动生成的变更列表
|
||||||
|
- name: Release notes
|
||||||
|
run: |
|
||||||
|
git fetch --force origin "refs/tags/$GITHUB_REF_NAME:refs/tags/$GITHUB_REF_NAME"
|
||||||
|
body=$(git tag -l --format='%(contents:body)' "$GITHUB_REF_NAME")
|
||||||
|
{
|
||||||
|
if [ -n "$body" ]; then printf "## 本次更新 · What's new\n\n%s\n\n" "$body"; fi
|
||||||
|
sed -e "s|{{VERSION}}|${GITHUB_REF_NAME#v}|g" \
|
||||||
|
-e "s|{{DOWNLOAD}}|https://github.com/$GITHUB_REPOSITORY/releases/download/$GITHUB_REF_NAME|g" \
|
||||||
|
-e "s|{{IMAGE}}|ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/portrelay-server|g" \
|
||||||
|
.github/release-notes.md
|
||||||
|
} > "$RUNNER_TEMP/notes.md"
|
||||||
|
cat "$RUNNER_TEMP/notes.md"
|
||||||
- name: Create release
|
- name: Create release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --generate-notes
|
run: gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md" --generate-notes
|
||||||
@@ -3,16 +3,20 @@
|
|||||||
# make server 交叉编译 Linux 服务端到 dist/
|
# make server 交叉编译 Linux 服务端到 dist/
|
||||||
# make agent 编译当前平台的 Agent 到 bin/
|
# make agent 编译当前平台的 Agent 到 bin/
|
||||||
# make app 构建 macOS 应用 PortRelay.app(需要 Xcode 命令行工具)
|
# 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 snapshots 离屏渲染 App 界面截图(tools/ui-snapshots/out)
|
||||||
|
|
||||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo dev)
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo dev)
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
GOBUILD := CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)"
|
GOBUILD := CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)"
|
||||||
|
|
||||||
PLATFORMS := linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64
|
# 服务端只发布 Linux 版本;macOS 用户用 App,其余平台用命令行 Agent
|
||||||
|
SERVER_PLATFORMS := linux/amd64 linux/arm64
|
||||||
|
AGENT_PLATFORMS := linux/amd64 linux/arm64 windows/amd64
|
||||||
|
|
||||||
.PHONY: all test fmt server agent app release snapshots clean
|
.PHONY: all test fmt server agent app install-app dmg release snapshots clean
|
||||||
|
|
||||||
all: test agent
|
all: test agent
|
||||||
|
|
||||||
@@ -34,20 +38,43 @@ agent:
|
|||||||
app:
|
app:
|
||||||
VERSION=$(VERSION) ./build-app.sh
|
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:
|
release:
|
||||||
@rm -rf dist && mkdir -p dist
|
@rm -rf dist/portrelay-* dist/SHA256SUMS && mkdir -p dist
|
||||||
@for p in $(PLATFORMS); do \
|
@pack() { \
|
||||||
os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \
|
cmd=$$1; os=$$2; arch=$$3; ext=; [ $$os = windows ] && ext=.exe; \
|
||||||
name=portrelay-$(VERSION)-$$os-$$arch; mkdir -p dist/$$name; \
|
name=$$cmd-$(VERSION)-$$os-$$arch; mkdir -p dist/$$name; \
|
||||||
echo "==> $$name"; \
|
echo "==> $$name"; \
|
||||||
GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/portrelay-server$$ext ./cmd/portrelay-server || exit 1; \
|
GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/$$cmd$$ext ./cmd/$$cmd || return 1; \
|
||||||
GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/portrelay-agent$$ext ./cmd/portrelay-agent || exit 1; \
|
|
||||||
cp LICENSE README.md README_EN.md dist/$$name/; \
|
cp LICENSE README.md README_EN.md dist/$$name/; \
|
||||||
if [ $$os = linux ]; then cp -R deploy dist/$$name/; fi; \
|
if [ $$cmd = portrelay-server ]; then cp -R deploy dist/$$name/; fi; \
|
||||||
if [ $$os = windows ]; then (cd dist && zip -qr $$name.zip $$name); \
|
if [ $$os = windows ]; then (cd dist && zip -qr $$name.zip $$name); \
|
||||||
else tar -C dist -czf dist/$$name.tar.gz $$name; fi; \
|
else tar -C dist -czf dist/$$name.tar.gz $$name; fi; \
|
||||||
rm -rf dist/$$name; \
|
rm -rf dist/$$name; \
|
||||||
done
|
}; \
|
||||||
|
for p in $(SERVER_PLATFORMS); do pack portrelay-server $${p%/*} $${p#*/} || exit 1; done; \
|
||||||
|
for p in $(AGENT_PLATFORMS); do pack portrelay-agent $${p%/*} $${p#*/} || exit 1; done
|
||||||
@cd dist && shasum -a 256 * > SHA256SUMS && cat SHA256SUMS
|
@cd dist && shasum -a 256 * > SHA256SUMS && cat SHA256SUMS
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|||||||
@@ -51,15 +51,18 @@ PortRelay 把这件事变成三步:
|
|||||||
**访问控制**
|
**访问控制**
|
||||||
|
|
||||||
- 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。
|
- 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。
|
||||||
- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(1 小时、2 小时、8 小时、当天有效,或直到通道关闭),以及是否需要审批。
|
- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(30 分钟、1 小时、2 小时、8 小时、当天有效、直到通道关闭,或自定义),以及是否需要审批。
|
||||||
- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
|
- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
|
||||||
- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
|
- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
|
||||||
- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
|
- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
|
||||||
|
- **申请延长**:访问结束后 2 小时内(通道已关闭也可以),同事重新打开原链接点「申请延长」;你在 App、菜单栏或网页面板里选择延长多久即批准,关闭的通道会自动重新开启。你也可以对已授权或刚结束的设备主动延长。
|
||||||
|
- **通道组**:添加通道时目标是一个列表,每行一台主机、多个端口用逗号隔开(也可以直接粘贴一串 IP),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。
|
||||||
|
|
||||||
**通道生命周期**
|
**通道生命周期**
|
||||||
|
|
||||||
- **闲置自动关闭**:没有连接、没有有效授权、没有待处理申请,也没有还能使用的邀请链接时开始计时,默认 30 分钟后关闭。
|
- **闲置自动关闭**:没有连接、没有有效授权、没有待处理申请,也没有还能使用的邀请链接时开始计时,默认 30 分钟后关闭。
|
||||||
- 可选的**最长开启时长**,到点无论是否在用都关闭。
|
- 可选的**最长开启时长**,到点无论是否在用都关闭。
|
||||||
|
- 通道组内任一地址在用,整组都不算闲置,一起开关、一起关闭。
|
||||||
- 记录首次连接时间、实际连接时长和每次关闭的原因。
|
- 记录首次连接时间、实际连接时长和每次关闭的原因。
|
||||||
- 编辑通道不会打断现有连接;更换端口时先绑定新端口再释放旧端口。
|
- 编辑通道不会打断现有连接;更换端口时先绑定新端口再释放旧端口。
|
||||||
|
|
||||||
@@ -101,6 +104,12 @@ PortRelay 把这件事变成三步:
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
**通道组**:组卡片汇总整组的状态与流量,下面逐行列出每个访问地址,可以单独开关、单独邀请或移出组。
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/images/app-group.png" alt="通道组" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
**连接与授权管理**:待审批的申请、已授权设备(在线状态与剩余时间),以及仍然有效的邀请链接,都在同一处管理。
|
**连接与授权管理**:待审批的申请、已授权设备(在线状态与剩余时间),以及仍然有效的邀请链接,都在同一处管理。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -144,9 +153,9 @@ PortRelay 把这件事变成三步:
|
|||||||
**方式 A:二进制 + systemd(推荐)**
|
**方式 A:二进制 + systemd(推荐)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VERSION=1.0.0 # 见 Releases 页面
|
VERSION=1.1.0 # 见 Releases 页面;ARM64 机器把 amd64 换成 arm64
|
||||||
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-$VERSION-linux-amd64.tar.gz
|
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-server-$VERSION-linux-amd64.tar.gz
|
||||||
tar xzf portrelay-$VERSION-linux-amd64.tar.gz && cd portrelay-$VERSION-linux-amd64
|
tar xzf portrelay-server-$VERSION-linux-amd64.tar.gz && cd portrelay-server-$VERSION-linux-amd64
|
||||||
sudo ./deploy/install-server.sh ./portrelay-server
|
sudo ./deploy/install-server.sh ./portrelay-server
|
||||||
|
|
||||||
# 安装脚本会生成随机 token,记下来填到 Agent 里
|
# 安装脚本会生成随机 token,记下来填到 Agent 里
|
||||||
@@ -176,7 +185,7 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
|
|||||||
|
|
||||||
### 2. 运行 Agent
|
### 2. 运行 Agent
|
||||||
|
|
||||||
**macOS App**:从 [Releases](https://github.com/DeepFal/PortRelay/releases) 下载 `PortRelay-*-macos-universal.zip`,解压后拖进“应用程序”。
|
**macOS App**:从 [Releases](https://github.com/DeepFal/PortRelay/releases) 下载 `PortRelay-*.dmg`(Apple Silicon 与 Intel 通用),打开后把 PortRelay 拖进“应用程序”。
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> App 未经 Apple 公证。首次打开时,在 Finder 中右键选择“打开”,或者执行 `xattr -dr com.apple.quarantine /Applications/PortRelay.app`。也可以 `make app` 自行构建。
|
> App 未经 Apple 公证。首次打开时,在 Finder 中右键选择“打开”,或者执行 `xattr -dr com.apple.quarantine /Applications/PortRelay.app`。也可以 `make app` 自行构建。
|
||||||
@@ -191,7 +200,7 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
|
|||||||
|
|
||||||
> 举例:Agent 通过 Tailscale 地址 `100.64.0.10` 连网关,同事在办公室局域网里用 `192.168.1.10` 访问。这时“服务器控制地址”填 `100.64.0.10:33000`,“对外访问地址”填 `192.168.1.10`,邀请链接和分享文案都会使用后者。
|
> 举例:Agent 通过 Tailscale 地址 `100.64.0.10` 连网关,同事在办公室局域网里用 `192.168.1.10` 访问。这时“服务器控制地址”填 `100.64.0.10:33000`,“对外访问地址”填 `192.168.1.10`,邀请链接和分享文案都会使用后者。
|
||||||
|
|
||||||
**Linux / Windows**:下载对应平台的压缩包,直接运行 Agent,在浏览器打开的面板里完成同样的设置。
|
**Linux / Windows**:下载对应平台的 `portrelay-agent-*` 压缩包,直接运行 Agent,在浏览器打开的面板里完成同样的设置。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./portrelay-agent # 面板:http://127.0.0.1:33080
|
./portrelay-agent # 面板:http://127.0.0.1:33080
|
||||||
@@ -200,10 +209,12 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
|
|||||||
|
|
||||||
### 3. 创建通道并邀请同事
|
### 3. 创建通道并邀请同事
|
||||||
|
|
||||||
1. 点 **添加通道**,填写名称和目标地址(如 `10.0.1.16:3306`)。可以先“探测”一下本机能否连通;端口会自动推荐。
|
1. 点 **添加通道**,填写名称和目标主机、端口(如 `10.0.1.16`、`3306`)。可以先测试一下本机能否连通;网关上的访问端口自动分配。
|
||||||
2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
|
2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
|
||||||
3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
|
3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
|
||||||
|
|
||||||
|
需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,每行填一台主机(端口用逗号隔开,可以直接粘贴一串 IP),表单会预览端口映射并自动放进新建的通道组,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。组的「连接与授权」可以按设备或按地址查看在线连接和接入记录。
|
||||||
|
|
||||||
## 配置参考
|
## 配置参考
|
||||||
|
|
||||||
**网关 `portrelay-server`**
|
**网关 `portrelay-server`**
|
||||||
|
|||||||
@@ -54,15 +54,18 @@ PortRelay turns that into three steps:
|
|||||||
**Access control**
|
**Access control**
|
||||||
|
|
||||||
- Three access modes: **invite-only** (the default and safest), an IP / CIDR allowlist (which can be combined with invites), and open to all.
|
- Three access modes: **invite-only** (the default and safest), an IP / CIDR allowlist (which can be combined with invites), and open to all.
|
||||||
- Each invite link has its own expiry, device limit, access duration once granted (1 h, 2 h, 8 h, until end of day, or until the tunnel closes) and an optional approval step.
|
- Each invite link has its own expiry, device limit, access duration once granted (30 min, 1 h, 2 h, 8 h, until end of day, until the tunnel closes, or a custom length) and an optional approval step.
|
||||||
- When approval is required, the app shows a system notification and you approve or deny with one click. The requester's page refreshes on its own.
|
- When approval is required, the app shows a system notification and you approve or deny with one click. The requester's page refreshes on its own.
|
||||||
- **The access clock starts at the first connection**, so a teammate can accept an invite now and use it later.
|
- **The access clock starts at the first connection**, so a teammate can accept an invite now and use it later.
|
||||||
- When access expires or is revoked, **existing connections are dropped too**. Unauthorized connections are reset (TCP RST) and logged.
|
- When access expires or is revoked, **existing connections are dropped too**. Unauthorized connections are reset (TCP RST) and logged.
|
||||||
|
- **Extensions.** Within two hours after access ends (even if the tunnel has been closed), a teammate reopens the original link and clicks 申请延长 (Request extension). You approve it by picking a duration in the app, the menu bar or the web panel, and a closed tunnel reopens on its own. You can also extend a running or just-ended grant yourself.
|
||||||
|
- **Tunnel groups**: the add-tunnel form takes a list of targets, one host per row with comma-separated ports (a pasted list of IPs is split into rows), creates one tunnel per address on a contiguous block of gateway ports and puts them in one group. A group shares one invite link and one set of grants: a teammate is authorized once for the whole group, and one revoke disconnects them everywhere.
|
||||||
|
|
||||||
**Tunnel lifecycle**
|
**Tunnel lifecycle**
|
||||||
|
|
||||||
- **Idle auto-close.** The idle timer starts when a tunnel has no connections, no active grants, no pending requests and no invite links with uses left. By default the tunnel closes after 30 minutes of idle time.
|
- **Idle auto-close.** The idle timer starts when a tunnel has no connections, no active grants, no pending requests and no invite links with uses left. By default the tunnel closes after 30 minutes of idle time.
|
||||||
- An optional **maximum open time** closes the tunnel at a fixed point, whether or not it is in use.
|
- An optional **maximum open time** closes the tunnel at a fixed point, whether or not it is in use.
|
||||||
|
- While any address in a group is in use, the whole group counts as in use; members are switched on and off together.
|
||||||
- PortRelay records the first-connection time, the actual connected time and why each tunnel was closed.
|
- PortRelay records the first-connection time, the actual connected time and why each tunnel was closed.
|
||||||
- Editing a tunnel keeps existing connections. Moving a tunnel binds the new port before releasing the old one.
|
- Editing a tunnel keeps existing connections. Moving a tunnel binds the new port before releasing the old one.
|
||||||
|
|
||||||
@@ -104,6 +107,12 @@ PortRelay turns that into three steps:
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
**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>
|
||||||
|
|
||||||
**Access management.** Pending requests, authorized devices (with online state and remaining time) and active invite links are all managed in one place.
|
**Access management.** Pending requests, authorized devices (with online state and remaining time) and active invite links are all managed in one place.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -149,9 +158,9 @@ The gateway must be reachable by **visitors** and by **the agent**. Open TCP `33
|
|||||||
**Option A: binary + systemd (recommended)**
|
**Option A: binary + systemd (recommended)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VERSION=1.0.0 # see the Releases page
|
VERSION=1.1.0 # see the Releases page; use arm64 instead of amd64 on ARM hosts
|
||||||
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-$VERSION-linux-amd64.tar.gz
|
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-server-$VERSION-linux-amd64.tar.gz
|
||||||
tar xzf portrelay-$VERSION-linux-amd64.tar.gz && cd portrelay-$VERSION-linux-amd64
|
tar xzf portrelay-server-$VERSION-linux-amd64.tar.gz && cd portrelay-server-$VERSION-linux-amd64
|
||||||
sudo ./deploy/install-server.sh ./portrelay-server
|
sudo ./deploy/install-server.sh ./portrelay-server
|
||||||
|
|
||||||
# The installer generates a random token; you will enter it in the agent
|
# The installer generates a random token; you will enter it in the agent
|
||||||
@@ -185,7 +194,7 @@ make server # dist/portrelay-server-linux-{amd64,arm64}
|
|||||||
|
|
||||||
### 2. Run the agent
|
### 2. Run the agent
|
||||||
|
|
||||||
**macOS app.** Download `PortRelay-*-macos-universal.zip` from [Releases](https://github.com/DeepFal/PortRelay/releases), unzip it and move it to Applications.
|
**macOS app.** Download `PortRelay-*.dmg` (universal: Apple Silicon and Intel) from [Releases](https://github.com/DeepFal/PortRelay/releases), open it and drag PortRelay to Applications.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> The app is not notarized. On first launch, right-click it in Finder and choose Open, or run `xattr -dr com.apple.quarantine /Applications/PortRelay.app`. You can also build it yourself with `make app`.
|
> The app is not notarized. On first launch, right-click it in Finder and choose Open, or run `xattr -dr com.apple.quarantine /Applications/PortRelay.app`. You can also build it yourself with `make app`.
|
||||||
@@ -200,7 +209,7 @@ Open ⚙️ Settings and fill in:
|
|||||||
|
|
||||||
> Example: the agent reaches the gateway over Tailscale at `100.64.0.10`, while teammates in the office use the LAN address `192.168.1.10`. Set the control address to `100.64.0.10:33000` and the public access address to `192.168.1.10`. Invite links and share messages will use the LAN address.
|
> Example: the agent reaches the gateway over Tailscale at `100.64.0.10`, while teammates in the office use the LAN address `192.168.1.10`. Set the control address to `100.64.0.10:33000` and the public access address to `192.168.1.10`. Invite links and share messages will use the LAN address.
|
||||||
|
|
||||||
**Linux / Windows.** Download the archive for your platform, run the agent, and use the web panel that opens in your browser.
|
**Linux / Windows.** Download the `portrelay-agent-*` archive for your platform, run the agent, and use the web panel that opens in your browser.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./portrelay-agent # panel at http://127.0.0.1:33080
|
./portrelay-agent # panel at http://127.0.0.1:33080
|
||||||
@@ -209,10 +218,12 @@ Open ⚙️ Settings and fill in:
|
|||||||
|
|
||||||
### 3. Create a tunnel and invite someone
|
### 3. Create a tunnel and invite someone
|
||||||
|
|
||||||
1. Click **添加通道** (Add tunnel) and enter a name and a target such as `10.0.1.16:3306`. You can probe the target from your machine first, and a free port is suggested automatically.
|
1. Click **添加通道** (Add tunnel) and enter a name, a target host and port such as `10.0.1.16` and `3306`. You can probe the target from your machine first; the gateway port is assigned automatically.
|
||||||
2. Keep the default access mode, **仅授权链接** (invite-only). Click **邀请** (Invite), then choose how long access lasts, how many devices may use the link, and whether approval is required.
|
2. Keep the default access mode, **仅授权链接** (invite-only). Click **邀请** (Invite), then choose how long access lasts, how many devices may use the link, and whether approval is required.
|
||||||
3. Send the generated message or QR code. Once the teammate has entered their name, they can connect their database client or SSH to `192.168.1.10:33660`.
|
3. Send the generated message or QR code. Once the teammate has entered their name, they can connect their database client or SSH to `192.168.1.10:33660`.
|
||||||
|
|
||||||
|
To open several hosts at once (a set of servers, say), use the same **添加通道** (Add tunnel) form with one host per row (comma-separated ports; you can paste a list of IPs). The form previews the port mapping and puts the addresses in a new tunnel group; idle close, bandwidth and access settings apply to every address. Then click **邀请整组** (Invite group). To add addresses later, click **添加地址** (Add address) on the group card; an existing tunnel can join a group from its edit form. A group's connections and history can be viewed by device or by address.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
**Gateway: `portrelay-server`**
|
**Gateway: `portrelay-server`**
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Agent struct {
|
|||||||
latestMetrics protocol.MetricsMessage
|
latestMetrics protocol.MetricsMessage
|
||||||
regErrors map[string]string // tunnelID -> 服务器返回的注册失败原因
|
regErrors map[string]string // tunnelID -> 服务器返回的注册失败原因
|
||||||
inviteWaiters map[string]chan protocol.InviteRespMessage
|
inviteWaiters map[string]chan protocol.InviteRespMessage
|
||||||
|
extendWaiters map[string]chan protocol.ExtendLeaseRespMessage
|
||||||
|
|
||||||
controlWriteMu sync.Mutex
|
controlWriteMu sync.Mutex
|
||||||
controlWriter *bufio.Writer
|
controlWriter *bufio.Writer
|
||||||
@@ -57,6 +58,7 @@ func NewAgent(cm *ConfigManager) *Agent {
|
|||||||
cm: cm,
|
cm: cm,
|
||||||
regErrors: make(map[string]string),
|
regErrors: make(map[string]string),
|
||||||
inviteWaiters: make(map[string]chan protocol.InviteRespMessage),
|
inviteWaiters: make(map[string]chan protocol.InviteRespMessage),
|
||||||
|
extendWaiters: make(map[string]chan protocol.ExtendLeaseRespMessage),
|
||||||
usage: make(map[string]*usageState),
|
usage: make(map[string]*usageState),
|
||||||
idleUnit: time.Minute,
|
idleUnit: time.Minute,
|
||||||
rates: newRateRecorder(),
|
rates: newRateRecorder(),
|
||||||
@@ -166,10 +168,14 @@ type TunnelActivity struct {
|
|||||||
|
|
||||||
// inUse: 有在线连接、有效授权(含尚未首次连接的)、待审批申请或仍可使用的邀请
|
// inUse: 有在线连接、有效授权(含尚未首次连接的)、待审批申请或仍可使用的邀请
|
||||||
func tunnelInUse(m protocol.TunnelMetrics) bool {
|
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
|
return true
|
||||||
}
|
}
|
||||||
for _, inv := range m.Invites {
|
for _, inv := range invites {
|
||||||
if inv.MaxUses <= 0 || len(inv.Uses) < inv.MaxUses {
|
if inv.MaxUses <= 0 || len(inv.Uses) < inv.MaxUses {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -188,6 +194,30 @@ func (a *Agent) trackUsage(now time.Time) {
|
|||||||
for _, m := range metrics.Tunnels {
|
for _, m := range metrics.Tunnels {
|
||||||
byID[m.TunnelID] = m
|
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 {
|
type closing struct {
|
||||||
id, reason string
|
id, reason string
|
||||||
@@ -226,7 +256,7 @@ func (a *Agent) trackUsage(now time.Time) {
|
|||||||
firstConn = append(firstConn, t.ID)
|
firstConn = append(firstConn, t.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
u.inUse = tunnelInUse(m)
|
u.inUse = tunnelInUse(m) || renewing[t.ID] || (t.GroupID != "" && groupBusy[t.GroupID])
|
||||||
if u.inUse {
|
if u.inUse {
|
||||||
u.lastActive = now
|
u.lastActive = now
|
||||||
}
|
}
|
||||||
@@ -292,6 +322,20 @@ func (a *Agent) RateSeries(id string) RateSeries {
|
|||||||
return a.rates.series(id, time.Now())
|
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)
|
// Activity returns the live usage of an enabled tunnel (nil when not tracked yet)
|
||||||
func (a *Agent) Activity(t protocol.TunnelConfig) *TunnelActivity {
|
func (a *Agent) Activity(t protocol.TunnelConfig) *TunnelActivity {
|
||||||
a.usageMu.Lock()
|
a.usageMu.Lock()
|
||||||
@@ -448,7 +492,7 @@ func (a *Agent) connectAndRun(cfg Config) error {
|
|||||||
for _, t := range a.cm.Get().Tunnels {
|
for _, t := range a.cm.Get().Tunnels {
|
||||||
if t.Enabled {
|
if t.Enabled {
|
||||||
log.Printf("[Agent] 注册通道: %s (:%d -> %s) [责任人: %s]", t.Name, t.ListenPort, t.Target, t.Owner)
|
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)
|
delete(a.inviteWaiters, resp.RequestID)
|
||||||
}
|
}
|
||||||
a.mu.Unlock()
|
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:
|
case protocol.TypeRegisterResp:
|
||||||
var resp protocol.RegisterRespMessage
|
var resp protocol.RegisterRespMessage
|
||||||
if json.Unmarshal(line, &resp) != nil {
|
if json.Unmarshal(line, &resp) != nil {
|
||||||
@@ -696,24 +751,51 @@ func (a *Agent) SyncTunnel(t protocol.TunnelConfig, edited bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if edited {
|
if edited {
|
||||||
_ = a.sendControl(protocol.UpdateTunnelMessage{Type: protocol.TypeUpdateTunnel, Tunnel: t})
|
_ = a.sendControl(protocol.UpdateTunnelMessage{Type: protocol.TypeUpdateTunnel, Tunnel: a.wire(t)})
|
||||||
} else {
|
} 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) {
|
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()
|
a.mu.Lock()
|
||||||
delete(a.regErrors, id)
|
delete(a.regErrors, id)
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
if !a.IsConnected() {
|
if !a.IsConnected() {
|
||||||
return
|
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
|
// 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() {
|
if !a.IsConnected() {
|
||||||
return protocol.Invite{}, fmt.Errorf("未连接中继服务器")
|
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{
|
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,
|
Note: note, ValidMinutes: validMinutes, MaxUses: maxUses, LeaseDuration: leaseDuration, RequireApproval: requireApproval,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return protocol.Invite{}, err
|
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
|
// 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() {
|
if !a.IsConnected() {
|
||||||
return fmt.Errorf("未连接中继服务器")
|
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
|
// 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() {
|
if !a.IsConnected() {
|
||||||
return fmt.Errorf("未连接中继服务器")
|
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)
|
// 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() {
|
if !a.IsConnected() {
|
||||||
return fmt.Errorf("未连接中继服务器")
|
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) {
|
func (a *Agent) KillConn(connID string) {
|
||||||
@@ -801,7 +923,9 @@ func (a *Agent) GetLatestMetrics() protocol.MetricsMessage {
|
|||||||
defer a.mu.RUnlock()
|
defer a.mu.RUnlock()
|
||||||
m := a.latestMetrics
|
m := a.latestMetrics
|
||||||
m.Tunnels = append([]protocol.TunnelMetrics(nil), m.Tunnels...)
|
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.ActiveConns = append([]protocol.ActiveConnInfo(nil), m.ActiveConns...)
|
||||||
m.SecurityEvents = append([]protocol.SecurityEvent(nil), m.SecurityEvents...)
|
m.SecurityEvents = append([]protocol.SecurityEvent(nil), m.SecurityEvents...)
|
||||||
|
m.Renewals = append([]protocol.RenewalInfo(nil), m.Renewals...)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,8 @@ func (s *APIServer) Handler() http.Handler {
|
|||||||
mux.HandleFunc("/api/status", s.handleStatus)
|
mux.HandleFunc("/api/status", s.handleStatus)
|
||||||
mux.HandleFunc("/api/tunnels", s.handleTunnels)
|
mux.HandleFunc("/api/tunnels", s.handleTunnels)
|
||||||
mux.HandleFunc("/api/tunnels/", s.handleTunnelItem)
|
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/probe", s.handleProbe)
|
||||||
mux.HandleFunc("/api/recommend-port", s.handleRecommendPort)
|
mux.HandleFunc("/api/recommend-port", s.handleRecommendPort)
|
||||||
mux.HandleFunc("/api/connections", s.handleConnections)
|
mux.HandleFunc("/api/connections", s.handleConnections)
|
||||||
@@ -130,6 +132,19 @@ type TunnelWithMetrics struct {
|
|||||||
EffectiveIdleCloseMinutes int `json:"effective_idle_close_minutes"`
|
EffectiveIdleCloseMinutes int `json:"effective_idle_close_minutes"`
|
||||||
// RateHistory 近 10 分钟流量曲线(仅开启中的通道)
|
// RateHistory 近 10 分钟流量曲线(仅开启中的通道)
|
||||||
RateHistory *RateSeries `json:"rate_history,omitempty"`
|
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.
|
// 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,
|
Metrics: tm,
|
||||||
Activity: s.agent.Activity(t),
|
Activity: s.agent.Activity(t),
|
||||||
EffectiveIdleCloseMinutes: IdleCloseMinutes(t),
|
EffectiveIdleCloseMinutes: IdleCloseMinutes(t),
|
||||||
|
Renewals: renewalsOf(metrics.Renewals, t.ID, ""),
|
||||||
}
|
}
|
||||||
if t.Enabled {
|
if t.Enabled {
|
||||||
series := s.agent.RateSeries(t.ID)
|
series := s.agent.RateSeries(t.ID)
|
||||||
@@ -207,52 +223,22 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
tunnelID := parts[2]
|
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 {
|
if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost {
|
||||||
s.handleToggle(w, r, tunnelID)
|
s.handleToggle(w, r, tunnelID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/tunnels/{id}/leases/revoke {"ip": "..."};ip 为空表示撤销该通道全部临时放通
|
// 授权相关:leases/revoke、invites、requests(与通道组共用同一套处理)
|
||||||
if len(parts) == 5 && parts[3] == "leases" && parts[4] == "revoke" && r.Method == http.MethodPost {
|
if len(parts) >= 4 && isAccessPath(parts[3]) {
|
||||||
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 _, ok := s.cm.FindTunnel(tunnelID); !ok {
|
if _, ok := s.cm.FindTunnel(tunnelID); !ok {
|
||||||
jsonError(w, http.StatusNotFound, "通道未找到")
|
jsonError(w, http.StatusNotFound, "通道未找到")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.agent.RevokeLease(tunnelID, strings.TrimSpace(req.IP)); err != nil {
|
s.handleAccess(w, r, Scope{TunnelID: tunnelID}, parts[3:])
|
||||||
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"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +279,7 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.agent.ConsumeSessionBytes(tunnelID)
|
s.agent.ConsumeSessionBytes(tunnelID)
|
||||||
s.agent.RemoveTunnel(tunnelID)
|
s.agent.DeleteTunnel(tunnelID)
|
||||||
jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
|
jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -303,44 +289,59 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// handleToggle 支持 {"enabled": true|false} 指定目标状态(幂等,防止重复点击来回翻转);不带 body 时取反
|
// handleToggle 支持 {"enabled": true|false} 指定目标状态(幂等,防止重复点击来回翻转);不带 body 时取反
|
||||||
func (s *APIServer) handleToggle(w http.ResponseWriter, r *http.Request, tunnelID string) {
|
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 {
|
var req struct {
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
if r.Body != nil {
|
if r.Body != nil {
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
||||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
return nil, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)
|
current, err := s.cm.CurrentEnabled(tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusNotFound, err.Error())
|
return protocol.TunnelConfig{}, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
want := !current
|
target := !current
|
||||||
if req.Enabled != nil {
|
if want != nil {
|
||||||
want = *req.Enabled
|
target = *want
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessIn, sessOut int64
|
var sessIn, sessOut int64
|
||||||
if !want && current {
|
if !target && current {
|
||||||
s.agent.FlushUsage(tunnelID)
|
s.agent.FlushUsage(tunnelID)
|
||||||
}
|
}
|
||||||
if want != current {
|
if target != current {
|
||||||
// 关闭时把本次会话流量并入历史;开启时清掉可能残留的旧指标
|
// 关闭时把本次会话流量并入历史;开启时清掉可能残留的旧指标
|
||||||
sessIn, sessOut = s.agent.ConsumeSessionBytes(tunnelID)
|
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 {
|
if err != nil {
|
||||||
jsonError(w, http.StatusNotFound, err.Error())
|
return t, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if changed {
|
if changed {
|
||||||
s.agent.dropUsage(tunnelID)
|
s.agent.dropUsage(tunnelID)
|
||||||
s.agent.SyncTunnel(t, false)
|
s.agent.SyncTunnel(t, false)
|
||||||
}
|
}
|
||||||
jsonResponse(w, http.StatusOK, t)
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// inviteBase is the URL prefix of invite links: http://<对外访问地址>:<控制端口>/i/
|
// 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
|
// 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 {
|
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
|
var b strings.Builder
|
||||||
b.WriteString("【PortRelay 访问邀请】")
|
b.WriteString("【PortRelay 访问邀请】")
|
||||||
if inv.Note != "" {
|
if inv.Note != "" {
|
||||||
b.WriteString("(给 " + inv.Note + ")")
|
b.WriteString("(给 " + inv.Note + ")")
|
||||||
}
|
}
|
||||||
b.WriteString("\n通道:" + t.Name)
|
b.WriteString("\n" + body)
|
||||||
b.WriteString(fmt.Sprintf("\n访问地址:%s:%d", cfg.PublicHostOrDefault(), t.ListenPort))
|
|
||||||
action := "填写姓名后即可访问"
|
action := "填写姓名后即可访问"
|
||||||
if inv.RequireApproval {
|
if inv.RequireApproval {
|
||||||
action = "填写姓名提交申请,管理员批准后即可访问"
|
action = "填写姓名提交申请,管理员批准后即可访问"
|
||||||
@@ -376,15 +390,198 @@ func shareText(cfg Config, t protocol.TunnelConfig, inv protocol.Invite, link st
|
|||||||
} else if inv.MaxUses > 1 {
|
} else if inv.MaxUses > 1 {
|
||||||
b.WriteString(fmt.Sprintf("\n此链接最多 %d 台设备使用,请勿转发。", inv.MaxUses))
|
b.WriteString(fmt.Sprintf("\n此链接最多 %d 台设备使用,请勿转发。", inv.MaxUses))
|
||||||
}
|
}
|
||||||
|
b.WriteString("\n授权结束后 2 小时内,可以重新打开此链接申请延长。")
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnelID string, rest []string) {
|
func isAccessPath(p string) bool {
|
||||||
t, ok := s.cm.FindTunnel(tunnelID)
|
return p == "leases" || p == "invites" || p == "requests"
|
||||||
if !ok {
|
}
|
||||||
jsonError(w, http.StatusNotFound, "通道未找到")
|
|
||||||
return
|
// 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()
|
cfg := s.cm.Get()
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@@ -415,11 +612,15 @@ func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnel
|
|||||||
if req.LeaseDuration == "" {
|
if req.LeaseDuration == "" {
|
||||||
req.LeaseDuration = protocol.LeaseDuration2h
|
req.LeaseDuration = protocol.LeaseDuration2h
|
||||||
}
|
}
|
||||||
if !t.Enabled {
|
if !s.scopeOpen(sc) {
|
||||||
jsonError(w, http.StatusConflict, "通道未开启,请先开启通道再生成邀请链接")
|
msg := "通道未开启,请先开启通道再生成邀请链接"
|
||||||
|
if sc.GroupID != "" {
|
||||||
|
msg = "通道组内没有开启的通道,请先开启再生成邀请链接"
|
||||||
|
}
|
||||||
|
jsonError(w, http.StatusConflict, msg)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
jsonError(w, http.StatusConflict, err.Error())
|
jsonError(w, http.StatusConflict, err.Error())
|
||||||
return
|
return
|
||||||
@@ -428,27 +629,22 @@ func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnel
|
|||||||
jsonResponse(w, http.StatusCreated, map[string]any{
|
jsonResponse(w, http.StatusCreated, map[string]any{
|
||||||
"invite": inv,
|
"invite": inv,
|
||||||
"link": link,
|
"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:
|
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())
|
jsonError(w, http.StatusConflict, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
|
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||||
|
|
||||||
case len(rest) == 2 && rest[1] == "share" && r.Method == http.MethodGet:
|
case len(rest) == 2 && rest[1] == "share" && r.Method == http.MethodGet:
|
||||||
for _, m := range s.agent.GetLatestMetrics().Tunnels {
|
for _, inv := range scopeInvites(s.agent.GetLatestMetrics(), sc) {
|
||||||
if m.TunnelID != tunnelID {
|
if inv.Code == rest[0] {
|
||||||
continue
|
link := s.inviteBase(cfg) + inv.Code
|
||||||
}
|
jsonResponse(w, http.StatusOK, map[string]any{"invite": inv, "link": link, "share_text": s.scopeShare(cfg, sc, inv, link)})
|
||||||
for _, inv := range m.Invites {
|
return
|
||||||
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)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jsonError(w, http.StatusNotFound, "邀请链接不存在或已过期")
|
jsonError(w, http.StatusNotFound, "邀请链接不存在或已过期")
|
||||||
@@ -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
|
// handleConnections returns live client connections (optionally for one tunnel) and recent security events
|
||||||
func (s *APIServer) handleConnections(w http.ResponseWriter, r *http.Request) {
|
func (s *APIServer) handleConnections(w http.ResponseWriter, r *http.Request) {
|
||||||
metrics := s.agent.GetLatestMetrics()
|
metrics := s.agent.GetLatestMetrics()
|
||||||
tunnelID := r.URL.Query().Get("tunnel_id")
|
match := s.tunnelFilter(r)
|
||||||
conns := make([]protocol.ActiveConnInfo, 0, len(metrics.ActiveConns))
|
conns := make([]protocol.ActiveConnInfo, 0, len(metrics.ActiveConns))
|
||||||
for _, c := range metrics.ActiveConns {
|
for _, c := range metrics.ActiveConns {
|
||||||
if tunnelID == "" || c.TunnelID == tunnelID {
|
if match(c.TunnelID) {
|
||||||
conns = append(conns, c)
|
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) {
|
func (s *APIServer) handleClientHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
tunnelID := r.URL.Query().Get("tunnel_id")
|
match := s.tunnelFilter(r)
|
||||||
jsonResponse(w, http.StatusOK, s.cm.GetClientHistory(tunnelID))
|
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) {
|
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 := s.statusPayload()
|
||||||
payload["metrics"] = metrics
|
payload["metrics"] = metrics
|
||||||
payload["tunnels"] = s.tunnelsWithMetrics(metrics)
|
payload["tunnels"] = s.tunnelsWithMetrics(metrics)
|
||||||
|
payload["groups"] = s.groupsView(metrics)
|
||||||
data, _ := json.Marshal(payload)
|
data, _ := json.Marshal(payload)
|
||||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
|
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
|
||||||
return false
|
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"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -49,12 +50,21 @@ func generateAuthCode() string {
|
|||||||
return string(b)
|
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
|
// Config represents persistent client settings
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServerAddr string `json:"server_addr"` // 中继服务器控制地址 (host:port)
|
ServerAddr string `json:"server_addr"` // 中继服务器控制地址 (host:port)
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
PublicHost string `json:"public_host,omitempty"` // 访问者连接通道时使用的网关地址
|
PublicHost string `json:"public_host,omitempty"` // 访问者连接通道时使用的网关地址
|
||||||
Tunnels []protocol.TunnelConfig `json:"tunnels"`
|
Tunnels []protocol.TunnelConfig `json:"tunnels"`
|
||||||
|
Groups []TunnelGroup `json:"groups,omitempty"`
|
||||||
ClientHistory []protocol.ClientHistoryRecord `json:"client_history,omitempty"`
|
ClientHistory []protocol.ClientHistoryRecord `json:"client_history,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +218,7 @@ func (cm *ConfigManager) Get() Config {
|
|||||||
for i, t := range cm.cfg.Tunnels {
|
for i, t := range cm.cfg.Tunnels {
|
||||||
out.Tunnels[i] = cloneTunnel(t)
|
out.Tunnels[i] = cloneTunnel(t)
|
||||||
}
|
}
|
||||||
|
out.Groups = append([]TunnelGroup(nil), cm.cfg.Groups...)
|
||||||
out.ClientHistory = append([]protocol.ClientHistoryRecord(nil), cm.cfg.ClientHistory...)
|
out.ClientHistory = append([]protocol.ClientHistoryRecord(nil), cm.cfg.ClientHistory...)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -316,9 +327,24 @@ func normalizeAndValidate(t *protocol.TunnelConfig) error {
|
|||||||
// 放通时长与是否审批已改为在每个邀请上设置,通道级字段不再使用
|
// 放通时长与是否审批已改为在每个邀请上设置,通道级字段不再使用
|
||||||
t.LeaseDuration = ""
|
t.LeaseDuration = ""
|
||||||
t.RequireApproval = false
|
t.RequireApproval = false
|
||||||
|
// 组名只在同步到服务端时填写,配置里以组 ID 为准
|
||||||
|
t.GroupName = ""
|
||||||
return nil
|
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.
|
// 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 {
|
func (cm *ConfigManager) checkPortLocked(id string, port int) error {
|
||||||
for _, existing := range cm.cfg.Tunnels {
|
for _, existing := range cm.cfg.Tunnels {
|
||||||
@@ -338,30 +364,131 @@ func (cm *ConfigManager) AddTunnel(t protocol.TunnelConfig) (protocol.TunnelConf
|
|||||||
cm.mu.Lock()
|
cm.mu.Lock()
|
||||||
defer cm.mu.Unlock()
|
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.ID = uuid.New().String()
|
||||||
t.AuthCode = generateAuthCode()
|
t.AuthCode = generateAuthCode()
|
||||||
t.HistoricalBytesIn, t.HistoricalBytesOut, t.LastDurationSec = 0, 0, 0
|
t.HistoricalBytesIn, t.HistoricalBytesOut, t.LastDurationSec = 0, 0, 0
|
||||||
t.CloseReason, t.FirstConnectedAt, t.ConnectedSec, t.LastConnectedSec = "", nil, 0, 0
|
t.CloseReason, t.FirstConnectedAt, t.ConnectedSec, t.LastConnectedSec = "", nil, 0, 0
|
||||||
|
t.DynamicWhitelist = nil
|
||||||
if t.IdleCloseMinutes == nil {
|
if t.IdleCloseMinutes == nil {
|
||||||
v := DefaultIdleCloseMinutes
|
v := DefaultIdleCloseMinutes
|
||||||
t.IdleCloseMinutes = &v
|
t.IdleCloseMinutes = &v
|
||||||
}
|
}
|
||||||
t.DynamicWhitelist = nil
|
|
||||||
if err := cm.checkPortLocked(t.ID, t.ListenPort); err != nil {
|
|
||||||
return t, err
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Enabled = true
|
t.Enabled = true
|
||||||
now := time.Now()
|
start := now
|
||||||
t.StartedAt = &now
|
t.StartedAt = &start
|
||||||
t.ExpiresAt = nil
|
t.ExpiresAt = nil
|
||||||
if t.DurationMinutes > 0 {
|
if t.DurationMinutes > 0 {
|
||||||
exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
|
exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
|
||||||
t.ExpiresAt = &exp
|
t.ExpiresAt = &exp
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cm.cfg.Tunnels = append(cm.cfg.Tunnels, t)
|
// NewTunnels is one submission of the add-tunnel form
|
||||||
return cloneTunnel(t), cm.save()
|
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.
|
// 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 {
|
if err := json.Unmarshal(patch, &updated); err != nil {
|
||||||
return existing, existing.ListenPort, fmt.Errorf("Invalid JSON")
|
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
|
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 {
|
if err := cm.checkPortLocked(id, updated.ListenPort); err != nil {
|
||||||
return existing, existing.ListenPort, err
|
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 {
|
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
|
cm.cfg.Tunnels[idx] = updated
|
||||||
return cloneTunnel(updated), existing.ListenPort, cm.save()
|
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
|
// RefreshAuthCode generates a new auth code for a tunnel
|
||||||
func (cm *ConfigManager) RefreshAuthCode(id string) (protocol.TunnelConfig, error) {
|
func (cm *ConfigManager) RefreshAuthCode(id string) (protocol.TunnelConfig, error) {
|
||||||
cm.mu.Lock()
|
cm.mu.Lock()
|
||||||
@@ -700,3 +864,168 @@ func (cm *ConfigManager) RecommendPort() int {
|
|||||||
}
|
}
|
||||||
return 0
|
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
|
// 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 {
|
func (w *Whitelist) RemoveDynamic(ip string) int {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package protocol
|
package protocol
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// Message types
|
// Message types
|
||||||
const (
|
const (
|
||||||
@@ -17,6 +22,9 @@ const (
|
|||||||
TypeInviteResp = "invite_resp"
|
TypeInviteResp = "invite_resp"
|
||||||
TypeRevokeInvite = "revoke_invite"
|
TypeRevokeInvite = "revoke_invite"
|
||||||
TypeDecideRequest = "decide_request"
|
TypeDecideRequest = "decide_request"
|
||||||
|
TypeDeleteGroup = "delete_group"
|
||||||
|
TypeExtendLease = "extend_lease"
|
||||||
|
TypeExtendResp = "extend_lease_resp"
|
||||||
TypeMetrics = "metrics_update"
|
TypeMetrics = "metrics_update"
|
||||||
TypePing = "ping"
|
TypePing = "ping"
|
||||||
TypePong = "pong"
|
TypePong = "pong"
|
||||||
@@ -68,6 +76,10 @@ type TunnelConfig struct {
|
|||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
LeaseDuration string `json:"lease_duration,omitempty"` // 已废弃:放通时长改为在每个邀请上设置,保留字段仅为兼容旧配置
|
LeaseDuration string `json:"lease_duration,omitempty"` // 已废弃:放通时长改为在每个邀请上设置,保留字段仅为兼容旧配置
|
||||||
RequireApproval bool `json:"require_approval,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
|
// RegisterTunnelMessage registers a tunnel on server
|
||||||
@@ -89,6 +101,8 @@ type RegisterRespMessage struct {
|
|||||||
type UnregisterMessage struct {
|
type UnregisterMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
TunnelID string `json:"tunnel_id"`
|
TunnelID string `json:"tunnel_id"`
|
||||||
|
// Deleted 为 true 表示通道被删除:连同可延长的授权记录一起清除;否则只是关闭
|
||||||
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnregisterRespMessage response to unregister
|
// UnregisterRespMessage response to unregister
|
||||||
@@ -110,14 +124,23 @@ type KillConnMessage struct {
|
|||||||
ConnID string `json:"conn_id"`
|
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 RevokeLeaseMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
TunnelID string `json:"tunnel_id"`
|
TunnelID string `json:"tunnel_id"`
|
||||||
|
GroupID string `json:"group_id,omitempty"`
|
||||||
IP string `json:"ip,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 (
|
const (
|
||||||
LeaseDuration1h = "1h"
|
LeaseDuration1h = "1h"
|
||||||
LeaseDuration2h = "2h"
|
LeaseDuration2h = "2h"
|
||||||
@@ -126,32 +149,75 @@ const (
|
|||||||
LeaseDurationTunnel = "tunnel" // 通道开启期间有效
|
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)
|
// ValidLeaseDuration reports whether d is a supported lease duration ("" means default 2h)
|
||||||
func ValidLeaseDuration(d string) bool {
|
func ValidLeaseDuration(d string) bool {
|
||||||
switch d {
|
if d == LeaseDurationToday || d == LeaseDurationTunnel {
|
||||||
case "", LeaseDuration1h, LeaseDuration2h, LeaseDuration8h, LeaseDurationToday, LeaseDurationTunnel:
|
|
||||||
return true
|
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
|
// DescribeLeaseDuration is the human-readable lease duration
|
||||||
func DescribeLeaseDuration(d string) string {
|
func DescribeLeaseDuration(d string) string {
|
||||||
switch d {
|
switch d {
|
||||||
case LeaseDuration1h:
|
|
||||||
return "1 小时"
|
|
||||||
case LeaseDuration8h:
|
|
||||||
return "8 小时"
|
|
||||||
case LeaseDurationToday:
|
case LeaseDurationToday:
|
||||||
return "当天有效"
|
return "当天有效"
|
||||||
case LeaseDurationTunnel:
|
case LeaseDurationTunnel:
|
||||||
return "通道开启期间有效"
|
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 {
|
type DynamicLease struct {
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
@@ -185,15 +251,55 @@ type Invite struct {
|
|||||||
Uses []InviteUse `json:"uses,omitempty"`
|
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 {
|
type AccessRequest struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TunnelID string `json:"tunnel_id"`
|
TunnelID string `json:"tunnel_id"`
|
||||||
|
GroupID string `json:"group_id,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Invite string `json:"invite,omitempty"`
|
Invite string `json:"invite,omitempty"`
|
||||||
At time.Time `json:"at"`
|
At time.Time `json:"at"`
|
||||||
Status string `json:"status"` // pending / approved / rejected
|
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
|
// CreateInviteMessage asks the server to create an invite link
|
||||||
@@ -201,6 +307,7 @@ type CreateInviteMessage struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
TunnelID string `json:"tunnel_id"`
|
TunnelID string `json:"tunnel_id"`
|
||||||
|
GroupID string `json:"group_id,omitempty"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
ValidMinutes int `json:"valid_minutes"`
|
ValidMinutes int `json:"valid_minutes"`
|
||||||
MaxUses int `json:"max_uses"`
|
MaxUses int `json:"max_uses"`
|
||||||
@@ -223,6 +330,7 @@ type InviteRespMessage struct {
|
|||||||
type RevokeInviteMessage struct {
|
type RevokeInviteMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
TunnelID string `json:"tunnel_id"`
|
TunnelID string `json:"tunnel_id"`
|
||||||
|
GroupID string `json:"group_id,omitempty"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +338,7 @@ type RevokeInviteMessage struct {
|
|||||||
type DecideRequestMessage struct {
|
type DecideRequestMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
TunnelID string `json:"tunnel_id"`
|
TunnelID string `json:"tunnel_id"`
|
||||||
|
GroupID string `json:"group_id,omitempty"`
|
||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
Approve bool `json:"approve"`
|
Approve bool `json:"approve"`
|
||||||
}
|
}
|
||||||
@@ -279,6 +388,14 @@ type TunnelMetrics struct {
|
|||||||
Pending []AccessRequest `json:"pending,omitempty"`
|
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
|
// SecurityEvent records blocked or flagged connection attempts
|
||||||
type SecurityEvent struct {
|
type SecurityEvent struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -293,8 +410,11 @@ type SecurityEvent struct {
|
|||||||
type MetricsMessage struct {
|
type MetricsMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Tunnels []TunnelMetrics `json:"tunnels"`
|
Tunnels []TunnelMetrics `json:"tunnels"`
|
||||||
|
Groups []GroupMetrics `json:"groups,omitempty"`
|
||||||
ActiveConns []ActiveConnInfo `json:"active_conns"`
|
ActiveConns []ActiveConnInfo `json:"active_conns"`
|
||||||
SecurityEvents []SecurityEvent `json:"security_events,omitempty"`
|
SecurityEvents []SecurityEvent `json:"security_events,omitempty"`
|
||||||
|
// Renewals 可延长的设备与延长申请,包括已关闭的通道
|
||||||
|
Renewals []RenewalInfo `json:"renewals,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamHandshake sent as first line when a data stream is opened
|
// StreamHandshake sent as first line when a data stream is opened
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 426 KiB After Width: | Height: | Size: 586 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"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -239,7 +240,7 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
|
|||||||
case protocol.TypeUnregister:
|
case protocol.TypeUnregister:
|
||||||
var msg protocol.UnregisterMessage
|
var msg protocol.UnregisterMessage
|
||||||
if json.Unmarshal(raw, &msg) == nil {
|
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})
|
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:
|
case protocol.TypeRevokeLease:
|
||||||
var msg protocol.RevokeLeaseMessage
|
var msg protocol.RevokeLeaseMessage
|
||||||
if json.Unmarshal(raw, &msg) == nil {
|
if json.Unmarshal(raw, &msg) == nil {
|
||||||
m.revokeLease(msg.TunnelID, msg.IP)
|
m.revokeLease(scopeOf(msg.TunnelID, msg.GroupID), msg.IP)
|
||||||
}
|
}
|
||||||
|
|
||||||
case protocol.TypeCreateInvite:
|
case protocol.TypeCreateInvite:
|
||||||
@@ -264,13 +265,25 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
|
|||||||
case protocol.TypeRevokeInvite:
|
case protocol.TypeRevokeInvite:
|
||||||
var msg protocol.RevokeInviteMessage
|
var msg protocol.RevokeInviteMessage
|
||||||
if json.Unmarshal(raw, &msg) == nil {
|
if json.Unmarshal(raw, &msg) == nil {
|
||||||
m.revokeInvite(msg.TunnelID, msg.Code)
|
m.revokeInvite(scopeOf(msg.TunnelID, msg.GroupID), msg.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
case protocol.TypeDecideRequest:
|
case protocol.TypeDecideRequest:
|
||||||
var msg protocol.DecideRequestMessage
|
var msg protocol.DecideRequestMessage
|
||||||
if json.Unmarshal(raw, &msg) == nil {
|
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:
|
case protocol.TypeKillConn:
|
||||||
@@ -330,30 +343,38 @@ func (m *Manager) registerTunnel(as *agentSession, cfg protocol.TunnelConfig) pr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return registerResp(cfg, err)
|
return registerResp(cfg, err)
|
||||||
}
|
}
|
||||||
if old == nil {
|
|
||||||
// 服务端重启或 Agent 重连后,恢复该通道尚未过期的临时放通
|
|
||||||
t.restoreDynamicLeases(m.state.leaseExpiries(cfg.ID))
|
|
||||||
}
|
|
||||||
m.tunnels[cfg.ID] = t
|
m.tunnels[cfg.ID] = t
|
||||||
m.portMap[cfg.ListenPort] = cfg.ID
|
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)
|
log.Printf("[Server] Registered tunnel '%s' listening on 0.0.0.0:%d", cfg.Name, cfg.ListenPort)
|
||||||
return registerResp(cfg, nil)
|
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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.active != as {
|
if m.active != as {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 主动关闭/删除通道时,放通、邀请链接与待审批申请一并失效(断线重连不会走到这里)
|
// 主动关闭通道时,放通、邀请链接与待审批申请一并失效,已授权的设备 2 小时内仍可申请延长;
|
||||||
m.state.forgetTunnel(tunnelID)
|
// 删除通道则全部清除(断线重连不会走到这里)
|
||||||
|
if deleted {
|
||||||
|
m.state.dropScope(tunnelID)
|
||||||
|
} else {
|
||||||
|
m.state.closeScope(tunnelID)
|
||||||
|
}
|
||||||
if t, exists := m.tunnels[tunnelID]; exists {
|
if t, exists := m.tunnels[tunnelID]; exists {
|
||||||
t.Close()
|
t.Close()
|
||||||
delete(m.portMap, t.ListenPort())
|
delete(m.portMap, t.ListenPort())
|
||||||
delete(m.tunnels, tunnelID)
|
delete(m.tunnels, tunnelID)
|
||||||
log.Printf("[Server] Unregistered tunnel '%s' (port %d freed)", t.Name(), t.ListenPort())
|
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()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
prevGroup := t.GroupID()
|
||||||
if t.ListenPort() == cfg.ListenPort {
|
if t.ListenPort() == cfg.ListenPort {
|
||||||
t.UpdateConfig(cfg)
|
t.UpdateConfig(cfg)
|
||||||
|
m.syncAccess(t)
|
||||||
|
if prevGroup != cfg.GroupID {
|
||||||
|
m.forgetGroupIfEmptyLocked(prevGroup)
|
||||||
|
}
|
||||||
log.Printf("[Server] Updated tunnel '%s' whitelist/metadata", cfg.Name)
|
log.Printf("[Server] Updated tunnel '%s' whitelist/metadata", cfg.Name)
|
||||||
return registerResp(cfg, nil)
|
return registerResp(cfg, nil)
|
||||||
}
|
}
|
||||||
@@ -384,6 +410,10 @@ func (m *Manager) updateTunnel(as *agentSession, cfg protocol.TunnelConfig) prot
|
|||||||
delete(m.portMap, t.ListenPort())
|
delete(m.portMap, t.ListenPort())
|
||||||
m.tunnels[cfg.ID] = nt
|
m.tunnels[cfg.ID] = nt
|
||||||
m.portMap[cfg.ListenPort] = cfg.ID
|
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)
|
log.Printf("[Server] Moved tunnel '%s' from port %d to %d", cfg.Name, t.ListenPort(), cfg.ListenPort)
|
||||||
return registerResp(cfg, nil)
|
return registerResp(cfg, nil)
|
||||||
}
|
}
|
||||||
@@ -399,21 +429,15 @@ func (m *Manager) killConn(connID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) revokeLease(tunnelID, ip string) {
|
func (m *Manager) revokeLease(scope, ip string) {
|
||||||
m.mu.RLock()
|
revoked := m.state.removeLease(scope, ip)
|
||||||
t := m.tunnels[tunnelID]
|
kicked := m.syncScope(scope)
|
||||||
m.mu.RUnlock()
|
|
||||||
if t == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
revoked, kicked := t.RevokeDynamic(ip)
|
|
||||||
m.state.removeLease(tunnelID, ip)
|
|
||||||
target := ip
|
target := ip
|
||||||
if target == "" {
|
if target == "" {
|
||||||
target = "all"
|
target = "all"
|
||||||
}
|
}
|
||||||
log.Printf("[WebAuth] Revoked %d temporary lease(s) (%s) on tunnel %s, disconnected %d client(s)",
|
log.Printf("[WebAuth] Revoked %d temporary lease(s) (%s) on %s, disconnected %d client(s)",
|
||||||
revoked, target, t.Name(), kicked)
|
revoked, target, m.scopeName(scope), kicked)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) tunnel(id string) *Tunnel {
|
func (m *Manager) tunnel(id string) *Tunnel {
|
||||||
@@ -422,11 +446,121 @@ func (m *Manager) tunnel(id string) *Tunnel {
|
|||||||
return m.tunnels[id]
|
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 {
|
func (m *Manager) createInvite(msg protocol.CreateInviteMessage) protocol.InviteRespMessage {
|
||||||
resp := protocol.InviteRespMessage{Type: protocol.TypeInviteResp, RequestID: msg.RequestID}
|
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 {
|
switch {
|
||||||
case t == nil:
|
case !open && msg.GroupID != "":
|
||||||
|
resp.Error = "通道组内没有开启的通道,无法生成邀请链接"
|
||||||
|
case !open:
|
||||||
resp.Error = "通道未开启,无法生成邀请链接"
|
resp.Error = "通道未开启,无法生成邀请链接"
|
||||||
case msg.ValidMinutes <= 0 || msg.ValidMinutes > 30*24*60:
|
case msg.ValidMinutes <= 0 || msg.ValidMinutes > 30*24*60:
|
||||||
resp.Error = "邀请有效期须在 1 分钟到 30 天之间"
|
resp.Error = "邀请有效期须在 1 分钟到 30 天之间"
|
||||||
@@ -435,50 +569,70 @@ func (m *Manager) createInvite(msg protocol.CreateInviteMessage) protocol.Invite
|
|||||||
case !protocol.ValidLeaseDuration(msg.LeaseDuration):
|
case !protocol.ValidLeaseDuration(msg.LeaseDuration):
|
||||||
resp.Error = "无效的授权时长"
|
resp.Error = "无效的授权时长"
|
||||||
default:
|
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
|
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
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) revokeInvite(tunnelID, code string) {
|
func (m *Manager) revokeInvite(scope, code string) {
|
||||||
ips := m.state.revokeInvite(tunnelID, code)
|
ips := m.state.revokeInvite(scope, code)
|
||||||
kicked := 0
|
kicked := m.syncScope(scope)
|
||||||
if t := m.tunnel(tunnelID); t != nil {
|
log.Printf("[WebAuth] Revoked invite on %s: %d lease(s) removed, %d client(s) disconnected", m.scopeName(scope), len(ips), kicked)
|
||||||
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) decideRequest(tunnelID, requestID string, approve bool) {
|
func (m *Manager) decideRequest(scope, requestID string, approve bool) {
|
||||||
t := m.tunnel(tunnelID)
|
tunnels := m.scopeTunnels(scope)
|
||||||
if t == nil {
|
r, _, err := m.state.decide(scope, requestID, approve, scopeExpiresOf(tunnels))
|
||||||
return
|
|
||||||
}
|
|
||||||
r, exp, err := m.state.decide(tunnelID, requestID, approve, t.Snapshot().ExpiresAt)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if approve {
|
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) {
|
func (m *Manager) onClientConnect(tunnelID, ip string) {
|
||||||
t := m.tunnel(tunnelID)
|
t := m.tunnel(tunnelID)
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if exp, started := m.state.startLease(tunnelID, ip, t.Snapshot().ExpiresAt, time.Now()); started {
|
now := time.Now()
|
||||||
t.AddDynamicWhitelist(ip, exp)
|
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"))
|
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 {
|
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))
|
tunnelMetricsList := make([]protocol.TunnelMetrics, 0, len(m.tunnels))
|
||||||
allActiveConns := make([]protocol.ActiveConnInfo, 0)
|
allActiveConns := make([]protocol.ActiveConnInfo, 0)
|
||||||
|
groupIDs := make(map[string]bool)
|
||||||
for _, t := range m.tunnels {
|
for _, t := range m.tunnels {
|
||||||
|
if gid := t.GroupID(); gid != "" {
|
||||||
|
groupIDs[gid] = true
|
||||||
|
}
|
||||||
// 授权到期(或白名单变更)后,已建立的连接也要断开,“授权后可访问 X”才名副其实
|
// 授权到期(或白名单变更)后,已建立的连接也要断开,“授权后可访问 X”才名副其实
|
||||||
for _, addr := range t.EnforceAccess() {
|
for _, addr := range t.EnforceAccess() {
|
||||||
log.Printf("[Tunnel %d] access expired, disconnected %s", t.ListenPort(), addr)
|
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)
|
tunnelMetricsList = append(tunnelMetricsList, tm)
|
||||||
allActiveConns = append(allActiveConns, conns...)
|
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.mu.RUnlock()
|
||||||
|
|
||||||
m.secEventsMu.Lock()
|
m.secEventsMu.Lock()
|
||||||
@@ -539,8 +707,10 @@ func (m *Manager) metricsLoop(as *agentSession) {
|
|||||||
as.send(protocol.MetricsMessage{
|
as.send(protocol.MetricsMessage{
|
||||||
Type: protocol.TypeMetrics,
|
Type: protocol.TypeMetrics,
|
||||||
Tunnels: tunnelMetricsList,
|
Tunnels: tunnelMetricsList,
|
||||||
|
Groups: groupMetrics,
|
||||||
ActiveConns: allActiveConns,
|
ActiveConns: allActiveConns,
|
||||||
SecurityEvents: secCopy,
|
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) {
|
func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
|
||||||
m := newTestManager()
|
m := newTestManager()
|
||||||
addr := startServer(t, m)
|
addr := startServer(t, m)
|
||||||
@@ -339,7 +347,7 @@ func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
|
|||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
tun := m.tunnels["a"]
|
tun := m.tunnels["a"]
|
||||||
m.mu.RUnlock()
|
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.Name = "renamed"
|
||||||
cfg.Whitelist = []string{"127.0.0.1"}
|
cfg.Whitelist = []string{"127.0.0.1"}
|
||||||
@@ -567,8 +575,9 @@ func TestRevokeInviteRemovesItsLeases(t *testing.T) {
|
|||||||
if hit(cfg.ListenPort) == "OK" {
|
if hit(cfg.ListenPort) == "OK" {
|
||||||
t.Fatal("leases of revoked invite still active")
|
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
|
cfg.Whitelist = nil
|
||||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||||
|
|
||||||
m.mu.RLock()
|
grantTestLease(m, "a", "127.0.0.1", time.Now().Add(1500*time.Millisecond))
|
||||||
tun := m.tunnels["a"]
|
|
||||||
m.mu.RUnlock()
|
|
||||||
tun.AddDynamicWhitelist("127.0.0.1", time.Now().Add(1500*time.Millisecond))
|
|
||||||
|
|
||||||
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.ListenPort))
|
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.ListenPort))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,10 +25,17 @@ const (
|
|||||||
leaseStartWindow = 24 * time.Hour
|
leaseStartWindow = 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// renewWindow 访问结束(授权到期或通道关闭)后多久内还能申请延长;之后清除记录,需要重新邀请
|
||||||
|
var renewWindow = 2 * time.Hour
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errInviteNotFound = errors.New("邀请链接无效或已过期")
|
errInviteNotFound = errors.New("邀请链接无效或已过期")
|
||||||
errInviteUsedUp = errors.New("该邀请链接的可用设备数已用完,请向管理员索取新的链接")
|
errInviteUsedUp = errors.New("该邀请链接的可用设备数已用完,请向管理员索取新的链接")
|
||||||
errRequestGone = errors.New("申请不存在或已过期")
|
errRequestGone = errors.New("申请不存在或已过期")
|
||||||
|
errAlreadyUsed = errors.New("这台设备已经用过该邀请链接,请重新打开链接申请延长")
|
||||||
|
errNothingToRenew = errors.New("访问已结束超过 2 小时,请联系管理员获取新的邀请链接")
|
||||||
|
errRevoked = errors.New("授权已被管理员撤销,如需继续使用请联系管理员")
|
||||||
|
errNotStarted = errors.New("授权尚未开始计时(首次连接后才开始),无需延长")
|
||||||
)
|
)
|
||||||
|
|
||||||
type leaseInfo struct {
|
type leaseInfo struct {
|
||||||
@@ -42,27 +50,27 @@ type leaseInfo struct {
|
|||||||
|
|
||||||
// newLease creates a lease whose countdown starts at the device's first connection.
|
// newLease creates a lease whose countdown starts at the device's first connection.
|
||||||
// Leases valid for the whole tunnel lifetime start immediately.
|
// 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}
|
l := leaseInfo{Name: name, Invite: invite, Duration: duration}
|
||||||
if duration == protocol.LeaseDurationTunnel {
|
if duration == protocol.LeaseDurationTunnel {
|
||||||
l.StartedAt = &now
|
l.StartedAt = &now
|
||||||
l.ExpiresAt = leaseExpiry(duration, tunnelExpires, now)
|
l.ExpiresAt = leaseExpiry(duration, scopeExpires, now)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
l.Deferred = true
|
l.Deferred = true
|
||||||
l.ExpiresAt = now.Add(leaseStartWindow)
|
l.ExpiresAt = now.Add(leaseStartWindow)
|
||||||
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(l.ExpiresAt) {
|
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(l.ExpiresAt) {
|
||||||
l.ExpiresAt = *tunnelExpires
|
l.ExpiresAt = *scopeExpires
|
||||||
}
|
}
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// startLease starts the countdown of a deferred lease on the device's first connection.
|
// 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.
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
st := s.data[tunnelID]
|
st := s.data[scope]
|
||||||
if st == nil {
|
if st == nil {
|
||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
@@ -72,34 +80,72 @@ func (s *stateStore) startLease(tunnelID, ip string, tunnelExpires *time.Time, n
|
|||||||
}
|
}
|
||||||
l.Deferred = false
|
l.Deferred = false
|
||||||
l.StartedAt = &now
|
l.StartedAt = &now
|
||||||
l.ExpiresAt = leaseExpiry(l.Duration, tunnelExpires, now)
|
l.ExpiresAt = leaseExpiry(l.Duration, scopeExpires, now)
|
||||||
st.Leases[ip] = l
|
st.Leases[ip] = l
|
||||||
s.saveLocked()
|
s.saveLocked()
|
||||||
return l.ExpiresAt, true
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if st := s.data[tunnelID]; st != nil {
|
if st := s.data[scope]; st != nil {
|
||||||
l, ok := st.Leases[ip]
|
l, ok := st.Leases[ip]
|
||||||
return l, ok && time.Now().Before(l.ExpiresAt)
|
return l, ok && time.Now().Before(l.ExpiresAt)
|
||||||
}
|
}
|
||||||
return leaseInfo{}, false
|
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 {
|
type tunnelState struct {
|
||||||
Leases map[string]leaseInfo `json:"leases,omitempty"` // ip -> lease
|
Leases map[string]leaseInfo `json:"leases,omitempty"` // ip -> lease
|
||||||
Invites map[string]*protocol.Invite `json:"invites,omitempty"` // code -> invite
|
Invites map[string]*protocol.Invite `json:"invites,omitempty"` // code -> invite
|
||||||
Requests map[string]*protocol.AccessRequest `json:"requests,omitempty"` // id -> request
|
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.
|
// It lives in memory and is optionally persisted to a file so it survives restarts.
|
||||||
type stateStore struct {
|
type stateStore struct {
|
||||||
path string
|
path string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
data map[string]*tunnelState // tunnelID -> state
|
data map[string]*tunnelState // scope(通道 ID 或 group:<组ID>)-> state
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStateStore() *stateStore {
|
func newStateStore() *stateStore {
|
||||||
@@ -116,7 +162,7 @@ func (m *Manager) EnableLeasePersistence(path string) error {
|
|||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
if err := json.Unmarshal(raw, &data); err != nil || !looksLikeState(data) {
|
if err := json.Unmarshal(raw, &data); err != nil || !looksLikeState(data) {
|
||||||
// 兼容旧格式:tunnelID -> ip -> 到期时间
|
// 兼容旧格式:通道 ID -> ip -> 到期时间
|
||||||
var legacy map[string]map[string]time.Time
|
var legacy map[string]map[string]time.Time
|
||||||
if json.Unmarshal(raw, &legacy) == nil {
|
if json.Unmarshal(raw, &legacy) == nil {
|
||||||
data = make(map[string]*tunnelState)
|
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)
|
// looksLikeState distinguishes the new format from the legacy one (both are JSON objects of objects)
|
||||||
func looksLikeState(d map[string]*tunnelState) bool {
|
func looksLikeState(d map[string]*tunnelState) bool {
|
||||||
for _, st := range d {
|
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 false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stateStore) tunnelLocked(id string) *tunnelState {
|
func (s *stateStore) scopeLocked(id string) *tunnelState {
|
||||||
st := s.data[id]
|
st := s.data[id]
|
||||||
if st == nil {
|
if st == nil {
|
||||||
st = &tunnelState{}
|
st = &tunnelState{}
|
||||||
@@ -177,9 +223,21 @@ func (s *stateStore) tunnelLocked(id string) *tunnelState {
|
|||||||
if st.Requests == nil {
|
if st.Requests == nil {
|
||||||
st.Requests = make(map[string]*protocol.AccessRequest)
|
st.Requests = make(map[string]*protocol.AccessRequest)
|
||||||
}
|
}
|
||||||
|
if st.Ended == nil {
|
||||||
|
st.Ended = make(map[string]leaseRecord)
|
||||||
|
}
|
||||||
return st
|
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 {
|
func randomCode(n int) string {
|
||||||
const chars = "23456789abcdefghjkmnpqrstuvwxyz"
|
const chars = "23456789abcdefghjkmnpqrstuvwxyz"
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
@@ -195,35 +253,44 @@ func randomCode(n int) string {
|
|||||||
|
|
||||||
// ---- leases ----
|
// ---- 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.tunnelLocked(tunnelID).Leases[ip] = leaseInfo{ExpiresAt: exp, Name: name, Invite: invite}
|
st := s.data[scope]
|
||||||
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]
|
|
||||||
if st == nil {
|
if st == nil {
|
||||||
return
|
return 0
|
||||||
}
|
}
|
||||||
if ip == "" {
|
// 被撤销的授权不能再申请延长
|
||||||
st.Leases = make(map[string]leaseInfo)
|
now := time.Now()
|
||||||
} else {
|
n := 0
|
||||||
delete(st.Leases, ip)
|
for lip, l := range st.Leases {
|
||||||
|
if ip == "" || lip == ip {
|
||||||
|
endLeaseLocked(st, lip, l, now, true)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for lip, r := range st.Ended {
|
||||||
|
if ip == "" || lip == ip {
|
||||||
|
r.Revoked = true
|
||||||
|
st.Ended[lip] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, r := range st.Requests {
|
||||||
|
if r.Renewal && r.Status == "pending" && (ip == "" || r.IP == ip) {
|
||||||
|
delete(st.Requests, id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.saveLocked()
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
out := make(map[string]time.Time)
|
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 {
|
for ip, l := range st.Leases {
|
||||||
if now.Before(l.ExpiresAt) {
|
if now.Before(l.ExpiresAt) {
|
||||||
out[ip] = l.ExpiresAt
|
out[ip] = l.ExpiresAt
|
||||||
@@ -233,12 +300,12 @@ func (s *stateStore) leaseExpiries(tunnelID string) map[string]time.Time {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
|
func (s *stateStore) leaseList(scope string) []protocol.DynamicLease {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var out []protocol.DynamicLease
|
var out []protocol.DynamicLease
|
||||||
if st := s.data[tunnelID]; st != nil {
|
if st := s.data[scope]; st != nil {
|
||||||
for ip, l := range st.Leases {
|
for ip, l := range st.Leases {
|
||||||
if now.Before(l.ExpiresAt) {
|
if now.Before(l.ExpiresAt) {
|
||||||
out = append(out, protocol.DynamicLease{
|
out = append(out, protocol.DynamicLease{
|
||||||
@@ -252,19 +319,65 @@ func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// forgetTunnel drops all access state of a tunnel (explicitly closed or deleted)
|
// closeScope runs when a tunnel, or the last open tunnel of a group, is closed: invites and
|
||||||
func (s *stateStore) forgetTunnel(tunnelID string) {
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if _, ok := s.data[tunnelID]; ok {
|
st := s.data[scope]
|
||||||
delete(s.data, tunnelID)
|
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()
|
s.saveLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- invites ----
|
// ---- 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -280,12 +393,12 @@ func (s *stateStore) createInvite(tunnelID, note string, valid time.Duration, ma
|
|||||||
LeaseDuration: leaseDuration,
|
LeaseDuration: leaseDuration,
|
||||||
RequireApproval: requireApproval,
|
RequireApproval: requireApproval,
|
||||||
}
|
}
|
||||||
s.tunnelLocked(tunnelID).Invites[inv.Code] = inv
|
s.scopeLocked(scope).Invites[inv.Code] = inv
|
||||||
s.saveLocked()
|
s.saveLocked()
|
||||||
return *inv
|
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) {
|
func (s *stateStore) findInvite(code string) (string, protocol.Invite, bool) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
st := s.data[tunnelID]
|
st := s.data[scope]
|
||||||
if st == nil {
|
if st == nil {
|
||||||
return errInviteNotFound
|
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
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
st := s.data[tunnelID]
|
st := s.data[scope]
|
||||||
if st == nil {
|
if st == nil {
|
||||||
return leaseInfo{}, errInviteNotFound
|
return leaseInfo{}, errInviteNotFound
|
||||||
}
|
}
|
||||||
@@ -360,31 +473,55 @@ func (s *stateStore) useInviteAndGrant(tunnelID, code, name, ip string, tunnelEx
|
|||||||
if !ok || !time.Now().Before(inv.ExpiresAt) {
|
if !ok || !time.Now().Before(inv.ExpiresAt) {
|
||||||
return leaseInfo{}, errInviteNotFound
|
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) {
|
if !capacityOKLocked(st, inv, ip) {
|
||||||
return leaseInfo{}, errInviteUsedUp
|
return leaseInfo{}, errInviteUsedUp
|
||||||
}
|
}
|
||||||
st = s.tunnelLocked(tunnelID)
|
st = s.scopeLocked(scope)
|
||||||
s.recordUseLocked(st, code, name, ip)
|
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
|
st.Leases[ip] = l
|
||||||
|
delete(st.Ended, ip)
|
||||||
s.saveLocked()
|
s.saveLocked()
|
||||||
return l, nil
|
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
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
st := s.data[tunnelID]
|
st := s.data[scope]
|
||||||
if st == nil {
|
if st == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
delete(st.Invites, code)
|
delete(st.Invites, code)
|
||||||
var ips []string
|
var ips []string
|
||||||
|
now := time.Now()
|
||||||
for ip, l := range st.Leases {
|
for ip, l := range st.Leases {
|
||||||
if l.Invite == code {
|
if l.Invite == code {
|
||||||
ips = append(ips, ip)
|
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 {
|
for id, r := range st.Requests {
|
||||||
@@ -396,12 +533,12 @@ func (s *stateStore) revokeInvite(tunnelID, code string) []string {
|
|||||||
return ips
|
return ips
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
|
func (s *stateStore) inviteList(scope string) []protocol.Invite {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var out []protocol.Invite
|
var out []protocol.Invite
|
||||||
if st := s.data[tunnelID]; st != nil {
|
if st := s.data[scope]; st != nil {
|
||||||
for _, inv := range st.Invites {
|
for _, inv := range st.Invites {
|
||||||
if now.Before(inv.ExpiresAt) {
|
if now.Before(inv.ExpiresAt) {
|
||||||
c := *inv
|
c := *inv
|
||||||
@@ -417,10 +554,10 @@ func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
|
|||||||
// ---- approval requests ----
|
// ---- approval requests ----
|
||||||
|
|
||||||
// addRequest files a pending request (re-using an existing pending one from the same IP)
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
st := s.data[tunnelID]
|
st := s.data[scope]
|
||||||
if st == nil {
|
if st == nil {
|
||||||
return protocol.AccessRequest{}, errInviteNotFound
|
return protocol.AccessRequest{}, errInviteNotFound
|
||||||
}
|
}
|
||||||
@@ -428,25 +565,32 @@ func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.Acces
|
|||||||
if !ok || !time.Now().Before(inv.ExpiresAt) {
|
if !ok || !time.Now().Before(inv.ExpiresAt) {
|
||||||
return protocol.AccessRequest{}, errInviteNotFound
|
return protocol.AccessRequest{}, errInviteNotFound
|
||||||
}
|
}
|
||||||
st = s.tunnelLocked(tunnelID)
|
st = s.scopeLocked(scope)
|
||||||
for _, r := range st.Requests {
|
for _, r := range st.Requests {
|
||||||
if r.IP == ip && r.Status == "pending" {
|
if r.IP == ip && r.Status == "pending" && !r.Renewal {
|
||||||
r.Name = name
|
r.Name = name
|
||||||
s.saveLocked()
|
s.saveLocked()
|
||||||
return *r, nil
|
return *r, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if usedByLocked(inv, ip) {
|
||||||
|
return protocol.AccessRequest{}, errAlreadyUsed
|
||||||
|
}
|
||||||
if !capacityOKLocked(st, inv, ip) {
|
if !capacityOKLocked(st, inv, ip) {
|
||||||
return protocol.AccessRequest{}, errInviteUsedUp
|
return protocol.AccessRequest{}, errInviteUsedUp
|
||||||
}
|
}
|
||||||
r := &protocol.AccessRequest{
|
r := &protocol.AccessRequest{
|
||||||
ID: randomCode(16),
|
ID: randomCode(16),
|
||||||
TunnelID: tunnelID,
|
Name: name,
|
||||||
Name: name,
|
IP: ip,
|
||||||
IP: ip,
|
Invite: code,
|
||||||
Invite: code,
|
At: time.Now(),
|
||||||
At: time.Now(),
|
Status: "pending",
|
||||||
Status: "pending",
|
}
|
||||||
|
if id, isGroup := splitScope(scope); isGroup {
|
||||||
|
r.GroupID = id
|
||||||
|
} else {
|
||||||
|
r.TunnelID = id
|
||||||
}
|
}
|
||||||
st.Requests[r.ID] = r
|
st.Requests[r.ID] = r
|
||||||
s.saveLocked()
|
s.saveLocked()
|
||||||
@@ -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,
|
// 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).
|
// 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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.pruneRequestsLocked()
|
s.pruneRequestsLocked()
|
||||||
st := s.data[tunnelID]
|
st := s.data[scope]
|
||||||
if st == nil {
|
if st == nil {
|
||||||
return protocol.AccessRequest{}, time.Time{}, errRequestGone
|
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" {
|
if !ok || r.Status != "pending" {
|
||||||
return protocol.AccessRequest{}, time.Time{}, errRequestGone
|
return protocol.AccessRequest{}, time.Time{}, errRequestGone
|
||||||
}
|
}
|
||||||
st = s.tunnelLocked(tunnelID)
|
st = s.scopeLocked(scope)
|
||||||
duration := ""
|
duration := ""
|
||||||
if inv, ok := st.Invites[r.Invite]; ok {
|
if inv, ok := st.Invites[r.Invite]; ok {
|
||||||
duration = inv.LeaseDuration
|
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
|
exp := l.ExpiresAt
|
||||||
r.At = time.Now()
|
r.At = time.Now()
|
||||||
if approve {
|
if approve {
|
||||||
r.Status = "approved"
|
r.Status = "approved"
|
||||||
s.recordUseLocked(st, r.Invite, r.Name, r.IP)
|
s.recordUseLocked(st, r.Invite, r.Name, r.IP)
|
||||||
st.Leases[r.IP] = l
|
st.Leases[r.IP] = l
|
||||||
|
delete(st.Ended, r.IP)
|
||||||
} else {
|
} else {
|
||||||
r.Status = "rejected"
|
r.Status = "rejected"
|
||||||
}
|
}
|
||||||
@@ -498,14 +661,14 @@ func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *ti
|
|||||||
return *r, exp, nil
|
return *r, exp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stateStore) pendingList(tunnelID string) []protocol.AccessRequest {
|
func (s *stateStore) pendingList(scope string) []protocol.AccessRequest {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.pruneRequestsLocked()
|
s.pruneRequestsLocked()
|
||||||
var out []protocol.AccessRequest
|
var out []protocol.AccessRequest
|
||||||
if st := s.data[tunnelID]; st != nil {
|
if st := s.data[scope]; st != nil {
|
||||||
for _, r := range st.Requests {
|
for _, r := range st.Requests {
|
||||||
if r.Status == "pending" {
|
if r.Status == "pending" && !r.Renewal {
|
||||||
out = append(out, *r)
|
out = append(out, *r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,6 +677,195 @@ func (s *stateStore) pendingList(tunnelID string) []protocol.AccessRequest {
|
|||||||
return out
|
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 ----
|
// ---- housekeeping ----
|
||||||
|
|
||||||
func (s *stateStore) pruneRequestsLocked() {
|
func (s *stateStore) pruneRequestsLocked() {
|
||||||
@@ -537,7 +889,12 @@ func (s *stateStore) pruneLocked() {
|
|||||||
}
|
}
|
||||||
for ip, l := range st.Leases {
|
for ip, l := range st.Leases {
|
||||||
if !now.Before(l.ExpiresAt) {
|
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 {
|
for code, inv := range st.Invites {
|
||||||
@@ -545,7 +902,7 @@ func (s *stateStore) pruneLocked() {
|
|||||||
delete(st.Invites, code)
|
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)
|
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,
|
// leaseExpiry computes when a lease granted now ends, per the invite's lease duration,
|
||||||
// never outliving the tunnel's own TTL.
|
// 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
|
var exp time.Time
|
||||||
switch duration {
|
switch duration {
|
||||||
case protocol.LeaseDuration1h:
|
|
||||||
exp = now.Add(time.Hour)
|
|
||||||
case protocol.LeaseDuration8h:
|
|
||||||
exp = now.Add(8 * time.Hour)
|
|
||||||
case protocol.LeaseDurationToday:
|
case protocol.LeaseDurationToday:
|
||||||
y, mo, d := now.Date()
|
y, mo, d := now.Date()
|
||||||
exp = time.Date(y, mo, d, 23, 59, 59, 0, now.Location())
|
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)
|
exp = now.Add(30 * 24 * time.Hour)
|
||||||
default:
|
default:
|
||||||
exp = now.Add(2 * time.Hour)
|
minutes, ok := protocol.LeaseMinutes(duration)
|
||||||
|
if !ok {
|
||||||
|
minutes = 120
|
||||||
|
}
|
||||||
|
exp = now.Add(time.Duration(minutes) * time.Minute)
|
||||||
}
|
}
|
||||||
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(exp) {
|
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(exp) {
|
||||||
exp = *tunnelExpires
|
exp = *scopeExpires
|
||||||
}
|
}
|
||||||
return exp
|
return exp
|
||||||
}
|
}
|
||||||
@@ -118,13 +118,28 @@ func (t *Tunnel) ID() string {
|
|||||||
return t.Config.ID
|
return t.Config.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tunnel) restoreDynamicLeases(leases map[string]time.Time) {
|
// SetDynamic replaces the temporary leases with the given set and disconnects
|
||||||
if len(leases) == 0 {
|
// active clients that are no longer permitted. Returns how many were disconnected.
|
||||||
return
|
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()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
t.whitelist.RestoreDynamic(leases)
|
return t.Config.GroupID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name returns the tunnel display name
|
// Name returns the tunnel display name
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/DeepFal/PortRelay/core/protocol"
|
"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"))
|
m.serveInviteForm(w, r, r.URL.Query().Get("code"))
|
||||||
case path == "/auth/status":
|
case path == "/auth/status":
|
||||||
m.serveRequestStatus(w, r)
|
m.serveRequestStatus(w, r)
|
||||||
|
case path == "/renew" && r.Method == http.MethodPost:
|
||||||
|
m.serveRenewSubmit(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -58,31 +61,68 @@ func (m *Manager) accessHost(r *http.Request) string {
|
|||||||
return r.Host
|
return r.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
// inviteTarget resolves an invite code to its (currently registered) tunnel
|
// accessScope is what an invite or request grants access to: one tunnel or a whole tunnel group
|
||||||
func (m *Manager) inviteTarget(code string) (*Tunnel, protocol.Invite, string) {
|
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)
|
code = strings.TrimSpace(code)
|
||||||
if 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 {
|
if !ok {
|
||||||
return nil, protocol.Invite{}, "链接无效或已过期,请向管理员索取新的邀请链接"
|
return accessScope{}, protocol.Invite{}, "链接无效或已过期,请向管理员索取新的邀请链接"
|
||||||
}
|
}
|
||||||
t := m.tunnel(tunnelID)
|
sc, ok := m.resolveScope(scope)
|
||||||
if t == nil {
|
if !ok {
|
||||||
return nil, protocol.Invite{}, "对应通道当前未开启,请联系管理员"
|
return accessScope{}, protocol.Invite{}, "对应通道当前未开启,请联系管理员"
|
||||||
}
|
}
|
||||||
return t, inv, ""
|
return sc, inv, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code string) {
|
func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code string) {
|
||||||
ip := clientIPOf(r)
|
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 == "" {
|
if problem == "" && ip == "" {
|
||||||
problem = "无法识别客户端 IP"
|
problem = "无法识别客户端 IP"
|
||||||
}
|
}
|
||||||
if problem == "" {
|
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()
|
problem = err.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +131,6 @@ func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code s
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := t.Snapshot()
|
|
||||||
action := "确认并获取访问权限"
|
action := "确认并获取访问权限"
|
||||||
approvalTip := ""
|
approvalTip := ""
|
||||||
if inv.RequireApproval {
|
if inv.RequireApproval {
|
||||||
@@ -107,9 +146,91 @@ func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code s
|
|||||||
<button type="submit">%s</button>
|
<button type="submit">%s</button>
|
||||||
</form>
|
</form>
|
||||||
%s`,
|
%s`,
|
||||||
m.detailRows(r, cfg, ip, durationText(inv.LeaseDuration)),
|
m.detailRows(r, sc, ip, durationText(inv.LeaseDuration)),
|
||||||
html.EscapeString(inv.Code), maxNameRunes, action, approvalTip)
|
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 {
|
func sanitizeName(s string) string {
|
||||||
@@ -130,7 +251,7 @@ func (m *Manager) serveInviteSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
ip := clientIPOf(r)
|
ip := clientIPOf(r)
|
||||||
name := sanitizeName(r.PostForm.Get("name"))
|
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 {
|
switch {
|
||||||
case problem != "":
|
case problem != "":
|
||||||
case ip == "":
|
case ip == "":
|
||||||
@@ -143,27 +264,26 @@ func (m *Manager) serveInviteSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := t.Snapshot()
|
|
||||||
if inv.RequireApproval {
|
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 {
|
if err != nil {
|
||||||
m.writePage(w, r, http.StatusConflict, pageError, "无法提交申请", problemHTML(err.Error()))
|
m.writePage(w, r, http.StatusConflict, pageError, "无法提交申请", problemHTML(err.Error()))
|
||||||
return
|
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)
|
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(req.ID), http.StatusSeeOther)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
m.writePage(w, r, http.StatusConflict, pageError, "无法完成授权", problemHTML(err.Error()))
|
m.writePage(w, r, http.StatusConflict, pageError, "无法完成授权", problemHTML(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.AddDynamicWhitelist(ip, lease.ExpiresAt)
|
m.syncScope(sc.key)
|
||||||
log.Printf("[WebAuth] Granted %s (%s) access to tunnel %s (port %d): %s",
|
log.Printf("[WebAuth] Granted %s (%s) access to %s (%d port(s)): %s",
|
||||||
name, ip, cfg.Name, cfg.ListenPort, leaseValidity(lease))
|
name, ip, sc.name, len(sc.tunnels), leaseValidity(lease))
|
||||||
m.writePage(w, r, http.StatusOK, pageSuccess, "授权成功", m.successBody(r, cfg, ip, lease))
|
m.writePage(w, r, http.StatusOK, pageSuccess, "授权成功", m.successBody(r, sc, ip, lease))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
|
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("申请不存在或已过期,请重新打开邀请链接提交"))
|
m.writePage(w, r, http.StatusNotFound, pageError, "申请已失效", problemHTML("申请不存在或已过期,请重新打开邀请链接提交"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t := m.tunnel(req.TunnelID)
|
scope := scopeOf(req.TunnelID, req.GroupID)
|
||||||
|
sc, open := m.resolveScope(scope)
|
||||||
switch {
|
switch {
|
||||||
|
case req.Status == "rejected" && req.Renewal:
|
||||||
|
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的延长申请"))
|
||||||
case req.Status == "rejected":
|
case req.Status == "rejected":
|
||||||
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的访问申请"))
|
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("对应通道当前未开启,请联系管理员"))
|
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":
|
case req.Status == "approved":
|
||||||
cfg := t.Snapshot()
|
lease, _ := m.state.lease(sc.key, req.IP)
|
||||||
lease, _ := m.state.lease(cfg.ID, req.IP)
|
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, sc, req.IP, lease))
|
||||||
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, cfg, 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:
|
default:
|
||||||
cfg := t.Snapshot()
|
|
||||||
duration := ""
|
duration := ""
|
||||||
if _, inv, ok := m.state.findInvite(req.Invite); ok {
|
if _, inv, ok := m.state.findInvite(req.Invite); ok {
|
||||||
duration = inv.LeaseDuration
|
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))
|
fmt.Sprintf(`<div class="tip">%s,您的申请已提交,正在等待管理员批准。此页面会自动刷新。</div>`, html.EscapeString(req.Name))
|
||||||
w.Header().Set("Refresh", "5")
|
w.Header().Set("Refresh", "5")
|
||||||
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", body)
|
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")
|
return "至 " + l.ExpiresAt.Format("01-02 15:04")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) successBody(r *http.Request, cfg protocol.TunnelConfig, ip string, l leaseInfo) string {
|
func (m *Manager) successBody(r *http.Request, sc accessScope, ip string, l leaseInfo) string {
|
||||||
return m.detailRows(r, cfg, ip, leaseValidity(l)) +
|
where := "上面的访问地址"
|
||||||
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接上面的访问地址,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。</div>`
|
if sc.group {
|
||||||
|
where = "上面任意一个访问地址"
|
||||||
|
}
|
||||||
|
return m.detailRows(r, sc, ip, leaseValidity(l)) +
|
||||||
|
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后 2 小时内,可以重新打开这个邀请链接申请延长。</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// detailRows renders the tunnel info table
|
// detailRows renders the info table; a group lists every access address with its target
|
||||||
func (m *Manager) detailRows(r *http.Request, cfg protocol.TunnelConfig, ip string, validity string) string {
|
func (m *Manager) detailRows(r *http.Request, sc accessScope, ip string, validity string) string {
|
||||||
validity = html.EscapeString(validity)
|
host := html.EscapeString(m.accessHost(r))
|
||||||
return fmt.Sprintf(`
|
var target, addresses string
|
||||||
<div class="row"><span class="label">目标通道</span><span class="value">%s</span></div>
|
if sc.group {
|
||||||
<div class="row"><span class="label">访问地址</span><span class="value mono">%s:%d</span></div>
|
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">您的设备 IP</span><span class="value mono">%s</span></div>
|
||||||
<div class="row"><span class="label">访问有效期</span><span class="value highlight">%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 {
|
func problemHTML(msg string) string {
|
||||||
@@ -284,6 +439,11 @@ func renderPage(kind pageKind, title, body string) string {
|
|||||||
.label { color:var(--sub); white-space:nowrap; }
|
.label { color:var(--sub); white-space:nowrap; }
|
||||||
.value { font-weight:600; text-align:right; word-break:break-all; }
|
.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; }
|
.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); }
|
.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 { 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; }
|
.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,
|
"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},
|
"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}},
|
"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 分钟自动关闭",
|
{"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}
|
"historical_bytes_in":3145728,"historical_bytes_out":524288}
|
||||||
]
|
]
|
||||||
@@ -136,9 +148,24 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
|
|||||||
vm.tunnels[2].rate_history = RateSeriesItem(end: Int64(Date().timeIntervalSince1970), step: 5,
|
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 },
|
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 })
|
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 })
|
||||||
// 卡片与顶部统计的实时速率取曲线最后一个点
|
// 卡片与顶部统计的实时速率取曲线最后一个点
|
||||||
var totalIn: Int64 = 0, totalOut: Int64 = 0
|
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!
|
let h = vm.tunnels[i].rate_history!
|
||||||
vm.tunnels[i].metrics?.rate_in = h.up.last ?? 0
|
vm.tunnels[i].metrics?.rate_in = h.up.last ?? 0
|
||||||
vm.tunnels[i].metrics?.rate_out = h.down.last ?? 0
|
vm.tunnels[i].metrics?.rate_out = h.down.last ?? 0
|
||||||
@@ -152,6 +179,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":"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":"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":"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}
|
{"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 +189,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":"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":"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.totalTransferred = 60000000000; vm.totalBlockedAttempts = 12
|
||||||
vm.runDiagnostics()
|
vm.runDiagnostics()
|
||||||
return vm
|
return vm
|
||||||
@@ -211,8 +244,8 @@ MainActor.assumeIsolated {
|
|||||||
render("01b-main-min", CGSize(width: 920, height: 640), ContentView(vm: vm))
|
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("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("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("06-edit-new", CGSize(width: 520, 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("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("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("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))
|
render("10-settings", CGSize(width: 400, height: 320), SettingsSheet(vm: vm, isPresented: yes))
|
||||||
@@ -221,12 +254,44 @@ MainActor.assumeIsolated {
|
|||||||
|
|
||||||
// README 截图
|
// README 截图
|
||||||
let demo = makeDemoVM()
|
let demo = makeDemoVM()
|
||||||
render("readme-main-light", CGSize(width: 980, height: 880), ContentView(vm: demo))
|
render("readme-main-light", CGSize(width: 980, height: 1180), ContentView(vm: demo))
|
||||||
render("readme-main-dark", CGSize(width: 980, height: 880), dark: true, ContentView(vm: demo))
|
render("readme-main-dark", CGSize(width: 980, height: 1180), dark: true, ContentView(vm: demo))
|
||||||
render("readme-invite", CGSize(width: 560, height: 250), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes))
|
render("readme-invite", CGSize(width: 560, height: 250), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes))
|
||||||
render("readme-clients", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 0, isPresented: yes))
|
render("readme-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-access", CGSize(width: 760, height: 490), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 2, isPresented: yes))
|
||||||
render("readme-edit", CGSize(width: 490, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
|
render("readme-edit", CGSize(width: 520, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
|
||||||
|
let g = demo.groups[0]
|
||||||
|
render("group-card", CGSize(width: 980, height: 330), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
||||||
|
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
||||||
|
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))
|
||||||
|
|
||||||
|
// 授权延长:申请延长、最近结束、已授权设备的「延长」
|
||||||
|
let rvm = makeDemoVM()
|
||||||
|
rvm.tunnels[0].renewals = decode([RenewalItem].self, """
|
||||||
|
[{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.35","name":"李四","invite":"ab1","ended_at":"\(t(-1200))","renew_before":"\(t(6000))",
|
||||||
|
"request":{"id":"rn1","tunnel_id":"\(rvm.tunnels[0].id)","name":"李四","ip":"192.168.1.35","invite":"ab1","at":"\(t(-60))","status":"pending","renewal":true}},
|
||||||
|
{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.36","name":"王五","invite":"ab2","ended_at":"\(t(-3000))","renew_before":"\(t(4200))"}]
|
||||||
|
""")
|
||||||
|
render("renew-access", CGSize(width: 760, height: 620), ClientDetailsSheet(vm: rvm, tunnel: rvm.tunnels[0], initialTab: 2, isPresented: yes))
|
||||||
|
render("renew-card", CGSize(width: 980, height: 220), TunnelCardView(tunnel: rvm.tunnels[0], vm: rvm, onEdit: {}, onViewClients: {}, onViewLeases: {}, onInvite: {}).padding(20))
|
||||||
|
render("renew-invite-form", CGSize(width: 600, height: 400), InviteSheet(vm: rvm, tunnel: rvm.tunnels[0], autoGenerate: false, isPresented: yes))
|
||||||
|
render("renew-custom", CGSize(width: 300, height: 90), CustomMinutesForm(actionTitle: "延长") { _ in })
|
||||||
|
|
||||||
// README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
|
// README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
|
||||||
if only.hasPrefix("gif") {
|
if only.hasPrefix("gif") {
|
||||||
|
|||||||