Compare commits
9
Commits
ac1681a241
..
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c71668e82d | ||
|
|
dceb59c0ca | ||
|
|
9a675ba96e | ||
|
|
dfb3a9fd8e | ||
|
|
659e69f8f7 | ||
|
|
5b355fb759 | ||
|
|
e02c4c1184 | ||
|
|
3797bef88a | ||
|
|
86e159b3f0 |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
|
||||
## 下载 · Downloads
|
||||
|
||||
### 客户端 · Client
|
||||
|
||||
装在能访问内网服务的电脑上,用来创建通道、邀请同事。
|
||||
Install it on a machine that can reach your internal services.
|
||||
|
||||
| 平台 Platform | 文件 File |
|
||||
| --- | --- |
|
||||
| macOS(Apple Silicon / Intel) | [PortRelay-{{VERSION}}.dmg]({{DOWNLOAD}}/PortRelay-{{VERSION}}.dmg) |
|
||||
| Linux x86_64 | [portrelay-agent-{{VERSION}}-linux-amd64.tar.gz]({{DOWNLOAD}}/portrelay-agent-{{VERSION}}-linux-amd64.tar.gz) |
|
||||
| Linux ARM64 | [portrelay-agent-{{VERSION}}-linux-arm64.tar.gz]({{DOWNLOAD}}/portrelay-agent-{{VERSION}}-linux-arm64.tar.gz) |
|
||||
| Windows x86_64 | [portrelay-agent-{{VERSION}}-windows-amd64.zip]({{DOWNLOAD}}/portrelay-agent-{{VERSION}}-windows-amd64.zip) |
|
||||
|
||||
macOS 是菜单栏 App:打开 dmg,把 PortRelay 拖进「应用程序」。App 未经 Apple 公证,首次打开请在 Finder 里右键选择「打开」。Linux / Windows 是命令行 Agent,运行后在浏览器里打开管理面板。
|
||||
On macOS, open the dmg and drag PortRelay to Applications. The app is not notarized, so right-click it and choose Open the first time. On Linux and Windows, run the agent and use the web panel it opens.
|
||||
|
||||
### 服务端 · Server (edge gateway)
|
||||
|
||||
部署在同事和客户端都能连到的 Linux 机器上。
|
||||
Deploy it on a Linux host that both teammates and the client can reach.
|
||||
|
||||
| 方式 Method | 文件 File |
|
||||
| --- | --- |
|
||||
| 二进制 + systemd · x86_64 | [portrelay-server-{{VERSION}}-linux-amd64.tar.gz]({{DOWNLOAD}}/portrelay-server-{{VERSION}}-linux-amd64.tar.gz) |
|
||||
| 二进制 + systemd · ARM64 | [portrelay-server-{{VERSION}}-linux-arm64.tar.gz]({{DOWNLOAD}}/portrelay-server-{{VERSION}}-linux-arm64.tar.gz) |
|
||||
| Docker | `{{IMAGE}}:{{VERSION}}` |
|
||||
|
||||
解压后运行 `sudo ./deploy/install-server.sh ./portrelay-server`,再次运行即可升级。
|
||||
Unpack and run `sudo ./deploy/install-server.sh ./portrelay-server`; run it again to upgrade.
|
||||
|
||||
校验文件 Checksums:`SHA256SUMS`
|
||||
@@ -12,7 +12,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
binaries:
|
||||
name: Server & agent binaries
|
||||
name: Server & agent archives
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -31,24 +31,22 @@ jobs:
|
||||
dist/*.zip
|
||||
|
||||
macos-app:
|
||||
name: macOS app
|
||||
name: macOS app (dmg)
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Build universal app
|
||||
- name: Build universal dmg
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
case "$VERSION" in [0-9]*) ;; *) VERSION=0.0.0-${GITHUB_SHA::7} ;; esac
|
||||
UNIVERSAL=1 VERSION=$VERSION ./build-app.sh
|
||||
mkdir -p dist
|
||||
ditto -c -k --sequesterRsrc --keepParent PortRelay.app "dist/PortRelay-$VERSION-macos-universal.zip"
|
||||
UNIVERSAL=1 make dmg VERSION=$VERSION
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-app
|
||||
path: dist/*.zip
|
||||
path: dist/*.dmg
|
||||
|
||||
docker:
|
||||
name: Docker image
|
||||
@@ -88,13 +86,27 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
run: cd dist && sha256sum * > SHA256SUMS
|
||||
# 说明 = 标签注释正文(本次更新)+ 下载说明(区分客户端与服务端)+ 自动生成的变更列表
|
||||
- name: Release notes
|
||||
run: |
|
||||
git fetch --force origin "refs/tags/$GITHUB_REF_NAME:refs/tags/$GITHUB_REF_NAME"
|
||||
body=$(git tag -l --format='%(contents:body)' "$GITHUB_REF_NAME")
|
||||
{
|
||||
if [ -n "$body" ]; then printf "## 本次更新 · What's new\n\n%s\n\n" "$body"; fi
|
||||
sed -e "s|{{VERSION}}|${GITHUB_REF_NAME#v}|g" \
|
||||
-e "s|{{DOWNLOAD}}|https://github.com/$GITHUB_REPOSITORY/releases/download/$GITHUB_REF_NAME|g" \
|
||||
-e "s|{{IMAGE}}|ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/portrelay-server|g" \
|
||||
.github/release-notes.md
|
||||
} > "$RUNNER_TEMP/notes.md"
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --generate-notes
|
||||
run: gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md" --generate-notes
|
||||
@@ -3,16 +3,20 @@
|
||||
# make server 交叉编译 Linux 服务端到 dist/
|
||||
# make agent 编译当前平台的 Agent 到 bin/
|
||||
# make app 构建 macOS 应用 PortRelay.app(需要 Xcode 命令行工具)
|
||||
# make release 打包所有平台的服务端 / Agent 到 dist/
|
||||
# make install-app 构建并替换 /Applications/PortRelay.app(正在运行的会先退出,装好后重新打开)
|
||||
# make dmg 打包 macOS 安装镜像 dist/PortRelay-<版本>.dmg(UNIVERSAL=1 构建通用版本)
|
||||
# make release 打包服务端(Linux)与命令行 Agent(Linux / Windows)到 dist/
|
||||
# make snapshots 离屏渲染 App 界面截图(tools/ui-snapshots/out)
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo dev)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
GOBUILD := CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)"
|
||||
|
||||
PLATFORMS := linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64
|
||||
# 服务端只发布 Linux 版本;macOS 用户用 App,其余平台用命令行 Agent
|
||||
SERVER_PLATFORMS := linux/amd64 linux/arm64
|
||||
AGENT_PLATFORMS := linux/amd64 linux/arm64 windows/amd64
|
||||
|
||||
.PHONY: all test fmt server agent app release snapshots clean
|
||||
.PHONY: all test fmt server agent app install-app dmg release snapshots clean
|
||||
|
||||
all: test agent
|
||||
|
||||
@@ -34,20 +38,43 @@ agent:
|
||||
app:
|
||||
VERSION=$(VERSION) ./build-app.sh
|
||||
|
||||
# 正常退出正在运行的 App(会一并停止后台引擎)后替换,装好后按原状态重新打开
|
||||
install-app: app
|
||||
@running=; \
|
||||
if pgrep -f /Applications/PortRelay.app/ >/dev/null; then \
|
||||
running=1; osascript -e 'quit app "PortRelay"' >/dev/null; \
|
||||
for i in $$(seq 1 30); do pgrep -f /Applications/PortRelay.app/ >/dev/null || break; sleep 0.5; done; \
|
||||
if pgrep -f /Applications/PortRelay.app/ >/dev/null; then echo "PortRelay 未能退出,已放弃替换"; exit 1; fi; \
|
||||
fi; \
|
||||
rm -rf /Applications/PortRelay.app && ditto PortRelay.app /Applications/PortRelay.app && rm -rf PortRelay.app; \
|
||||
echo "✓ 已安装 /Applications/PortRelay.app ($(VERSION))"; \
|
||||
if [ -n "$$running" ]; then open /Applications/PortRelay.app; fi
|
||||
|
||||
# 安装镜像:PortRelay.app 和「应用程序」的快捷方式,打开后拖进去即可
|
||||
dmg: app
|
||||
@rm -rf build/dmg && mkdir -p build/dmg dist
|
||||
ditto PortRelay.app build/dmg/PortRelay.app && rm -rf PortRelay.app
|
||||
ln -s /Applications build/dmg/Applications
|
||||
hdiutil create -volname PortRelay -srcfolder build/dmg -fs HFS+ -format UDZO -ov dist/PortRelay-$(VERSION).dmg
|
||||
@rm -rf build/dmg
|
||||
|
||||
# 服务端:portrelay-server-<版本>-linux-<架构>.tar.gz(含 deploy/ 安装脚本)
|
||||
# 客户端:portrelay-agent-<版本>-<系统>-<架构>.tar.gz / .zip
|
||||
release:
|
||||
@rm -rf dist && mkdir -p dist
|
||||
@for p in $(PLATFORMS); do \
|
||||
os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \
|
||||
name=portrelay-$(VERSION)-$$os-$$arch; mkdir -p dist/$$name; \
|
||||
@rm -rf dist/portrelay-* dist/SHA256SUMS && mkdir -p dist
|
||||
@pack() { \
|
||||
cmd=$$1; os=$$2; arch=$$3; ext=; [ $$os = windows ] && ext=.exe; \
|
||||
name=$$cmd-$(VERSION)-$$os-$$arch; mkdir -p dist/$$name; \
|
||||
echo "==> $$name"; \
|
||||
GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/portrelay-server$$ext ./cmd/portrelay-server || exit 1; \
|
||||
GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/portrelay-agent$$ext ./cmd/portrelay-agent || exit 1; \
|
||||
GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/$$cmd$$ext ./cmd/$$cmd || return 1; \
|
||||
cp LICENSE README.md README_EN.md dist/$$name/; \
|
||||
if [ $$os = linux ]; then cp -R deploy dist/$$name/; fi; \
|
||||
if [ $$cmd = portrelay-server ]; then cp -R deploy dist/$$name/; fi; \
|
||||
if [ $$os = windows ]; then (cd dist && zip -qr $$name.zip $$name); \
|
||||
else tar -C dist -czf dist/$$name.tar.gz $$name; fi; \
|
||||
rm -rf dist/$$name; \
|
||||
done
|
||||
}; \
|
||||
for p in $(SERVER_PLATFORMS); do pack portrelay-server $${p%/*} $${p#*/} || exit 1; done; \
|
||||
for p in $(AGENT_PLATFORMS); do pack portrelay-agent $${p%/*} $${p#*/} || exit 1; done
|
||||
@cd dist && shasum -a 256 * > SHA256SUMS && cat SHA256SUMS
|
||||
|
||||
snapshots:
|
||||
|
||||
@@ -51,11 +51,12 @@ PortRelay 把这件事变成三步:
|
||||
**访问控制**
|
||||
|
||||
- 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。
|
||||
- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(1 小时、2 小时、8 小时、当天有效,或直到通道关闭),以及是否需要审批。
|
||||
- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(30 分钟、1 小时、2 小时、8 小时、当天有效、直到通道关闭,或自定义),以及是否需要审批。
|
||||
- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
|
||||
- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
|
||||
- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
|
||||
- **通道组**:添加通道时目标可以一次填多个(例如 `10.0.1.11-13:22, 10.0.1.11:8080`),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。
|
||||
- **申请延长**:访问结束后 2 小时内(通道已关闭也可以),同事重新打开原链接点「申请延长」;你在 App、菜单栏或网页面板里选择延长多久即批准,关闭的通道会自动重新开启。你也可以对已授权或刚结束的设备主动延长。
|
||||
- **通道组**:添加通道时目标是一个列表,每行一台主机、多个端口用逗号隔开(也可以直接粘贴一串 IP),每个地址一个通道,自动分配一段连续端口,并放进同一个通道组。组内地址共用一条邀请链接和同一份授权:同事授权一次即可访问整组,撤销一次全组断开。
|
||||
|
||||
**通道生命周期**
|
||||
|
||||
@@ -152,9 +153,9 @@ PortRelay 把这件事变成三步:
|
||||
**方式 A:二进制 + systemd(推荐)**
|
||||
|
||||
```bash
|
||||
VERSION=1.0.0 # 见 Releases 页面
|
||||
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-$VERSION-linux-amd64.tar.gz
|
||||
tar xzf portrelay-$VERSION-linux-amd64.tar.gz && cd portrelay-$VERSION-linux-amd64
|
||||
VERSION=1.1.0 # 见 Releases 页面;ARM64 机器把 amd64 换成 arm64
|
||||
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-server-$VERSION-linux-amd64.tar.gz
|
||||
tar xzf portrelay-server-$VERSION-linux-amd64.tar.gz && cd portrelay-server-$VERSION-linux-amd64
|
||||
sudo ./deploy/install-server.sh ./portrelay-server
|
||||
|
||||
# 安装脚本会生成随机 token,记下来填到 Agent 里
|
||||
@@ -184,7 +185,7 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
|
||||
|
||||
### 2. 运行 Agent
|
||||
|
||||
**macOS App**:从 [Releases](https://github.com/DeepFal/PortRelay/releases) 下载 `PortRelay-*-macos-universal.zip`,解压后拖进“应用程序”。
|
||||
**macOS App**:从 [Releases](https://github.com/DeepFal/PortRelay/releases) 下载 `PortRelay-*.dmg`(Apple Silicon 与 Intel 通用),打开后把 PortRelay 拖进“应用程序”。
|
||||
|
||||
> [!NOTE]
|
||||
> App 未经 Apple 公证。首次打开时,在 Finder 中右键选择“打开”,或者执行 `xattr -dr com.apple.quarantine /Applications/PortRelay.app`。也可以 `make app` 自行构建。
|
||||
@@ -199,7 +200,7 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
|
||||
|
||||
> 举例:Agent 通过 Tailscale 地址 `100.64.0.10` 连网关,同事在办公室局域网里用 `192.168.1.10` 访问。这时“服务器控制地址”填 `100.64.0.10:33000`,“对外访问地址”填 `192.168.1.10`,邀请链接和分享文案都会使用后者。
|
||||
|
||||
**Linux / Windows**:下载对应平台的压缩包,直接运行 Agent,在浏览器打开的面板里完成同样的设置。
|
||||
**Linux / Windows**:下载对应平台的 `portrelay-agent-*` 压缩包,直接运行 Agent,在浏览器打开的面板里完成同样的设置。
|
||||
|
||||
```bash
|
||||
./portrelay-agent # 面板:http://127.0.0.1:33080
|
||||
@@ -208,11 +209,11 @@ make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
|
||||
|
||||
### 3. 创建通道并邀请同事
|
||||
|
||||
1. 点 **添加通道**,填写名称和目标地址(如 `10.0.1.16:3306`)。可以先“探测”一下本机能否连通;端口会自动推荐。
|
||||
1. 点 **添加通道**,填写名称和目标主机、端口(如 `10.0.1.16`、`3306`)。可以先测试一下本机能否连通;网关上的访问端口自动分配。
|
||||
2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
|
||||
3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
|
||||
|
||||
需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,目标填 `10.0.1.11-13:22`,表单会预览端口映射并自动选上「新建通道组」,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。
|
||||
需要一次开放多台主机时(比如一组服务器),同样点 **添加通道**,每行填一台主机(端口用逗号隔开,可以直接粘贴一串 IP),表单会预览端口映射并自动放进新建的通道组,闲置关闭、限速、访问控制等设置对每个地址都生效。创建后点 **邀请整组**。之后要加地址,点组卡片上的 **添加地址**;已有通道可以在编辑时选择加入某个组。组的「连接与授权」可以按设备或按地址查看在线连接和接入记录。
|
||||
|
||||
## 配置参考
|
||||
|
||||
|
||||
+10
-9
@@ -54,11 +54,12 @@ PortRelay turns that into three steps:
|
||||
**Access control**
|
||||
|
||||
- Three access modes: **invite-only** (the default and safest), an IP / CIDR allowlist (which can be combined with invites), and open to all.
|
||||
- Each invite link has its own expiry, device limit, access duration once granted (1 h, 2 h, 8 h, until end of day, or until the tunnel closes) and an optional approval step.
|
||||
- Each invite link has its own expiry, device limit, access duration once granted (30 min, 1 h, 2 h, 8 h, until end of day, until the tunnel closes, or a custom length) and an optional approval step.
|
||||
- When approval is required, the app shows a system notification and you approve or deny with one click. The requester's page refreshes on its own.
|
||||
- **The access clock starts at the first connection**, so a teammate can accept an invite now and use it later.
|
||||
- When access expires or is revoked, **existing connections are dropped too**. Unauthorized connections are reset (TCP RST) and logged.
|
||||
- **Tunnel groups**: the add-tunnel form accepts several targets at once (for example `10.0.1.11-13:22, 10.0.1.11:8080`), creates one tunnel per address on a contiguous block of gateway ports and puts them in one group. A group shares one invite link and one set of grants: a teammate is authorized once for the whole group, and one revoke disconnects them everywhere.
|
||||
- **Extensions.** Within two hours after access ends (even if the tunnel has been closed), a teammate reopens the original link and clicks 申请延长 (Request extension). You approve it by picking a duration in the app, the menu bar or the web panel, and a closed tunnel reopens on its own. You can also extend a running or just-ended grant yourself.
|
||||
- **Tunnel groups**: the add-tunnel form takes a list of targets, one host per row with comma-separated ports (a pasted list of IPs is split into rows), creates one tunnel per address on a contiguous block of gateway ports and puts them in one group. A group shares one invite link and one set of grants: a teammate is authorized once for the whole group, and one revoke disconnects them everywhere.
|
||||
|
||||
**Tunnel lifecycle**
|
||||
|
||||
@@ -157,9 +158,9 @@ The gateway must be reachable by **visitors** and by **the agent**. Open TCP `33
|
||||
**Option A: binary + systemd (recommended)**
|
||||
|
||||
```bash
|
||||
VERSION=1.0.0 # see the Releases page
|
||||
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-$VERSION-linux-amd64.tar.gz
|
||||
tar xzf portrelay-$VERSION-linux-amd64.tar.gz && cd portrelay-$VERSION-linux-amd64
|
||||
VERSION=1.1.0 # see the Releases page; use arm64 instead of amd64 on ARM hosts
|
||||
curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-server-$VERSION-linux-amd64.tar.gz
|
||||
tar xzf portrelay-server-$VERSION-linux-amd64.tar.gz && cd portrelay-server-$VERSION-linux-amd64
|
||||
sudo ./deploy/install-server.sh ./portrelay-server
|
||||
|
||||
# The installer generates a random token; you will enter it in the agent
|
||||
@@ -193,7 +194,7 @@ make server # dist/portrelay-server-linux-{amd64,arm64}
|
||||
|
||||
### 2. Run the agent
|
||||
|
||||
**macOS app.** Download `PortRelay-*-macos-universal.zip` from [Releases](https://github.com/DeepFal/PortRelay/releases), unzip it and move it to Applications.
|
||||
**macOS app.** Download `PortRelay-*.dmg` (universal: Apple Silicon and Intel) from [Releases](https://github.com/DeepFal/PortRelay/releases), open it and drag PortRelay to Applications.
|
||||
|
||||
> [!NOTE]
|
||||
> The app is not notarized. On first launch, right-click it in Finder and choose Open, or run `xattr -dr com.apple.quarantine /Applications/PortRelay.app`. You can also build it yourself with `make app`.
|
||||
@@ -208,7 +209,7 @@ Open ⚙️ Settings and fill in:
|
||||
|
||||
> Example: the agent reaches the gateway over Tailscale at `100.64.0.10`, while teammates in the office use the LAN address `192.168.1.10`. Set the control address to `100.64.0.10:33000` and the public access address to `192.168.1.10`. Invite links and share messages will use the LAN address.
|
||||
|
||||
**Linux / Windows.** Download the archive for your platform, run the agent, and use the web panel that opens in your browser.
|
||||
**Linux / Windows.** Download the `portrelay-agent-*` archive for your platform, run the agent, and use the web panel that opens in your browser.
|
||||
|
||||
```bash
|
||||
./portrelay-agent # panel at http://127.0.0.1:33080
|
||||
@@ -217,11 +218,11 @@ Open ⚙️ Settings and fill in:
|
||||
|
||||
### 3. Create a tunnel and invite someone
|
||||
|
||||
1. Click **添加通道** (Add tunnel) and enter a name and a target such as `10.0.1.16:3306`. You can probe the target from your machine first, and a free port is suggested automatically.
|
||||
1. Click **添加通道** (Add tunnel) and enter a name, a target host and port such as `10.0.1.16` and `3306`. You can probe the target from your machine first; the gateway port is assigned automatically.
|
||||
2. Keep the default access mode, **仅授权链接** (invite-only). Click **邀请** (Invite), then choose how long access lasts, how many devices may use the link, and whether approval is required.
|
||||
3. Send the generated message or QR code. Once the teammate has entered their name, they can connect their database client or SSH to `192.168.1.10:33660`.
|
||||
|
||||
To open several hosts at once (a set of servers, say), use the same **添加通道** (Add tunnel) form and enter `10.0.1.11-13:22` as the target. The form previews the port mapping and selects 新建通道组 (New tunnel group); idle close, bandwidth and access settings apply to every address. Then click **邀请整组** (Invite group). To add addresses later, click **添加地址** (Add address) on the group card; an existing tunnel can join a group from its edit form.
|
||||
To open several hosts at once (a set of servers, say), use the same **添加通道** (Add tunnel) form with one host per row (comma-separated ports; you can paste a list of IPs). The form previews the port mapping and puts the addresses in a new tunnel group; idle close, bandwidth and access settings apply to every address. Then click **邀请整组** (Invite group). To add addresses later, click **添加地址** (Add address) on the group card; an existing tunnel can join a group from its edit form. A group's connections and history can be viewed by device or by address.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
+70
-2
@@ -34,6 +34,7 @@ type Agent struct {
|
||||
latestMetrics protocol.MetricsMessage
|
||||
regErrors map[string]string // tunnelID -> 服务器返回的注册失败原因
|
||||
inviteWaiters map[string]chan protocol.InviteRespMessage
|
||||
extendWaiters map[string]chan protocol.ExtendLeaseRespMessage
|
||||
|
||||
controlWriteMu sync.Mutex
|
||||
controlWriter *bufio.Writer
|
||||
@@ -57,6 +58,7 @@ func NewAgent(cm *ConfigManager) *Agent {
|
||||
cm: cm,
|
||||
regErrors: make(map[string]string),
|
||||
inviteWaiters: make(map[string]chan protocol.InviteRespMessage),
|
||||
extendWaiters: make(map[string]chan protocol.ExtendLeaseRespMessage),
|
||||
usage: make(map[string]*usageState),
|
||||
idleUnit: time.Minute,
|
||||
rates: newRateRecorder(),
|
||||
@@ -199,6 +201,18 @@ func (a *Agent) trackUsage(now time.Time) {
|
||||
groupBusy[gm.GroupID] = true
|
||||
}
|
||||
}
|
||||
// 等待批准的延长申请:通道(组)保持开启,避免关了又被批准重开
|
||||
renewing := make(map[string]bool)
|
||||
for _, r := range metrics.Renewals {
|
||||
if r.Request == nil {
|
||||
continue
|
||||
}
|
||||
if r.GroupID != "" {
|
||||
groupBusy[r.GroupID] = true
|
||||
} else {
|
||||
renewing[r.TunnelID] = true
|
||||
}
|
||||
}
|
||||
for _, t := range cfg.Tunnels {
|
||||
if m, ok := byID[t.ID]; ok && t.Enabled && t.GroupID != "" && m.Status == "online" && tunnelInUse(m) {
|
||||
groupBusy[t.GroupID] = true
|
||||
@@ -242,7 +256,7 @@ func (a *Agent) trackUsage(now time.Time) {
|
||||
firstConn = append(firstConn, t.ID)
|
||||
}
|
||||
}
|
||||
u.inUse = tunnelInUse(m) || (t.GroupID != "" && groupBusy[t.GroupID])
|
||||
u.inUse = tunnelInUse(m) || renewing[t.ID] || (t.GroupID != "" && groupBusy[t.GroupID])
|
||||
if u.inUse {
|
||||
u.lastActive = now
|
||||
}
|
||||
@@ -520,6 +534,17 @@ func (a *Agent) connectAndRun(cfg Config) error {
|
||||
delete(a.inviteWaiters, resp.RequestID)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
case protocol.TypeExtendResp:
|
||||
var resp protocol.ExtendLeaseRespMessage
|
||||
if json.Unmarshal(line, &resp) != nil {
|
||||
continue
|
||||
}
|
||||
a.mu.Lock()
|
||||
if ch, ok := a.extendWaiters[resp.RequestID]; ok {
|
||||
ch <- resp
|
||||
delete(a.extendWaiters, resp.RequestID)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
case protocol.TypeRegisterResp:
|
||||
var resp protocol.RegisterRespMessage
|
||||
if json.Unmarshal(line, &resp) != nil {
|
||||
@@ -743,14 +768,24 @@ func (a *Agent) wire(t protocol.TunnelConfig) protocol.TunnelConfig {
|
||||
return t
|
||||
}
|
||||
|
||||
// RemoveTunnel closes a tunnel on the relay server; its devices can still ask for an extension
|
||||
func (a *Agent) RemoveTunnel(id string) {
|
||||
a.unregister(id, false)
|
||||
}
|
||||
|
||||
// DeleteTunnel removes a deleted tunnel from the relay server, renewal records included
|
||||
func (a *Agent) DeleteTunnel(id string) {
|
||||
a.unregister(id, true)
|
||||
}
|
||||
|
||||
func (a *Agent) unregister(id string, deleted bool) {
|
||||
a.mu.Lock()
|
||||
delete(a.regErrors, id)
|
||||
a.mu.Unlock()
|
||||
if !a.IsConnected() {
|
||||
return
|
||||
}
|
||||
_ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id})
|
||||
_ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id, Deleted: deleted})
|
||||
}
|
||||
|
||||
// Scope selects what an invite, request or lease applies to: one tunnel, or a whole group
|
||||
@@ -792,6 +827,38 @@ func (a *Agent) CreateInvite(scope Scope, note string, validMinutes, maxUses int
|
||||
}
|
||||
}
|
||||
|
||||
// ExtendLease extends a device's access by minutes (approving its renewal requests) and returns
|
||||
// the new expiry
|
||||
func (a *Agent) ExtendLease(scope Scope, ip string, minutes int) (time.Time, error) {
|
||||
if !a.IsConnected() {
|
||||
return time.Time{}, fmt.Errorf("未连接中继服务器")
|
||||
}
|
||||
reqID := uuid.New().String()
|
||||
ch := make(chan protocol.ExtendLeaseRespMessage, 1)
|
||||
a.mu.Lock()
|
||||
a.extendWaiters[reqID] = ch
|
||||
a.mu.Unlock()
|
||||
defer func() {
|
||||
a.mu.Lock()
|
||||
delete(a.extendWaiters, reqID)
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
if err := a.sendControl(protocol.ExtendLeaseMessage{
|
||||
Type: protocol.TypeExtendLease, RequestID: reqID, TunnelID: scope.TunnelID, GroupID: scope.GroupID, IP: ip, Minutes: minutes,
|
||||
}); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
select {
|
||||
case resp := <-ch:
|
||||
if !resp.Success {
|
||||
return time.Time{}, fmt.Errorf("%s", resp.Error)
|
||||
}
|
||||
return resp.ExpiresAt, nil
|
||||
case <-time.After(5 * time.Second):
|
||||
return time.Time{}, fmt.Errorf("服务器未响应,请先把服务端更新到支持延长授权的版本")
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeInvite invalidates an invite link and the leases it granted
|
||||
func (a *Agent) RevokeInvite(scope Scope, code string) error {
|
||||
if !a.IsConnected() {
|
||||
@@ -859,5 +926,6 @@ func (a *Agent) GetLatestMetrics() protocol.MetricsMessage {
|
||||
m.Groups = append([]protocol.GroupMetrics(nil), m.Groups...)
|
||||
m.ActiveConns = append([]protocol.ActiveConnInfo(nil), m.ActiveConns...)
|
||||
m.SecurityEvents = append([]protocol.SecurityEvent(nil), m.SecurityEvents...)
|
||||
m.Renewals = append([]protocol.RenewalInfo(nil), m.Renewals...)
|
||||
return m
|
||||
}
|
||||
+106
-1
@@ -132,6 +132,19 @@ type TunnelWithMetrics struct {
|
||||
EffectiveIdleCloseMinutes int `json:"effective_idle_close_minutes"`
|
||||
// RateHistory 近 10 分钟流量曲线(仅开启中的通道)
|
||||
RateHistory *RateSeries `json:"rate_history,omitempty"`
|
||||
// Renewals 可延长的设备与延长申请(通道关闭后 2 小时内仍有)
|
||||
Renewals []protocol.RenewalInfo `json:"renewals,omitempty"`
|
||||
}
|
||||
|
||||
// renewalsOf picks the renewals of one tunnel (groupID empty) or one group
|
||||
func renewalsOf(all []protocol.RenewalInfo, tunnelID, groupID string) []protocol.RenewalInfo {
|
||||
var out []protocol.RenewalInfo
|
||||
for _, r := range all {
|
||||
if (groupID != "" && r.GroupID == groupID) || (groupID == "" && r.GroupID == "" && r.TunnelID == tunnelID) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tunnelsWithMetrics merges config, live metrics and registration errors into one consistent view.
|
||||
@@ -167,6 +180,7 @@ func (s *APIServer) tunnelsWithMetrics(metrics protocol.MetricsMessage) []Tunnel
|
||||
Metrics: tm,
|
||||
Activity: s.agent.Activity(t),
|
||||
EffectiveIdleCloseMinutes: IdleCloseMinutes(t),
|
||||
Renewals: renewalsOf(metrics.Renewals, t.ID, ""),
|
||||
}
|
||||
if t.Enabled {
|
||||
series := s.agent.RateSeries(t.ID)
|
||||
@@ -265,7 +279,7 @@ func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.agent.ConsumeSessionBytes(tunnelID)
|
||||
s.agent.RemoveTunnel(tunnelID)
|
||||
s.agent.DeleteTunnel(tunnelID)
|
||||
jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
|
||||
default:
|
||||
@@ -376,6 +390,7 @@ func shareMessage(inv protocol.Invite, body, link string) string {
|
||||
} else if inv.MaxUses > 1 {
|
||||
b.WriteString(fmt.Sprintf("\n此链接最多 %d 台设备使用,请勿转发。", inv.MaxUses))
|
||||
}
|
||||
b.WriteString("\n授权结束后 2 小时内,可以重新打开此链接申请延长。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -386,6 +401,7 @@ func isAccessPath(p string) bool {
|
||||
// handleAccess serves the access endpoints shared by tunnels and groups:
|
||||
//
|
||||
// POST leases/revoke {"ip": "..."}(ip 为空表示撤销全部)
|
||||
// POST leases/extend {"ip": "...", "minutes": 60} 延长授权(同时批准该设备的延长申请)
|
||||
// POST invites 新建邀请
|
||||
// DELETE invites/{code} 作废邀请
|
||||
// GET invites/{code}/share 分享文案
|
||||
@@ -406,6 +422,26 @@ func (s *APIServer) handleAccess(w http.ResponseWriter, r *http.Request, sc Scop
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
|
||||
case len(rest) == 2 && rest[0] == "leases" && rest[1] == "extend" && r.Method == http.MethodPost:
|
||||
var req struct {
|
||||
IP string `json:"ip"`
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Minutes < 1 || req.Minutes > protocol.MaxLeaseMinutes {
|
||||
jsonError(w, http.StatusBadRequest, "延长时长须在 1 分钟到 30 天之间")
|
||||
return
|
||||
}
|
||||
exp, err := s.extendAccess(sc, strings.TrimSpace(req.IP), req.Minutes)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, map[string]any{"expires_at": exp})
|
||||
|
||||
case rest[0] == "invites":
|
||||
s.handleInvites(w, r, sc, rest[1:])
|
||||
|
||||
@@ -428,6 +464,75 @@ func (s *APIServer) handleAccess(w http.ResponseWriter, r *http.Request, sc Scop
|
||||
}
|
||||
}
|
||||
|
||||
// extendAccess extends a device's access in a tunnel or group. Closed tunnels are reopened first
|
||||
// (a group reopens all its members), and each is kept open at least until the new expiry.
|
||||
func (s *APIServer) extendAccess(sc Scope, ip string, minutes int) (time.Time, error) {
|
||||
if ip == "" {
|
||||
return time.Time{}, fmt.Errorf("缺少设备 IP")
|
||||
}
|
||||
var members []protocol.TunnelConfig
|
||||
if sc.GroupID != "" {
|
||||
members = s.cm.GroupMembers(sc.GroupID)
|
||||
} else if t, ok := s.cm.FindTunnel(sc.TunnelID); ok {
|
||||
members = []protocol.TunnelConfig{t}
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return time.Time{}, fmt.Errorf("通道不存在或组内没有通道")
|
||||
}
|
||||
if !s.canExtend(sc, ip) {
|
||||
return time.Time{}, fmt.Errorf("这台设备没有可延长的授权(访问结束已超过 2 小时或已被撤销)")
|
||||
}
|
||||
on := true
|
||||
for _, t := range members {
|
||||
if !t.Enabled {
|
||||
if _, err := s.setEnabled(t.ID, &on); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
exp, err := s.agent.ExtendLease(sc, ip, minutes)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
for _, t := range members {
|
||||
if updated, changed, err := s.cm.ExtendUntil(t.ID, exp); err == nil && changed {
|
||||
s.agent.SyncTunnel(updated, true)
|
||||
}
|
||||
}
|
||||
return exp, nil
|
||||
}
|
||||
|
||||
// canExtend reports whether ip has a running lease, or an access ended within two hours, in the scope.
|
||||
// 先检查再开启通道,避免对没有可延长授权的设备白白打开通道。
|
||||
func (s *APIServer) canExtend(sc Scope, ip string) bool {
|
||||
metrics := s.agent.GetLatestMetrics()
|
||||
for _, r := range renewalsOf(metrics.Renewals, sc.TunnelID, sc.GroupID) {
|
||||
if r.IP == ip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
var leases []protocol.DynamicLease
|
||||
if sc.GroupID != "" {
|
||||
for _, g := range metrics.Groups {
|
||||
if g.GroupID == sc.GroupID {
|
||||
leases = g.DynamicLeases
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, t := range metrics.Tunnels {
|
||||
if t.TunnelID == sc.TunnelID {
|
||||
leases = t.DynamicLeases
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, l := range leases {
|
||||
if l.IP == ip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scopeShare builds the share message for an invite of a tunnel or group
|
||||
func (s *APIServer) scopeShare(cfg Config, sc Scope, inv protocol.Invite, link string) string {
|
||||
if sc.GroupID != "" {
|
||||
|
||||
@@ -16,6 +16,8 @@ type GroupView struct {
|
||||
Metrics protocol.GroupMetrics `json:"metrics"`
|
||||
// RateHistory 开启中成员的近 10 分钟流量之和
|
||||
RateHistory *RateSeries `json:"rate_history,omitempty"`
|
||||
// Renewals 可延长的设备与延长申请(组关闭后 2 小时内仍有)
|
||||
Renewals []protocol.RenewalInfo `json:"renewals,omitempty"`
|
||||
}
|
||||
|
||||
func (s *APIServer) groupsView(metrics protocol.MetricsMessage) []GroupView {
|
||||
@@ -26,7 +28,8 @@ func (s *APIServer) groupsView(metrics protocol.MetricsMessage) []GroupView {
|
||||
cfg := s.cm.Get()
|
||||
out := make([]GroupView, 0, len(cfg.Groups))
|
||||
for _, g := range cfg.Groups {
|
||||
v := GroupView{TunnelGroup: g, TunnelIDs: make([]string, 0), Metrics: protocol.GroupMetrics{GroupID: g.ID}}
|
||||
v := GroupView{TunnelGroup: g, TunnelIDs: make([]string, 0), Metrics: protocol.GroupMetrics{GroupID: g.ID},
|
||||
Renewals: renewalsOf(metrics.Renewals, "", g.ID)}
|
||||
if m, ok := shared[g.ID]; ok {
|
||||
v.Metrics = m
|
||||
}
|
||||
@@ -183,7 +186,7 @@ func (s *APIServer) handleGroupItem(w http.ResponseWriter, r *http.Request) {
|
||||
if deleteMembers {
|
||||
s.agent.ConsumeSessionBytes(t.ID)
|
||||
s.agent.dropUsage(t.ID)
|
||||
s.agent.RemoveTunnel(t.ID)
|
||||
s.agent.DeleteTunnel(t.ID)
|
||||
} else if t.Enabled {
|
||||
s.agent.SyncTunnel(t, true)
|
||||
}
|
||||
|
||||
@@ -572,6 +572,25 @@ func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelCo
|
||||
return cloneTunnel(updated), existing.ListenPort, cm.save()
|
||||
}
|
||||
|
||||
// ExtendUntil keeps an open tunnel open at least until t: an approved access extension outlasts the
|
||||
// tunnel's maximum open time. Returns the tunnel and whether it changed.
|
||||
func (cm *ConfigManager) ExtendUntil(id string, t time.Time) (protocol.TunnelConfig, bool, error) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
for i, tc := range cm.cfg.Tunnels {
|
||||
if tc.ID != id {
|
||||
continue
|
||||
}
|
||||
if !tc.Enabled || tc.ExpiresAt == nil || !tc.ExpiresAt.Before(t) {
|
||||
return cloneTunnel(tc), false, nil
|
||||
}
|
||||
v := t
|
||||
cm.cfg.Tunnels[i].ExpiresAt = &v
|
||||
return cloneTunnel(cm.cfg.Tunnels[i]), true, cm.save()
|
||||
}
|
||||
return protocol.TunnelConfig{}, false, fmt.Errorf("通道 %s 未找到", id)
|
||||
}
|
||||
|
||||
// RefreshAuthCode generates a new auth code for a tunnel
|
||||
func (cm *ConfigManager) RefreshAuthCode(id string) (protocol.TunnelConfig, error) {
|
||||
cm.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExtendReopensClosedTunnel(t *testing.T) {
|
||||
e := startEnv(t, testToken)
|
||||
waitFor(t, "agent connected", e.agent.IsConnected)
|
||||
echo := startEcho(t)
|
||||
// 最长开启 10 分钟的仅授权链接通道
|
||||
one := e.addTunnels(t, map[string]any{"name": "db", "target": echo, "duration_minutes": 10, "whitelist": []string{}})
|
||||
tn := one.Tunnels[0]
|
||||
waitFor(t, "online", func() bool { return e.tunnel(t, tn.ID).Metrics.Status == "online" })
|
||||
|
||||
_, body := e.call(t, "POST", "/api/tunnels/"+tn.ID+"/invites", map[string]any{"lease_duration": "30m"})
|
||||
var inv struct {
|
||||
Invite struct{ Code string } `json:"invite"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &inv)
|
||||
grantViaInvite(t, e, inv.Invite.Code, "张三")
|
||||
if got, err := echoVia(tn.ListenPort, "hi"); err != nil || got != "hi" {
|
||||
t.Fatalf("granted device cannot connect: %q %v", got, err)
|
||||
}
|
||||
|
||||
// 关闭通道:设备仍可在 2 小时内被延长
|
||||
e.call(t, "POST", "/api/tunnels/"+tn.ID+"/toggle", map[string]bool{"enabled": false})
|
||||
waitFor(t, "renewable after close", func() bool {
|
||||
rs := e.tunnel(t, tn.ID).Renewals
|
||||
return len(rs) == 1 && rs[0].IP == "127.0.0.1" && rs[0].EndedAt != nil
|
||||
})
|
||||
|
||||
status, body := e.call(t, "POST", "/api/tunnels/"+tn.ID+"/leases/extend", map[string]any{"ip": "127.0.0.1", "minutes": 60})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("extend: %d %s", status, body)
|
||||
}
|
||||
var out struct {
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &out)
|
||||
if d := time.Until(out.ExpiresAt); d < 59*time.Minute || d > 61*time.Minute {
|
||||
t.Fatalf("an ended access is extended from now: %v", d)
|
||||
}
|
||||
reopened := e.tunnel(t, tn.ID)
|
||||
if !reopened.Enabled || reopened.ExpiresAt == nil || reopened.ExpiresAt.Before(out.ExpiresAt) {
|
||||
t.Fatalf("the tunnel must reopen and stay open until the extended access ends: %+v", reopened.TunnelConfig)
|
||||
}
|
||||
waitFor(t, "reachable again", func() bool {
|
||||
got, err := echoVia(tn.ListenPort, "x")
|
||||
return err == nil && got == "x"
|
||||
})
|
||||
|
||||
// 没有可延长授权的设备:不打开通道
|
||||
e.call(t, "POST", "/api/tunnels/"+tn.ID+"/toggle", map[string]bool{"enabled": false})
|
||||
if status, _ := e.call(t, "POST", "/api/tunnels/"+tn.ID+"/leases/extend", map[string]any{"ip": "10.1.2.3", "minutes": 30}); status != http.StatusConflict {
|
||||
t.Fatalf("unknown device: %d", status)
|
||||
}
|
||||
if e.tunnel(t, tn.ID).Enabled {
|
||||
t.Fatal("a refused extension must not reopen the tunnel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendReopensWholeGroup(t *testing.T) {
|
||||
e := startEnv(t, testToken)
|
||||
waitFor(t, "agent connected", e.agent.IsConnected)
|
||||
echo1, echo2 := startEcho(t), startEcho(t)
|
||||
g := e.addTunnels(t, map[string]any{"name": "订单服务", "target": echo1 + ", " + echo2, "new_group": "订单服务"})
|
||||
gid := g.Group.ID
|
||||
waitFor(t, "online", func() bool {
|
||||
return e.tunnel(t, g.Tunnels[0].ID).Metrics.Status == "online" && e.tunnel(t, g.Tunnels[1].ID).Metrics.Status == "online"
|
||||
})
|
||||
_, body := e.call(t, "POST", "/api/groups/"+gid+"/invites", map[string]any{})
|
||||
var inv struct {
|
||||
Invite struct{ Code string } `json:"invite"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &inv)
|
||||
grantViaInvite(t, e, inv.Invite.Code, "小王")
|
||||
|
||||
e.call(t, "POST", "/api/groups/"+gid+"/toggle", map[string]bool{"enabled": false})
|
||||
waitFor(t, "group renewable after close", func() bool { return len(e.group(t, gid).Renewals) == 1 })
|
||||
if status, body := e.call(t, "POST", "/api/groups/"+gid+"/leases/extend", map[string]any{"ip": "127.0.0.1", "minutes": 30}); status != http.StatusOK {
|
||||
t.Fatalf("extend group: %d %s", status, body)
|
||||
}
|
||||
for _, m := range g.Tunnels {
|
||||
if !e.tunnel(t, m.ID).Enabled {
|
||||
t.Fatal("extending a group's access reopens every member")
|
||||
}
|
||||
}
|
||||
waitFor(t, "every member reachable", func() bool {
|
||||
_, err1 := echoVia(g.Tunnels[0].ListenPort, "a")
|
||||
_, err2 := echoVia(g.Tunnels[1].ListenPort, "b")
|
||||
return err1 == nil && err2 == nil
|
||||
})
|
||||
}
|
||||
+429
-113
@@ -240,7 +240,7 @@
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(8px);
|
||||
display: none; align-items: center; justify-content: center; z-index: 1000;
|
||||
display: none; align-items: center; justify-content: center; z-index: 1002;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal-card {
|
||||
@@ -329,6 +329,19 @@
|
||||
.preview-row { font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
|
||||
.plan-box { margin-top: 8px; padding: 8px 10px; border-radius: 6px; background: rgba(148, 163, 184, 0.08); color: var(--text-muted); font-size: 12px; line-height: 1.7; }
|
||||
.plan-box .route-endpoint { color: var(--accent, #38bdf8); }
|
||||
.target-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||
.target-row input { min-width: 0; }
|
||||
.target-row .t-ports { flex: 0 0 150px; }
|
||||
.target-head { display: flex; gap: 8px; font-size: 11px; color: var(--text-muted); margin-bottom: 4px; }
|
||||
.target-head span:first-child { flex: 1; }
|
||||
.target-head span:last-child { flex: 0 0 182px; }
|
||||
.plan-box .ok { color: var(--success); } .plan-box .bad { color: var(--danger); }
|
||||
.dim-toggle { display: inline-flex; border: 1px solid var(--card-border); border-radius: 6px; overflow: hidden; float: right; }
|
||||
.dim-toggle button { background: none; border: 0; color: var(--text-muted); font-size: 11px; padding: 2px 10px; cursor: pointer; }
|
||||
.dim-toggle button.on { background: rgba(56, 189, 248, 0.18); color: var(--text); }
|
||||
.chip-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip-btn { background: rgba(148, 163, 184, 0.1); border: 1px solid var(--card-border); color: var(--text); font-size: 12px; padding: 4px 10px; border-radius: 6px; cursor: pointer; }
|
||||
.chip-btn.on { background: rgba(56, 189, 248, 0.18); border-color: #38bdf8; color: #38bdf8; font-weight: 600; }
|
||||
.add-member { background: none; border: 0; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 8px 0; }
|
||||
.add-member:hover { color: var(--text); }
|
||||
@media (max-width: 860px) { .member-name, .member-meta { display: none; } }
|
||||
@@ -442,22 +455,25 @@
|
||||
<input type="hidden" id="tunnelId">
|
||||
<div class="input-row">
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label>通道名称 / 备注</label>
|
||||
<input type="text" id="tunnelName" placeholder="例如:MySQL 实习库 或 财务系统 SSH" oninput="schedulePlan()">
|
||||
<label id="tunnelNameLabel">通道名称</label>
|
||||
<input type="text" id="tunnelName" placeholder="如:订单数据库" oninput="schedulePlan()">
|
||||
</div>
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label>责任人(可选)</label>
|
||||
<input type="text" id="tunnelOwner" placeholder="例如:张三 / 数据组">
|
||||
<input type="text" id="tunnelOwner" placeholder="负责人或团队,如:数据组">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="tunnelTargetLabel">目标地址(中转机 Mac 可直连的内网目标,可填多个)</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tunnelTarget" required placeholder="例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22" oninput="schedulePlan()">
|
||||
<button type="button" class="btn-secondary" onclick="probeTarget()">探测测试</button>
|
||||
<label id="tunnelTargetLabel">目标服务(每行一台主机,多个端口用逗号隔开)</label>
|
||||
<div class="target-head" id="targetHead"><span>主机</span><span>端口</span></div>
|
||||
<div id="targetRows"></div>
|
||||
<div class="input-row" style="justify-content: space-between; align-items: center;">
|
||||
<button type="button" class="add-member" id="addRowBtn" onclick="addTargetRow()" title="也可以直接粘贴多个地址(每行一个或逗号分隔,可带 :端口),会自动拆成多行">+ 添加主机</button>
|
||||
<button type="button" class="btn-secondary btn-sm" onclick="probeTarget()">探测测试</button>
|
||||
</div>
|
||||
<div class="input-hint" id="probeResult"></div>
|
||||
<div class="plan-box" id="tunnelPlan" style="display:none;"></div>
|
||||
<div class="input-hint" id="tunnelAddHint"></div>
|
||||
</div>
|
||||
<div class="form-group" id="tunnelPortWrap">
|
||||
<label>对外监听端口 (范围: 33001 - 34000)</label>
|
||||
@@ -466,7 +482,7 @@
|
||||
<button type="button" class="btn-secondary" onclick="autoRecommendPort()">推荐空闲端口</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group" id="tunnelGroupWrap">
|
||||
<label>通道组</label>
|
||||
<div class="input-row">
|
||||
<select id="tunnelGroup" onchange="onTunnelGroupChange()"></select>
|
||||
@@ -486,12 +502,14 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>最长开启时长(可选,到时无论是否在用都会关闭)</label>
|
||||
<select id="tunnelMaxDuration">
|
||||
<select id="tunnelMaxDuration" onchange="onCustomDuration(this, false)">
|
||||
<option value="0" selected>不限制</option>
|
||||
<option value="30">30 分钟</option>
|
||||
<option value="120">2 小时</option>
|
||||
<option value="480">8 小时</option>
|
||||
<option value="1440">1 天</option>
|
||||
<option value="10080">7 天</option>
|
||||
<option value="custom">自定义…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -511,7 +529,7 @@
|
||||
<option value="specific">固定 IP / 网段 + 授权链接</option>
|
||||
<option value="all">全部放通 *(不安全)</option>
|
||||
</select>
|
||||
<input type="text" id="tunnelWhitelist" style="margin-top: 8px; display: none;" placeholder="例如 192.168.1.100, 192.168.1.0/24(逗号分隔)">
|
||||
<input type="text" id="tunnelWhitelist" style="margin-top: 8px; display: none;" placeholder="IP 或 CIDR 网段,逗号分隔,如 192.168.1.100, 10.0.0.0/24">
|
||||
<div class="input-hint" id="accessModeHint"></div>
|
||||
</div>
|
||||
|
||||
@@ -523,6 +541,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Extend access(批准延长申请 / 主动延长) -->
|
||||
<div class="modal-overlay" id="extendModal">
|
||||
<div class="modal-card" style="width: 420px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="extendTitle">延长访问</h3>
|
||||
<button class="btn-secondary btn-sm" onclick="closeExtendModal()">✕</button>
|
||||
</div>
|
||||
<div class="input-hint" id="extendHint" style="margin: -8px 0 14px;"></div>
|
||||
<div class="form-group">
|
||||
<label>延长多久</label>
|
||||
<div class="chip-row" id="extendChips"></div>
|
||||
<div class="input-row" style="margin-top: 8px;">
|
||||
<input type="number" id="extendAmount" min="1" placeholder="自定义数量" oninput="updateExtend()">
|
||||
<select id="extendUnit" style="max-width: 100px;" onchange="updateExtend()">
|
||||
<option value="1">分钟</option>
|
||||
<option value="60">小时</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeExtendModal()">取消</button>
|
||||
<button type="button" class="btn-primary" id="extendConfirm" onclick="confirmExtend()">延长</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Invite -->
|
||||
<div class="modal-overlay" id="inviteModal">
|
||||
<div class="modal-card">
|
||||
@@ -533,7 +577,7 @@
|
||||
<div id="inviteForm">
|
||||
<div class="form-group">
|
||||
<label>备注(发给谁,可选)</label>
|
||||
<input type="text" id="inviteNote" placeholder="例如:张三">
|
||||
<input type="text" id="inviteNote" placeholder="如:张三">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>链接有效期</label>
|
||||
@@ -553,12 +597,14 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>授权后可访问</label>
|
||||
<select id="inviteLease">
|
||||
<select id="inviteLease" onchange="onCustomDuration(this, true)">
|
||||
<option value="30m">30 分钟</option>
|
||||
<option value="1h">1 小时</option>
|
||||
<option value="2h" selected>2 小时</option>
|
||||
<option value="8h">8 小时</option>
|
||||
<option value="today">当天有效</option>
|
||||
<option value="tunnel">通道开启期间</option>
|
||||
<option value="custom">自定义…</option>
|
||||
</select>
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top:10px; font-weight: normal;">
|
||||
<input type="checkbox" id="inviteApproval" style="width:auto;"> 同事打开链接后需要我批准才放通
|
||||
@@ -595,11 +641,11 @@
|
||||
<div class="input-hint" style="margin: -8px 0 14px;">组内地址共用邀请与授权:同事授权一次即可访问整组;组内任一地址在用时,整组都不会闲置关闭。</div>
|
||||
<div class="form-group">
|
||||
<label>通道组名称</label>
|
||||
<input type="text" id="groupName" required placeholder="例如:订单服务集群">
|
||||
<input type="text" id="groupName" required placeholder="如:订单服务集群">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>责任人(可选)</label>
|
||||
<input type="text" id="groupOwner" placeholder="例如:张三 / 运维组">
|
||||
<input type="text" id="groupOwner" placeholder="负责人或团队,如:运维组">
|
||||
</div>
|
||||
<div class="form-group" id="groupExistingWrap">
|
||||
<label>把已有通道加入该组(可选)</label>
|
||||
@@ -640,7 +686,7 @@
|
||||
<form onsubmit="saveSettings(event)">
|
||||
<div class="form-group">
|
||||
<label>服务器控制地址 (地址:端口)</label>
|
||||
<input type="text" id="settingServerAddr" required placeholder="例如 gw.example.com:33000">
|
||||
<input type="text" id="settingServerAddr" required placeholder="地址:端口,如 gw.example.com:33000">
|
||||
<div class="input-hint">边缘网关的地址与控制端口。局域网、VPN、虚拟组网或公网地址均可,只要本机能连通</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -703,7 +749,30 @@
|
||||
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), sec = secs % 60;
|
||||
return h > 0 ? `${h}小时${m}分` : (m > 0 ? `${m}分${sec}秒` : `${sec}秒`);
|
||||
};
|
||||
const fmtMinutes = m => m >= 1440 && m % 1440 === 0 ? `${m / 1440} 天` : (m >= 60 && m % 60 === 0 ? `${m / 60} 小时` : `${m} 分钟`);
|
||||
const fmtMinutes = m => m >= 1440 && m % 1440 === 0 ? `${m / 1440} 天`
|
||||
: (m >= 60 && m % 60 === 0 ? `${m / 60} 小时` : (m > 60 ? `${Math.floor(m / 60)} 小时 ${m % 60} 分钟` : `${m} 分钟`));
|
||||
|
||||
// 自定义时长:输入数量 + 单位(如 90、90m、1.5h),返回分钟数;取消或无效返回 0
|
||||
function askMinutes() {
|
||||
const v = (prompt('自定义时长(如 90 表示 90 分钟,3h 表示 3 小时)') || '').trim().toLowerCase();
|
||||
const m = /^(\d+(?:\.\d+)?)\s*(h|小时|m|分钟)?$/.exec(v);
|
||||
if (!m) return 0;
|
||||
const minutes = Math.round(parseFloat(m[1]) * (m[2] === 'h' || m[2] === '小时' ? 60 : 1));
|
||||
return minutes >= 5 && minutes <= 43200 ? minutes : 0;
|
||||
}
|
||||
|
||||
// 下拉框选「自定义…」:询问时长并加入为一个选项;lease 为 true 时取值形如 "90m"
|
||||
function onCustomDuration(sel, lease) {
|
||||
if (sel.value !== 'custom') { sel.dataset.prev = sel.value; return; }
|
||||
const m = askMinutes();
|
||||
if (!m) { sel.value = sel.dataset.prev || (lease ? '2h' : '0'); return; }
|
||||
const value = lease ? `${m}m` : String(m);
|
||||
if (![...sel.options].some(o => o.value === value)) {
|
||||
sel.add(new Option(fmtMinutes(m), value), sel.querySelector('option[value="custom"]'));
|
||||
}
|
||||
sel.value = value;
|
||||
sel.dataset.prev = value;
|
||||
}
|
||||
const ago = v => (Date.now() - new Date(v)) / 1000;
|
||||
|
||||
let totalRateHistory = null;
|
||||
@@ -766,6 +835,8 @@
|
||||
let html = '';
|
||||
if (t.max_bandwidth_mb) html += `<span class="chip purple">限速 ${t.max_bandwidth_mb} MB/s</span>`;
|
||||
if ((m.pending || []).length) html += `<span class="chip orange" onclick="openDrawer('t:${id}')">🔔 待审批 ${m.pending.length}</span>`;
|
||||
const asks = (t.renewals || []).filter(r => r.request).length;
|
||||
if (asks) html += `<span class="chip orange" onclick="openDrawer('t:${id}')">⏱ 申请延长 ${asks}</span>`;
|
||||
if ((m.dynamic_leases || []).length) html += `<span class="chip green" onclick="openDrawer('t:${id}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`;
|
||||
return html;
|
||||
}
|
||||
@@ -1064,39 +1135,42 @@
|
||||
}
|
||||
|
||||
// 新建代理通道;groupId 非空时默认加入该通道组(通道组卡片上的「添加地址」)
|
||||
// 组卡片「添加地址」打开时固定加入的通道组;新建时不需要选择通道组
|
||||
let fixedGroupId = null;
|
||||
|
||||
function openCreateModal(groupId) {
|
||||
document.getElementById('modalTitle').innerText = '新建代理通道';
|
||||
fixedGroupId = groupId || null;
|
||||
const g = currentGroups.find(x => x.id === fixedGroupId);
|
||||
document.getElementById('modalTitle').innerText = fixedGroupId ? `添加地址到「${g ? g.name : '通道组'}」` : '新建代理通道';
|
||||
document.getElementById('tunnelId').value = '';
|
||||
document.getElementById('tunnelName').value = '';
|
||||
document.getElementById('tunnelOwner').value = '';
|
||||
document.getElementById('tunnelTarget').value = '';
|
||||
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标,可填多个)';
|
||||
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306;多台主机写 10.0.1.11-13:22';
|
||||
document.getElementById('tunnelTargetLabel').innerText = '目标服务(每行一台主机,多个端口用逗号隔开)';
|
||||
fillTunnelGroupSelect('');
|
||||
setAccessForm([]);
|
||||
setLifecycleForm(30, 0, 0);
|
||||
fillTunnelGroupSelect(groupId || '');
|
||||
document.getElementById('probeResult').innerText = '';
|
||||
resetPlan();
|
||||
autoRecommendPort();
|
||||
setTargetRows([{ host: '', ports: '' }]);
|
||||
document.getElementById('tunnelModal').classList.add('open');
|
||||
document.querySelector('#targetRows .t-host').focus();
|
||||
}
|
||||
|
||||
function editTunnel(id) {
|
||||
const t = currentTunnels.find(item => item.id === id);
|
||||
if (!t) return;
|
||||
fixedGroupId = null;
|
||||
document.getElementById('modalTitle').innerText = '编辑代理通道';
|
||||
document.getElementById('tunnelId').value = t.id;
|
||||
document.getElementById('tunnelName').value = t.name;
|
||||
document.getElementById('tunnelOwner').value = t.owner || '';
|
||||
document.getElementById('tunnelTarget').value = t.target;
|
||||
document.getElementById('tunnelTargetLabel').innerText = '目标地址(中转机 Mac 可直连的内网目标)';
|
||||
document.getElementById('tunnelTarget').placeholder = '例如:10.0.0.10:3306';
|
||||
document.getElementById('tunnelTargetLabel').innerText = '目标服务(内网主机与端口)';
|
||||
const hp = splitHostPort(t.target) || [t.target, ''];
|
||||
document.getElementById('tunnelPort').value = t.listen_port;
|
||||
setAccessForm(t.whitelist || []);
|
||||
setLifecycleForm(t.effective_idle_close_minutes ?? 30, t.duration_minutes || 0, t.max_bandwidth_mb || 0);
|
||||
fillTunnelGroupSelect(t.group_id || '');
|
||||
document.getElementById('probeResult').innerText = '';
|
||||
resetPlan();
|
||||
setTargetRows([{ host: hp[0], ports: hp[1] }]);
|
||||
document.getElementById('tunnelModal').classList.add('open');
|
||||
}
|
||||
|
||||
@@ -1114,6 +1188,96 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 目标列表:每行一台主机 + 一个或多个端口 ----
|
||||
const isEditing = () => !!document.getElementById('tunnelId').value;
|
||||
const listItems = s => s.split(/[\s,,、;;]+/).filter(Boolean);
|
||||
|
||||
// "10.0.1.11:22" / "[fd00::1]:22" → [主机, 端口];不带端口(含裸 IPv6)返回 null
|
||||
function splitHostPort(item) {
|
||||
const m = /^\[(.+)\]:(.+)$/.exec(item);
|
||||
if (m) return [m[1], m[2]];
|
||||
const parts = item.split(':');
|
||||
return parts.length === 2 && parts[0] && parts[1] ? parts : null;
|
||||
}
|
||||
|
||||
// 粘贴进来的一串目标拆成多行:每项是 "主机" 或 "主机:端口",同一主机的端口并到一行
|
||||
function parseTargets(text, defaultPorts) {
|
||||
const out = [];
|
||||
for (const item of listItems(text)) {
|
||||
const [host, port] = splitHostPort(item) || [item, ''];
|
||||
const row = out.find(r => r.host === host);
|
||||
if (!row) out.push({ host, ports: port || defaultPorts });
|
||||
else if (port && !listItems(row.ports).includes(port)) row.ports = row.ports ? `${row.ports}, ${port}` : port;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 拼成 "主机:端口, …" 交给服务端展开;还没填完整的行先跳过
|
||||
function composeTarget(rows) {
|
||||
const out = [];
|
||||
for (const r of rows) {
|
||||
const ports = listItems(r.ports);
|
||||
for (const h of listItems(r.host)) {
|
||||
if (splitHostPort(h)) { out.push(h); continue; }
|
||||
if (!ports.length) continue;
|
||||
const host = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
|
||||
ports.forEach(p => out.push(`${host}:${p}`));
|
||||
}
|
||||
}
|
||||
return out.join(', ');
|
||||
}
|
||||
|
||||
function readTargetRows() {
|
||||
return [...document.querySelectorAll('#targetRows .target-row')].map(r => ({
|
||||
host: r.querySelector('.t-host').value.trim(), ports: r.querySelector('.t-ports').value.trim()
|
||||
}));
|
||||
}
|
||||
|
||||
function setTargetRows(rows) {
|
||||
const edit = isEditing();
|
||||
document.getElementById('targetRows').innerHTML = rows.map(r => `
|
||||
<div class="target-row">
|
||||
<input type="text" class="t-host" value="${escapeHtml(r.host)}" placeholder="${edit ? 'IP 或域名' : 'IP 或域名,支持粘贴多行'}" oninput="schedulePlan()" onpaste="onHostPaste(event)">
|
||||
<input type="text" class="t-ports" value="${escapeHtml(r.ports)}" placeholder="${edit ? '如 3306' : '如 22, 3306'}" oninput="schedulePlan()">
|
||||
${edit ? '' : `<button type="button" class="icon-btn" onclick="removeTargetRow(this)" title="删除这一行" ${rows.length === 1 ? 'disabled' : ''}>⊖</button>`}
|
||||
</div>`).join('');
|
||||
document.getElementById('addRowBtn').style.display = edit ? 'none' : '';
|
||||
document.getElementById('targetHead').style.display = edit ? 'none' : 'flex';
|
||||
schedulePlan();
|
||||
}
|
||||
|
||||
// 新的一行沿用上一行的端口(同一组机器通常开同样的端口)
|
||||
function addTargetRow() {
|
||||
const rows = readTargetRows();
|
||||
rows.push({ host: '', ports: rows.length ? rows[rows.length - 1].ports : '' });
|
||||
setTargetRows(rows);
|
||||
const hosts = document.querySelectorAll('#targetRows .t-host');
|
||||
hosts[hosts.length - 1].focus();
|
||||
}
|
||||
|
||||
function removeTargetRow(btn) {
|
||||
const all = [...document.querySelectorAll('#targetRows .target-row')];
|
||||
const rows = readTargetRows();
|
||||
rows.splice(all.indexOf(btn.closest('.target-row')), 1);
|
||||
setTargetRows(rows.length ? rows : [{ host: '', ports: '' }]);
|
||||
}
|
||||
|
||||
// 一次粘贴多个主机(或 主机:端口)时拆成多行
|
||||
function onHostPaste(e) {
|
||||
if (isEditing()) return;
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text');
|
||||
if (listItems(text).length < 2 && !splitHostPort(text.trim())) return;
|
||||
e.preventDefault();
|
||||
const all = [...document.querySelectorAll('#targetRows .target-row')];
|
||||
const idx = all.indexOf(e.target.closest('.target-row'));
|
||||
const rows = readTargetRows();
|
||||
const parsed = parseTargets(text, rows[idx].ports);
|
||||
// 粘贴到空行时替换这一行,否则插在它后面
|
||||
if (rows[idx].host) rows.splice(idx + 1, 0, ...parsed);
|
||||
else rows.splice(idx, 1, ...parsed);
|
||||
setTargetRows(rows);
|
||||
}
|
||||
|
||||
async function probeOne(target) {
|
||||
const res = await fetch('/api/probe', {
|
||||
method: 'POST',
|
||||
@@ -1123,71 +1287,62 @@
|
||||
return res.json();
|
||||
}
|
||||
|
||||
let probeResults = {};
|
||||
|
||||
async function probeTarget() {
|
||||
const target = document.getElementById('tunnelTarget').value.trim();
|
||||
if (!target) {
|
||||
alert('请先输入目标地址');
|
||||
return;
|
||||
}
|
||||
const targets = isBatch() ? tunnelPlan.map(t => t.target) : [target];
|
||||
const target = composeTarget(readTargetRows());
|
||||
if (!target) { alert('请先填写目标主机和端口'); return; }
|
||||
const targets = isEditing() ? [target] : tunnelPlan.map(t => t.target);
|
||||
if (!targets.length) return;
|
||||
const hint = document.getElementById('probeResult');
|
||||
hint.innerText = '探测中...';
|
||||
hint.style.color = 'var(--text-muted)';
|
||||
try {
|
||||
const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
|
||||
const failed = results.map((r, i) => [targets[i], r]).filter(([, r]) => !r.reachable);
|
||||
if (targets.length === 1) {
|
||||
const data = results[0];
|
||||
hint.innerText = data.reachable ? `✓ 连通性测试通过!延迟: ${data.rtt}` : `✗ 连通失败: ${data.error || '连接超时'}`;
|
||||
} else if (!failed.length) {
|
||||
hint.innerText = `✓ 全部 ${targets.length} 个目标连通`;
|
||||
} else {
|
||||
const [t, r] = failed[0];
|
||||
hint.innerText = `✗ ${targets.length - failed.length}/${targets.length} 连通;${t} 连通失败: ${r.error || '连接超时'}` + (failed.length > 1 ? `(共 ${failed.length} 个失败)` : '');
|
||||
}
|
||||
hint.style.color = failed.length ? 'var(--danger)' : 'var(--success)';
|
||||
} catch (e) {
|
||||
hint.innerText = '探测请求失败';
|
||||
hint.style.color = 'var(--danger)';
|
||||
const results = await Promise.all(targets.map(t => probeOne(t).catch(() => ({ reachable: false, error: '探测请求失败' }))));
|
||||
probeResults = {};
|
||||
targets.forEach((t, i) => { probeResults[t] = results[i]; });
|
||||
if (isEditing()) {
|
||||
const r = results[0];
|
||||
hint.innerText = r.reachable ? `✓ 连通性测试通过!延迟: ${r.rtt}` : `✗ 连通失败: ${r.error || '连接超时'}`;
|
||||
hint.style.color = r.reachable ? 'var(--success)' : 'var(--danger)';
|
||||
return;
|
||||
}
|
||||
hint.innerText = '';
|
||||
renderPlan();
|
||||
}
|
||||
|
||||
// ---- 多个目标:预览将创建的通道,并放进通道组 ----
|
||||
let tunnelPlan = [], tunnelPlanError = null, planTimer = null;
|
||||
// ---- 预览将创建的通道(含自动分配的访问端口);多个地址自动放进通道组 ----
|
||||
let tunnelPlan = [], tunnelPlanError = null, planTimer = null, plannedTarget = '';
|
||||
|
||||
function resetPlan() {
|
||||
tunnelPlan = [];
|
||||
tunnelPlanError = null;
|
||||
clearTimeout(planTimer);
|
||||
probeResults = {};
|
||||
document.getElementById('probeResult').innerText = '';
|
||||
renderPlan();
|
||||
}
|
||||
|
||||
// 可能填了多个目标(逗号 / 空格分隔,或含范围 "10.0.1.11-13:22"),需要服务端展开
|
||||
function mayBeMulti() {
|
||||
if (document.getElementById('tunnelId').value) return false;
|
||||
const target = document.getElementById('tunnelTarget').value.trim();
|
||||
return target.split(/[\s,,、;;]+/).filter(Boolean).length > 1 || target.includes('-');
|
||||
}
|
||||
|
||||
function isBatch() {
|
||||
return mayBeMulti() && tunnelPlan.length > 1;
|
||||
return !isEditing() && tunnelPlan.length > 1;
|
||||
}
|
||||
|
||||
// 通道组选项:'' 不分组,'__new__' 新建,其余为已有组 ID
|
||||
function groupFields() {
|
||||
const sel = document.getElementById('tunnelGroup').value;
|
||||
if (sel === '__new__') {
|
||||
const name = document.getElementById('tunnelNewGroup').value.trim() || document.getElementById('tunnelName').value.trim();
|
||||
return { group_id: '', new_group: name };
|
||||
const name = document.getElementById('tunnelName').value.trim();
|
||||
if (!isEditing()) {
|
||||
// 新建:「添加地址」固定加入该组;多个地址自动新建以名称命名的组;一个地址不分组
|
||||
if (fixedGroupId) return { group_id: fixedGroupId, new_group: '' };
|
||||
return { group_id: '', new_group: isBatch() ? name : '' };
|
||||
}
|
||||
return { group_id: sel, new_group: '' };
|
||||
const sel = document.getElementById('tunnelGroup').value;
|
||||
if (sel !== '__new__') return { group_id: sel, new_group: '' };
|
||||
return { group_id: '', new_group: document.getElementById('tunnelNewGroup').value.trim() || name };
|
||||
}
|
||||
|
||||
function tunnelPayload(port, whitelist) {
|
||||
return {
|
||||
name: document.getElementById('tunnelName').value.trim(),
|
||||
owner: document.getElementById('tunnelOwner').value.trim(),
|
||||
target: document.getElementById('tunnelTarget').value.trim(),
|
||||
target: composeTarget(readTargetRows()),
|
||||
listen_port: port,
|
||||
whitelist,
|
||||
idle_close_minutes: parseInt(document.getElementById('tunnelIdleClose').value, 10),
|
||||
@@ -1199,7 +1354,9 @@
|
||||
|
||||
function schedulePlan() {
|
||||
clearTimeout(planTimer);
|
||||
if (!mayBeMulti()) { tunnelPlan = []; tunnelPlanError = null; renderPlan(); return; }
|
||||
const target = composeTarget(readTargetRows());
|
||||
if (target !== plannedTarget) { probeResults = {}; plannedTarget = target; }
|
||||
if (isEditing() || !target) { tunnelPlan = []; tunnelPlanError = null; renderPlan(); return; }
|
||||
planTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/tunnels/batch', {
|
||||
@@ -1222,28 +1379,37 @@
|
||||
}
|
||||
|
||||
function renderPlan() {
|
||||
const batch = isBatch();
|
||||
const box = document.getElementById('tunnelPlan');
|
||||
document.getElementById('tunnelPortWrap').style.display = batch ? 'none' : 'block';
|
||||
// 多个目标必须放进同一个通道组
|
||||
const sel = document.getElementById('tunnelGroup');
|
||||
const none = sel.querySelector('option[value=""]');
|
||||
if (none) none.disabled = batch;
|
||||
if (batch && sel.value === '') { sel.value = '__new__'; onTunnelGroupChange(true); }
|
||||
const batch = isBatch(), edit = isEditing();
|
||||
document.getElementById('tunnelPortWrap').style.display = edit ? 'block' : 'none';
|
||||
document.getElementById('tunnelGroupWrap').style.display = edit ? 'block' : 'none';
|
||||
updateGroupHint();
|
||||
document.getElementById('tunnelSaveBtn').innerText = batch ? `创建 ${tunnelPlan.length} 个通道` : '保存通道';
|
||||
if (mayBeMulti() && tunnelPlanError) {
|
||||
const n = tunnelPlan.length;
|
||||
document.getElementById('tunnelSaveBtn').innerText = fixedGroupId ? (batch ? `添加 ${n} 个地址` : '添加')
|
||||
: (batch ? `创建 ${n} 个通道` : '保存通道');
|
||||
|
||||
const box = document.getElementById('tunnelPlan');
|
||||
if (tunnelPlanError) {
|
||||
box.style.display = 'block';
|
||||
box.style.color = 'var(--danger)';
|
||||
box.innerText = tunnelPlanError;
|
||||
return;
|
||||
}
|
||||
if (!batch) { box.style.display = 'none'; box.innerHTML = ''; return; }
|
||||
const shown = tunnelPlan.slice(0, 6);
|
||||
if (!tunnelPlan.length) { box.style.display = 'none'; box.innerHTML = ''; return; }
|
||||
const shown = tunnelPlan.slice(0, 8);
|
||||
const tested = tunnelPlan.filter(m => probeResults[m.target]);
|
||||
const failed = tested.filter(m => !probeResults[m.target].reachable);
|
||||
const status = m => {
|
||||
const r = probeResults[m.target];
|
||||
if (!r) return '';
|
||||
return r.reachable ? ` <span class="ok">✓ ${escapeHtml(r.rtt)}</span>` : ` <span class="bad" title="${escapeHtml(r.error || '连接超时')}">✗ 不通</span>`;
|
||||
};
|
||||
let caption = tunnelPlan.length === 1 ? '访问端口自动分配' : '每个地址一个通道,访问端口自动连续分配';
|
||||
if (tested.length === tunnelPlan.length && !failed.length) caption = tunnelPlan.length === 1 ? '目标连接成功' : `全部 ${tunnelPlan.length} 个目标连接成功`;
|
||||
if (failed.length) caption = `<span class="bad">${failed.length} 个目标连不上,鼠标停在 ✗ 上可查看原因</span>`;
|
||||
box.style.display = 'block';
|
||||
box.style.color = '';
|
||||
box.innerHTML = shown.map(m => `<div class="preview-row"><span class="route-endpoint">${escapeHtml(publicHost)}:${m.listen_port}</span> <span class="route-arrow">➔</span> <span class="route-target">${escapeHtml(m.target)}</span></div>`).join('')
|
||||
+ `<div>${tunnelPlan.length > shown.length ? '… ' : ''}共 ${tunnelPlan.length} 个地址,每个地址一个通道,访问端口自动连续分配</div>`;
|
||||
box.innerHTML = shown.map(m => `<div class="preview-row"><span class="route-endpoint">${escapeHtml(publicHost)}:${m.listen_port}</span> <span class="route-arrow">➔</span> <span class="route-target">${escapeHtml(m.target)}</span>${status(m)}</div>`).join('')
|
||||
+ `<div>${tunnelPlan.length > shown.length ? `… 共 ${tunnelPlan.length} 个地址,` : ''}${caption}</div>`;
|
||||
}
|
||||
|
||||
function fillTunnelGroupSelect(selected) {
|
||||
@@ -1252,28 +1418,38 @@
|
||||
(currentGroups.length ? '<option disabled>──────────</option>' : '') +
|
||||
currentGroups.map(g => `<option value="${escapeHtml(g.id)}" ${g.id === selected ? 'selected' : ''}>${escapeHtml(g.name)}</option>`).join('');
|
||||
document.getElementById('tunnelNewGroup').value = '';
|
||||
onTunnelGroupChange(true);
|
||||
updateGroupHint();
|
||||
}
|
||||
|
||||
function updateGroupHint() {
|
||||
const sel = document.getElementById('tunnelGroup').value;
|
||||
const isNew = sel === '__new__';
|
||||
const name = document.getElementById('tunnelName').value.trim();
|
||||
const nameInput = document.getElementById('tunnelName');
|
||||
const label = document.getElementById('tunnelNameLabel');
|
||||
if (!isEditing()) {
|
||||
// 新建:不需要选择通道组,只说明结果
|
||||
const g = fixedGroupId ? ((currentGroups.find(x => x.id === fixedGroupId) || {}).name || '通道组') : null;
|
||||
let hint = '';
|
||||
if (g) hint = `新地址加入通道组「${g}」,自动沿用组的邀请和授权,已授权的同事无需重新授权`;
|
||||
else if (isBatch()) hint = `${tunnelPlan.length} 个地址将放进通道组${name ? `「${name}」` : '(以上面填写的名称命名)'}:共用邀请和授权,同事授权一次即可访问全部地址`;
|
||||
document.getElementById('tunnelAddHint').innerText = hint;
|
||||
label.innerText = g ? '通道名称(可选)' : (isBatch() ? '通道组名称' : '通道名称');
|
||||
nameInput.placeholder = g ? `留空则命名为「${g} (主机)」` : (isBatch() ? '如:订单服务集群' : '如:订单数据库');
|
||||
return;
|
||||
}
|
||||
document.getElementById('tunnelAddHint').innerText = '';
|
||||
label.innerText = '通道名称';
|
||||
nameInput.placeholder = '如:订单数据库';
|
||||
const sel = document.getElementById('tunnelGroup').value;
|
||||
const newInput = document.getElementById('tunnelNewGroup');
|
||||
newInput.style.display = isNew ? 'block' : 'none';
|
||||
newInput.placeholder = name ? `组名,默认「${name}」` : '通道组名称';
|
||||
document.getElementById('tunnelGroupHint').innerText = isBatch()
|
||||
? '多个地址会放进同一个通道组:共用邀请和授权,同事授权一次即可访问全部地址'
|
||||
newInput.style.display = sel === '__new__' ? 'block' : 'none';
|
||||
newInput.placeholder = name ? `通道组名称,留空则用「${name}」` : '通道组名称';
|
||||
document.getElementById('tunnelGroupHint').innerText = sel === '__new__' ? '新建通道组并移入;之后可在组卡片的「编辑」里把其它通道加入'
|
||||
: (sel ? '组内通道共用邀请和授权,同事授权一次即可访问组内全部地址' : '');
|
||||
// 放进通道组时名称可以不填,按组名命名
|
||||
const g = isNew ? newInput.value.trim() : (currentGroups.find(x => x.id === sel) || {}).name;
|
||||
document.getElementById('tunnelName').placeholder = (!document.getElementById('tunnelId').value && g)
|
||||
? `可不填,默认「${g} (主机)」` : '例如:MySQL 实习库 或 财务系统 SSH';
|
||||
}
|
||||
|
||||
function onTunnelGroupChange(skipPlan) {
|
||||
function onTunnelGroupChange() {
|
||||
updateGroupHint();
|
||||
if (!skipPlan) schedulePlan();
|
||||
schedulePlan();
|
||||
}
|
||||
|
||||
// 闲置自动关闭 / 最长开启时长;旧配置中不在选项里的值临时加入下拉框
|
||||
@@ -1309,11 +1485,22 @@
|
||||
const id = document.getElementById('tunnelId').value;
|
||||
const batch = isBatch();
|
||||
const sel = document.getElementById('tunnelGroup').value;
|
||||
const port = parseInt(document.getElementById('tunnelPort').value, 10);
|
||||
if (!batch && !(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; }
|
||||
if (mayBeMulti() && tunnelPlanError) { alert(tunnelPlanError); return; }
|
||||
if (!document.getElementById('tunnelName').value.trim() && (id || !sel)) { alert('请填写通道名称'); return; }
|
||||
if (sel === '__new__' && !groupFields().new_group) { alert('请填写通道组名称'); return; }
|
||||
// 加入已有组时组的授权自动覆盖新地址,不需要再发邀请
|
||||
const joinsExisting = !id && !!fixedGroupId;
|
||||
const name = document.getElementById('tunnelName').value.trim();
|
||||
const target = composeTarget(readTargetRows());
|
||||
let port = 0;
|
||||
if (id) {
|
||||
port = parseInt(document.getElementById('tunnelPort').value, 10);
|
||||
if (!(port > 33000 && port <= 34000)) { alert('端口须在 33001-34000 范围内'); return; }
|
||||
if (!target) { alert('请填写目标主机和端口'); return; }
|
||||
if (target.includes(',')) { alert('一个通道只对应一个目标端口;要开放更多端口,请用「新建代理通道」'); return; }
|
||||
} else {
|
||||
if (tunnelPlanError) { alert(tunnelPlanError); return; }
|
||||
if (!tunnelPlan.length) { alert('请填写目标主机和端口'); return; }
|
||||
}
|
||||
if (!name && (id || !joinsExisting)) { alert(batch ? '请填写通道组名称' : '请填写通道名称'); return; }
|
||||
if (id && sel === '__new__' && !groupFields().new_group) { alert('请填写通道组名称'); return; }
|
||||
const mode = document.getElementById('tunnelAccessMode').value;
|
||||
let whitelist = [];
|
||||
if (mode === 'all') whitelist = ['*'];
|
||||
@@ -1322,10 +1509,10 @@
|
||||
if (whitelist.length === 0) { alert('请填写允许访问的 IP 或网段,或选择“仅授权链接”'); return; }
|
||||
if (whitelist.includes('*')) { alert('如需放通全部,请选择“全部放通 *”'); return; }
|
||||
}
|
||||
const payload = tunnelPayload(batch ? 0 : port, whitelist);
|
||||
const payload = tunnelPayload(port, whitelist);
|
||||
|
||||
try {
|
||||
// 新建统一走 /api/tunnels/batch:单个目标、多个目标、新建或加入通道组都在这一个表单里
|
||||
// 新建统一走 /api/tunnels/batch:一个或多个地址、新建或加入通道组都在这一个表单里,访问端口自动分配
|
||||
const res = await fetch(id ? `/api/tunnels/${encodeURIComponent(id)}` : '/api/tunnels/batch', {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1339,7 +1526,6 @@
|
||||
closeTunnelModal();
|
||||
await fetchTunnels();
|
||||
// 新建后直接生成邀请:新建的通道组邀请整组;加入已有组时组的授权自动覆盖新地址,不用再邀请
|
||||
const joinsExisting = sel !== '' && sel !== '__new__';
|
||||
if (!id && mode !== 'all' && !joinsExisting) {
|
||||
const key = data.group ? 'g:' + data.group.id : 't:' + data.tunnels[0].id;
|
||||
setTimeout(() => openInviteModal(key, false), 800);
|
||||
@@ -1394,19 +1580,19 @@
|
||||
}
|
||||
function scopeInfo(key) {
|
||||
const s = parseScope(key);
|
||||
let name, members, m;
|
||||
let name, members, m, renewals;
|
||||
if (s.group) {
|
||||
const g = currentGroups.find(x => x.id === s.id);
|
||||
if (!g) return null;
|
||||
name = g.name; members = groupMembers(g); m = g.metrics || {};
|
||||
name = g.name; members = groupMembers(g); m = g.metrics || {}; renewals = g.renewals;
|
||||
} else {
|
||||
const t = currentTunnels.find(x => x.id === s.id);
|
||||
if (!t) return null;
|
||||
name = t.name; members = [t]; m = t.metrics || {};
|
||||
name = t.name; members = [t]; m = t.metrics || {}; renewals = t.renewals;
|
||||
}
|
||||
return {
|
||||
group: s.group, name, members, enabled: members.some(t => t.enabled),
|
||||
leases: m.dynamic_leases || [], invites: m.invites || [], pending: m.pending || [],
|
||||
leases: m.dynamic_leases || [], invites: m.invites || [], pending: m.pending || [], renewals: renewals || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1429,7 +1615,10 @@
|
||||
|
||||
const cardStyle = 'background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; display:flex; justify-content:space-between; align-items:center; gap: 10px;';
|
||||
const pad2 = n => String(n).padStart(2, '0');
|
||||
const leaseTitle = d => ({ '1h': '1 小时', '8h': '8 小时', today: '当天有效', tunnel: '通道开启期间' }[d] || '2 小时');
|
||||
const leaseTitle = d => ({ '1h': '1 小时', '8h': '8 小时', today: '当天有效', tunnel: '通道开启期间' }[d]
|
||||
|| (/^\d+m$/.test(d || '') ? fmtMinutes(parseInt(d, 10)) : '2 小时'));
|
||||
const fmtClock = v => { const d = new Date(v); const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
return d.toDateString() === new Date().toDateString() ? hm : `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${hm}`; };
|
||||
const fmtDT = v => {
|
||||
const d = new Date(v);
|
||||
const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
@@ -1439,9 +1628,31 @@
|
||||
function renderLeases(key) {
|
||||
const info = scopeInfo(key);
|
||||
if (!info) return { pending: '', auth: '' };
|
||||
const { leases, pending, invites } = info;
|
||||
const { leases, pending, invites, renewals } = info;
|
||||
const k = escapeHtml(key);
|
||||
let html = '';
|
||||
const closedTag = info.enabled ? '' : ' <span style="font-size: 11px; color: var(--text-muted);">· 通道已关闭,批准后自动开启</span>';
|
||||
const extendBtn = (r, asking) => `<button class="${asking ? 'btn-primary' : 'btn-secondary'} btn-sm" onclick='openExtendModal(${escapeHtml(JSON.stringify(key))}, ${escapeHtml(JSON.stringify(r.ip))}, ${escapeHtml(JSON.stringify(r.name || ''))}, ${asking})'>${asking ? '批准延长…' : '延长…'}</button>`;
|
||||
const renewDetail = r => [
|
||||
r.request ? `申请于 ${fmtClock(r.request.at)}` : '',
|
||||
r.ended_at ? `访问已于 ${fmtClock(r.ended_at)} 结束` : '',
|
||||
r.renew_before ? `${fmtClock(r.renew_before)} 前可延长` : '',
|
||||
!r.ended_at ? '当前仍有效,批准后在原到期时间上顺延' : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
const renewCard = r => `
|
||||
<div style="${cardStyle}">
|
||||
<div><b>${escapeHtml(r.name || '未署名')}</b> <code style="color:#38bdf8;">${escapeHtml(r.ip)}</code>${closedTag}
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${renewDetail(r)}</div></div>
|
||||
<div class="drawer-row-actions">
|
||||
${r.request ? `<button class="btn-secondary btn-sm" onclick="decideRequest('${k}', '${escapeHtml(r.request.id)}', false)">拒绝</button>` : ''}
|
||||
${extendBtn(r, !!r.request)}
|
||||
</div>
|
||||
</div>`;
|
||||
const asks = renewals.filter(r => r.request), ended = renewals.filter(r => !r.request && r.ended_at);
|
||||
if (asks.length) {
|
||||
html += `<b style="font-size:13px; display:block; margin: 4px 0 10px; color:#fbbf24;">申请延长 (${asks.length})</b>`;
|
||||
html += asks.map(renewCard).join('');
|
||||
}
|
||||
|
||||
if (pending.length) {
|
||||
html += `<b style="font-size:13px; display:block; margin: 4px 0 10px; color:#fbbf24;">待审批 (${pending.length})</b>`;
|
||||
@@ -1479,10 +1690,18 @@
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${l.awaiting_connect
|
||||
? `已授权,尚未连接 · 首次连接后可访问 ${leaseTitle(l.duration)} · 请在 ${fmtDT(l.expires_at)} 前连接`
|
||||
: `${l.started_at ? '首次连接 ' + fmtDT(l.started_at) + ' · ' : ''}有效期至 ${fmtDT(l.expires_at)} · 剩余约 ${mins} 分钟`}</div></div>
|
||||
<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '${escapeHtml(l.ip)}')">撤销</button>
|
||||
<div class="drawer-row-actions">
|
||||
${l.awaiting_connect ? '' : extendBtn(l, false)}
|
||||
<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '${escapeHtml(l.ip)}')">撤销</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
if (ended.length) {
|
||||
html += `<b style="font-size:13px; display:block; margin: 8px 0 10px;">最近结束 · 2 小时内可延长 (${ended.length})</b>`;
|
||||
html += ended.map(renewCard).join('');
|
||||
}
|
||||
|
||||
html += `<b style="font-size:13px; display:block; margin: 8px 0 10px;">有效的邀请链接 (${invites.length})</b>`;
|
||||
html += invites.length === 0
|
||||
? '<div style="color: var(--text-muted); font-size: 12px; margin-bottom: 14px;">暂无有效的邀请链接</div>'
|
||||
@@ -1588,6 +1807,63 @@
|
||||
document.getElementById('inviteResult').style.display = 'block';
|
||||
}
|
||||
|
||||
// ---- 延长访问:批准延长申请或主动延长,确定时长后立即生效;通道已关闭时会自动重新开启 ----
|
||||
let extendTarget = null;
|
||||
const EXTEND_OPTIONS = [[30, '30 分钟'], [60, '1 小时'], [120, '2 小时'], [480, '8 小时']];
|
||||
|
||||
function openExtendModal(key, ip, name, asking) {
|
||||
const info = scopeInfo(key);
|
||||
extendTarget = { key, ip };
|
||||
document.getElementById('extendTitle').innerText = `${asking ? '批准延长' : '延长访问'} · ${name || ip}`;
|
||||
document.getElementById('extendHint').innerText = (info && !info.enabled ? '通道当前已关闭,延长后会自动重新开启。' : '')
|
||||
+ '访问还没结束时从原到期时间顺延;已结束时从现在开始计算。';
|
||||
document.getElementById('extendChips').innerHTML = EXTEND_OPTIONS.map(([m, t]) =>
|
||||
`<button type="button" class="chip-btn" data-m="${m}" onclick="setExtendMinutes(${m})">${t}</button>`).join('');
|
||||
setExtendMinutes(60);
|
||||
document.getElementById('extendModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeExtendModal() {
|
||||
document.getElementById('extendModal').classList.remove('open');
|
||||
extendTarget = null;
|
||||
}
|
||||
|
||||
function setExtendMinutes(m) {
|
||||
const hours = m >= 60 && m % 60 === 0;
|
||||
document.getElementById('extendAmount').value = hours ? m / 60 : m;
|
||||
document.getElementById('extendUnit').value = hours ? '60' : '1';
|
||||
updateExtend();
|
||||
}
|
||||
|
||||
function extendMinutes() {
|
||||
const n = parseInt(document.getElementById('extendAmount').value, 10);
|
||||
const m = n * parseInt(document.getElementById('extendUnit').value, 10);
|
||||
return n > 0 && m <= 43200 ? m : 0;
|
||||
}
|
||||
|
||||
function updateExtend() {
|
||||
const m = extendMinutes();
|
||||
document.querySelectorAll('#extendChips .chip-btn').forEach(b => b.classList.toggle('on', +b.dataset.m === m));
|
||||
const btn = document.getElementById('extendConfirm');
|
||||
btn.disabled = !m;
|
||||
btn.innerText = m ? `延长 ${fmtMinutes(m)}` : '请输入 1 分钟到 30 天';
|
||||
}
|
||||
|
||||
async function confirmExtend() {
|
||||
const m = extendMinutes();
|
||||
if (!extendTarget || !m) return;
|
||||
const btn = document.getElementById('extendConfirm');
|
||||
btn.disabled = true;
|
||||
btn.innerText = '处理中…';
|
||||
const res = await fetch(`${scopeBase(extendTarget.key)}/leases/extend`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ip: extendTarget.ip, minutes: m })
|
||||
});
|
||||
if (!res.ok) { alert('延长失败: ' + await apiError(res)); updateExtend(); return; }
|
||||
closeExtendModal();
|
||||
await fetchTunnels();
|
||||
}
|
||||
|
||||
async function revokeLease(key, ip) {
|
||||
if (!confirm(ip ? `撤销 ${ip} 的访问权限?该设备的连接会被立即断开。` : '撤销全部已授权设备?')) return;
|
||||
const res = await fetch(`${scopeBase(key)}/leases/revoke`, {
|
||||
@@ -1598,6 +1874,14 @@
|
||||
if (!res.ok) alert('撤销失败: ' + await apiError(res));
|
||||
}
|
||||
|
||||
// 通道组的在线连接可以按设备(来源 IP)或按组内地址查看
|
||||
let drawerByAddress = false;
|
||||
function setDrawerDimension(v) {
|
||||
drawerByAddress = v;
|
||||
renderDrawerClients();
|
||||
}
|
||||
const hostOfAddr = a => { const m = /^\[(.+)\]:\d+$/.exec(a) || /^([^:]+):\d+$/.exec(a); return m ? m[1] : a; };
|
||||
|
||||
function renderDrawerClients() {
|
||||
if (!drawerScope) return;
|
||||
const info = scopeInfo(drawerScope);
|
||||
@@ -1616,15 +1900,45 @@
|
||||
// 同一 IP 的连接合并展示
|
||||
const groups = {};
|
||||
list.forEach(c => {
|
||||
const m = /^\[(.+)\]:\d+$/.exec(c.client_ip) || /^([^:]+):\d+$/.exec(c.client_ip);
|
||||
const ip = m ? m[1] : c.client_ip;
|
||||
const ip = hostOfAddr(c.client_ip);
|
||||
(groups[ip] = groups[ip] || []).push(c);
|
||||
});
|
||||
const leaseNames = new Map(info.leases.map(l => [l.ip, l.name || '']));
|
||||
const sum = (arr, k) => arr.reduce((a, c) => a + (c[k] || 0), 0);
|
||||
const ips = Object.keys(groups).sort((x, y) => groups[y].length - groups[x].length || x.localeCompare(y));
|
||||
const byAddress = info.group && drawerByAddress;
|
||||
const inUse = new Set(list.map(c => c.tunnel_id)).size;
|
||||
const toggle = info.group ? `<span class="dim-toggle"><button type="button" class="${byAddress ? '' : 'on'}" onclick="setDrawerDimension(false)">按设备</button><button type="button" class="${byAddress ? 'on' : ''}" onclick="setDrawerDimension(true)">按地址</button></span>` : '';
|
||||
const header = `<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (${byAddress ? `${inUse}/${info.members.length} 个地址在用` : `${ips.length} 台 · ${list.length} 连接`})${toggle}</b>`;
|
||||
|
||||
container.innerHTML = parts.pending + `<b style="font-size:13px; display:block; margin-bottom:10px;">在线连接 (${ips.length} 台 · ${list.length} 连接)</b>` + ips.map(ip => {
|
||||
if (byAddress) {
|
||||
container.innerHTML = parts.pending + header + info.members.map(t => {
|
||||
const conns = list.filter(c => c.tunnel_id === t.id);
|
||||
const byIp = {};
|
||||
conns.forEach(c => { const ip = hostOfAddr(c.client_ip); (byIp[ip] = byIp[ip] || []).push(c); });
|
||||
const devs = Object.keys(byIp).sort();
|
||||
const kill = cs => escapeHtml(JSON.stringify(cs.map(c => c.conn_id)));
|
||||
return `
|
||||
<div style="background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; ${conns.length ? '' : 'opacity: 0.55;'}">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; gap: 8px;">
|
||||
<div style="font-size: 13px;">
|
||||
<code style="color: #38bdf8; font-weight: 600;">${escapeHtml(publicHost)}:${t.listen_port}</code> <span class="route-arrow">➔</span> <code>${escapeHtml(t.target)}</code>
|
||||
<span style="font-size: 11px; color: var(--text-muted); margin-left: 6px;">${conns.length ? `${devs.length} 台设备 · ${conns.length} 个连接` : (t.enabled ? '无连接' : '已关闭')}</span>
|
||||
</div>
|
||||
${conns.length ? `<button class="btn-danger btn-sm" onclick='killConns(${kill(conns)})'>全部断开</button>` : ''}
|
||||
</div>
|
||||
${devs.map(ip => `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--text-muted); padding: 6px 0 0;">
|
||||
<span><code style="color: var(--text);">${escapeHtml(ip)}</code>${leaseNames.get(ip) ? ` <span style="color: #34d399;">${escapeHtml(leaseNames.get(ip))}</span>` : ''}
|
||||
· ${byIp[ip].length} 个连接 · ↑ ${formatRate(sum(byIp[ip], 'rate_in'))} / ↓ ${formatRate(sum(byIp[ip], 'rate_out'))}</span>
|
||||
<button class="btn-secondary btn-sm" onclick='killConns(${kill(byIp[ip])})'>断开</button>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}).join('') + parts.auth;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = parts.pending + header + ips.map(ip => {
|
||||
const conns = groups[ip];
|
||||
const first = conns.map(c => new Date(c.connected_at)).sort((x, y) => x - y)[0];
|
||||
const ids = conns.map(c => c.conn_id);
|
||||
@@ -1708,6 +2022,8 @@
|
||||
}
|
||||
const m = g.metrics || {}, key = escapeHtml('g:' + g.id);
|
||||
if ((m.pending || []).length) parts.push(`<span class="chip orange" onclick="openDrawer('${key}')">🔔 待审批 ${m.pending.length}</span>`);
|
||||
const asks = (g.renewals || []).filter(r => r.request).length;
|
||||
if (asks) parts.push(`<span class="chip orange" onclick="openDrawer('${key}')">⏱ 申请延长 ${asks}</span>`);
|
||||
if ((m.dynamic_leases || []).length) parts.push(`<span class="chip green" onclick="openDrawer('${key}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
+106
-12
@@ -1,6 +1,11 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message types
|
||||
const (
|
||||
@@ -18,6 +23,8 @@ const (
|
||||
TypeRevokeInvite = "revoke_invite"
|
||||
TypeDecideRequest = "decide_request"
|
||||
TypeDeleteGroup = "delete_group"
|
||||
TypeExtendLease = "extend_lease"
|
||||
TypeExtendResp = "extend_lease_resp"
|
||||
TypeMetrics = "metrics_update"
|
||||
TypePing = "ping"
|
||||
TypePong = "pong"
|
||||
@@ -94,6 +101,8 @@ type RegisterRespMessage struct {
|
||||
type UnregisterMessage struct {
|
||||
Type string `json:"type"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
// Deleted 为 true 表示通道被删除:连同可延长的授权记录一起清除;否则只是关闭
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
// UnregisterRespMessage response to unregister
|
||||
@@ -130,7 +139,8 @@ type DeleteGroupMessage struct {
|
||||
GroupID string `json:"group_id"`
|
||||
}
|
||||
|
||||
// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久
|
||||
// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久。
|
||||
// 除下列取值外,也可以是分钟数,如 "30m"、"90m"(见 LeaseMinutes)。
|
||||
const (
|
||||
LeaseDuration1h = "1h"
|
||||
LeaseDuration2h = "2h"
|
||||
@@ -139,32 +149,75 @@ const (
|
||||
LeaseDurationTunnel = "tunnel" // 通道开启期间有效
|
||||
)
|
||||
|
||||
// 自定义授权时长的范围(分钟)
|
||||
const (
|
||||
MinLeaseMinutes = 5
|
||||
MaxLeaseMinutes = 30 * 24 * 60
|
||||
)
|
||||
|
||||
// LeaseMinutes returns how many minutes a fixed-length lease duration lasts ("" means 2 hours);
|
||||
// false for "today", "tunnel" and invalid values
|
||||
func LeaseMinutes(d string) (int, bool) {
|
||||
switch d {
|
||||
case "", LeaseDuration2h:
|
||||
return 120, true
|
||||
case LeaseDuration1h:
|
||||
return 60, true
|
||||
case LeaseDuration8h:
|
||||
return 480, true
|
||||
}
|
||||
if !strings.HasSuffix(d, "m") {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSuffix(d, "m"))
|
||||
if err != nil || n < MinLeaseMinutes || n > MaxLeaseMinutes {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// LeaseDurationOf is the lease duration value for a number of minutes
|
||||
func LeaseDurationOf(minutes int) string {
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
|
||||
// ValidLeaseDuration reports whether d is a supported lease duration ("" means default 2h)
|
||||
func ValidLeaseDuration(d string) bool {
|
||||
switch d {
|
||||
case "", LeaseDuration1h, LeaseDuration2h, LeaseDuration8h, LeaseDurationToday, LeaseDurationTunnel:
|
||||
if d == LeaseDurationToday || d == LeaseDurationTunnel {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
_, ok := LeaseMinutes(d)
|
||||
return ok
|
||||
}
|
||||
|
||||
// DescribeMinutes renders a duration in minutes: "30 分钟"、"2 小时"、"1 小时 30 分钟"、"3 天"
|
||||
func DescribeMinutes(n int) string {
|
||||
switch {
|
||||
case n >= 1440 && n%1440 == 0:
|
||||
return fmt.Sprintf("%d 天", n/1440)
|
||||
case n >= 60 && n%60 == 0:
|
||||
return fmt.Sprintf("%d 小时", n/60)
|
||||
case n > 60:
|
||||
return fmt.Sprintf("%d 小时 %d 分钟", n/60, n%60)
|
||||
default:
|
||||
return fmt.Sprintf("%d 分钟", n)
|
||||
}
|
||||
}
|
||||
|
||||
// DescribeLeaseDuration is the human-readable lease duration
|
||||
func DescribeLeaseDuration(d string) string {
|
||||
switch d {
|
||||
case LeaseDuration1h:
|
||||
return "1 小时"
|
||||
case LeaseDuration8h:
|
||||
return "8 小时"
|
||||
case LeaseDurationToday:
|
||||
return "当天有效"
|
||||
case LeaseDurationTunnel:
|
||||
return "通道开启期间有效"
|
||||
default:
|
||||
return "2 小时"
|
||||
}
|
||||
if n, ok := LeaseMinutes(d); ok {
|
||||
return DescribeMinutes(n)
|
||||
}
|
||||
return "2 小时"
|
||||
}
|
||||
|
||||
// DynamicLease is a temporary whitelist entry granted through an invite link
|
||||
type DynamicLease struct {
|
||||
IP string `json:"ip"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
@@ -208,6 +261,45 @@ type AccessRequest struct {
|
||||
Invite string `json:"invite,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Status string `json:"status"` // pending / approved / rejected
|
||||
// Renewal 为 true 表示授权已结束(或即将结束)的设备申请延长;由管理员批准时决定延长多久
|
||||
Renewal bool `json:"renewal,omitempty"`
|
||||
}
|
||||
|
||||
// RenewalInfo is a device whose access can still be extended: it ended less than two hours ago
|
||||
// (even if its tunnel has been closed since), or it asked to extend an access that is still running.
|
||||
type RenewalInfo struct {
|
||||
TunnelID string `json:"tunnel_id,omitempty"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
IP string `json:"ip"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Invite string `json:"invite,omitempty"`
|
||||
// EndedAt 访问结束的时间(授权到期或通道关闭);授权仍有效时为空
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
// RenewBefore 可以延长的截止时间(结束后 2 小时)
|
||||
RenewBefore *time.Time `json:"renew_before,omitempty"`
|
||||
// Request 该设备待处理的延长申请
|
||||
Request *AccessRequest `json:"request,omitempty"`
|
||||
}
|
||||
|
||||
// ExtendLeaseMessage extends a device's access by Minutes: a running lease from its expiry, an
|
||||
// ended one (within two hours) from now. It approves the pending renewal requests of that device,
|
||||
// and is not capped by the tunnel's own maximum open time.
|
||||
type ExtendLeaseMessage struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id"` // 用于匹配 ExtendLeaseRespMessage
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
IP string `json:"ip"`
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
|
||||
// ExtendLeaseRespMessage answers ExtendLeaseMessage with the new expiry
|
||||
type ExtendLeaseRespMessage struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"request_id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// CreateInviteMessage asks the server to create an invite link
|
||||
@@ -321,6 +413,8 @@ type MetricsMessage struct {
|
||||
Groups []GroupMetrics `json:"groups,omitempty"`
|
||||
ActiveConns []ActiveConnInfo `json:"active_conns"`
|
||||
SecurityEvents []SecurityEvent `json:"security_events,omitempty"`
|
||||
// Renewals 可延长的设备与延长申请,包括已关闭的通道
|
||||
Renewals []RenewalInfo `json:"renewals,omitempty"`
|
||||
}
|
||||
|
||||
// StreamHandshake sent as first line when a data stream is opened
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 144 KiB |
+995
-166
File diff suppressed because it is too large.
Load diff
+45
-12
@@ -240,7 +240,7 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
|
||||
case protocol.TypeUnregister:
|
||||
var msg protocol.UnregisterMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
m.unregisterTunnel(as, msg.TunnelID)
|
||||
m.unregisterTunnel(as, msg.TunnelID, msg.Deleted)
|
||||
as.send(protocol.UnregisterRespMessage{Type: protocol.TypeUnregisterResp, TunnelID: msg.TunnelID, Success: true})
|
||||
}
|
||||
|
||||
@@ -274,6 +274,12 @@ func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []b
|
||||
m.decideRequest(scopeOf(msg.TunnelID, msg.GroupID), msg.RequestID, msg.Approve)
|
||||
}
|
||||
|
||||
case protocol.TypeExtendLease:
|
||||
var msg protocol.ExtendLeaseMessage
|
||||
if json.Unmarshal(raw, &msg) == nil {
|
||||
as.send(m.extendLease(msg))
|
||||
}
|
||||
|
||||
case protocol.TypeDeleteGroup:
|
||||
var msg protocol.DeleteGroupMessage
|
||||
if json.Unmarshal(raw, &msg) == nil && msg.GroupID != "" {
|
||||
@@ -349,14 +355,19 @@ func (m *Manager) registerTunnel(as *agentSession, cfg protocol.TunnelConfig) pr
|
||||
return registerResp(cfg, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) unregisterTunnel(as *agentSession, tunnelID string) {
|
||||
func (m *Manager) unregisterTunnel(as *agentSession, tunnelID string, deleted bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.active != as {
|
||||
return
|
||||
}
|
||||
// 主动关闭/删除通道时,放通、邀请链接与待审批申请一并失效(断线重连不会走到这里)
|
||||
m.state.forgetScope(tunnelID)
|
||||
// 主动关闭通道时,放通、邀请链接与待审批申请一并失效,已授权的设备 2 小时内仍可申请延长;
|
||||
// 删除通道则全部清除(断线重连不会走到这里)
|
||||
if deleted {
|
||||
m.state.dropScope(tunnelID)
|
||||
} else {
|
||||
m.state.closeScope(tunnelID)
|
||||
}
|
||||
if t, exists := m.tunnels[tunnelID]; exists {
|
||||
t.Close()
|
||||
delete(m.portMap, t.ListenPort())
|
||||
@@ -501,8 +512,12 @@ func scopeExpiresOf(tunnels []*Tunnel) *time.Time {
|
||||
// syncAccess makes a tunnel's temporary allowlist match the access state — its own leases plus
|
||||
// those of its group — and disconnects clients that are no longer permitted.
|
||||
func (m *Manager) syncAccess(t *Tunnel) int {
|
||||
m.state.setName(t.ID(), t.Name())
|
||||
leases := m.state.leaseExpiries(t.ID())
|
||||
if gid := t.GroupID(); gid != "" {
|
||||
if name := t.Snapshot().GroupName; name != "" {
|
||||
m.state.setName(groupScope(gid), name)
|
||||
}
|
||||
for ip, exp := range m.state.leaseExpiries(groupScope(gid)) {
|
||||
if cur, ok := leases[ip]; !ok || exp.After(cur) {
|
||||
leases[ip] = exp
|
||||
@@ -521,19 +536,19 @@ func (m *Manager) syncScope(scope string) int {
|
||||
return kicked
|
||||
}
|
||||
|
||||
// forgetGroupIfEmptyLocked drops a group's shared access state once none of its tunnels is open.
|
||||
// Caller must hold m.mu.
|
||||
// forgetGroupIfEmptyLocked closes a group's shared access state once none of its tunnels is open
|
||||
// (ended devices can still ask for an extension). Caller must hold m.mu.
|
||||
func (m *Manager) forgetGroupIfEmptyLocked(groupID string) {
|
||||
if groupID == "" || len(m.groupMembersLocked(groupID)) > 0 {
|
||||
return
|
||||
}
|
||||
m.state.forgetScope(groupScope(groupID))
|
||||
m.state.closeScope(groupScope(groupID))
|
||||
}
|
||||
|
||||
// deleteGroup drops a dissolved group's invites, leases and requests
|
||||
// deleteGroup drops a dissolved group's invites, leases, requests and renewal records
|
||||
func (m *Manager) deleteGroup(groupID string) {
|
||||
scope := groupScope(groupID)
|
||||
m.state.forgetScope(scope)
|
||||
m.state.dropScope(scope)
|
||||
kicked := m.syncScope(scope)
|
||||
log.Printf("[WebAuth] Tunnel group %s dissolved, disconnected %d client(s)", groupID, kicked)
|
||||
}
|
||||
@@ -569,9 +584,6 @@ func (m *Manager) revokeInvite(scope, code string) {
|
||||
|
||||
func (m *Manager) decideRequest(scope, requestID string, approve bool) {
|
||||
tunnels := m.scopeTunnels(scope)
|
||||
if len(tunnels) == 0 {
|
||||
return
|
||||
}
|
||||
r, _, err := m.state.decide(scope, requestID, approve, scopeExpiresOf(tunnels))
|
||||
if err != nil {
|
||||
return
|
||||
@@ -582,6 +594,26 @@ func (m *Manager) decideRequest(scope, requestID string, approve bool) {
|
||||
log.Printf("[WebAuth] Request from %s (%s) on %s: %s", r.Name, r.IP, m.scopeName(scope), r.Status)
|
||||
}
|
||||
|
||||
// extendLease extends a device's access (approving its renewal requests); the scope may be
|
||||
// closed — the Agent reopens it, and registering re-applies the lease.
|
||||
func (m *Manager) extendLease(msg protocol.ExtendLeaseMessage) protocol.ExtendLeaseRespMessage {
|
||||
resp := protocol.ExtendLeaseRespMessage{Type: protocol.TypeExtendResp, RequestID: msg.RequestID}
|
||||
scope := scopeOf(msg.TunnelID, msg.GroupID)
|
||||
if msg.Minutes < 1 || msg.Minutes > protocol.MaxLeaseMinutes || msg.IP == "" {
|
||||
resp.Error = "延长时长须在 1 分钟到 30 天之间"
|
||||
return resp
|
||||
}
|
||||
exp, err := m.state.extendLease(scope, msg.IP, msg.Minutes)
|
||||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
return resp
|
||||
}
|
||||
m.syncScope(scope)
|
||||
resp.Success, resp.ExpiresAt = true, exp
|
||||
log.Printf("[WebAuth] Extended %s on %s by %s, until %s", msg.IP, m.scopeName(scope), protocol.DescribeMinutes(msg.Minutes), exp.Format("01-02 15:04"))
|
||||
return resp
|
||||
}
|
||||
|
||||
// onClientConnect starts the countdown of a device's lease on its first connection.
|
||||
// 组授权在首次连上组内任意一个通道时开始计时。
|
||||
func (m *Manager) onClientConnect(tunnelID, ip string) {
|
||||
@@ -678,6 +710,7 @@ func (m *Manager) metricsLoop(as *agentSession) {
|
||||
Groups: groupMetrics,
|
||||
ActiveConns: allActiveConns,
|
||||
SecurityEvents: secCopy,
|
||||
Renewals: m.state.renewalList(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,8 +575,9 @@ func TestRevokeInviteRemovesItsLeases(t *testing.T) {
|
||||
if hit(cfg.ListenPort) == "OK" {
|
||||
t.Fatal("leases of revoked invite still active")
|
||||
}
|
||||
if status, _ := httpGet(t, addr, "/i/"+inv.Code, nil); status != 404 {
|
||||
t.Fatal("revoked invite still usable")
|
||||
// 用过它的设备看到「授权已撤销」,不能申请延长
|
||||
if status, body := httpGet(t, addr, "/i/"+inv.Code, nil); status != 403 || !strings.Contains(body, "授权已撤销") {
|
||||
t.Fatalf("revoked invite still usable: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DeepFal/PortRelay/core/protocol"
|
||||
)
|
||||
|
||||
// linkOnly registers a tunnel that only invited devices can reach
|
||||
func linkOnly(t *testing.T, a *testAgent, id string) protocol.TunnelConfig {
|
||||
t.Helper()
|
||||
cfg := tunnelCfg(id, freePort(t))
|
||||
cfg.Whitelist = nil
|
||||
if resp := a.register(t, protocol.TypeRegisterTunnel, cfg); !resp.Success {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// grantVia uses an invite from 127.0.0.1 and connects once so the countdown starts
|
||||
func grantVia(t *testing.T, m *Manager, addr string, cfg protocol.TunnelConfig, code string) {
|
||||
t.Helper()
|
||||
if status, _, body := postForm(t, addr, code, "张三"); status != 200 {
|
||||
t.Fatalf("grant: %d %s", status, body)
|
||||
}
|
||||
if hit(cfg.ListenPort) != "OK" {
|
||||
t.Fatal("granted device cannot connect")
|
||||
}
|
||||
waitFor(t, func() bool { l := m.state.leaseList(cfg.ID); return len(l) == 1 && !l[0].AwaitingConnect })
|
||||
}
|
||||
|
||||
// expire moves a lease's end into the past by ago
|
||||
func expire(m *Manager, scope, ip string, ago time.Duration) {
|
||||
m.state.mu.Lock()
|
||||
l := m.state.data[scope].Leases[ip]
|
||||
l.ExpiresAt = time.Now().Add(-ago)
|
||||
m.state.data[scope].Leases[ip] = l
|
||||
m.state.mu.Unlock()
|
||||
m.syncScope(scope)
|
||||
}
|
||||
|
||||
// renew presses「申请延长」and returns the status page path
|
||||
func renew(t *testing.T, addr, code string) string {
|
||||
t.Helper()
|
||||
resp, err := noRedirect.PostForm("http://"+addr+"/renew", url.Values{"code": {code}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 303 || !strings.HasPrefix(resp.Header.Get("Location"), "/auth/status?id=") {
|
||||
t.Fatalf("renew: %d %s", resp.StatusCode, b)
|
||||
}
|
||||
return resp.Header.Get("Location")
|
||||
}
|
||||
|
||||
func (a *testAgent) extend(t *testing.T, tunnelID, ip string, minutes int) protocol.ExtendLeaseRespMessage {
|
||||
t.Helper()
|
||||
a.send(t, protocol.ExtendLeaseMessage{Type: protocol.TypeExtendLease, RequestID: "e1", TunnelID: tunnelID, IP: ip, Minutes: minutes})
|
||||
var resp protocol.ExtendLeaseRespMessage
|
||||
_ = json.Unmarshal(a.next(t), &resp)
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestRenewAfterExpiry(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
cfg := linkOnly(t, a, "a")
|
||||
inv := a.createInviteWith(t, "a", 1, "30m")
|
||||
grantVia(t, m, addr, cfg, inv.Code)
|
||||
|
||||
// 授权有效时再打开链接:显示剩余时间与「申请延长」,不会重新授权
|
||||
if _, body := httpGet(t, addr, "/i/"+inv.Code, nil); !strings.Contains(body, "访问已授权") || !strings.Contains(body, "申请延长") {
|
||||
t.Fatalf("running access page: %s", body)
|
||||
}
|
||||
|
||||
expire(m, "a", "127.0.0.1", time.Minute)
|
||||
waitFor(t, func() bool { return hit(cfg.ListenPort) != "OK" })
|
||||
if _, body := httpGet(t, addr, "/i/"+inv.Code, nil); !strings.Contains(body, "访问已结束") || !strings.Contains(body, "申请延长") {
|
||||
t.Fatalf("ended access page: %s", body)
|
||||
}
|
||||
// 用过链接的设备不能再直接授权
|
||||
if status, _, _ := postForm(t, addr, inv.Code, "张三"); status != 409 {
|
||||
t.Fatalf("a returning device must not be re-granted directly, got %d", status)
|
||||
}
|
||||
|
||||
status := renew(t, addr, inv.Code)
|
||||
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "等待管理员批准") {
|
||||
t.Fatalf("pending renewal page: %s", body)
|
||||
}
|
||||
if again := renew(t, addr, inv.Code); again != status {
|
||||
t.Fatal("one device should have one pending renewal")
|
||||
}
|
||||
rl := m.state.renewalList()
|
||||
if len(rl) != 1 || rl[0].Request == nil || rl[0].EndedAt == nil || rl[0].TunnelID != "a" || rl[0].Name != "张三" {
|
||||
t.Fatalf("renewal list: %+v", rl)
|
||||
}
|
||||
if p := m.state.pendingList("a"); len(p) != 0 {
|
||||
t.Fatalf("renewals are not first-time requests: %+v", p)
|
||||
}
|
||||
|
||||
resp := a.extend(t, "a", "127.0.0.1", 45)
|
||||
if !resp.Success || time.Until(resp.ExpiresAt) < 44*time.Minute || time.Until(resp.ExpiresAt) > 46*time.Minute {
|
||||
t.Fatalf("extend an ended lease from now: %+v", resp)
|
||||
}
|
||||
if hit(cfg.ListenPort) != "OK" {
|
||||
t.Fatal("extended device cannot connect")
|
||||
}
|
||||
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "延长已批准") {
|
||||
t.Fatalf("approved renewal page: %s", body)
|
||||
}
|
||||
if rl := m.state.renewalList(); len(rl) != 0 {
|
||||
t.Fatalf("nothing left to renew: %+v", rl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewAfterTunnelClosed(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
cfg := linkOnly(t, a, "a")
|
||||
inv := a.createInvite(t, "a", 1)
|
||||
grantVia(t, m, addr, cfg, inv.Code)
|
||||
|
||||
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "a"})
|
||||
a.next(t)
|
||||
_, body := httpGet(t, addr, "/i/"+inv.Code, nil)
|
||||
if !strings.Contains(body, "访问已结束") || !strings.Contains(body, "通道当前已关闭") || !strings.Contains(body, "目标通道") {
|
||||
t.Fatalf("closed tunnel page: %s", body)
|
||||
}
|
||||
status := renew(t, addr, inv.Code)
|
||||
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "等待管理员批准") || !strings.Contains(body, "自动重新开启") {
|
||||
t.Fatalf("pending renewal on a closed tunnel: %s", body)
|
||||
}
|
||||
// 拒绝也不需要通道开启
|
||||
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: "a", RequestID: m.state.renewalList()[0].Request.ID, Approve: false})
|
||||
waitFor(t, func() bool {
|
||||
_, b := httpGet(t, addr, status, nil)
|
||||
return strings.Contains(b, "拒绝了您的延长申请")
|
||||
})
|
||||
|
||||
// 再申请一次,批准后 Agent 重新开启通道,授权随注册生效
|
||||
status = renew(t, addr, inv.Code)
|
||||
if resp := a.extend(t, "a", "127.0.0.1", 60); !resp.Success {
|
||||
t.Fatalf("extend while closed: %s", resp.Error)
|
||||
}
|
||||
if _, body := httpGet(t, addr, status, nil); !strings.Contains(body, "正在开启通道") {
|
||||
t.Fatalf("approved while closed: %s", body)
|
||||
}
|
||||
if resp := a.register(t, protocol.TypeRegisterTunnel, cfg); !resp.Success {
|
||||
t.Fatal(resp.Error)
|
||||
}
|
||||
if hit(cfg.ListenPort) != "OK" {
|
||||
t.Fatal("the reopened tunnel must admit the extended device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewWindowRevokeAndDelete(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
cfg := linkOnly(t, a, "a")
|
||||
inv := a.createInvite(t, "a", 0)
|
||||
grantVia(t, m, addr, cfg, inv.Code)
|
||||
|
||||
// 结束超过 2 小时:只能重新邀请
|
||||
expire(m, "a", "127.0.0.1", renewWindow+time.Minute)
|
||||
if _, body := httpGet(t, addr, "/i/"+inv.Code, nil); !strings.Contains(body, "超过 2 小时") {
|
||||
t.Fatalf("expired window page: %s", body)
|
||||
}
|
||||
if resp := a.extend(t, "a", "127.0.0.1", 30); resp.Success {
|
||||
t.Fatal("cannot extend after the renewal window")
|
||||
}
|
||||
|
||||
// 撤销的授权不能延长
|
||||
grantTestLease(m, "a", "10.9.9.9", time.Now().Add(time.Hour))
|
||||
a.send(t, protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, TunnelID: "a", IP: "10.9.9.9"})
|
||||
waitFor(t, func() bool { return len(m.state.leaseList("a")) == 0 })
|
||||
if resp := a.extend(t, "a", "10.9.9.9", 30); resp.Success || !strings.Contains(resp.Error, "撤销") {
|
||||
t.Fatalf("revoked access must not be extended: %+v", resp)
|
||||
}
|
||||
|
||||
// 删除通道:连同记录一起清除
|
||||
grantTestLease(m, "a", "10.9.9.8", time.Now().Add(time.Hour))
|
||||
a.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "a", Deleted: true})
|
||||
a.next(t)
|
||||
if rl := m.state.renewalList(); len(rl) != 0 {
|
||||
t.Fatalf("a deleted tunnel leaves nothing to renew: %+v", rl)
|
||||
}
|
||||
if resp := a.extend(t, "a", "10.9.9.8", 30); resp.Success {
|
||||
t.Fatal("cannot extend on a deleted tunnel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendRunningLeaseIsNotCappedByTunnelTTL(t *testing.T) {
|
||||
m := newTestManager()
|
||||
addr := startServer(t, m)
|
||||
a := dialAgent(t, addr, testToken)
|
||||
a.expectAuth(t)
|
||||
cfg := tunnelCfg("a", freePort(t))
|
||||
cfg.Whitelist = nil
|
||||
end := time.Now().Add(10 * time.Minute)
|
||||
cfg.ExpiresAt = &end
|
||||
a.register(t, protocol.TypeRegisterTunnel, cfg)
|
||||
inv := a.createInvite(t, "a", 1)
|
||||
grantVia(t, m, addr, cfg, inv.Code)
|
||||
before := m.state.leaseList("a")[0].ExpiresAt
|
||||
if before.After(end.Add(time.Second)) {
|
||||
t.Fatalf("a regular grant is capped by the tunnel TTL: %v > %v", before, end)
|
||||
}
|
||||
|
||||
resp := a.extend(t, "a", "127.0.0.1", 60)
|
||||
if !resp.Success || !resp.ExpiresAt.Equal(before.Add(time.Hour)) {
|
||||
t.Fatalf("a running lease extends from its expiry: %+v (was %v)", resp, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomLeaseDurations(t *testing.T) {
|
||||
for d, want := range map[string]int{"30m": 30, "90m": 90, "1h": 60, "": 120, "8h": 480} {
|
||||
if n, ok := protocol.LeaseMinutes(d); !ok || n != want {
|
||||
t.Errorf("LeaseMinutes(%q) = %d, %v", d, n, ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"3m", "abc", "90", "100000m"} {
|
||||
if protocol.ValidLeaseDuration(bad) {
|
||||
t.Errorf("%q should be invalid", bad)
|
||||
}
|
||||
}
|
||||
if got := protocol.DescribeLeaseDuration("90m"); got != "1 小时 30 分钟" {
|
||||
t.Errorf("describe 90m: %q", got)
|
||||
}
|
||||
now := time.Now()
|
||||
if got := leaseExpiry("45m", nil, now); !got.Equal(now.Add(45 * time.Minute)) {
|
||||
t.Errorf("leaseExpiry 45m: %v", got.Sub(now))
|
||||
}
|
||||
}
|
||||
+353
-19
@@ -25,10 +25,17 @@ const (
|
||||
leaseStartWindow = 24 * time.Hour
|
||||
)
|
||||
|
||||
// renewWindow 访问结束(授权到期或通道关闭)后多久内还能申请延长;之后清除记录,需要重新邀请
|
||||
var renewWindow = 2 * time.Hour
|
||||
|
||||
var (
|
||||
errInviteNotFound = errors.New("邀请链接无效或已过期")
|
||||
errInviteUsedUp = errors.New("该邀请链接的可用设备数已用完,请向管理员索取新的链接")
|
||||
errRequestGone = errors.New("申请不存在或已过期")
|
||||
errAlreadyUsed = errors.New("这台设备已经用过该邀请链接,请重新打开链接申请延长")
|
||||
errNothingToRenew = errors.New("访问已结束超过 2 小时,请联系管理员获取新的邀请链接")
|
||||
errRevoked = errors.New("授权已被管理员撤销,如需继续使用请联系管理员")
|
||||
errNotStarted = errors.New("授权尚未开始计时(首次连接后才开始),无需延长")
|
||||
)
|
||||
|
||||
type leaseInfo struct {
|
||||
@@ -89,10 +96,27 @@ func (s *stateStore) lease(scope, ip string) (leaseInfo, bool) {
|
||||
return leaseInfo{}, false
|
||||
}
|
||||
|
||||
// leaseRecord remembers a device whose access ended, so it can ask for an extension
|
||||
type leaseRecord struct {
|
||||
EndedAt time.Time `json:"ended_at"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Invite string `json:"invite,omitempty"`
|
||||
// Revoked 为 true 表示被管理员撤销,不能申请延长
|
||||
Revoked bool `json:"revoked,omitempty"`
|
||||
}
|
||||
|
||||
func (r leaseRecord) renewable(now time.Time) bool {
|
||||
return !r.Revoked && now.Before(r.EndedAt.Add(renewWindow))
|
||||
}
|
||||
|
||||
type tunnelState struct {
|
||||
Leases map[string]leaseInfo `json:"leases,omitempty"` // ip -> lease
|
||||
Invites map[string]*protocol.Invite `json:"invites,omitempty"` // code -> invite
|
||||
Requests map[string]*protocol.AccessRequest `json:"requests,omitempty"` // id -> request
|
||||
// Ended 访问已结束的设备(ip -> 记录),结束后 renewWindow 内可申请延长;通道关闭后仍保留
|
||||
Ended map[string]leaseRecord `json:"ended,omitempty"`
|
||||
// Name 通道或组最近的名称,通道关闭后用于申请延长页面
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// 授权状态的作用范围:单个通道(键为通道 ID,与旧数据兼容)或整个通道组(键为 "group:<组ID>")
|
||||
@@ -177,7 +201,7 @@ func (m *Manager) EnableLeasePersistence(path string) error {
|
||||
// looksLikeState distinguishes the new format from the legacy one (both are JSON objects of objects)
|
||||
func looksLikeState(d map[string]*tunnelState) bool {
|
||||
for _, st := range d {
|
||||
if st == nil || (st.Leases == nil && st.Invites == nil && st.Requests == nil) {
|
||||
if st == nil || (st.Leases == nil && st.Invites == nil && st.Requests == nil && st.Ended == nil) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -199,9 +223,21 @@ func (s *stateStore) scopeLocked(id string) *tunnelState {
|
||||
if st.Requests == nil {
|
||||
st.Requests = make(map[string]*protocol.AccessRequest)
|
||||
}
|
||||
if st.Ended == nil {
|
||||
st.Ended = make(map[string]leaseRecord)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// endLeaseLocked turns a lease into an ended record (renewable unless revoked)
|
||||
func endLeaseLocked(st *tunnelState, ip string, l leaseInfo, at time.Time, revoked bool) {
|
||||
if st.Ended == nil {
|
||||
st.Ended = make(map[string]leaseRecord)
|
||||
}
|
||||
st.Ended[ip] = leaseRecord{EndedAt: at, Name: l.Name, Invite: l.Invite, Revoked: revoked}
|
||||
delete(st.Leases, ip)
|
||||
}
|
||||
|
||||
func randomCode(n int) string {
|
||||
const chars = "23456789abcdefghjkmnpqrstuvwxyz"
|
||||
b := make([]byte, n)
|
||||
@@ -225,13 +261,25 @@ func (s *stateStore) removeLease(scope, ip string) int {
|
||||
if st == nil {
|
||||
return 0
|
||||
}
|
||||
// 被撤销的授权不能再申请延长
|
||||
now := time.Now()
|
||||
n := 0
|
||||
if ip == "" {
|
||||
n = len(st.Leases)
|
||||
st.Leases = make(map[string]leaseInfo)
|
||||
} else if _, ok := st.Leases[ip]; ok {
|
||||
n = 1
|
||||
delete(st.Leases, ip)
|
||||
for lip, l := range st.Leases {
|
||||
if ip == "" || lip == ip {
|
||||
endLeaseLocked(st, lip, l, now, true)
|
||||
n++
|
||||
}
|
||||
}
|
||||
for lip, r := range st.Ended {
|
||||
if ip == "" || lip == ip {
|
||||
r.Revoked = true
|
||||
st.Ended[lip] = r
|
||||
}
|
||||
}
|
||||
for id, r := range st.Requests {
|
||||
if r.Renewal && r.Status == "pending" && (ip == "" || r.IP == ip) {
|
||||
delete(st.Requests, id)
|
||||
}
|
||||
}
|
||||
s.saveLocked()
|
||||
return n
|
||||
@@ -271,8 +319,54 @@ func (s *stateStore) leaseList(scope string) []protocol.DynamicLease {
|
||||
return out
|
||||
}
|
||||
|
||||
// forgetScope drops all access state of a tunnel or group (explicitly closed, deleted or dissolved)
|
||||
func (s *stateStore) forgetScope(scope string) {
|
||||
// closeScope runs when a tunnel, or the last open tunnel of a group, is closed: invites and
|
||||
// first-time requests become invalid, running leases end — but ended devices are remembered for
|
||||
// renewWindow so they can still ask for an extension.
|
||||
func (s *stateStore) closeScope(scope string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for ip, l := range st.Leases {
|
||||
end := now
|
||||
if l.ExpiresAt.Before(now) {
|
||||
end = l.ExpiresAt
|
||||
}
|
||||
endLeaseLocked(st, ip, l, end, false)
|
||||
}
|
||||
st.Invites = nil
|
||||
for id, r := range st.Requests {
|
||||
if !r.Renewal && r.Status == "pending" {
|
||||
delete(st.Requests, id)
|
||||
}
|
||||
}
|
||||
s.saveLocked()
|
||||
}
|
||||
|
||||
// scopeDisplayName is the last known name of a scope (for pages shown after it closed)
|
||||
func (s *stateStore) scopeDisplayName(scope string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if st := s.data[scope]; st != nil {
|
||||
return st.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// setName records the display name of a scope that has access state
|
||||
func (s *stateStore) setName(scope, name string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if st := s.data[scope]; st != nil && name != "" && st.Name != name {
|
||||
st.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
// dropScope drops all access state of a deleted tunnel or a dissolved group, renewal records included
|
||||
func (s *stateStore) dropScope(scope string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.data[scope]; ok {
|
||||
@@ -379,6 +473,13 @@ func (s *stateStore) useInviteAndGrant(scope, code, name, ip string, scopeExpire
|
||||
if !ok || !time.Now().Before(inv.ExpiresAt) {
|
||||
return leaseInfo{}, errInviteNotFound
|
||||
}
|
||||
// 已授权的设备再次提交不重新计时;用过链接、授权已结束的设备要申请延长
|
||||
if l, ok := st.Leases[ip]; ok && time.Now().Before(l.ExpiresAt) {
|
||||
return l, nil
|
||||
}
|
||||
if usedByLocked(inv, ip) {
|
||||
return leaseInfo{}, errAlreadyUsed
|
||||
}
|
||||
if !capacityOKLocked(st, inv, ip) {
|
||||
return leaseInfo{}, errInviteUsedUp
|
||||
}
|
||||
@@ -386,10 +487,20 @@ func (s *stateStore) useInviteAndGrant(scope, code, name, ip string, scopeExpire
|
||||
s.recordUseLocked(st, code, name, ip)
|
||||
l := newLease(name, code, inv.LeaseDuration, scopeExpires, time.Now())
|
||||
st.Leases[ip] = l
|
||||
delete(st.Ended, ip)
|
||||
s.saveLocked()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func usedByLocked(inv *protocol.Invite, ip string) bool {
|
||||
for _, u := range inv.Uses {
|
||||
if u.IP == ip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// revokeInvite removes the invite and the leases it granted; returns the affected IPs
|
||||
func (s *stateStore) revokeInvite(scope, code string) []string {
|
||||
s.mu.Lock()
|
||||
@@ -400,10 +511,17 @@ func (s *stateStore) revokeInvite(scope, code string) []string {
|
||||
}
|
||||
delete(st.Invites, code)
|
||||
var ips []string
|
||||
now := time.Now()
|
||||
for ip, l := range st.Leases {
|
||||
if l.Invite == code {
|
||||
ips = append(ips, ip)
|
||||
delete(st.Leases, ip)
|
||||
endLeaseLocked(st, ip, l, now, true)
|
||||
}
|
||||
}
|
||||
for ip, r := range st.Ended {
|
||||
if r.Invite == code {
|
||||
r.Revoked = true
|
||||
st.Ended[ip] = r
|
||||
}
|
||||
}
|
||||
for id, r := range st.Requests {
|
||||
@@ -449,12 +567,15 @@ func (s *stateStore) addRequest(scope, code, name, ip string) (protocol.AccessRe
|
||||
}
|
||||
st = s.scopeLocked(scope)
|
||||
for _, r := range st.Requests {
|
||||
if r.IP == ip && r.Status == "pending" {
|
||||
if r.IP == ip && r.Status == "pending" && !r.Renewal {
|
||||
r.Name = name
|
||||
s.saveLocked()
|
||||
return *r, nil
|
||||
}
|
||||
}
|
||||
if usedByLocked(inv, ip) {
|
||||
return protocol.AccessRequest{}, errAlreadyUsed
|
||||
}
|
||||
if !capacityOKLocked(st, inv, ip) {
|
||||
return protocol.AccessRequest{}, errInviteUsedUp
|
||||
}
|
||||
@@ -507,6 +628,24 @@ func (s *stateStore) decide(scope, id string, approve bool, scopeExpires *time.T
|
||||
if inv, ok := st.Invites[r.Invite]; ok {
|
||||
duration = inv.LeaseDuration
|
||||
}
|
||||
if r.Renewal {
|
||||
// 延长申请通常由 extendLease 按管理员选择的时长批准;这里按邀请的时长(默认 2 小时)处理
|
||||
if !approve {
|
||||
r.Status, r.At = "rejected", time.Now()
|
||||
s.saveLocked()
|
||||
return *r, time.Time{}, nil
|
||||
}
|
||||
minutes, ok := protocol.LeaseMinutes(duration)
|
||||
if !ok {
|
||||
minutes = 120
|
||||
}
|
||||
exp, err := s.extendLocked(st, r.IP, minutes, time.Now())
|
||||
if err != nil {
|
||||
return protocol.AccessRequest{}, time.Time{}, err
|
||||
}
|
||||
s.saveLocked()
|
||||
return *r, exp, nil
|
||||
}
|
||||
l := newLease(r.Name, r.Invite, duration, scopeExpires, time.Now())
|
||||
exp := l.ExpiresAt
|
||||
r.At = time.Now()
|
||||
@@ -514,6 +653,7 @@ func (s *stateStore) decide(scope, id string, approve bool, scopeExpires *time.T
|
||||
r.Status = "approved"
|
||||
s.recordUseLocked(st, r.Invite, r.Name, r.IP)
|
||||
st.Leases[r.IP] = l
|
||||
delete(st.Ended, r.IP)
|
||||
} else {
|
||||
r.Status = "rejected"
|
||||
}
|
||||
@@ -528,7 +668,7 @@ func (s *stateStore) pendingList(scope string) []protocol.AccessRequest {
|
||||
var out []protocol.AccessRequest
|
||||
if st := s.data[scope]; st != nil {
|
||||
for _, r := range st.Requests {
|
||||
if r.Status == "pending" {
|
||||
if r.Status == "pending" && !r.Renewal {
|
||||
out = append(out, *r)
|
||||
}
|
||||
}
|
||||
@@ -537,6 +677,195 @@ func (s *stateStore) pendingList(scope string) []protocol.AccessRequest {
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- renewals ----
|
||||
|
||||
// deviceAccess is what a device that opens an invite link already has in the invite's scope
|
||||
type deviceAccess struct {
|
||||
scope string
|
||||
name string // 通道或组的名称(通道关闭后为最近一次的名称)
|
||||
lease *leaseInfo
|
||||
ended *leaseRecord
|
||||
pending *protocol.AccessRequest // 待处理的延长申请
|
||||
// usedBefore 用过该链接,但访问已结束超过 renewWindow
|
||||
usedBefore bool
|
||||
}
|
||||
|
||||
// deviceAccess looks up ip's access in the scope of invite code, also after the invite or the
|
||||
// tunnel is gone; false when the device never used this invite (a new visitor)
|
||||
func (s *stateStore) deviceAccess(code, ip string) (deviceAccess, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneLocked()
|
||||
now := time.Now()
|
||||
for scope, st := range s.data {
|
||||
inv := st.Invites[code]
|
||||
l, hasLease := st.Leases[ip]
|
||||
rec, hasRec := st.Ended[ip]
|
||||
mine := (inv != nil && usedByLocked(inv, ip)) || (hasLease && l.Invite == code) || (hasRec && rec.Invite == code)
|
||||
if !mine {
|
||||
continue
|
||||
}
|
||||
da := deviceAccess{scope: scope, name: st.Name}
|
||||
switch {
|
||||
case hasLease && now.Before(l.ExpiresAt):
|
||||
da.lease = &l
|
||||
case hasRec && (rec.Revoked || rec.renewable(now)):
|
||||
da.ended = &rec
|
||||
default:
|
||||
da.usedBefore = true
|
||||
}
|
||||
for _, r := range st.Requests {
|
||||
if r.IP == ip && r.Renewal && r.Status == "pending" {
|
||||
c := *r
|
||||
da.pending = &c
|
||||
}
|
||||
}
|
||||
return da, true
|
||||
}
|
||||
return deviceAccess{}, false
|
||||
}
|
||||
|
||||
// addRenewal files (or re-uses) a renewal request for ip: its lease must be running, or have
|
||||
// ended less than renewWindow ago without being revoked
|
||||
func (s *stateStore) addRenewal(scope, ip string) (protocol.AccessRequest, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneLocked()
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return protocol.AccessRequest{}, errNothingToRenew
|
||||
}
|
||||
now := time.Now()
|
||||
var name, invite string
|
||||
if l, ok := st.Leases[ip]; ok && now.Before(l.ExpiresAt) {
|
||||
if l.Deferred {
|
||||
return protocol.AccessRequest{}, errNotStarted
|
||||
}
|
||||
name, invite = l.Name, l.Invite
|
||||
} else if rec, ok := st.Ended[ip]; ok && rec.Revoked {
|
||||
return protocol.AccessRequest{}, errRevoked
|
||||
} else if ok && rec.renewable(now) {
|
||||
name, invite = rec.Name, rec.Invite
|
||||
} else {
|
||||
return protocol.AccessRequest{}, errNothingToRenew
|
||||
}
|
||||
st = s.scopeLocked(scope)
|
||||
for _, r := range st.Requests {
|
||||
if r.IP == ip && r.Renewal && r.Status == "pending" {
|
||||
return *r, nil
|
||||
}
|
||||
}
|
||||
r := &protocol.AccessRequest{ID: randomCode(16), Name: name, IP: ip, Invite: invite, At: now, Status: "pending", Renewal: true}
|
||||
if id, isGroup := splitScope(scope); isGroup {
|
||||
r.GroupID = id
|
||||
} else {
|
||||
r.TunnelID = id
|
||||
}
|
||||
st.Requests[r.ID] = r
|
||||
s.saveLocked()
|
||||
return *r, nil
|
||||
}
|
||||
|
||||
// extendLease extends ip's access in scope by minutes and approves its pending renewal requests.
|
||||
// 不受通道最长开启时长限制:管理员批准的时长优先。
|
||||
func (s *stateStore) extendLease(scope, ip string, minutes int) (time.Time, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneLocked()
|
||||
st := s.data[scope]
|
||||
if st == nil {
|
||||
return time.Time{}, errNothingToRenew
|
||||
}
|
||||
exp, err := s.extendLocked(s.scopeLocked(scope), ip, minutes, time.Now())
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
s.saveLocked()
|
||||
return exp, nil
|
||||
}
|
||||
|
||||
// extendLocked: a running lease is extended from its expiry (one not yet started starts now),
|
||||
// an ended one is restored from now. Caller must hold s.mu and save.
|
||||
func (s *stateStore) extendLocked(st *tunnelState, ip string, minutes int, now time.Time) (time.Time, error) {
|
||||
d := time.Duration(minutes) * time.Minute
|
||||
if l, ok := st.Leases[ip]; ok && now.Before(l.ExpiresAt) {
|
||||
if l.Deferred {
|
||||
l.Deferred, l.StartedAt, l.ExpiresAt = false, &now, now.Add(d)
|
||||
} else {
|
||||
l.ExpiresAt = l.ExpiresAt.Add(d)
|
||||
}
|
||||
st.Leases[ip] = l
|
||||
} else if rec, ok := st.Ended[ip]; ok && rec.Revoked {
|
||||
return time.Time{}, errRevoked
|
||||
} else if ok && rec.renewable(now) {
|
||||
st.Leases[ip] = leaseInfo{Name: rec.Name, Invite: rec.Invite, Duration: protocol.LeaseDurationOf(minutes), StartedAt: &now, ExpiresAt: now.Add(d)}
|
||||
delete(st.Ended, ip)
|
||||
} else {
|
||||
return time.Time{}, errNothingToRenew
|
||||
}
|
||||
for _, r := range st.Requests {
|
||||
if r.IP == ip && r.Renewal && r.Status == "pending" {
|
||||
r.Status, r.At = "approved", now
|
||||
}
|
||||
}
|
||||
return st.Leases[ip].ExpiresAt, nil
|
||||
}
|
||||
|
||||
// renewalList reports, across all scopes (closed ones included), the devices that can still be
|
||||
// extended and the pending renewal requests
|
||||
func (s *stateStore) renewalList() []protocol.RenewalInfo {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneLocked()
|
||||
now := time.Now()
|
||||
var out []protocol.RenewalInfo
|
||||
for scope, st := range s.data {
|
||||
id, isGroup := splitScope(scope)
|
||||
base := protocol.RenewalInfo{TunnelID: id}
|
||||
if isGroup {
|
||||
base = protocol.RenewalInfo{GroupID: id}
|
||||
}
|
||||
byIP := make(map[string]int)
|
||||
for ip, rec := range st.Ended {
|
||||
if !rec.renewable(now) {
|
||||
continue
|
||||
}
|
||||
info := base
|
||||
end, before := rec.EndedAt, rec.EndedAt.Add(renewWindow)
|
||||
info.IP, info.Name, info.Invite, info.EndedAt, info.RenewBefore = ip, rec.Name, rec.Invite, &end, &before
|
||||
byIP[ip] = len(out)
|
||||
out = append(out, info)
|
||||
}
|
||||
for _, r := range st.Requests {
|
||||
if !r.Renewal || r.Status != "pending" {
|
||||
continue
|
||||
}
|
||||
c := *r
|
||||
if i, ok := byIP[r.IP]; ok {
|
||||
out[i].Request = &c
|
||||
continue
|
||||
}
|
||||
info := base
|
||||
info.IP, info.Name, info.Invite, info.Request = r.IP, r.Name, r.Invite, &c
|
||||
out = append(out, info)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if (out[i].Request != nil) != (out[j].Request != nil) {
|
||||
return out[i].Request != nil
|
||||
}
|
||||
ti, tj := now, now
|
||||
if out[i].EndedAt != nil {
|
||||
ti = *out[i].EndedAt
|
||||
}
|
||||
if out[j].EndedAt != nil {
|
||||
tj = *out[j].EndedAt
|
||||
}
|
||||
return ti.After(tj)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- housekeeping ----
|
||||
|
||||
func (s *stateStore) pruneRequestsLocked() {
|
||||
@@ -560,7 +889,12 @@ func (s *stateStore) pruneLocked() {
|
||||
}
|
||||
for ip, l := range st.Leases {
|
||||
if !now.Before(l.ExpiresAt) {
|
||||
delete(st.Leases, ip)
|
||||
endLeaseLocked(st, ip, l, l.ExpiresAt, false)
|
||||
}
|
||||
}
|
||||
for ip, r := range st.Ended {
|
||||
if !now.Before(r.EndedAt.Add(renewWindow)) {
|
||||
delete(st.Ended, ip)
|
||||
}
|
||||
}
|
||||
for code, inv := range st.Invites {
|
||||
@@ -568,7 +902,7 @@ func (s *stateStore) pruneLocked() {
|
||||
delete(st.Invites, code)
|
||||
}
|
||||
}
|
||||
if len(st.Leases) == 0 && len(st.Invites) == 0 && len(st.Requests) == 0 {
|
||||
if len(st.Leases) == 0 && len(st.Invites) == 0 && len(st.Requests) == 0 && len(st.Ended) == 0 {
|
||||
delete(s.data, id)
|
||||
}
|
||||
}
|
||||
@@ -607,10 +941,6 @@ func (s *stateStore) saveLocked() {
|
||||
func leaseExpiry(duration string, scopeExpires *time.Time, now time.Time) time.Time {
|
||||
var exp time.Time
|
||||
switch duration {
|
||||
case protocol.LeaseDuration1h:
|
||||
exp = now.Add(time.Hour)
|
||||
case protocol.LeaseDuration8h:
|
||||
exp = now.Add(8 * time.Hour)
|
||||
case protocol.LeaseDurationToday:
|
||||
y, mo, d := now.Date()
|
||||
exp = time.Date(y, mo, d, 23, 59, 59, 0, now.Location())
|
||||
@@ -618,7 +948,11 @@ func leaseExpiry(duration string, scopeExpires *time.Time, now time.Time) time.T
|
||||
// 通道开启期间有效:通道被主动关闭时放通会被清除;设一个上限避免永久放通
|
||||
exp = now.Add(30 * 24 * time.Hour)
|
||||
default:
|
||||
exp = now.Add(2 * time.Hour)
|
||||
minutes, ok := protocol.LeaseMinutes(duration)
|
||||
if !ok {
|
||||
minutes = 120
|
||||
}
|
||||
exp = now.Add(time.Duration(minutes) * time.Minute)
|
||||
}
|
||||
if scopeExpires != nil && scopeExpires.After(now) && scopeExpires.Before(exp) {
|
||||
exp = *scopeExpires
|
||||
|
||||
+113
-2
@@ -31,6 +31,8 @@ func (m *Manager) handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
|
||||
m.serveInviteForm(w, r, r.URL.Query().Get("code"))
|
||||
case path == "/auth/status":
|
||||
m.serveRequestStatus(w, r)
|
||||
case path == "/renew" && r.Method == http.MethodPost:
|
||||
m.serveRenewSubmit(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -108,6 +110,13 @@ func (m *Manager) inviteTarget(code string) (accessScope, protocol.Invite, strin
|
||||
|
||||
func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code string) {
|
||||
ip := clientIPOf(r)
|
||||
// 用过这个链接的设备:显示它的授权状态,到期前后都可以申请延长(链接或通道失效后也一样)
|
||||
if ip != "" {
|
||||
if da, ok := m.state.deviceAccess(strings.TrimSpace(code), ip); ok {
|
||||
m.serveDeviceAccess(w, r, strings.TrimSpace(code), ip, da)
|
||||
return
|
||||
}
|
||||
}
|
||||
sc, inv, problem := m.inviteTarget(code)
|
||||
if problem == "" && ip == "" {
|
||||
problem = "无法识别客户端 IP"
|
||||
@@ -142,6 +151,88 @@ func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code s
|
||||
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+sc.name, body)
|
||||
}
|
||||
|
||||
// serveDeviceAccess shows a returning device its access and, when it can, the renewal button
|
||||
func (m *Manager) serveDeviceAccess(w http.ResponseWriter, r *http.Request, code, ip string, da deviceAccess) {
|
||||
if da.pending != nil {
|
||||
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(da.pending.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
sc, open := m.resolveScope(da.scope)
|
||||
rows := func(validity string) string {
|
||||
if open {
|
||||
return m.detailRows(r, sc, ip, validity)
|
||||
}
|
||||
return closedRows(da, ip, validity)
|
||||
}
|
||||
renewForm := fmt.Sprintf(`
|
||||
<form method="post" action="/renew" class="form">
|
||||
<input type="hidden" name="code" value="%s">
|
||||
<button type="submit">申请延长</button>
|
||||
</form>`, html.EscapeString(code))
|
||||
switch {
|
||||
case da.lease != nil && da.lease.Deferred && open:
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "已获得访问权限", m.successBody(r, sc, ip, *da.lease))
|
||||
case da.lease != nil && da.lease.Deferred:
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "已获得访问权限", rows(leaseValidity(*da.lease)))
|
||||
case da.lease != nil:
|
||||
left := time.Until(da.lease.ExpiresAt).Round(time.Minute)
|
||||
body := rows(fmt.Sprintf("至 %s(剩余 %s)", da.lease.ExpiresAt.Format("01-02 15:04"), protocol.DescribeMinutes(max(1, int(left/time.Minute))))) +
|
||||
`<div class="tip">需要更多时间时可以申请延长,管理员批准后在原到期时间上顺延。</div>` + renewForm
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "访问已授权", body)
|
||||
case da.ended != nil && da.ended.Revoked:
|
||||
m.writePage(w, r, http.StatusForbidden, pageError, "授权已撤销", problemHTML(errRevoked.Error()))
|
||||
case da.ended != nil:
|
||||
closedTip := ""
|
||||
if !open {
|
||||
closedTip = "通道当前已关闭,批准后会自动重新开启。"
|
||||
}
|
||||
body := rows("已于 "+da.ended.EndedAt.Format("01-02 15:04")+" 结束") +
|
||||
fmt.Sprintf(`<div class="tip">可在 %s 前申请延长,管理员批准并确定时长后即可继续使用。%s</div>`,
|
||||
da.ended.EndedAt.Add(renewWindow).Format("15:04"), closedTip) + renewForm
|
||||
m.writePage(w, r, http.StatusOK, pageForm, "访问已结束", body)
|
||||
default:
|
||||
m.writePage(w, r, http.StatusGone, pageError, "访问已结束", problemHTML(errNothingToRenew.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
// closedRows describes a scope whose tunnels are all closed (only its last known name is left)
|
||||
func closedRows(da deviceAccess, ip, validity string) string {
|
||||
label := "目标通道"
|
||||
if _, isGroup := splitScope(da.scope); isGroup {
|
||||
label = "目标通道组"
|
||||
}
|
||||
name := da.name
|
||||
if name == "" {
|
||||
name = "(已关闭)"
|
||||
}
|
||||
return fmt.Sprintf(`
|
||||
<div class="row"><span class="label">%s</span><span class="value">%s</span></div>
|
||||
<div class="row"><span class="label">您的设备 IP</span><span class="value mono">%s</span></div>
|
||||
<div class="row"><span class="label">访问有效期</span><span class="value highlight">%s</span></div>`,
|
||||
label, html.EscapeString(name), html.EscapeString(ip), html.EscapeString(validity))
|
||||
}
|
||||
|
||||
func (m *Manager) serveRenewSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = io.NopCloser(io.LimitReader(r.Body, 8<<10))
|
||||
if err := r.ParseForm(); err != nil {
|
||||
m.writePage(w, r, http.StatusBadRequest, pageError, "请求无效", problemHTML("表单内容无法解析"))
|
||||
return
|
||||
}
|
||||
ip := clientIPOf(r)
|
||||
da, ok := m.state.deviceAccess(strings.TrimSpace(r.PostForm.Get("code")), ip)
|
||||
if ip == "" || !ok {
|
||||
m.writePage(w, r, http.StatusNotFound, pageError, "无法申请延长", problemHTML("没有找到这台设备的授权,请联系管理员获取新的邀请链接"))
|
||||
return
|
||||
}
|
||||
req, err := m.state.addRenewal(da.scope, ip)
|
||||
if err != nil {
|
||||
m.writePage(w, r, http.StatusConflict, pageError, "无法申请延长", problemHTML(err.Error()))
|
||||
return
|
||||
}
|
||||
log.Printf("[WebAuth] %s (%s) asked to extend access to %s", req.Name, ip, da.name)
|
||||
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(req.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func sanitizeName(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
@@ -201,15 +292,35 @@ func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
|
||||
m.writePage(w, r, http.StatusNotFound, pageError, "申请已失效", problemHTML("申请不存在或已过期,请重新打开邀请链接提交"))
|
||||
return
|
||||
}
|
||||
sc, open := m.resolveScope(scopeOf(req.TunnelID, req.GroupID))
|
||||
scope := scopeOf(req.TunnelID, req.GroupID)
|
||||
sc, open := m.resolveScope(scope)
|
||||
switch {
|
||||
case req.Status == "rejected" && req.Renewal:
|
||||
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的延长申请"))
|
||||
case req.Status == "rejected":
|
||||
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的访问申请"))
|
||||
case req.Renewal && req.Status == "approved" && !open:
|
||||
// 批准后 Agent 正在重新开启通道
|
||||
w.Header().Set("Refresh", "3")
|
||||
m.writePage(w, r, http.StatusOK, pagePending, "已批准,正在开启通道", `<div class="tip">管理员已批准延长,通道正在重新开启,此页面会自动刷新。</div>`)
|
||||
case req.Renewal && !open:
|
||||
da := deviceAccess{scope: scope, name: m.state.scopeDisplayName(scope)}
|
||||
w.Header().Set("Refresh", "5")
|
||||
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", closedRows(da, req.IP, "等待批准")+
|
||||
fmt.Sprintf(`<div class="tip">%s,您的延长申请已提交,管理员批准时会确定延长多久;通道当前已关闭,批准后会自动重新开启。此页面会自动刷新。</div>`, html.EscapeString(req.Name)))
|
||||
case !open:
|
||||
m.writePage(w, r, http.StatusOK, pageError, "通道未开启", problemHTML("对应通道当前未开启,请联系管理员"))
|
||||
case req.Status == "approved" && req.Renewal:
|
||||
lease, _ := m.state.lease(sc.key, req.IP)
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "延长已批准", m.detailRows(r, sc, req.IP, leaseValidity(lease))+
|
||||
`<div class="tip">管理员已批准延长,现在可以继续使用。到期后 2 小时内还可以重新打开邀请链接申请延长。</div>`)
|
||||
case req.Status == "approved":
|
||||
lease, _ := m.state.lease(sc.key, req.IP)
|
||||
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, sc, req.IP, lease))
|
||||
case req.Renewal:
|
||||
w.Header().Set("Refresh", "5")
|
||||
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", m.detailRows(r, sc, req.IP, "等待批准")+
|
||||
fmt.Sprintf(`<div class="tip">%s,您的延长申请已提交,管理员批准时会确定延长多久。此页面会自动刷新。</div>`, html.EscapeString(req.Name)))
|
||||
default:
|
||||
duration := ""
|
||||
if _, inv, ok := m.state.findInvite(req.Invite); ok {
|
||||
@@ -244,7 +355,7 @@ func (m *Manager) successBody(r *http.Request, sc accessScope, ip string, l leas
|
||||
where = "上面任意一个访问地址"
|
||||
}
|
||||
return m.detailRows(r, sc, ip, leaseValidity(l)) +
|
||||
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。</div>`
|
||||
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接` + where + `,计时从首次连接开始。到期后 2 小时内,可以重新打开这个邀请链接申请延长。</div>`
|
||||
}
|
||||
|
||||
// detailRows renders the info table; a group lists every access address with its target
|
||||
|
||||
@@ -189,7 +189,10 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
|
||||
[
|
||||
{"id":"h1","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51000","connected_at":"\(t(-5000))","disconnected_at":"\(t(-4000))","duration_sec":1000,"bytes_in":1288490188,"bytes_out":5368709},
|
||||
{"id":"h2","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51001","connected_at":"\(t(-3000))","disconnected_at":"\(t(-2990))","duration_sec":10,"bytes_in":2048,"bytes_out":4096},
|
||||
{"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1}
|
||||
{"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1},
|
||||
{"id":"h4","tunnel_id":"g1a","tunnel_name":"订单服务 (10.0.3.11)","port":33670,"client_ip":"192.168.1.42:50100","connected_at":"\(t(-7000))","disconnected_at":"\(t(-6400))","duration_sec":600,"bytes_in":820000,"bytes_out":5200000},
|
||||
{"id":"h5","tunnel_id":"g1b","tunnel_name":"订单服务 (10.0.3.12)","port":33671,"client_ip":"192.168.1.42:50120","connected_at":"\(t(-6800))","disconnected_at":"\(t(-6000))","duration_sec":800,"bytes_in":420000,"bytes_out":3100000},
|
||||
{"id":"h6","tunnel_id":"g1a","tunnel_name":"订单服务 (10.0.3.11)","port":33670,"client_ip":"192.168.1.66:41022","connected_at":"\(t(-90000))","disconnected_at":"\(t(-88200))","duration_sec":1800,"bytes_in":1200000,"bytes_out":9400000}
|
||||
]
|
||||
""")
|
||||
vm.totalActiveConns = 8; vm.totalHistoricalConns = 1656; vm.totalRateIn = totalIn; vm.totalRateOut = totalOut
|
||||
@@ -241,8 +244,8 @@ MainActor.assumeIsolated {
|
||||
render("01b-main-min", CGSize(width: 920, height: 640), ContentView(vm: vm))
|
||||
render("04-clients-history", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 1, isPresented: yes))
|
||||
render("05-clients-auth", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 2, isPresented: yes))
|
||||
render("06-edit-new", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: nil, isPresented: yes))
|
||||
render("07-edit-existing", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: vm.tunnels[1], isPresented: yes))
|
||||
render("06-edit-new", CGSize(width: 520, height: 760), TunnelEditSheet(vm: vm, tunnel: nil, isPresented: yes))
|
||||
render("07-edit-existing", CGSize(width: 520, height: 760), TunnelEditSheet(vm: vm, tunnel: vm.tunnels[1], isPresented: yes))
|
||||
render("08-invite-form", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: false, isPresented: yes))
|
||||
render("09-invite-result", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: true, isPresented: yes))
|
||||
render("10-settings", CGSize(width: 400, height: 320), SettingsSheet(vm: vm, isPresented: yes))
|
||||
@@ -256,7 +259,7 @@ MainActor.assumeIsolated {
|
||||
render("readme-invite", CGSize(width: 560, height: 250), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes))
|
||||
render("readme-clients", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 0, isPresented: yes))
|
||||
render("readme-access", CGSize(width: 760, height: 490), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 2, isPresented: yes))
|
||||
render("readme-edit", CGSize(width: 490, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
|
||||
render("readme-edit", CGSize(width: 520, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
|
||||
let g = demo.groups[0]
|
||||
render("group-card", CGSize(width: 980, height: 330), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
||||
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
||||
@@ -264,14 +267,31 @@ MainActor.assumeIsolated {
|
||||
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
||||
render("group-card-narrow", CGSize(width: 720, height: 360), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
||||
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
||||
render("group-add-batch", CGSize(width: 490, height: 860), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes,
|
||||
draftName: "订单服务", draftTarget: "10.0.3.11-13:22, 10.0.3.11-13:8080"))
|
||||
render("group-add-member", CGSize(width: 490, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, groupId: g.id))
|
||||
render("group-add-batch", CGSize(width: 520, height: 900), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes,
|
||||
draftName: "订单服务", draftRows: [("10.0.3.11", "22, 8080"), ("10.0.3.12", "22, 8080"), ("10.0.3.13", "22")]))
|
||||
render("group-add-single", CGSize(width: 520, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes,
|
||||
draftName: "MySQL 实习库", draftRows: [("10.0.1.16", "3306")]))
|
||||
render("group-add-member", CGSize(width: 520, height: 780), TunnelEditSheet(vm: demo, tunnel: nil, isPresented: yes, groupId: g.id))
|
||||
render("group-sheet-edit", CGSize(width: 520, height: 420), GroupEditSheet(vm: demo, group: g, isPresented: yes))
|
||||
render("group-invite", CGSize(width: 560, height: 300), InviteSheet(vm: demo, scope: .group(g.id), autoGenerate: true, isPresented: yes))
|
||||
render("group-access", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 2, isPresented: yes))
|
||||
render("group-online", CGSize(width: 760, height: 400), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 0, isPresented: yes))
|
||||
render("group-edit-member", CGSize(width: 490, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes))
|
||||
render("group-online-addr", CGSize(width: 760, height: 460), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 0, byAddress: true, isPresented: yes))
|
||||
render("group-history", CGSize(width: 760, height: 400), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 1, isPresented: yes))
|
||||
render("group-history-addr", CGSize(width: 760, height: 520), ClientDetailsSheet(vm: demo, scope: .group(g.id), initialTab: 1, byAddress: true, isPresented: yes))
|
||||
render("group-edit-member", CGSize(width: 520, height: 800), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[3], isPresented: yes))
|
||||
|
||||
// 授权延长:申请延长、最近结束、已授权设备的「延长」
|
||||
let rvm = makeDemoVM()
|
||||
rvm.tunnels[0].renewals = decode([RenewalItem].self, """
|
||||
[{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.35","name":"李四","invite":"ab1","ended_at":"\(t(-1200))","renew_before":"\(t(6000))",
|
||||
"request":{"id":"rn1","tunnel_id":"\(rvm.tunnels[0].id)","name":"李四","ip":"192.168.1.35","invite":"ab1","at":"\(t(-60))","status":"pending","renewal":true}},
|
||||
{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.36","name":"王五","invite":"ab2","ended_at":"\(t(-3000))","renew_before":"\(t(4200))"}]
|
||||
""")
|
||||
render("renew-access", CGSize(width: 760, height: 620), ClientDetailsSheet(vm: rvm, tunnel: rvm.tunnels[0], initialTab: 2, isPresented: yes))
|
||||
render("renew-card", CGSize(width: 980, height: 220), TunnelCardView(tunnel: rvm.tunnels[0], vm: rvm, onEdit: {}, onViewClients: {}, onViewLeases: {}, onInvite: {}).padding(20))
|
||||
render("renew-invite-form", CGSize(width: 600, height: 400), InviteSheet(vm: rvm, tunnel: rvm.tunnels[0], autoGenerate: false, isPresented: yes))
|
||||
render("renew-custom", CGSize(width: 300, height: 90), CustomMinutesForm(actionTitle: "延长") { _ in })
|
||||
|
||||
// README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
|
||||
if only.hasPrefix("gif") {
|
||||
|
||||
Reference in new issue
Block a user