commit 86e159b3f01fe6d6215342965c91318ff52cfa95
Author: DeepFal <41166795+DeepFal@users.noreply.github.com>
Date: Tue Oct 13 16:24:25 2026 +0800
PortRelay:把内网服务临时、可控地开放给同事
边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上;
通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。
- portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页)
- portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计)
- mac-native:SwiftUI 菜单栏 App
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..776e8c2
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+.git
+.github
+bin
+build
+dist
+docs
+mac-native
+tools
+PortRelay.app
+*.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e6fb6d2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,86 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ go:
+ name: Go (vet / test / cross-build)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: gofmt
+ run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
+ - name: vet
+ run: go vet ./...
+ - name: test
+ run: go test -race -count=1 ./...
+ - name: cross-build
+ run: |
+ for p in linux/amd64 linux/arm64 darwin/arm64 windows/amd64; do
+ GOOS=${p%/*} GOARCH=${p#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/...
+ done
+
+ install:
+ name: Install script (systemd)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Build
+ run: CGO_ENABLED=0 go build -o portrelay-server ./cmd/portrelay-server
+ - name: Install
+ run: sudo deploy/install-server.sh ./portrelay-server
+ - name: Verify
+ run: |
+ sleep 2
+ systemctl is-active portrelay-server
+ sudo test -s /etc/portrelay-server.env
+ test "$(sudo stat -c %a /etc/portrelay-server.env)" = 600
+ curl -fsS http://127.0.0.1:33000/health
+ sudo deploy/install-server.sh ./portrelay-server # 升级路径:保留已有 token
+ sleep 2
+ systemctl is-active portrelay-server
+ ls /usr/local/bin/portrelay-server.bak-*
+ - name: Logs
+ if: always()
+ run: sudo journalctl -u portrelay-server --no-pager | tail -30
+
+ macos:
+ name: macOS app
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Build PortRelay.app (universal)
+ run: UNIVERSAL=1 ./build-app.sh
+ - name: Check bundle
+ run: |
+ lipo -info PortRelay.app/Contents/MacOS/PortRelay
+ lipo -info PortRelay.app/Contents/Resources/portrelay-agent
+ codesign --verify --deep PortRelay.app
+
+ docker:
+ name: Docker image
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: false
+ tags: portrelay-server:ci
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..535d4b2
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,100 @@
+name: Release
+
+# 推送 v* 标签时构建并发布;手动触发时只构建、上传 artifact,不发布
+on:
+ push:
+ tags: ['v*']
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ packages: write
+
+jobs:
+ binaries:
+ name: Server & agent binaries
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Build
+ run: make release VERSION=${GITHUB_REF_NAME#v}
+ - uses: actions/upload-artifact@v4
+ with:
+ name: binaries
+ path: |
+ dist/*.tar.gz
+ dist/*.zip
+
+ macos-app:
+ name: macOS app
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Build universal app
+ 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"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: macos-app
+ path: dist/*.zip
+
+ docker:
+ name: Docker image
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/v')
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-qemu-action@v3
+ - uses: docker/setup-buildx-action@v3
+ - name: Image name (lowercase)
+ run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/portrelay-server" >> "$GITHUB_ENV"
+ - uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.IMAGE }}
+ tags: |
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=raw,value=latest
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: true
+ build-args: VERSION=${{ steps.meta.outputs.version }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+
+ publish:
+ name: GitHub release
+ needs: [binaries, macos-app]
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/v')
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ path: dist
+ merge-multiple: true
+ - name: Checksums
+ run: cd dist && sha256sum * > SHA256SUMS
+ - 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
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4a5092e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
+# macOS
+.DS_Store
+
+# 构建产物
+bin/
+build/
+dist/
+PortRelay.app/
+mac-native/PortRelay
+mac-native/test_bin
+mac-native/test_app_bin
+
+# UI 快照工具的构建产物与输出
+tools/ui-snapshots/.build/
+tools/ui-snapshots/out/
+
+# 本地配置与密钥
+*.env
+!deploy/*.env.example
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0ea19b3
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,21 @@
+# PortRelay 边缘网关(服务端)镜像
+# docker build -t portrelay-server .
+# docker run -d --network host -e PORTRELAY_TOKEN=... -v portrelay:/var/lib/portrelay portrelay-server
+# 请使用 host 网络:白名单依赖访问者的真实来源 IP,端口映射可能把它变成网桥地址。
+FROM golang:1.23-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+ARG VERSION=dev
+RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
+ -o /out/portrelay-server ./cmd/portrelay-server \
+ && mkdir -p /out/state
+
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY --from=build /out/portrelay-server /usr/local/bin/portrelay-server
+COPY --from=build --chown=65532:65532 /out/state /var/lib/portrelay
+VOLUME ["/var/lib/portrelay"]
+EXPOSE 33000-34000
+ENTRYPOINT ["/usr/local/bin/portrelay-server"]
+CMD ["-listen", ":33000", "-port-min", "33000", "-port-max", "34000", "-state", "/var/lib/portrelay/leases.json"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..048441f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 DeepFal
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..55a9663
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,57 @@
+# PortRelay —— 常用命令
+# make test 运行 gofmt 检查、go vet 与全部测试(-race)
+# make server 交叉编译 Linux 服务端到 dist/
+# make agent 编译当前平台的 Agent 到 bin/
+# make app 构建 macOS 应用 PortRelay.app(需要 Xcode 命令行工具)
+# make release 打包所有平台的服务端 / Agent 到 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
+
+.PHONY: all test fmt server agent app release snapshots clean
+
+all: test agent
+
+test:
+ @test -z "$$(gofmt -l .)" || (echo "需要 gofmt:" && gofmt -l . && exit 1)
+ go vet ./...
+ go test -race -count=1 ./...
+
+fmt:
+ gofmt -w .
+
+server:
+ GOOS=linux GOARCH=amd64 $(GOBUILD) -o dist/portrelay-server-linux-amd64 ./cmd/portrelay-server
+ GOOS=linux GOARCH=arm64 $(GOBUILD) -o dist/portrelay-server-linux-arm64 ./cmd/portrelay-server
+
+agent:
+ $(GOBUILD) -o bin/portrelay-agent ./cmd/portrelay-agent
+
+app:
+ VERSION=$(VERSION) ./build-app.sh
+
+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; \
+ 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; \
+ cp LICENSE README.md README_EN.md dist/$$name/; \
+ if [ $$os = linux ]; 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
+ @cd dist && shasum -a 256 * > SHA256SUMS && cat SHA256SUMS
+
+snapshots:
+ tools/ui-snapshots/render.sh
+
+clean:
+ rm -rf bin build dist PortRelay.app mac-native/PortRelay tools/ui-snapshots/.build tools/ui-snapshots/out
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..04e8a23
--- /dev/null
+++ b/README.md
@@ -0,0 +1,310 @@
+
+
+
+
+PortRelay
+
+
+ 把内网服务临时、可控地开放给同事:发一条邀请链接,到期自动收回。
+
+
+
+
+
+
+
+
+
+
+
+ 简体中文 · English
+
+
+
+
+
+
+你的电脑能连上某个内网网段:数据库、跳板机、内部面板,但同事连不上。常见的做法是开 VPN 账号、改防火墙,或者干脆把端口对所有人放开,事后还经常忘记关。
+
+PortRelay 把这件事变成三步:
+
+1. **Agent** 把内网目标挂到 **边缘网关** 的一个端口上。
+2. 你把**邀请链接**(或二维码)发给同事。
+3. 同事打开链接、填写姓名,他的设备就获得一段时间的访问权。到期、闲置或被你撤销后,连接立即断开。
+
+> [!TIP]
+> **不依赖任何特定组网方案。** 只要 Agent 所在的机器**同时能连到目标网段和边缘网关**就能工作。Agent 到网关的线路随你选:同一局域网、公司 VPN、Tailscale、ZeroTier、WireGuard,或者网关本身有公网地址。Agent 只需主动连出,本机不用开放任何端口。
+
+## 目录
+
+- [功能](#功能)
+- [界面一览](#界面一览)
+- [工作原理](#工作原理)
+- [快速开始](#快速开始)
+- [配置参考](#配置参考)
+- [安全模型](#安全模型)
+- [常见问题](#常见问题)
+- [开发](#开发)
+
+## 功能
+
+**访问控制**
+
+- 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。
+- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(1 小时、2 小时、8 小时、当天有效,或直到通道关闭),以及是否需要审批。
+- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
+- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
+- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
+
+**通道生命周期**
+
+- **闲置自动关闭**:没有连接、没有有效授权、没有待处理申请,也没有还能使用的邀请链接时开始计时,默认 30 分钟后关闭。
+- 可选的**最长开启时长**,到点无论是否在用都关闭。
+- 记录首次连接时间、实际连接时长和每次关闭的原因。
+- 编辑通道不会打断现有连接;更换端口时先绑定新端口再释放旧端口。
+
+**可观测**
+
+- 每个通道近 10 分钟的流量曲线,以及实时速率、连接数、本次与历史累计流量。
+- 在线设备按 IP 合并展示,可逐条断开;历史接入台账同样按 IP 合并。
+- 按通道限速(MB/s)。
+
+**客户端**
+
+- 原生 macOS 菜单栏 App(SwiftUI):关闭窗口只保留菜单栏图标,从菜单栏退出时连同后台 Agent 一起退出。
+- 内嵌网页面板,Linux、Windows 上也能用同一套功能。
+- 通道搜索(支持拼音首字母,比如输入 `sjk` 就能找到“数据库”),以及按运行状态筛选。
+
+## 界面一览
+
+
+
+ 通道卡片:访问地址、授权状态、实际连接时长,以及近 10 分钟流量(动图为加速演示)
+
+
+
+
+
+
+
+**邀请与授权**:生成链接后得到一段可直接发送的文案和二维码。同事在手机或电脑上打开链接填写姓名,需要审批时进入等待页,批准后页面自动更新。
+
+
+
+
+
+
+
+ ① 打开邀请链接
+ ② 等待管理员批准(可选)
+ ③ 授权成功
+
+
+
+**连接与授权管理**:待审批的申请、已授权设备(在线状态与剩余时间),以及仍然有效的邀请链接,都在同一处管理。
+
+
+
+
+
+
+网页面板与更多截图
+
+
+不使用 macOS App 时,Agent 自带网页面板,地址为 `http://127.0.0.1:33080`:
+
+
+
+
+
+ 编辑通道:访问方式、闲置关闭、最长时长
+ 在线设备:按 IP 合并,可逐条断开
+
+
+
+
+
+## 工作原理
+
+
+
+
+
+- **边缘网关**(`portrelay-server`)运行在访问者能连到的机器上,管理一个端口池(默认 `33000-34000`)。`33000` 是控制端口:Agent 的隧道和访问者打开的授权网页共用这一个端口,网关根据请求开头自动分流(HTTP 请求进入授权网页,其余视为 Agent 会话)。
+- **Agent**(`portrelay-agent`,macOS App 内置)运行在能访问目标网段的机器上。它主动连接网关的控制端口,先用 token 认证,再通过 [yamux](https://github.com/hashicorp/yamux) 在这条 TCP 连接上多路复用所有通道。
+- 访问者连接 `网关:33660` 时,网关先检查白名单和授权租约。通过后在隧道里开一个流,由 Agent 连向 `10.0.1.16:3306`,双向转发。不通过则直接重置连接并记录。
+- 授权租约、邀请和审批请求保存在网关的状态文件里,重启后仍然有效。通道配置、流量历史和接入台账保存在 Agent 本机。
+
+## 快速开始
+
+### 1. 部署边缘网关(Linux)
+
+网关需要满足两点:**访问者能连到它**,**Agent 也能连到它**。防火墙放通 TCP `33000-34000`。
+
+**方式 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
+sudo ./deploy/install-server.sh ./portrelay-server
+
+# 安装脚本会生成随机 token,记下来填到 Agent 里
+sudo grep PORTRELAY_TOKEN /etc/portrelay-server.env
+```
+
+安装脚本会把程序装到 `/usr/local/bin`,写入 [systemd 单元](deploy/portrelay-server.service)(以临时系统用户运行,状态保存在 `/var/lib/portrelay`),并设置开机自启。再次运行同一个脚本即可升级,已有配置会保留。
+
+**方式 B:Docker**
+
+```bash
+TOKEN=$(openssl rand -hex 24); echo "token: $TOKEN"
+docker run -d --name portrelay --network host --restart unless-stopped \
+ -e PORTRELAY_TOKEN=$TOKEN -v portrelay:/var/lib/portrelay \
+ ghcr.io/deepfal/portrelay-server:latest
+```
+
+> [!IMPORTANT]
+> 请使用 `--network host`。白名单依赖访问者的真实 IP,端口映射可能会把来源地址换成网桥地址。另见 [docker-compose 示例](deploy/docker-compose.yml)。
+
+**方式 C:从源码构建**
+
+```bash
+git clone https://github.com/DeepFal/PortRelay.git && cd PortRelay
+make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
+```
+
+### 2. 运行 Agent
+
+**macOS App**:从 [Releases](https://github.com/DeepFal/PortRelay/releases) 下载 `PortRelay-*-macos-universal.zip`,解压后拖进“应用程序”。
+
+> [!NOTE]
+> App 未经 Apple 公证。首次打开时,在 Finder 中右键选择“打开”,或者执行 `xattr -dr com.apple.quarantine /Applications/PortRelay.app`。也可以 `make app` 自行构建。
+
+打开后点右上角的 ⚙️ 设置:
+
+| 设置项 | 填什么 |
+| --- | --- |
+| 服务器控制地址 | Agent 连接网关用的地址,例如 `gw.example.com:33000`、`192.168.1.10:33000` 或 `100.x.y.z:33000` |
+| 安全令牌 | 网关上的 `PORTRELAY_TOKEN` |
+| 对外访问地址 | **同事**访问网关用的地址,留空则与上面相同 |
+
+> 举例:Agent 通过 Tailscale 地址 `100.64.0.10` 连网关,同事在办公室局域网里用 `192.168.1.10` 访问。这时“服务器控制地址”填 `100.64.0.10:33000`,“对外访问地址”填 `192.168.1.10`,邀请链接和分享文案都会使用后者。
+
+**Linux / Windows**:下载对应平台的压缩包,直接运行 Agent,在浏览器打开的面板里完成同样的设置。
+
+```bash
+./portrelay-agent # 面板:http://127.0.0.1:33080
+./portrelay-agent -no-browser # 不自动打开浏览器
+```
+
+### 3. 创建通道并邀请同事
+
+1. 点 **添加通道**,填写名称和目标地址(如 `10.0.1.16:3306`)。可以先“探测”一下本机能否连通;端口会自动推荐。
+2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
+3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
+
+## 配置参考
+
+**网关 `portrelay-server`**
+
+| 参数 | 默认值 | 说明 |
+| --- | --- | --- |
+| `-token` / `PORTRELAY_TOKEN` | 必填 | Agent 认证用的共享密钥,建议用 `openssl rand -hex 24` 生成 |
+| `-listen` | `:33000` | 控制端口(Agent 隧道与授权网页共用) |
+| `-port-min` / `-port-max` | `33000` / `34000` | 通道端口池 |
+| `-state` | `/var/lib/portrelay/leases.json` | 授权租约、邀请与审批的持久化文件,留空则只保存在内存 |
+| `-public-host` | 空 | 授权页上展示给访问者的地址,默认取访问者打开链接时使用的地址 |
+| `-trust-proxy` | `false` | 信任 `X-Forwarded-For` / `X-Real-IP`,**仅在网关前有可信反向代理时开启** |
+| `-version` | | 打印版本 |
+
+**Agent `portrelay-agent`**
+
+| 参数 | 默认值 | 说明 |
+| --- | --- | --- |
+| `-port` | `33080` | 本地面板 / API 端口(只监听 `127.0.0.1`) |
+| `-no-browser` | `false` | 启动时不自动打开面板 |
+| `-version` | | 打印版本 |
+
+Agent 的配置保存在 `~/.portrelay/config.json`(权限 0600,原子写入;文件损坏时会先备份再使用默认值)。
+
+## 安全模型
+
+- **Agent 认证**:连接建立后的第一条消息必须是 token 认证(常量时间比较),认证通过的会话才能注册通道。新会话会替换旧会话。
+- **默认拒绝**:新通道默认只允许持有授权的设备访问。授权按“通道 + 来源 IP”生效,并且有明确的到期时间。
+- **到期即断**:网关每秒检查一次存量连接,授权失效的连接立即断开。
+- **本地 API 只对本机开放**:面板只监听回环地址,并校验 `Host` 与 `Origin`,防止 DNS 重绑定和跨站请求。
+
+> [!WARNING]
+> - **Agent 与网关之间的链路目前没有加密**,token 与转发的数据都以明文传输。请让这段链路跑在可信网络或加密通道上(WireGuard、Tailscale、ZeroTier、IPSec VPN 等),或者在两端之间自行加一层 TLS 隧道。
+> - 授权以**来源 IP** 为准。如果多人通过同一个 NAT 出口访问网关,他们会共享同一份授权。
+> - 访问者到网关的流量就是目标服务本身的协议,加密与否取决于目标服务(例如 MySQL 是否启用 TLS)。
+
+## 常见问题
+
+
+必须用 Tailscale 吗?
+
+不需要。PortRelay 只要求 Agent 所在机器能访问目标网段,并且能通过 TCP 连到网关的控制端口。Tailscale 只是让 Agent 连上网关的方式之一,局域网直连、VPN 或公网地址都可以。
+
+
+
+和 frp、ssh -R 有什么区别?
+
+它们都能做端口转发,而且更通用。PortRelay 专注于**临时、按人授权**的场景:默认拒绝、通过邀请链接按设备授权、可选审批、首次连接起计时、到期或闲置自动收回,并配有图形界面和接入台账。如果只需要一条长期开着的转发,frp 或 SSH 更简单。
+
+
+
+一个网关能接多个 Agent 吗?
+
+目前同一时间只服务一个 Agent,新的认证会话会替换旧会话。需要多个 Agent 时,可以部署多个网关实例(使用不同的端口池和 token)。
+
+
+
+网关重启会怎样?
+
+正在进行的连接会断开。授权租约、邀请链接和审批请求保存在状态文件里,重启后依然有效。Agent 会自动重连并重新注册通道。
+
+
+
+访问者看到的 IP 不对 / 白名单不生效?
+
+检查网关前面是否有 NAT、端口映射或反向代理。Docker 部署请使用 `--network host`。只有在可信反向代理之后才开启 `-trust-proxy`,否则 `X-Forwarded-For` 可以被伪造。
+
+
+
+App 提示“无法验证开发者”?
+
+发布的 App 只做了 ad-hoc 签名,没有经过 Apple 公证。请右键选择“打开”,或者执行 `xattr -dr com.apple.quarantine /Applications/PortRelay.app`。
+
+
+## 开发
+
+需要 Go 1.23+;构建 macOS App 还需要 Xcode 命令行工具,运行环境为 macOS 13 及以上。
+
+```bash
+make test # gofmt 检查 + go vet + go test -race
+make agent # 编译当前平台的 Agent 到 bin/
+make app # 构建 PortRelay.app(UNIVERSAL=1 ./build-app.sh 构建通用二进制)
+make release # 打包全部平台的网关与 Agent 到 dist/
+make snapshots # 离屏渲染 App 各界面截图到 tools/ui-snapshots/out/
+```
+
+```text
+cmd/portrelay-server 边缘网关入口
+cmd/portrelay-agent Agent 入口(内嵌网页面板 web/)
+server/ 网关:Agent 会话、端口监听、白名单与授权租约、邀请与授权网页
+client/ Agent:配置、隧道、本地 API、闲置关闭、流量统计
+core/ 协议消息、ACL、流量计量与限速
+mac-native/ SwiftUI 菜单栏 App
+deploy/ systemd、Docker Compose 部署示例与安装脚本
+tools/ui-snapshots/ 离屏截图工具与 README 动图生成(mkgif)
+```
+
+README 里的截图和动图都用模拟数据生成:`tools/ui-snapshots/render.sh readme` 渲染截图,`FRAMES=40 tools/ui-snapshots/render.sh gif` 渲染逐帧画面,再用 `go run ./tools/ui-snapshots/mkgif` 合成 GIF。
+
+欢迎提交 Issue 和 Pull Request。提交前请先运行 `make test`。
+
+## 许可证
+
+[MIT](LICENSE) © DeepFal
diff --git a/README_EN.md b/README_EN.md
new file mode 100644
index 0000000..ca0b026
--- /dev/null
+++ b/README_EN.md
@@ -0,0 +1,329 @@
+
+
+
+
+PortRelay
+
+
+ Share internal services with teammates temporarily and safely: send an invite link, and access expires on its own.
+
+
+
+
+
+
+
+
+
+
+
+ 简体中文 · English
+
+
+
+
+
+
+Your machine can reach an internal network: a database, a jump host, an internal dashboard. Your teammates can't. The usual fixes are VPN accounts, firewall tickets, or opening the port to everyone and forgetting to close it again.
+
+PortRelay turns that into three steps:
+
+1. The **agent** exposes an internal target on a port of the **edge gateway**.
+2. You send a teammate an **invite link** (or QR code).
+3. They open it and enter their name. Their device can then connect for a limited time. Access ends when it expires, when the tunnel sits idle, or when you revoke it, and live connections are cut immediately.
+
+> [!TIP]
+> **No particular network setup is required.** The machine running the agent only needs to reach **both the target network and the edge gateway**. How the agent reaches the gateway is up to you: the same LAN, a corporate VPN, Tailscale, ZeroTier, WireGuard, or a gateway with a public address. The agent only dials out and opens no inbound ports.
+
+> [!NOTE]
+> The user interface (macOS app, web panel and invite pages) is currently in Simplified Chinese.
+
+## Contents
+
+- [Features](#features)
+- [Screenshots](#screenshots)
+- [How it works](#how-it-works)
+- [Quick start](#quick-start)
+- [Configuration](#configuration)
+- [Security model](#security-model)
+- [FAQ](#faq)
+- [Development](#development)
+
+## Features
+
+**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.
+- 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 lifecycle**
+
+- **Idle auto-close.** The idle timer starts when a tunnel has no connections, no active grants, no pending requests and no invite links with uses left. By default the tunnel closes after 30 minutes of idle time.
+- An optional **maximum open time** closes the tunnel at a fixed point, whether or not it is in use.
+- PortRelay records the first-connection time, the actual connected time and why each tunnel was closed.
+- Editing a tunnel keeps existing connections. Moving a tunnel binds the new port before releasing the old one.
+
+**Observability**
+
+- A 10-minute traffic chart for each tunnel, plus live rates, connection counts and session/total bytes.
+- Online devices are grouped by IP and can be disconnected one by one. The connection history is grouped the same way.
+- Per-tunnel bandwidth limit in MB/s.
+
+**Clients**
+
+- A native macOS menu-bar app (SwiftUI). Closing the window leaves only the menu-bar icon; quitting from the menu also stops the background agent.
+- An embedded web panel with the same features on Linux and Windows.
+- Tunnel search, including pinyin initials (for example `sjk` finds “数据库”), and a status filter.
+
+## Screenshots
+
+
+
+ A tunnel card: access address, grants, connected time and the last 10 minutes of traffic (sped up)
+
+
+
+
+
+
+
+**Invites.** Generating an invite gives you a ready-to-send message and a QR code. The teammate opens the link on a phone or laptop and enters their name. If approval is required, they wait on a status page that updates when you decide.
+
+
+
+
+
+
+
+ ① Open the invite link
+ ② Wait for approval (optional)
+ ③ Access granted
+
+
+
+**Access management.** Pending requests, authorized devices (with online state and remaining time) and active invite links are all managed in one place.
+
+
+
+
+
+
+Web panel and more screenshots
+
+
+Without the macOS app, the agent serves a web panel at `http://127.0.0.1:33080`:
+
+
+
+
+
+ Edit tunnel: access mode, idle close, max open time
+ Online devices, grouped by IP
+
+
+
+
+
+## How it works
+
+
+
+
+
+- The **edge gateway** (`portrelay-server`) runs where visitors can reach it and manages a port pool (default `33000-34000`). Port `33000` is the control port. The agent tunnel and the invite web pages share it: HTTP requests go to the invite pages, and anything else is treated as an agent session.
+- The **agent** (`portrelay-agent`, bundled in the macOS app) runs on a machine that can reach the target network. It dials the gateway's control port, authenticates with a token, and multiplexes all tunnels over that single TCP connection using [yamux](https://github.com/hashicorp/yamux).
+- When a visitor connects to `gateway:33660`, the gateway checks the allowlist and access grants.
+ - If the visitor is allowed, the gateway opens a stream through the tunnel, the agent dials `10.0.1.16:3306`, and bytes are relayed both ways.
+ - Otherwise the connection is reset and logged.
+- Grants, invites and approval requests live in a state file on the gateway and survive restarts. Tunnel settings, traffic history and the connection log are kept on the agent's machine.
+
+## Quick start
+
+### 1. Deploy the edge gateway (Linux)
+
+The gateway must be reachable by **visitors** and by **the agent**. Open TCP `33000-34000` in your firewall.
+
+**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
+sudo ./deploy/install-server.sh ./portrelay-server
+
+# The installer generates a random token; you will enter it in the agent
+sudo grep PORTRELAY_TOKEN /etc/portrelay-server.env
+```
+
+The installer does four things:
+- installs the binary into `/usr/local/bin`;
+- writes a [systemd unit](deploy/portrelay-server.service), which runs as a dynamic system user and keeps state in `/var/lib/portrelay`;
+- enables the service at boot;
+- keeps your existing configuration when you re-run it to upgrade.
+
+**Option B: Docker**
+
+```bash
+TOKEN=$(openssl rand -hex 24); echo "token: $TOKEN"
+docker run -d --name portrelay --network host --restart unless-stopped \
+ -e PORTRELAY_TOKEN=$TOKEN -v portrelay:/var/lib/portrelay \
+ ghcr.io/deepfal/portrelay-server:latest
+```
+
+> [!IMPORTANT]
+> Use `--network host`. Allowlists rely on the visitor's real source IP, and port publishing may replace it with a bridge address. See also the [docker-compose example](deploy/docker-compose.yml).
+
+**Option C: build from source**
+
+```bash
+git clone https://github.com/DeepFal/PortRelay.git && cd PortRelay
+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.
+
+> [!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`.
+
+Open ⚙️ Settings and fill in:
+
+| Setting | Value |
+| --- | --- |
+| Server control address (服务器控制地址) | The address the **agent** uses to reach the gateway, e.g. `gw.example.com:33000`, `192.168.1.10:33000` or `100.x.y.z:33000` |
+| Token (安全令牌) | `PORTRELAY_TOKEN` from the gateway |
+| Public access address (对外访问地址) | The address **teammates** use to reach the gateway. Leave it empty to use the same host. |
+
+> 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.
+
+```bash
+./portrelay-agent # panel at http://127.0.0.1:33080
+./portrelay-agent -no-browser # don't open a browser
+```
+
+### 3. Create a tunnel and invite someone
+
+1. Click **添加通道** (Add tunnel) and enter a name and a target such as `10.0.1.16:3306`. You can probe the target from your machine first, and a free port is suggested automatically.
+2. Keep the default access mode, **仅授权链接** (invite-only). Click **邀请** (Invite), then choose how long access lasts, how many devices may use the link, and whether approval is required.
+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`.
+
+## Configuration
+
+**Gateway: `portrelay-server`**
+
+| Flag | Default | Description |
+| --- | --- | --- |
+| `-token` / `PORTRELAY_TOKEN` | required | Shared secret for agent authentication; generate it with `openssl rand -hex 24` |
+| `-listen` | `:33000` | Control port, shared by the agent tunnel and the invite pages |
+| `-port-min` / `-port-max` | `33000` / `34000` | Tunnel port pool |
+| `-state` | `/var/lib/portrelay/leases.json` | Where grants, invites and requests are persisted; leave it empty to keep them in memory only |
+| `-public-host` | empty | Address shown to visitors on the invite page; defaults to the host the visitor used to open the link |
+| `-trust-proxy` | `false` | Trust `X-Forwarded-For` / `X-Real-IP`. **Enable it only behind a trusted reverse proxy.** |
+| `-version` | | Print the version |
+
+**Agent: `portrelay-agent`**
+
+| Flag | Default | Description |
+| --- | --- | --- |
+| `-port` | `33080` | Local panel / API port; listens on `127.0.0.1` only |
+| `-no-browser` | `false` | Don't open the panel on start |
+| `-version` | | Print the version |
+
+Agent settings live in `~/.portrelay/config.json`. The file has mode 0600 and is written atomically. If it is corrupt, it is backed up and replaced with defaults.
+
+## Security model
+
+- **Agent authentication.** The first message on a new connection must be a token check, compared in constant time. Only authenticated sessions can register tunnels, and a new session replaces the old one.
+- **Deny by default.** New tunnels admit only devices that hold a grant. A grant applies to one tunnel and one source IP, and always has an expiry.
+- **Expiry is enforced live.** The gateway re-checks open connections every second and drops any whose grant has ended.
+- **The local API is loopback-only.** The panel binds to `127.0.0.1` and validates `Host` and `Origin` to block DNS rebinding and cross-site requests.
+
+> [!WARNING]
+> - **The agent ↔ gateway link is not encrypted yet.** The token and all relayed data travel in plain text. Run that hop over a trusted network or an encrypted overlay (WireGuard, Tailscale, ZeroTier, an IPsec VPN, …), or wrap it in your own TLS tunnel.
+> - Grants are tied to the **source IP**. Visitors behind the same NAT address share a grant.
+> - Traffic between visitors and the gateway uses the target service's own protocol. Whether it is encrypted depends on that service (for example, whether MySQL has TLS enabled).
+
+## FAQ
+
+
+Do I need Tailscale?
+
+No. PortRelay only needs the agent's machine to reach the target network and to open a TCP connection to the gateway's control port. Tailscale is just one way to provide that path; a LAN, a VPN or a public address work equally well.
+
+
+
+How is this different from frp or ssh -R?
+
+Those tools are more general port forwarders. PortRelay focuses on **temporary, per-person access**:
+- deny by default;
+- per-device grants through invite links, with optional approval;
+- clocks that start at the first connection;
+- automatic expiry and idle close;
+- a GUI and a connection log.
+
+If you only need one long-lived forward, frp or SSH is simpler.
+
+
+
+Can one gateway serve several agents?
+
+Not at the same time: a new authenticated session replaces the previous one. For several agents, run several gateway instances with different port pools and tokens.
+
+
+
+What happens when the gateway restarts?
+
+Live connections drop. Grants, invite links and approval requests are kept in the state file and stay valid. The agent reconnects and re-registers its tunnels automatically.
+
+
+
+Visitors show up with the wrong IP, or the allowlist doesn't work
+
+Check for NAT, port publishing or a reverse proxy in front of the gateway. With Docker, use `--network host`. Enable `-trust-proxy` only behind a trusted proxy; otherwise `X-Forwarded-For` can be spoofed.
+
+
+
+macOS says the developer cannot be verified
+
+Release builds are ad-hoc signed, not notarized. Right-click the app and choose Open, or run `xattr -dr com.apple.quarantine /Applications/PortRelay.app`.
+
+
+## Development
+
+Building requires Go 1.23+. The macOS app also needs the Xcode command-line tools and runs on macOS 13 or later.
+
+```bash
+make test # gofmt check + go vet + go test -race
+make agent # build the agent for this platform into bin/
+make app # build PortRelay.app (UNIVERSAL=1 ./build-app.sh for a universal binary)
+make release # package gateway + agent for all platforms into dist/
+make snapshots # render app screens offscreen into tools/ui-snapshots/out/
+```
+
+```text
+cmd/portrelay-server gateway entry point
+cmd/portrelay-agent agent entry point (embeds the web panel in web/)
+server/ gateway: agent sessions, listeners, allowlists and grants, invites and invite pages
+client/ agent: config, tunnels, local API, idle close, traffic stats
+core/ protocol messages, ACL, traffic metering and rate limiting
+mac-native/ SwiftUI menu-bar app
+deploy/ systemd and Docker Compose examples, install script
+tools/ui-snapshots/ offscreen screenshot renderer and README GIF builder (mkgif)
+```
+
+All screenshots and animations in this README come from mock data:
+- `tools/ui-snapshots/render.sh readme` renders the screenshots;
+- `FRAMES=40 tools/ui-snapshots/render.sh gif` renders the animation frames;
+- `go run ./tools/ui-snapshots/mkgif` encodes the frames as a GIF.
+
+Issues and pull requests are welcome. Please run `make test` before submitting.
+
+## License
+
+[MIT](LICENSE) © DeepFal
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..34beace
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,15 @@
+# 安全策略 / Security Policy
+
+如果你发现了安全漏洞,请**不要**公开提交 Issue,而是通过 GitHub 的
+[私密漏洞报告](https://github.com/DeepFal/PortRelay/security/advisories/new) 提交,
+我们会尽快确认并修复。
+
+If you find a security vulnerability, please do **not** open a public issue.
+Report it privately via [GitHub Security Advisories](https://github.com/DeepFal/PortRelay/security/advisories/new) instead.
+
+已知限制(不视为漏洞)/ Known limitations (not considered vulnerabilities):
+
+- Agent 与网关之间的链路未加密,请运行在可信网络或加密组网之上。
+ The agent ↔ gateway link is not encrypted; run it over a trusted network or an encrypted overlay.
+- 授权以来源 IP 为准,同一 NAT 出口后的设备共享授权。
+ Grants are tied to the source IP; devices behind the same NAT share a grant.
diff --git a/build-app.sh b/build-app.sh
new file mode 100755
index 0000000..2ce5dcc
--- /dev/null
+++ b/build-app.sh
@@ -0,0 +1,102 @@
+#!/bin/bash
+# 构建 macOS 菜单栏应用 PortRelay.app(内含 Go Agent)。
+#
+# ./build-app.sh # 当前架构
+# UNIVERSAL=1 ./build-app.sh # Apple Silicon + Intel 通用二进制
+# VERSION=1.2.0 ./build-app.sh # 写入版本号(默认 0.0.0-dev)
+set -euo pipefail
+
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$DIR"
+
+VERSION="${VERSION:-0.0.0-dev}"
+BUNDLE_VERSION="${VERSION%%-*}"
+[[ "$BUNDLE_VERSION" =~ ^[0-9]+(\.[0-9]+)*$ ]] || BUNDLE_VERSION="0.0.0"
+MIN_MACOS="13.0"
+APP="PortRelay.app"
+BUILD="build/app"
+mkdir -p "$BUILD" bin
+
+if [ "${UNIVERSAL:-0}" = "1" ]; then
+ ARCHS="arm64 x86_64"
+else
+ ARCHS="$(uname -m)"
+fi
+
+goarch() { [ "$1" = "x86_64" ] && echo amd64 || echo "$1"; }
+
+echo "==> 1. Building Go agent ($ARCHS)..."
+agents=()
+for a in $ARCHS; do
+ CGO_ENABLED=0 GOOS=darwin GOARCH="$(goarch "$a")" \
+ go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$BUILD/portrelay-agent-$a" ./cmd/portrelay-agent
+ agents+=("$BUILD/portrelay-agent-$a")
+done
+lipo -create -output bin/portrelay-agent "${agents[@]}"
+
+echo "==> 2. Generating AppIcon if needed..."
+if [ ! -f mac-native/AppIcon.icns ]; then
+ xcrun swift mac-native/generate_icon.swift
+ iconutil -c icns mac-native/AppIcon.iconset -o mac-native/AppIcon.icns
+fi
+
+echo "==> 3. Compiling native Swift app ($ARCHS)..."
+apps=()
+for a in $ARCHS; do
+ xcrun swiftc -parse-as-library -O -target "$a-apple-macos$MIN_MACOS" \
+ mac-native/PortRelayApp.swift -o "$BUILD/PortRelay-$a"
+ apps+=("$BUILD/PortRelay-$a")
+done
+lipo -create -output "$BUILD/PortRelay" "${apps[@]}"
+
+echo "==> 4. Assembling $APP..."
+rm -rf "$APP"
+mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
+cp "$BUILD/PortRelay" "$APP/Contents/MacOS/PortRelay"
+cp bin/portrelay-agent "$APP/Contents/Resources/portrelay-agent"
+cp mac-native/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns"
+chmod +x "$APP/Contents/MacOS/PortRelay" "$APP/Contents/Resources/portrelay-agent"
+
+cat << PLIST > "$APP/Contents/Info.plist"
+
+
+
+
+ CFBundleExecutable
+ PortRelay
+ CFBundleIdentifier
+ com.deepfal.portrelay
+ CFBundleName
+ PortRelay
+ CFBundleDisplayName
+ PortRelay
+ CFBundleIconFile
+ AppIcon.icns
+ CFBundleIconName
+ AppIcon
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ ${VERSION}
+ CFBundleVersion
+ ${BUNDLE_VERSION}
+ LSMinimumSystemVersion
+ ${MIN_MACOS}
+ NSHighResolutionCapable
+
+ NSLocalNetworkUsageDescription
+ PortRelay 需要访问本地网络,以连接边缘网关并把流量转发到内网目标服务。
+
+
+PLIST
+
+echo "==> 5. Ad-hoc signing..."
+xattr -cr "$APP"
+codesign --force --deep --sign - "$APP"
+touch "$APP"
+if [ -z "${CI:-}" ]; then
+ /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$APP" || true
+ xcrun swift mac-native/set_icon.swift "$APP" || true
+fi
+
+echo "✓ Build complete: $DIR/$APP ($VERSION, $ARCHS)"
diff --git a/client/agent.go b/client/agent.go
new file mode 100644
index 0000000..60e9b47
--- /dev/null
+++ b/client/agent.go
@@ -0,0 +1,807 @@
+package client
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/hashicorp/yamux"
+
+ "github.com/DeepFal/PortRelay/core/protocol"
+)
+
+const (
+ authRespTimeout = 10 * time.Second
+ reconnectDelay = 2 * time.Second
+)
+
+// Agent runs on Mac and connects to the relay server
+type Agent struct {
+ cm *ConfigManager
+
+ mu sync.RWMutex
+ session *yamux.Session
+ connected bool
+ lastError string
+ latestMetrics protocol.MetricsMessage
+ regErrors map[string]string // tunnelID -> 服务器返回的注册失败原因
+ inviteWaiters map[string]chan protocol.InviteRespMessage
+
+ controlWriteMu sync.Mutex
+ controlWriter *bufio.Writer
+
+ // 闲置自动关闭与实际连接时长统计(按通道)
+ usageMu sync.Mutex
+ usage map[string]*usageState
+ idleUnit time.Duration // 闲置时长单位,正常为分钟(测试可调小)
+ rates *rateRecorder // 近 10 分钟流量曲线
+
+ metricsSub chan struct{}
+ closed chan struct{}
+ supervisorDone chan struct{}
+ stopOnce sync.Once
+ reconnectSignal chan struct{}
+}
+
+// NewAgent initializes the agent
+func NewAgent(cm *ConfigManager) *Agent {
+ return &Agent{
+ cm: cm,
+ regErrors: make(map[string]string),
+ inviteWaiters: make(map[string]chan protocol.InviteRespMessage),
+ usage: make(map[string]*usageState),
+ idleUnit: time.Minute,
+ rates: newRateRecorder(),
+ metricsSub: make(chan struct{}, 1),
+ closed: make(chan struct{}),
+ supervisorDone: make(chan struct{}),
+ reconnectSignal: make(chan struct{}, 1),
+ }
+}
+
+// Reconnect drops the current session and reconnects immediately.
+// force=false 时先探测现有会话,健康则保留(避免屏幕唤醒等场景无谓地切断所有连接)。
+func (a *Agent) Reconnect(force bool) {
+ a.mu.RLock()
+ sess := a.session
+ a.mu.RUnlock()
+
+ if !force && sess != nil {
+ if healthy(sess) {
+ log.Printf("[Agent] 会话健康,无需重连")
+ return
+ }
+ }
+
+ log.Printf("[Agent] ⚡ 触发主动重连...")
+ if sess != nil {
+ _ = sess.Close()
+ }
+ select {
+ case a.reconnectSignal <- struct{}{}:
+ default:
+ }
+}
+
+func healthy(sess *yamux.Session) bool {
+ res := make(chan error, 1)
+ go func() {
+ _, err := sess.Ping()
+ res <- err
+ }()
+ select {
+ case err := <-res:
+ return err == nil
+ case <-time.After(3 * time.Second):
+ return false
+ }
+}
+
+// Start runs background connection supervisor and TTL checker
+func (a *Agent) Start() {
+ go a.supervisorLoop()
+ go a.ttlCheckerLoop()
+}
+
+// Stop shuts down and waits (briefly) for the session to wind down, so the final
+// traffic merge is in the config before the caller persists it. Safe to call more than once.
+func (a *Agent) Stop() {
+ a.stopOnce.Do(func() {
+ close(a.closed)
+ a.mu.Lock()
+ if a.session != nil {
+ _ = a.session.Close()
+ }
+ a.mu.Unlock()
+ select {
+ case <-a.supervisorDone:
+ case <-time.After(3 * time.Second):
+ }
+ })
+}
+
+func (a *Agent) ttlCheckerLoop() {
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-a.closed:
+ return
+ case <-ticker.C:
+ for _, t := range a.cm.ExpireDue(a.ConsumeSessionBytes) {
+ log.Printf("[Agent] ⏰ 通道「%s」(端口 %d) %s,已自动关闭", t.Name, t.ListenPort, t.CloseReason)
+ a.dropUsage(t.ID)
+ a.SyncTunnel(t, false)
+ }
+ a.trackUsage(time.Now())
+ }
+ }
+}
+
+// usageState tracks one enabled tunnel's activity during the current session
+type usageState struct {
+ lastActive time.Time // 最近一次“在用”的时间(闲置计时起点)
+ lastTick time.Time // 上次统计时间
+ inUse bool // 当前是否在用
+ pendingConn time.Duration // 尚未落盘的实际连接时长
+ firstMarked bool
+}
+
+// TunnelActivity is the live usage view of an enabled tunnel
+type TunnelActivity struct {
+ InUse bool `json:"in_use"`
+ IdleSince *time.Time `json:"idle_since,omitempty"`
+ AutoCloseAt *time.Time `json:"auto_close_at,omitempty"`
+ ConnectedSec int64 `json:"connected_sec"` // 本次开启后有连接在线的累计秒数
+}
+
+// inUse: 有在线连接、有效授权(含尚未首次连接的)、待审批申请或仍可使用的邀请
+func tunnelInUse(m protocol.TunnelMetrics) bool {
+ if m.ActiveConns > 0 || len(m.DynamicLeases) > 0 || len(m.Pending) > 0 {
+ return true
+ }
+ for _, inv := range m.Invites {
+ if inv.MaxUses <= 0 || len(inv.Uses) < inv.MaxUses {
+ return true
+ }
+ }
+ return false
+}
+
+const connectedFlushInterval = 10 * time.Second
+
+// trackUsage runs every second: updates idle timers and real connected time, and closes idle tunnels
+func (a *Agent) trackUsage(now time.Time) {
+ cfg := a.cm.Get()
+ metrics := a.GetLatestMetrics()
+ connected := a.IsConnected()
+ byID := make(map[string]protocol.TunnelMetrics, len(metrics.Tunnels))
+ for _, m := range metrics.Tunnels {
+ byID[m.TunnelID] = m
+ }
+
+ type closing struct {
+ id, reason string
+ flush int64
+ }
+ var toClose []closing
+ var firstConn []string
+ flush := map[string]int64{}
+
+ a.usageMu.Lock()
+ enabled := map[string]bool{}
+ for _, t := range cfg.Tunnels {
+ if !t.Enabled {
+ continue
+ }
+ enabled[t.ID] = true
+ u := a.usage[t.ID]
+ if u == nil {
+ u = &usageState{lastActive: now, lastTick: now, firstMarked: t.FirstConnectedAt != nil}
+ a.usage[t.ID] = u
+ }
+ dt := now.Sub(u.lastTick)
+ u.lastTick = now
+
+ m, ok := byID[t.ID]
+ if !connected || !ok || m.Status != "online" {
+ // 未连接服务器或尚未收到指标:暂停闲置计时,避免把网络中断误判为闲置
+ u.lastActive = now
+ u.inUse = false
+ continue
+ }
+ if m.ActiveConns > 0 {
+ u.pendingConn += dt
+ if !u.firstMarked {
+ u.firstMarked = true
+ firstConn = append(firstConn, t.ID)
+ }
+ }
+ u.inUse = tunnelInUse(m)
+ if u.inUse {
+ u.lastActive = now
+ }
+ // 即将因最长开启时长关闭时先落盘,避免丢失最后一段连接时长
+ expiring := t.ExpiresAt != nil && !now.Before(t.ExpiresAt.Add(-time.Second))
+ if u.pendingConn >= connectedFlushInterval || (expiring && u.pendingConn >= time.Second) {
+ secs := int64(u.pendingConn / time.Second)
+ u.pendingConn -= time.Duration(secs) * time.Second
+ flush[t.ID] = secs
+ }
+ if idle := IdleCloseMinutes(t); idle > 0 && now.Sub(u.lastActive) >= time.Duration(idle)*a.idleUnit {
+ secs := int64(u.pendingConn / time.Second)
+ toClose = append(toClose, closing{t.ID, fmt.Sprintf("闲置 %s 自动关闭", formatMinutes(idle)), secs})
+ delete(a.usage, t.ID)
+ }
+ }
+ for id := range a.usage {
+ if !enabled[id] {
+ delete(a.usage, id)
+ }
+ }
+ a.usageMu.Unlock()
+ a.rates.retain(enabled)
+
+ for _, id := range firstConn {
+ a.cm.MarkFirstConnected(id, now)
+ }
+ for id, secs := range flush {
+ a.cm.AddConnectedSeconds(id, secs)
+ }
+ for _, c := range toClose {
+ a.cm.AddConnectedSeconds(c.id, c.flush)
+ if t, ok := a.cm.CloseTunnel(c.id, c.reason, a.ConsumeSessionBytes); ok {
+ log.Printf("[Agent] 💤 通道「%s」(端口 %d) %s", t.Name, t.ListenPort, c.reason)
+ a.SyncTunnel(t, false)
+ }
+ }
+}
+
+// FlushUsage persists the not-yet-saved connected time of a tunnel (before it is stopped)
+func (a *Agent) FlushUsage(id string) {
+ a.usageMu.Lock()
+ var secs int64
+ if u := a.usage[id]; u != nil {
+ secs = int64(u.pendingConn / time.Second)
+ u.pendingConn -= time.Duration(secs) * time.Second
+ }
+ a.usageMu.Unlock()
+ a.cm.AddConnectedSeconds(id, secs)
+}
+
+func (a *Agent) dropUsage(id string) {
+ a.usageMu.Lock()
+ delete(a.usage, id)
+ a.usageMu.Unlock()
+}
+
+// RateSeries returns the last 10 minutes of a tunnel's rates (id "" = all tunnels)
+func (a *Agent) RateSeries(id string) RateSeries {
+ if id == "" {
+ id = totalSeries
+ }
+ return a.rates.series(id, time.Now())
+}
+
+// Activity returns the live usage of an enabled tunnel (nil when not tracked yet)
+func (a *Agent) Activity(t protocol.TunnelConfig) *TunnelActivity {
+ a.usageMu.Lock()
+ defer a.usageMu.Unlock()
+ u := a.usage[t.ID]
+ if u == nil || !t.Enabled {
+ return nil
+ }
+ act := &TunnelActivity{InUse: u.inUse, ConnectedSec: t.ConnectedSec + int64(u.pendingConn/time.Second)}
+ if !u.inUse {
+ since := u.lastActive
+ act.IdleSince = &since
+ if idle := IdleCloseMinutes(t); idle > 0 {
+ at := since.Add(time.Duration(idle) * a.idleUnit)
+ act.AutoCloseAt = &at
+ }
+ }
+ return act
+}
+
+func (a *Agent) supervisorLoop() {
+ defer close(a.supervisorDone)
+ for {
+ select {
+ case <-a.closed:
+ return
+ default:
+ }
+
+ cfg := a.cm.Get()
+ if cfg.ServerAddr == "" {
+ // 首次运行尚未配置服务器:等用户在设置中填写后再连接
+ a.mu.Lock()
+ a.lastError = "尚未配置服务器地址,请在设置中填写"
+ a.mu.Unlock()
+ select {
+ case <-a.closed:
+ return
+ case <-a.reconnectSignal:
+ case <-time.After(reconnectDelay):
+ }
+ continue
+ }
+ log.Printf("[Agent] 正在连接中继服务器 (%s)...", cfg.ServerAddr)
+
+ err := a.connectAndRun(cfg)
+ if err != nil {
+ log.Printf("[Agent] 连接中断或失败: %v, %v 后尝试重连...", err, reconnectDelay)
+ }
+ a.resetSessionState(err)
+
+ select {
+ case <-a.closed:
+ return
+ case <-a.reconnectSignal:
+ log.Printf("[Agent] 立即执行重连...")
+ case <-time.After(reconnectDelay):
+ }
+ }
+}
+
+// resetSessionState 会话结束:把本次会话流量并入历史(服务端会重置计数),清掉过期指标
+func (a *Agent) resetSessionState(err error) {
+ a.controlWriteMu.Lock()
+ a.controlWriter = nil
+ a.controlWriteMu.Unlock()
+
+ // 锁顺序固定为 cm.mu -> a.mu(ExpireDue 持 cm.mu 回调 ConsumeSessionBytes),这里先读配置再加锁
+ enabled := make(map[string]bool)
+ for _, t := range a.cm.Get().Tunnels {
+ enabled[t.ID] = t.Enabled
+ }
+ a.mu.Lock()
+ carry := make(map[string][2]int64)
+ for _, m := range a.latestMetrics.Tunnels {
+ if enabled[m.TunnelID] {
+ carry[m.TunnelID] = [2]int64{m.BytesIn, m.BytesOut}
+ }
+ }
+ a.connected = false
+ a.session = nil
+ a.latestMetrics = protocol.MetricsMessage{}
+ a.regErrors = make(map[string]string)
+ if err != nil && !errors.Is(err, io.EOF) {
+ a.lastError = err.Error()
+ } else {
+ a.lastError = "与中继服务器的连接已断开"
+ }
+ a.mu.Unlock()
+
+ a.cm.AddHistoricalTraffic(carry)
+}
+
+func (a *Agent) connectAndRun(cfg Config) error {
+ rawConn, err := net.DialTimeout("tcp", cfg.ServerAddr, 5*time.Second)
+ if err != nil {
+ return fmt.Errorf("dial server failed: %w", err)
+ }
+ defer rawConn.Close()
+
+ yCfg := yamux.DefaultConfig()
+ yCfg.EnableKeepAlive = true
+ yCfg.KeepAliveInterval = 10 * time.Second
+ yCfg.LogOutput = io.Discard
+
+ session, err := yamux.Client(rawConn, yCfg)
+ if err != nil {
+ return fmt.Errorf("yamux client init error: %w", err)
+ }
+ defer session.Close()
+
+ a.mu.Lock()
+ a.session = session
+ a.mu.Unlock()
+
+ controlStream, err := session.OpenStream()
+ if err != nil {
+ return fmt.Errorf("open control stream failed: %w", err)
+ }
+ defer controlStream.Close()
+
+ writer := bufio.NewWriter(controlStream)
+ reader := bufio.NewReader(controlStream)
+
+ // 1. Authenticate and wait for the verdict before declaring the link usable
+ if err := writeJSONLine(writer, protocol.AuthMessage{Type: protocol.TypeAuth, Token: cfg.Token}); err != nil {
+ return err
+ }
+ _ = controlStream.SetReadDeadline(time.Now().Add(authRespTimeout))
+ line, err := reader.ReadBytes('\n')
+ if err != nil {
+ return fmt.Errorf("等待鉴权结果失败: %w", err)
+ }
+ _ = controlStream.SetReadDeadline(time.Time{})
+ var authResp protocol.AuthRespMessage
+ if err := json.Unmarshal(line, &authResp); err != nil || authResp.Type != protocol.TypeAuthResp {
+ return fmt.Errorf("服务端返回了非预期的鉴权响应")
+ }
+ if !authResp.Success {
+ return fmt.Errorf("身份鉴权失败: %s", authResp.Error)
+ }
+
+ a.controlWriteMu.Lock()
+ a.controlWriter = writer
+ a.controlWriteMu.Unlock()
+
+ a.mu.Lock()
+ a.connected = true
+ a.lastError = ""
+ a.mu.Unlock()
+ log.Printf("[Agent] 成功与中继服务器握手并通过鉴权!")
+
+ // 2. Register all enabled tunnels
+ for _, t := range a.cm.Get().Tunnels {
+ if t.Enabled {
+ log.Printf("[Agent] 注册通道: %s (:%d -> %s) [责任人: %s]", t.Name, t.ListenPort, t.Target, t.Owner)
+ _ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: t})
+ }
+ }
+
+ // 3. Start data stream acceptor loop
+ go a.acceptDataStreams(session)
+
+ // 4. Read incoming control messages
+ for {
+ line, err := reader.ReadBytes('\n')
+ if err != nil {
+ if err != io.EOF {
+ log.Printf("[Agent] control stream read error: %v", err)
+ }
+ return err
+ }
+ if len(line) == 0 {
+ continue
+ }
+
+ var base protocol.BaseMessage
+ if err := json.Unmarshal(line, &base); err != nil {
+ continue
+ }
+
+ switch base.Type {
+ case protocol.TypeMetrics:
+ var msg protocol.MetricsMessage
+ if err := json.Unmarshal(line, &msg); err == nil {
+ a.storeMetrics(msg)
+ }
+ case protocol.TypeInviteResp:
+ var resp protocol.InviteRespMessage
+ if json.Unmarshal(line, &resp) != nil {
+ continue
+ }
+ a.mu.Lock()
+ if ch, ok := a.inviteWaiters[resp.RequestID]; ok {
+ ch <- resp
+ delete(a.inviteWaiters, resp.RequestID)
+ }
+ a.mu.Unlock()
+ case protocol.TypeRegisterResp:
+ var resp protocol.RegisterRespMessage
+ if json.Unmarshal(line, &resp) != nil {
+ continue
+ }
+ a.mu.Lock()
+ if resp.Success {
+ delete(a.regErrors, resp.TunnelID)
+ } else {
+ a.regErrors[resp.TunnelID] = resp.Error
+ log.Printf("[Agent] 通道 %s 注册失败: %s", resp.TunnelID, resp.Error)
+ }
+ a.mu.Unlock()
+ }
+ }
+}
+
+// storeMetrics 只保留当前处于开启状态的通道,避免已关闭通道的旧指标被重复计入历史流量
+func (a *Agent) storeMetrics(msg protocol.MetricsMessage) {
+ enabled := make(map[string]bool)
+ for _, t := range a.cm.Get().Tunnels {
+ if t.Enabled {
+ enabled[t.ID] = true
+ }
+ }
+ filtered := msg.Tunnels[:0]
+ for _, m := range msg.Tunnels {
+ if enabled[m.TunnelID] {
+ filtered = append(filtered, m)
+ }
+ }
+ msg.Tunnels = filtered
+
+ rates := make(map[string][2]int64, len(msg.Tunnels))
+ for _, m := range msg.Tunnels {
+ rates[m.TunnelID] = [2]int64{m.RateIn, m.RateOut}
+ }
+ a.rates.record(time.Now(), rates)
+
+ a.mu.Lock()
+ a.latestMetrics = msg
+ a.mu.Unlock()
+
+ select {
+ case a.metricsSub <- struct{}{}:
+ default:
+ }
+}
+
+// ConsumeSessionBytes returns the current session traffic of a tunnel and drops it from the
+// cached metrics so it cannot be merged into history twice.
+func (a *Agent) ConsumeSessionBytes(id string) (int64, int64) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ tunnels := a.latestMetrics.Tunnels
+ for i, m := range tunnels {
+ if m.TunnelID == id {
+ out := make([]protocol.TunnelMetrics, 0, len(tunnels)-1)
+ out = append(out, tunnels[:i]...)
+ out = append(out, tunnels[i+1:]...)
+ a.latestMetrics.Tunnels = out
+ return m.BytesIn, m.BytesOut
+ }
+ }
+ return 0, 0
+}
+
+func (a *Agent) acceptDataStreams(session *yamux.Session) {
+ for {
+ stream, err := session.AcceptStream()
+ if err != nil {
+ return
+ }
+ go a.handleDataStream(stream)
+ }
+}
+
+func (a *Agent) handleDataStream(stream net.Conn) {
+ defer stream.Close()
+
+ _ = stream.SetReadDeadline(time.Now().Add(10 * time.Second))
+ reader := bufio.NewReader(stream)
+ line, err := reader.ReadBytes('\n')
+ if err != nil {
+ return
+ }
+ _ = stream.SetReadDeadline(time.Time{})
+
+ var hs protocol.StreamHandshake
+ if err := json.Unmarshal(line, &hs); err != nil {
+ return
+ }
+
+ t, ok := a.cm.FindTunnel(hs.TunnelID)
+ if !ok || !t.Enabled {
+ return
+ }
+
+ connStartTime := time.Now()
+
+ targetConn, err := net.DialTimeout("tcp", t.Target, 3*time.Second)
+ if err != nil {
+ log.Printf("[Agent] 目标主机 %s 无法连接 (客户端 %s): %v", t.Target, hs.ClientIP, err)
+ return
+ }
+ defer targetConn.Close()
+
+ // Count bytes for connection history
+ var bytesIn, bytesOut atomic.Int64
+ done := make(chan struct{}, 2)
+
+ go func() {
+ buf := make([]byte, 32*1024)
+ for {
+ nr, er := reader.Read(buf)
+ if nr > 0 {
+ bytesIn.Add(int64(nr))
+ nw, ew := targetConn.Write(buf[:nr])
+ if ew != nil || nr != nw {
+ break
+ }
+ }
+ if er != nil {
+ break
+ }
+ }
+ _ = targetConn.Close()
+ _ = stream.Close()
+ done <- struct{}{}
+ }()
+
+ go func() {
+ buf := make([]byte, 32*1024)
+ for {
+ nr, er := targetConn.Read(buf)
+ if nr > 0 {
+ bytesOut.Add(int64(nr))
+ nw, ew := stream.Write(buf[:nr])
+ if ew != nil || nr != nw {
+ break
+ }
+ }
+ if er != nil {
+ break
+ }
+ }
+ _ = targetConn.Close()
+ _ = stream.Close()
+ done <- struct{}{}
+ }()
+
+ <-done
+ <-done
+
+ // Record client connection history
+ connEndTime := time.Now()
+ a.cm.AddClientHistory(protocol.ClientHistoryRecord{
+ ID: uuid.New().String(),
+ TunnelID: hs.TunnelID,
+ TunnelName: t.Name,
+ Port: t.ListenPort,
+ ClientIP: hs.ClientIP,
+ ConnectedAt: connStartTime,
+ DisconnectedAt: connEndTime,
+ DurationSec: int64(connEndTime.Sub(connStartTime).Seconds()),
+ BytesIn: bytesIn.Load(),
+ BytesOut: bytesOut.Load(),
+ })
+}
+
+func writeJSONLine(w *bufio.Writer, v any) error {
+ data, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+ if _, err := w.Write(data); err != nil {
+ return err
+ }
+ return w.Flush()
+}
+
+func (a *Agent) sendControl(v any) error {
+ a.controlWriteMu.Lock()
+ defer a.controlWriteMu.Unlock()
+
+ if a.controlWriter == nil {
+ return fmt.Errorf("control stream not connected")
+ }
+ return writeJSONLine(a.controlWriter, v)
+}
+
+// SyncTunnel pushes a tunnel's current state to the relay server.
+// edited=true 时使用 update_tunnel(端口不变则原地更新,不断开现有连接)。
+func (a *Agent) SyncTunnel(t protocol.TunnelConfig, edited bool) {
+ if !t.Enabled {
+ a.mu.Lock()
+ delete(a.regErrors, t.ID)
+ a.mu.Unlock()
+ a.RemoveTunnel(t.ID)
+ return
+ }
+ if !a.IsConnected() {
+ return
+ }
+ if edited {
+ _ = a.sendControl(protocol.UpdateTunnelMessage{Type: protocol.TypeUpdateTunnel, Tunnel: t})
+ } else {
+ _ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: t})
+ }
+}
+
+func (a *Agent) RemoveTunnel(id string) {
+ a.mu.Lock()
+ delete(a.regErrors, id)
+ a.mu.Unlock()
+ if !a.IsConnected() {
+ return
+ }
+ _ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id})
+}
+
+// CreateInvite asks the relay server for a new invite link and waits for the result
+func (a *Agent) CreateInvite(tunnelID, note string, validMinutes, maxUses int, leaseDuration string, requireApproval bool) (protocol.Invite, error) {
+ if !a.IsConnected() {
+ return protocol.Invite{}, fmt.Errorf("未连接中继服务器")
+ }
+ reqID := uuid.New().String()
+ ch := make(chan protocol.InviteRespMessage, 1)
+ a.mu.Lock()
+ a.inviteWaiters[reqID] = ch
+ a.mu.Unlock()
+ defer func() {
+ a.mu.Lock()
+ delete(a.inviteWaiters, reqID)
+ a.mu.Unlock()
+ }()
+
+ if err := a.sendControl(protocol.CreateInviteMessage{
+ Type: protocol.TypeCreateInvite, RequestID: reqID, TunnelID: tunnelID,
+ Note: note, ValidMinutes: validMinutes, MaxUses: maxUses, LeaseDuration: leaseDuration, RequireApproval: requireApproval,
+ }); err != nil {
+ return protocol.Invite{}, err
+ }
+ select {
+ case resp := <-ch:
+ if !resp.Success {
+ return protocol.Invite{}, fmt.Errorf("%s", resp.Error)
+ }
+ return resp.Invite, nil
+ case <-time.After(5 * time.Second):
+ return protocol.Invite{}, fmt.Errorf("服务器未响应,请检查是否已升级到支持邀请链接的版本")
+ }
+}
+
+// RevokeInvite invalidates an invite link and the leases it granted
+func (a *Agent) RevokeInvite(tunnelID, code string) error {
+ if !a.IsConnected() {
+ return fmt.Errorf("未连接中继服务器")
+ }
+ return a.sendControl(protocol.RevokeInviteMessage{Type: protocol.TypeRevokeInvite, TunnelID: tunnelID, Code: code})
+}
+
+// DecideRequest approves or rejects a pending access request
+func (a *Agent) DecideRequest(tunnelID, requestID string, approve bool) error {
+ if !a.IsConnected() {
+ return fmt.Errorf("未连接中继服务器")
+ }
+ return a.sendControl(protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: tunnelID, RequestID: requestID, Approve: approve})
+}
+
+// RevokeLease removes a temporary whitelist lease on the relay server (all leases when ip is empty)
+func (a *Agent) RevokeLease(tunnelID, ip string) error {
+ if !a.IsConnected() {
+ return fmt.Errorf("未连接中继服务器")
+ }
+ return a.sendControl(protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, TunnelID: tunnelID, IP: ip})
+}
+
+func (a *Agent) KillConn(connID string) {
+ if !a.IsConnected() {
+ return
+ }
+ _ = a.sendControl(protocol.KillConnMessage{Type: protocol.TypeKillConn, ConnID: connID})
+}
+
+func (a *Agent) IsConnected() bool {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ return a.connected
+}
+
+// LastError returns why the agent is currently not connected (empty when connected)
+func (a *Agent) LastError() string {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ return a.lastError
+}
+
+// TunnelError returns the last registration error reported by the relay server for a tunnel
+func (a *Agent) TunnelError(id string) string {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ return a.regErrors[id]
+}
+
+func (a *Agent) GetLatestMetrics() protocol.MetricsMessage {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ m := a.latestMetrics
+ m.Tunnels = append([]protocol.TunnelMetrics(nil), m.Tunnels...)
+ m.ActiveConns = append([]protocol.ActiveConnInfo(nil), m.ActiveConns...)
+ m.SecurityEvents = append([]protocol.SecurityEvent(nil), m.SecurityEvents...)
+ return m
+}
diff --git a/client/api.go b/client/api.go
new file mode 100644
index 0000000..c6cb3c3
--- /dev/null
+++ b/client/api.go
@@ -0,0 +1,647 @@
+package client
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/DeepFal/PortRelay/core/protocol"
+)
+
+const maxBodyBytes = 64 * 1024
+
+// APIServer provides REST and SSE endpoints for the dashboard
+type APIServer struct {
+ cm *ConfigManager
+ agent *Agent
+ fs http.FileSystem
+
+ // OnShutdown 由 POST /api/shutdown 触发(菜单栏“退出”时彻底结束后台引擎)
+ OnShutdown func()
+}
+
+// NewAPIServer creates an API server
+func NewAPIServer(cm *ConfigManager, agent *Agent, fs http.FileSystem) *APIServer {
+ return &APIServer{
+ cm: cm,
+ agent: agent,
+ fs: fs,
+ }
+}
+
+// Handler returns the http handler with routing
+func (s *APIServer) Handler() http.Handler {
+ mux := http.NewServeMux()
+
+ // API endpoints
+ mux.HandleFunc("/api/status", s.handleStatus)
+ mux.HandleFunc("/api/tunnels", s.handleTunnels)
+ mux.HandleFunc("/api/tunnels/", s.handleTunnelItem)
+ mux.HandleFunc("/api/probe", s.handleProbe)
+ mux.HandleFunc("/api/recommend-port", s.handleRecommendPort)
+ mux.HandleFunc("/api/connections", s.handleConnections)
+ mux.HandleFunc("/api/connections/kill", s.handleKillConn)
+ mux.HandleFunc("/api/history", s.handleClientHistory)
+ mux.HandleFunc("/api/reconnect", s.handleReconnect)
+ mux.HandleFunc("/api/settings", s.handleSettings)
+ mux.HandleFunc("/api/stream", s.handleSSE)
+ mux.HandleFunc("/api/shutdown", s.handleShutdown)
+
+ // Static web files
+ if s.fs != nil {
+ fileServer := http.FileServer(s.fs)
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api") {
+ http.NotFound(w, r)
+ return
+ }
+ fileServer.ServeHTTP(w, r)
+ })
+ }
+
+ return localOnlyMiddleware(mux)
+}
+
+func isLoopbackHost(hostport string) bool {
+ host := hostport
+ if h, _, err := net.SplitHostPort(hostport); err == nil {
+ host = h
+ }
+ host = strings.Trim(host, "[]")
+ if strings.EqualFold(host, "localhost") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+// localOnlyMiddleware 只接受本机面板与原生 App 的请求:
+// - Host 必须是回环地址(防 DNS rebinding)
+// - 带 Origin 的请求必须同源(防任意网页通过浏览器跨站调用本地 API)
+func localOnlyMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !isLoopbackHost(r.Host) {
+ http.Error(w, "forbidden host", http.StatusForbidden)
+ return
+ }
+ if origin := r.Header.Get("Origin"); origin != "" {
+ u, err := url.Parse(origin)
+ if err != nil || !strings.EqualFold(u.Host, r.Host) {
+ http.Error(w, "cross-origin request rejected", http.StatusForbidden)
+ return
+ }
+ }
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (s *APIServer) statusPayload() map[string]any {
+ cfg := s.cm.Get()
+ return map[string]any{
+ "connected": s.agent.IsConnected(),
+ "server_addr": cfg.ServerAddr,
+ "public_host": cfg.PublicHostOrDefault(),
+ "last_error": s.agent.LastError(),
+ "invite_base": s.inviteBase(cfg),
+ "total_rate_history": s.agent.RateSeries(""),
+ "port_range": fmt.Sprintf("%d - %d", PortMin, PortMax),
+ "timestamp": time.Now(),
+ }
+}
+
+func (s *APIServer) handleStatus(w http.ResponseWriter, r *http.Request) {
+ jsonResponse(w, http.StatusOK, s.statusPayload())
+}
+
+// TunnelWithMetrics joins config with current metrics
+type TunnelWithMetrics struct {
+ protocol.TunnelConfig
+ Metrics protocol.TunnelMetrics `json:"metrics"`
+ Activity *TunnelActivity `json:"activity,omitempty"`
+ // EffectiveIdleCloseMinutes 生效的闲置自动关闭时长(0 表示不自动关闭)
+ EffectiveIdleCloseMinutes int `json:"effective_idle_close_minutes"`
+ // RateHistory 近 10 分钟流量曲线(仅开启中的通道)
+ RateHistory *RateSeries `json:"rate_history,omitempty"`
+}
+
+// tunnelsWithMetrics merges config, live metrics and registration errors into one consistent view.
+// status: disabled | offline (未连接服务器) | error (服务器拒绝注册) | syncing (等待指标) | online
+func (s *APIServer) tunnelsWithMetrics(metrics protocol.MetricsMessage) []TunnelWithMetrics {
+ cfg := s.cm.Get()
+ connected := s.agent.IsConnected()
+ metricsMap := make(map[string]protocol.TunnelMetrics)
+ for _, m := range metrics.Tunnels {
+ metricsMap[m.TunnelID] = m
+ }
+
+ result := make([]TunnelWithMetrics, 0, len(cfg.Tunnels))
+ for _, t := range cfg.Tunnels {
+ tm := protocol.TunnelMetrics{TunnelID: t.ID, ListenPort: t.ListenPort}
+ switch {
+ case !t.Enabled:
+ tm.Status = "disabled"
+ case !connected:
+ tm.Status = "offline"
+ case s.agent.TunnelError(t.ID) != "":
+ tm.Status = "error"
+ tm.Error = s.agent.TunnelError(t.ID)
+ default:
+ if m, ok := metricsMap[t.ID]; ok {
+ tm = m
+ } else {
+ tm.Status = "syncing"
+ }
+ }
+ item := TunnelWithMetrics{
+ TunnelConfig: t,
+ Metrics: tm,
+ Activity: s.agent.Activity(t),
+ EffectiveIdleCloseMinutes: IdleCloseMinutes(t),
+ }
+ if t.Enabled {
+ series := s.agent.RateSeries(t.ID)
+ item.RateHistory = &series
+ }
+ result = append(result, item)
+ }
+ return result
+}
+
+func (s *APIServer) handleTunnels(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ jsonResponse(w, http.StatusOK, s.tunnelsWithMetrics(s.agent.GetLatestMetrics()))
+
+ case http.MethodPost:
+ var t protocol.TunnelConfig
+ if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON payload")
+ return
+ }
+ created, err := s.cm.AddTunnel(t)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ s.agent.SyncTunnel(created, false)
+ jsonResponse(w, http.StatusCreated, created)
+
+ default:
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+ if len(parts) < 3 || parts[2] == "" {
+ jsonError(w, http.StatusBadRequest, "Missing tunnel ID")
+ return
+ }
+ tunnelID := parts[2]
+
+ if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost {
+ s.handleToggle(w, r, tunnelID)
+ return
+ }
+
+ // POST /api/tunnels/{id}/leases/revoke {"ip": "..."};ip 为空表示撤销该通道全部临时放通
+ if len(parts) == 5 && parts[3] == "leases" && parts[4] == "revoke" && r.Method == http.MethodPost {
+ var req struct {
+ IP string `json:"ip"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+ if _, ok := s.cm.FindTunnel(tunnelID); !ok {
+ jsonError(w, http.StatusNotFound, "通道未找到")
+ return
+ }
+ if err := s.agent.RevokeLease(tunnelID, strings.TrimSpace(req.IP)); err != nil {
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
+ return
+ }
+
+ // 邀请链接:POST 新建;DELETE /invites/{code} 作废;GET /invites/{code}/share 获取分享文案
+ if len(parts) >= 4 && parts[3] == "invites" {
+ s.handleInvites(w, r, tunnelID, parts[4:])
+ return
+ }
+
+ // POST /api/tunnels/{id}/requests/{rid} {"approve": true|false}
+ if len(parts) == 5 && parts[3] == "requests" && r.Method == http.MethodPost {
+ var req struct {
+ Approve bool `json:"approve"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+ if err := s.agent.DecideRequest(tunnelID, parts[4], req.Approve); err != nil {
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"})
+ return
+ }
+
+ if len(parts) == 4 && parts[3] == "refresh-auth-code" && r.Method == http.MethodPost {
+ t, err := s.cm.RefreshAuthCode(tunnelID)
+ if err != nil {
+ jsonError(w, http.StatusNotFound, err.Error())
+ return
+ }
+ s.agent.SyncTunnel(t, true)
+ jsonResponse(w, http.StatusOK, t)
+ return
+ }
+
+ if len(parts) != 3 {
+ http.NotFound(w, r)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodPut:
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, "Invalid body")
+ return
+ }
+ updated, _, err := s.cm.PatchTunnel(tunnelID, body)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ s.agent.SyncTunnel(updated, true)
+ jsonResponse(w, http.StatusOK, updated)
+
+ case http.MethodDelete:
+ if err := s.cm.DeleteTunnel(tunnelID); err != nil {
+ jsonError(w, http.StatusNotFound, err.Error())
+ return
+ }
+ s.agent.ConsumeSessionBytes(tunnelID)
+ s.agent.RemoveTunnel(tunnelID)
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
+
+ default:
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ }
+}
+
+// handleToggle 支持 {"enabled": true|false} 指定目标状态(幂等,防止重复点击来回翻转);不带 body 时取反
+func (s *APIServer) handleToggle(w http.ResponseWriter, r *http.Request, tunnelID string) {
+ var req struct {
+ Enabled *bool `json:"enabled"`
+ }
+ if r.Body != nil {
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+ }
+
+ current, err := s.cm.CurrentEnabled(tunnelID)
+ if err != nil {
+ jsonError(w, http.StatusNotFound, err.Error())
+ return
+ }
+ want := !current
+ if req.Enabled != nil {
+ want = *req.Enabled
+ }
+
+ var sessIn, sessOut int64
+ if !want && current {
+ s.agent.FlushUsage(tunnelID)
+ }
+ if want != current {
+ // 关闭时把本次会话流量并入历史;开启时清掉可能残留的旧指标
+ sessIn, sessOut = s.agent.ConsumeSessionBytes(tunnelID)
+ }
+ t, changed, err := s.cm.SetTunnelEnabled(tunnelID, want, sessIn, sessOut)
+ if err != nil {
+ jsonError(w, http.StatusNotFound, err.Error())
+ return
+ }
+ if changed {
+ s.agent.dropUsage(tunnelID)
+ s.agent.SyncTunnel(t, false)
+ }
+ jsonResponse(w, http.StatusOK, t)
+}
+
+// inviteBase is the URL prefix of invite links: http://<对外访问地址>:<控制端口>/i/
+func (s *APIServer) inviteBase(cfg Config) string {
+ port := "33000"
+ if _, p, err := net.SplitHostPort(cfg.ServerAddr); err == nil {
+ port = p
+ }
+ host := cfg.PublicHostOrDefault()
+ if host == "" {
+ return ""
+ }
+ return "http://" + net.JoinHostPort(host, port) + "/i/"
+}
+
+// shareText is the ready-to-send message (e.g. for WeChat) for an invite
+func shareText(cfg Config, t protocol.TunnelConfig, inv protocol.Invite, link string) string {
+ var b strings.Builder
+ b.WriteString("【PortRelay 访问邀请】")
+ if inv.Note != "" {
+ b.WriteString("(给 " + inv.Note + ")")
+ }
+ b.WriteString("\n通道:" + t.Name)
+ b.WriteString(fmt.Sprintf("\n访问地址:%s:%d", cfg.PublicHostOrDefault(), t.ListenPort))
+ action := "填写姓名后即可访问"
+ if inv.RequireApproval {
+ action = "填写姓名提交申请,管理员批准后即可访问"
+ }
+ b.WriteString(fmt.Sprintf("\n请在 %s 前用浏览器打开下面的链接,%s(访问有效期:%s):\n%s",
+ inv.ExpiresAt.Local().Format("01-02 15:04"), action, protocol.DescribeLeaseDuration(inv.LeaseDuration), link))
+ if inv.MaxUses == 1 {
+ b.WriteString("\n此链接仅限 1 台设备使用,请勿转发。")
+ } else if inv.MaxUses > 1 {
+ b.WriteString(fmt.Sprintf("\n此链接最多 %d 台设备使用,请勿转发。", inv.MaxUses))
+ }
+ return b.String()
+}
+
+func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnelID string, rest []string) {
+ t, ok := s.cm.FindTunnel(tunnelID)
+ if !ok {
+ jsonError(w, http.StatusNotFound, "通道未找到")
+ return
+ }
+ cfg := s.cm.Get()
+
+ switch {
+ case len(rest) == 0 && r.Method == http.MethodPost:
+ var req struct {
+ Note string `json:"note"`
+ ValidMinutes int `json:"valid_minutes"`
+ MaxUses *int `json:"max_uses"`
+ LeaseDuration string `json:"lease_duration"` // 授权后可访问时长,默认 2 小时
+ RequireApproval bool `json:"require_approval"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+ // 默认:24 小时内有效、限 1 台设备
+ if req.ValidMinutes == 0 {
+ req.ValidMinutes = 24 * 60
+ }
+ maxUses := 1
+ if req.MaxUses != nil {
+ maxUses = *req.MaxUses
+ }
+ if !protocol.ValidLeaseDuration(req.LeaseDuration) {
+ jsonError(w, http.StatusBadRequest, "无效的授权时长")
+ return
+ }
+ if req.LeaseDuration == "" {
+ req.LeaseDuration = protocol.LeaseDuration2h
+ }
+ if !t.Enabled {
+ jsonError(w, http.StatusConflict, "通道未开启,请先开启通道再生成邀请链接")
+ return
+ }
+ inv, err := s.agent.CreateInvite(tunnelID, strings.TrimSpace(req.Note), req.ValidMinutes, maxUses, req.LeaseDuration, req.RequireApproval)
+ if err != nil {
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ link := s.inviteBase(cfg) + inv.Code
+ jsonResponse(w, http.StatusCreated, map[string]any{
+ "invite": inv,
+ "link": link,
+ "share_text": shareText(cfg, t, inv, link),
+ })
+
+ case len(rest) == 1 && r.Method == http.MethodDelete:
+ if err := s.agent.RevokeInvite(tunnelID, rest[0]); err != nil {
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
+
+ case len(rest) == 2 && rest[1] == "share" && r.Method == http.MethodGet:
+ for _, m := range s.agent.GetLatestMetrics().Tunnels {
+ if m.TunnelID != tunnelID {
+ continue
+ }
+ for _, inv := range m.Invites {
+ if inv.Code == rest[0] {
+ link := s.inviteBase(cfg) + inv.Code
+ jsonResponse(w, http.StatusOK, map[string]any{"invite": inv, "link": link, "share_text": shareText(cfg, t, inv, link)})
+ return
+ }
+ }
+ }
+ jsonError(w, http.StatusNotFound, "邀请链接不存在或已过期")
+
+ default:
+ http.NotFound(w, r)
+ }
+}
+
+func (s *APIServer) handleReconnect(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ // mode=check:仅在会话不健康时重连(系统唤醒时使用)
+ force := r.URL.Query().Get("mode") != "check"
+ go s.agent.Reconnect(force)
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "reconnecting"})
+}
+
+func (s *APIServer) handleProbe(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ var req struct {
+ Target string `json:"target"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ req.Target = strings.TrimSpace(req.Target)
+ if req.Target == "" {
+ jsonError(w, http.StatusBadRequest, "target is required")
+ return
+ }
+ res := ProbeTarget(req.Target, 2*time.Second)
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "target": res.Target,
+ "reachable": res.Reachable,
+ "rtt": res.FormatRTT(),
+ "error": res.Error,
+ })
+}
+
+func (s *APIServer) handleRecommendPort(w http.ResponseWriter, r *http.Request) {
+ port := s.cm.RecommendPort()
+ if port == 0 {
+ jsonError(w, http.StatusConflict, "端口池已无空闲端口")
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]int{"recommended_port": port})
+}
+
+// handleConnections returns live client connections (optionally for one tunnel) and recent security events
+func (s *APIServer) handleConnections(w http.ResponseWriter, r *http.Request) {
+ metrics := s.agent.GetLatestMetrics()
+ tunnelID := r.URL.Query().Get("tunnel_id")
+ conns := make([]protocol.ActiveConnInfo, 0, len(metrics.ActiveConns))
+ for _, c := range metrics.ActiveConns {
+ if tunnelID == "" || c.TunnelID == tunnelID {
+ conns = append(conns, c)
+ }
+ }
+ events := metrics.SecurityEvents
+ if events == nil {
+ events = []protocol.SecurityEvent{}
+ }
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "active_conns": conns,
+ "security_events": events,
+ })
+}
+
+func (s *APIServer) handleKillConn(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ var req struct {
+ ConnID string `json:"conn_id"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ if req.ConnID != "" {
+ s.agent.KillConn(req.ConnID)
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+func (s *APIServer) handleClientHistory(w http.ResponseWriter, r *http.Request) {
+ tunnelID := r.URL.Query().Get("tunnel_id")
+ jsonResponse(w, http.StatusOK, s.cm.GetClientHistory(tunnelID))
+}
+
+func (s *APIServer) handleSettings(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ cfg := s.cm.Get()
+ jsonResponse(w, http.StatusOK, map[string]string{
+ "server_addr": cfg.ServerAddr,
+ "token": cfg.Token,
+ "public_host": cfg.PublicHostOrDefault(),
+ })
+ case http.MethodPost:
+ var req struct {
+ ServerAddr string `json:"server_addr"`
+ Token string `json:"token"`
+ PublicHost *string `json:"public_host"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+ publicHost := s.cm.Get().PublicHost
+ if req.PublicHost != nil {
+ publicHost = *req.PublicHost
+ }
+ changed, err := s.cm.UpdateServer(req.ServerAddr, req.Token, publicHost)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ // 新地址/令牌必须重连才会生效;只改对外访问地址时不打断连接
+ if changed {
+ go s.agent.Reconnect(true)
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "updated"})
+ default:
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *APIServer) handleSSE(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+
+ send := func() bool {
+ metrics := s.agent.GetLatestMetrics()
+ payload := s.statusPayload()
+ payload["metrics"] = metrics
+ payload["tunnels"] = s.tunnelsWithMetrics(metrics)
+ data, _ := json.Marshal(payload)
+ if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
+ return false
+ }
+ flusher.Flush()
+ return true
+ }
+
+ if !send() {
+ return
+ }
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+ case <-ticker.C:
+ if !send() {
+ return
+ }
+ }
+ }
+}
+
+func (s *APIServer) handleShutdown(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ if s.OnShutdown == nil {
+ jsonError(w, http.StatusNotImplemented, "shutdown not supported")
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "shutting down"})
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+ go s.OnShutdown()
+}
+
+func jsonResponse(w http.ResponseWriter, status int, data any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(data)
+}
+
+func jsonError(w http.ResponseWriter, status int, msg string) {
+ jsonResponse(w, status, map[string]string{"error": msg})
+}
diff --git a/client/client_test.go b/client/client_test.go
new file mode 100644
index 0000000..a01198b
--- /dev/null
+++ b/client/client_test.go
@@ -0,0 +1,712 @@
+package client
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DeepFal/PortRelay/core/protocol"
+ "github.com/DeepFal/PortRelay/server"
+)
+
+const testToken = "secret"
+
+type peekedConn struct {
+ net.Conn
+ r io.Reader
+}
+
+func (c *peekedConn) Read(b []byte) (int, error) { return c.r.Read(b) }
+
+// startRelayServer runs an in-process relay server (same dispatch as cmd/portrelay-server)
+func startRelayServer(t *testing.T) (string, *server.Manager) {
+ t.Helper()
+ m := server.NewManager(PortMin, PortMax, testToken)
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func(c net.Conn) {
+ br := bufio.NewReader(c)
+ p, err := br.Peek(3)
+ if err != nil {
+ c.Close()
+ return
+ }
+ if s := string(p); s == "GET" || s == "POS" || s == "HEA" {
+ m.HandleHTTP(&peekedConn{c, br})
+ return
+ }
+ _ = m.HandleSession(&peekedConn{c, br})
+ }(c)
+ }
+ }()
+ return ln.Addr().String(), m
+}
+
+// startEcho runs a TCP echo server acting as the intranet target
+func startEcho(t *testing.T) string {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func() { _, _ = io.Copy(c, c); c.Close() }()
+ }
+ }()
+ return ln.Addr().String()
+}
+
+// freeRelayPort finds a free port inside the tunnel range
+func freeRelayPort(t *testing.T, exclude ...int) int {
+ t.Helper()
+ skip := map[int]bool{ControlPort: true}
+ for _, p := range exclude {
+ skip[p] = true
+ }
+ for p := 33900; p < PortMax; p++ {
+ if skip[p] {
+ continue
+ }
+ if ln, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", p)); err == nil {
+ ln.Close()
+ return p
+ }
+ }
+ t.Fatal("no free port in tunnel range")
+ return 0
+}
+
+func newTestConfig(t *testing.T, serverAddr, token string) *ConfigManager {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "config.json")
+ data, _ := json.Marshal(Config{ServerAddr: serverAddr, Token: token, Tunnels: []protocol.TunnelConfig{}})
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ cm, err := NewConfigManagerAt(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // 在临时目录删除前停止延迟写盘(Cleanup 按注册的逆序执行,Agent 会先停止)
+ t.Cleanup(func() { _ = cm.Close() })
+ return cm
+}
+
+type env struct {
+ cm *ConfigManager
+ agent *Agent
+ api *httptest.Server
+ addr string
+}
+
+func startEnv(t *testing.T, token string) *env {
+ t.Helper()
+ return startEnvWith(t, token, nil)
+}
+
+func startEnvWith(t *testing.T, token string, tweak func(*Agent)) *env {
+ t.Helper()
+ addr, _ := startRelayServer(t)
+ cm := newTestConfig(t, addr, token)
+ agent := NewAgent(cm)
+ if tweak != nil {
+ tweak(agent)
+ }
+ agent.Start()
+ t.Cleanup(agent.Stop)
+ api := httptest.NewServer(NewAPIServer(cm, agent, nil).Handler())
+ t.Cleanup(api.Close)
+ return &env{cm: cm, agent: agent, api: api, addr: addr}
+}
+
+func (e *env) call(t *testing.T, method, path string, body any) (int, []byte) {
+ t.Helper()
+ var r io.Reader
+ if body != nil {
+ b, _ := json.Marshal(body)
+ r = strings.NewReader(string(b))
+ }
+ req, _ := http.NewRequest(method, e.api.URL+path, r)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, out
+}
+
+func (e *env) tunnel(t *testing.T, id string) TunnelWithMetrics {
+ t.Helper()
+ _, body := e.call(t, "GET", "/api/tunnels", nil)
+ var list []TunnelWithMetrics
+ _ = json.Unmarshal(body, &list)
+ for _, x := range list {
+ if x.ID == id {
+ return x
+ }
+ }
+ t.Fatalf("tunnel %s not found", id)
+ return TunnelWithMetrics{}
+}
+
+func waitFor(t *testing.T, what string, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ t.Fatalf("timed out waiting for: %s", what)
+}
+
+func echoVia(port int, msg string) (string, error) {
+ c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
+ if err != nil {
+ return "", err
+ }
+ defer c.Close()
+ _ = c.SetDeadline(time.Now().Add(2 * time.Second))
+ if _, err := c.Write([]byte(msg)); err != nil {
+ return "", err
+ }
+ buf := make([]byte, len(msg))
+ _, err = io.ReadFull(c, buf)
+ return string(buf), err
+}
+
+func TestEndToEndEditKeepsStateAndConnections(t *testing.T) {
+ e := startEnv(t, testToken)
+ waitFor(t, "agent connected", e.agent.IsConnected)
+
+ target := startEcho(t)
+ port := freeRelayPort(t)
+ status, body := e.call(t, "POST", "/api/tunnels", map[string]any{
+ "name": "db", "target": target, "listen_port": port, "owner": "ops",
+ "duration_minutes": 0, "max_bandwidth_mb": 5, "whitelist": []string{"*"},
+ })
+ if status != http.StatusCreated {
+ t.Fatalf("create: %d %s", status, body)
+ }
+ var created protocol.TunnelConfig
+ _ = json.Unmarshal(body, &created)
+
+ waitFor(t, "tunnel online", func() bool { return e.tunnel(t, created.ID).Metrics.Status == "online" })
+ if got, err := echoVia(port, "hello"); err != nil || got != "hello" {
+ t.Fatalf("relay broken: %q %v", got, err)
+ }
+
+ // 编辑前生成一个邀请链接,编辑后应仍然有效
+ status, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/invites", map[string]any{"max_uses": 0})
+ if status != http.StatusCreated {
+ t.Fatalf("create invite: %d %s", status, body)
+ }
+ var invResp struct {
+ Link string `json:"link"`
+ }
+ _ = json.Unmarshal(body, &invResp)
+
+ // 打开一条长连接,编辑后应保持可用
+ long, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer long.Close()
+
+ // Web 端只提交部分字段
+ status, body = e.call(t, "PUT", "/api/tunnels/"+created.ID, map[string]any{
+ "name": "db-renamed", "target": target, "listen_port": port, "whitelist": []string{"*"}, "enabled": true,
+ })
+ if status != http.StatusOK {
+ t.Fatalf("edit: %d %s", status, body)
+ }
+ var edited protocol.TunnelConfig
+ _ = json.Unmarshal(body, &edited)
+ if edited.Owner != "ops" || edited.MaxBandwidthMB != 5 {
+ t.Fatalf("partial edit wiped other fields: owner=%q bw=%v", edited.Owner, edited.MaxBandwidthMB)
+ }
+
+ _ = long.SetDeadline(time.Now().Add(2 * time.Second))
+ if _, err := long.Write([]byte("ping")); err != nil {
+ t.Fatal(err)
+ }
+ buf := make([]byte, 4)
+ if _, err := io.ReadFull(long, buf); err != nil || string(buf) != "ping" {
+ t.Fatalf("existing connection dropped by metadata edit: %v", err)
+ }
+
+ // 编辑后邀请链接仍然有效
+ resp, err := http.Get(invResp.Link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("invite link rejected after edit: %d", resp.StatusCode)
+ }
+
+ // 关闭后再编辑,不能被重新打开
+ e.call(t, "POST", "/api/tunnels/"+created.ID+"/toggle", map[string]bool{"enabled": false})
+ e.call(t, "PUT", "/api/tunnels/"+created.ID, map[string]any{"name": "x", "enabled": true})
+ if got := e.tunnel(t, created.ID); got.Enabled || got.Metrics.Status != "disabled" {
+ t.Fatalf("editing a disabled tunnel re-enabled it: enabled=%v status=%s", got.Enabled, got.Metrics.Status)
+ }
+
+ // 幂等开关:重复提交 enabled=false 不会翻转
+ e.call(t, "POST", "/api/tunnels/"+created.ID+"/toggle", map[string]bool{"enabled": false})
+ if e.tunnel(t, created.ID).Enabled {
+ t.Fatal("toggle with explicit state is not idempotent")
+ }
+}
+
+func TestWrongTokenIsReportedAsDisconnected(t *testing.T) {
+ e := startEnv(t, "WRONG")
+ waitFor(t, "auth error surfaced", func() bool { return strings.Contains(e.agent.LastError(), "鉴权") })
+ if e.agent.IsConnected() {
+ t.Fatal("agent reports connected with a wrong token")
+ }
+
+ // 改成正确 token 后应自动重连
+ status, body := e.call(t, "POST", "/api/settings", map[string]string{"server_addr": e.addr, "token": testToken})
+ if status != http.StatusOK {
+ t.Fatalf("settings: %d %s", status, body)
+ }
+ waitFor(t, "reconnected after settings change", e.agent.IsConnected)
+ if e.agent.LastError() != "" {
+ t.Fatalf("last error not cleared: %s", e.agent.LastError())
+ }
+}
+
+func TestRegistrationErrorIsVisible(t *testing.T) {
+ e := startEnv(t, testToken)
+ waitFor(t, "agent connected", e.agent.IsConnected)
+
+ port := freeRelayPort(t)
+ blocker, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer blocker.Close()
+
+ _, body := e.call(t, "POST", "/api/tunnels", map[string]any{"name": "x", "target": "127.0.0.1:1", "listen_port": port})
+ var created protocol.TunnelConfig
+ _ = json.Unmarshal(body, &created)
+ waitFor(t, "error status", func() bool {
+ m := e.tunnel(t, created.ID).Metrics
+ return m.Status == "error" && strings.Contains(m.Error, "bind")
+ })
+}
+
+func TestAPIRejectsCrossOriginAndForeignHost(t *testing.T) {
+ cm := newTestConfig(t, "127.0.0.1:1", testToken)
+ h := NewAPIServer(cm, NewAgent(cm), nil).Handler()
+
+ cases := []struct {
+ name, host, origin string
+ want int
+ }{
+ {"same origin", "127.0.0.1:33080", "http://127.0.0.1:33080", 200},
+ {"native app (no origin)", "127.0.0.1:33080", "", 200},
+ {"localhost", "localhost:33080", "", 200},
+ {"evil site", "127.0.0.1:33080", "https://evil.example", 403},
+ {"dns rebinding", "evil.example:33080", "", 403},
+ }
+ for _, c := range cases {
+ req := httptest.NewRequest("GET", "/api/settings", nil)
+ req.Host = c.host
+ if c.origin != "" {
+ req.Header.Set("Origin", c.origin)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != c.want {
+ t.Errorf("%s: got %d want %d", c.name, rec.Code, c.want)
+ }
+ if rec.Header().Get("Access-Control-Allow-Origin") != "" {
+ t.Errorf("%s: CORS header must not be set", c.name)
+ }
+ }
+}
+
+func TestCorruptConfigIsBackedUp(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+ if err := os.WriteFile(path, []byte(`{"tunnels": [`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := NewConfigManagerAt(path); err != nil {
+ t.Fatal(err)
+ }
+ matches, _ := filepath.Glob(path + ".corrupt-*")
+ if len(matches) != 1 {
+ t.Fatalf("expected a backup of the corrupt config, got %v", matches)
+ }
+ if b, _ := os.ReadFile(matches[0]); string(b) != `{"tunnels": [` {
+ t.Fatal("backup content differs from original")
+ }
+}
+
+func TestPatchValidationAndTTL(t *testing.T) {
+ cm := newTestConfig(t, "127.0.0.1:1", testToken)
+ a, err := cm.AddTunnel(protocol.TunnelConfig{Name: "a", Target: "10.0.0.1:22", ListenPort: 33701, DurationMinutes: 60})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cm.AddTunnel(protocol.TunnelConfig{Name: "b", Target: "10.0.0.1:22", ListenPort: 33701}); err == nil {
+ t.Fatal("duplicate port accepted")
+ }
+ for _, bad := range []string{
+ `{"listen_port": 33000}`,
+ `{"listen_port": 40000}`,
+ `{"target": "10.0.0.1"}`,
+ `{"whitelist": ["192.168.1.*"]}`,
+ } {
+ if _, _, err := cm.PatchTunnel(a.ID, []byte(bad)); err == nil {
+ t.Errorf("patch %s accepted", bad)
+ }
+ }
+
+ // 改成不限时:到期时间应清除
+ upd, _, err := cm.PatchTunnel(a.ID, []byte(`{"duration_minutes": 0}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if upd.ExpiresAt != nil {
+ t.Fatal("expires_at kept after switching to unlimited")
+ }
+ // 受保护字段不能被编辑
+ upd, _, _ = cm.PatchTunnel(a.ID, []byte(`{"enabled": false, "auth_code": "hacked", "historical_bytes_in": 999}`))
+ if !upd.Enabled || upd.AuthCode != a.AuthCode || upd.HistoricalBytesIn != 0 {
+ t.Fatalf("protected fields modified: %+v", upd)
+ }
+}
+
+func TestGetReturnsDeepCopy(t *testing.T) {
+ cm := newTestConfig(t, "127.0.0.1:1", testToken)
+ if _, err := cm.AddTunnel(protocol.TunnelConfig{Name: "a", Target: "10.0.0.1:22", ListenPort: 33702, Whitelist: []string{"*"}}); err != nil {
+ t.Fatal(err)
+ }
+ snap := cm.Get()
+ snap.Tunnels[0].Name = "mutated"
+ snap.Tunnels[0].Whitelist[0] = "1.1.1.1"
+ if got := cm.Get().Tunnels[0]; got.Name != "a" || got.Whitelist[0] != "*" {
+ t.Fatal("Get() leaks internal state")
+ }
+}
+
+func TestFirstRunHasNoUserData(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.json")
+ cm, err := NewConfigManagerAt(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := cm.Get()
+ if cfg.ServerAddr != "" || cfg.Token != "" || len(cfg.Tunnels) != 0 {
+ t.Fatalf("first-run config must be empty, got addr=%q token=%q tunnels=%d", cfg.ServerAddr, cfg.Token, len(cfg.Tunnels))
+ }
+
+ agent := NewAgent(cm)
+ agent.Start()
+ defer agent.Stop()
+ waitFor(t, "not-configured hint", func() bool { return strings.Contains(agent.LastError(), "尚未配置") })
+ if agent.IsConnected() {
+ t.Fatal("agent connected without a configured server")
+ }
+
+ // 未单独设置对外访问地址时,取服务器地址的主机部分
+ if _, err := cm.UpdateServer("10.1.2.3:33000", "t", ""); err != nil {
+ t.Fatal(err)
+ }
+ if got := cm.Get().PublicHostOrDefault(); got != "10.1.2.3" {
+ t.Fatalf("public host fallback = %q", got)
+ }
+}
+
+func TestLeaseListAndRevoke(t *testing.T) {
+ e := startEnv(t, testToken)
+ waitFor(t, "agent connected", e.agent.IsConnected)
+
+ target := startEcho(t)
+ port := freeRelayPort(t)
+ // 固定白名单不包含 127.0.0.1,只有临时放通后才能访问
+ _, body := e.call(t, "POST", "/api/tunnels", map[string]any{
+ "name": "lease", "target": target, "listen_port": port, "whitelist": []string{"10.9.9.9"},
+ })
+ var created protocol.TunnelConfig
+ _ = json.Unmarshal(body, &created)
+ waitFor(t, "tunnel online", func() bool { return e.tunnel(t, created.ID).Metrics.Status == "online" })
+
+ if _, err := echoVia(port, "x"); err == nil {
+ t.Fatal("client outside whitelist should be blocked before web auth")
+ }
+
+ _, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/invites", map[string]any{})
+ var inv struct {
+ Invite protocol.Invite `json:"invite"`
+ }
+ _ = json.Unmarshal(body, &inv)
+ resp, err := http.PostForm(fmt.Sprintf("http://%s/auth", e.addr), url.Values{"code": {inv.Invite.Code}, "name": {"张三"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+
+ waitFor(t, "lease listed with expiry and name", func() bool {
+ leases := e.tunnel(t, created.ID).Metrics.DynamicLeases
+ return len(leases) == 1 && leases[0].IP == "127.0.0.1" && leases[0].Name == "张三" && time.Until(leases[0].ExpiresAt) > time.Hour
+ })
+
+ long, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer long.Close()
+ _ = long.SetDeadline(time.Now().Add(3 * time.Second))
+ _, _ = long.Write([]byte("hi"))
+ buf := make([]byte, 2)
+ if _, err := io.ReadFull(long, buf); err != nil {
+ t.Fatalf("leased client cannot use tunnel: %v", err)
+ }
+
+ // 在线连接接口应能看到这条连接
+ waitFor(t, "active connection listed", func() bool {
+ _, b := e.call(t, "GET", "/api/connections?tunnel_id="+created.ID, nil)
+ var out struct {
+ ActiveConns []protocol.ActiveConnInfo `json:"active_conns"`
+ }
+ _ = json.Unmarshal(b, &out)
+ return len(out.ActiveConns) == 1
+ })
+
+ status, b := e.call(t, "POST", "/api/tunnels/"+created.ID+"/leases/revoke", map[string]string{"ip": "127.0.0.1"})
+ if status != http.StatusOK {
+ t.Fatalf("revoke: %d %s", status, b)
+ }
+ waitFor(t, "lease removed", func() bool { return len(e.tunnel(t, created.ID).Metrics.DynamicLeases) == 0 })
+
+ // 撤销后已建立的连接被断开,新连接被拒绝
+ if _, err := long.Read(buf); err == nil {
+ t.Fatal("existing connection of revoked IP still alive")
+ }
+ if _, err := echoVia(port, "x"); err == nil {
+ t.Fatal("revoked IP can still connect")
+ }
+}
+
+func TestLinkOnlyInviteShareAndApproval(t *testing.T) {
+ e := startEnv(t, testToken)
+ waitFor(t, "agent connected", e.agent.IsConnected)
+
+ target := startEcho(t)
+ port := freeRelayPort(t)
+ status, body := e.call(t, "POST", "/api/tunnels", map[string]any{
+ "name": "db", "target": target, "listen_port": port,
+ })
+ if status != http.StatusCreated {
+ t.Fatalf("create: %d %s", status, body)
+ }
+ var created protocol.TunnelConfig
+ _ = json.Unmarshal(body, &created)
+ if len(created.Whitelist) != 0 {
+ t.Fatalf("empty whitelist must stay empty (link-only), got %v", created.Whitelist)
+ }
+ waitFor(t, "tunnel online", func() bool { return e.tunnel(t, created.ID).Metrics.Status == "online" })
+ if _, err := echoVia(port, "x"); err == nil {
+ t.Fatal("link-only tunnel must block clients without authorisation")
+ }
+
+ status, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/invites", map[string]any{"note": "张三", "lease_duration": "8h", "require_approval": true})
+ if status != http.StatusCreated {
+ t.Fatalf("create invite: %d %s", status, body)
+ }
+ var out struct {
+ Invite protocol.Invite `json:"invite"`
+ Link string `json:"link"`
+ ShareText string `json:"share_text"`
+ }
+ _ = json.Unmarshal(body, &out)
+ if out.Invite.MaxUses != 1 || time.Until(out.Invite.ExpiresAt) < 23*time.Hour || out.Invite.LeaseDuration != "8h" || !out.Invite.RequireApproval {
+ t.Fatalf("default invite should be 24h / 1 device: %+v", out.Invite)
+ }
+ for _, want := range []string{"给 张三", fmt.Sprintf("127.0.0.1:%d", port), "管理员批准", "8 小时", out.Link, "仅限 1 台设备"} {
+ if !strings.Contains(out.ShareText, want) {
+ t.Errorf("share text missing %q:\n%s", want, out.ShareText)
+ }
+ }
+ waitFor(t, "invite listed", func() bool { return len(e.tunnel(t, created.ID).Metrics.Invites) == 1 })
+ if status, _ := e.call(t, "GET", "/api/tunnels/"+created.ID+"/invites/"+out.Invite.Code+"/share", nil); status != http.StatusOK {
+ t.Fatalf("share text for existing invite: %d", status)
+ }
+
+ // 同事打开链接提交申请
+ resp, err := http.PostForm(fmt.Sprintf("http://%s/auth", e.addr), url.Values{"code": {out.Invite.Code}, "name": {"张三"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ var pending protocol.AccessRequest
+ waitFor(t, "pending request listed", func() bool {
+ p := e.tunnel(t, created.ID).Metrics.Pending
+ if len(p) == 1 {
+ pending = p[0]
+ }
+ return len(p) == 1
+ })
+ if _, err := echoVia(port, "x"); err == nil {
+ t.Fatal("pending request must not grant access")
+ }
+
+ status, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/requests/"+pending.ID, map[string]bool{"approve": true})
+ if status != http.StatusOK {
+ t.Fatalf("approve: %d %s", status, body)
+ }
+ waitFor(t, "lease granted after approval", func() bool {
+ l := e.tunnel(t, created.ID).Metrics.DynamicLeases
+ return len(l) == 1 && l[0].Name == "张三" && time.Until(l[0].ExpiresAt) > 7*time.Hour
+ })
+ if got, err := echoVia(port, "hi"); err != nil || got != "hi" {
+ t.Fatalf("approved client cannot use tunnel: %q %v", got, err)
+ }
+
+ // 作废邀请后放通被收回
+ if status, _ := e.call(t, "DELETE", "/api/tunnels/"+created.ID+"/invites/"+out.Invite.Code, nil); status != http.StatusOK {
+ t.Fatal("revoke invite failed")
+ }
+ waitFor(t, "lease removed with invite", func() bool { return len(e.tunnel(t, created.ID).Metrics.DynamicLeases) == 0 })
+}
+
+func TestLinkOnlyWhitelistSerializesAsEmptyArray(t *testing.T) {
+ cm := newTestConfig(t, "127.0.0.1:1", testToken)
+ if _, err := cm.AddTunnel(protocol.TunnelConfig{Name: "a", Target: "10.0.0.1:22", ListenPort: 33703}); err != nil {
+ t.Fatal(err)
+ }
+ h := NewAPIServer(cm, NewAgent(cm), nil).Handler()
+ req := httptest.NewRequest("GET", "/api/tunnels", nil)
+ req.Host = "127.0.0.1:33080"
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if !strings.Contains(rec.Body.String(), `"whitelist":[]`) {
+ t.Fatalf("link-only whitelist must be [] (not null): %s", rec.Body.String())
+ }
+}
+
+func TestIdleAutoClose(t *testing.T) {
+ // 闲置单位缩短为 3 秒,便于测试(配置里的“1 分钟”即 3 秒)
+ e := startEnvWith(t, testToken, func(a *Agent) { a.idleUnit = 3 * time.Second })
+ waitFor(t, "agent connected", e.agent.IsConnected)
+
+ // 1) 没有任何连接/授权/邀请:闲置到期后自动关闭并记录原因
+ port := freeRelayPort(t)
+ _, body := e.call(t, "POST", "/api/tunnels", map[string]any{"name": "idle", "target": "127.0.0.1:1", "listen_port": port, "idle_close_minutes": 1})
+ var idle protocol.TunnelConfig
+ _ = json.Unmarshal(body, &idle)
+ waitFor(t, "idle tunnel closed", func() bool { return !e.tunnel(t, idle.ID).Enabled })
+ if got := e.tunnel(t, idle.ID).CloseReason; !strings.Contains(got, "闲置") {
+ t.Fatalf("close reason should mention idle, got %q", got)
+ }
+
+ // 2) 有可用的邀请链接时保持开启;作废后闲置关闭
+ port2 := freeRelayPort(t, port)
+ _, body = e.call(t, "POST", "/api/tunnels", map[string]any{"name": "invited", "target": "127.0.0.1:1", "listen_port": port2, "idle_close_minutes": 1})
+ var inv protocol.TunnelConfig
+ _ = json.Unmarshal(body, &inv)
+ waitFor(t, "tunnel online", func() bool { return e.tunnel(t, inv.ID).Metrics.Status == "online" })
+ _, ib := e.call(t, "POST", "/api/tunnels/"+inv.ID+"/invites", map[string]any{})
+ var ir struct {
+ Invite protocol.Invite `json:"invite"`
+ }
+ _ = json.Unmarshal(ib, &ir)
+ time.Sleep(6 * time.Second)
+ if !e.tunnel(t, inv.ID).Enabled {
+ t.Fatal("tunnel with an unused invite must stay open")
+ }
+ if act := e.tunnel(t, inv.ID).Activity; act == nil || !act.InUse {
+ t.Fatalf("tunnel with an unused invite should be reported in use: %+v", act)
+ }
+ e.call(t, "DELETE", "/api/tunnels/"+inv.ID+"/invites/"+ir.Invite.Code, nil)
+ waitFor(t, "tunnel closed after invite revoked", func() bool { return !e.tunnel(t, inv.ID).Enabled })
+
+ // 3) 不自动关闭(0)
+ port3 := freeRelayPort(t, port, port2)
+ _, body = e.call(t, "POST", "/api/tunnels", map[string]any{"name": "keep", "target": "127.0.0.1:1", "listen_port": port3, "idle_close_minutes": 0})
+ var keep protocol.TunnelConfig
+ _ = json.Unmarshal(body, &keep)
+ time.Sleep(6 * time.Second)
+ if !e.tunnel(t, keep.ID).Enabled {
+ t.Fatal("idle_close_minutes=0 must never auto-close")
+ }
+}
+
+func TestFirstConnectionAndConnectedTime(t *testing.T) {
+ e := startEnv(t, testToken)
+ waitFor(t, "agent connected", e.agent.IsConnected)
+ target := startEcho(t)
+ port := freeRelayPort(t)
+ _, body := e.call(t, "POST", "/api/tunnels", map[string]any{"name": "db", "target": target, "listen_port": port, "whitelist": []string{"*"}})
+ var tun protocol.TunnelConfig
+ _ = json.Unmarshal(body, &tun)
+ waitFor(t, "tunnel online", func() bool { return e.tunnel(t, tun.ID).Metrics.Status == "online" })
+ if e.tunnel(t, tun.ID).FirstConnectedAt != nil {
+ t.Fatal("no connection yet")
+ }
+
+ c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
+ if err != nil {
+ t.Fatal(err)
+ }
+ waitFor(t, "first connection recorded", func() bool { return e.tunnel(t, tun.ID).FirstConnectedAt != nil })
+ if rh := e.tunnel(t, tun.ID).RateHistory; rh == nil || len(rh.Up) != ratePoints || len(rh.Down) != ratePoints {
+ t.Fatalf("enabled tunnel should expose a %d-point rate history: %+v", ratePoints, rh)
+ }
+ time.Sleep(3 * time.Second)
+ act := e.tunnel(t, tun.ID).Activity
+ if act == nil || act.ConnectedSec < 2 || !act.InUse {
+ t.Fatalf("connected time should accumulate while a client is connected: %+v", act)
+ }
+ c.Close()
+ time.Sleep(2 * time.Second)
+ before := e.tunnel(t, tun.ID).Activity.ConnectedSec
+ time.Sleep(2 * time.Second)
+ if after := e.tunnel(t, tun.ID).Activity.ConnectedSec; after != before {
+ t.Fatalf("connected time must not grow without connections: %d -> %d", before, after)
+ }
+
+ e.call(t, "POST", "/api/tunnels/"+tun.ID+"/toggle", map[string]bool{"enabled": false})
+ got := e.tunnel(t, tun.ID)
+ if got.LastConnectedSec < 2 || got.CloseReason != "手动关闭" {
+ t.Fatalf("stop should keep last connected time and reason: sec=%d reason=%q", got.LastConnectedSec, got.CloseReason)
+ }
+ // 重新开启:本次统计清零
+ e.call(t, "POST", "/api/tunnels/"+tun.ID+"/toggle", map[string]bool{"enabled": true})
+ if got := e.tunnel(t, tun.ID); got.FirstConnectedAt != nil || got.ConnectedSec != 0 || got.CloseReason != "" {
+ t.Fatalf("new session should reset counters: %+v", got)
+ }
+}
diff --git a/client/config.go b/client/config.go
new file mode 100644
index 0000000..6ac3b32
--- /dev/null
+++ b/client/config.go
@@ -0,0 +1,702 @@
+package client
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "log"
+ "math/big"
+ "net"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/DeepFal/PortRelay/core/acl"
+ "github.com/DeepFal/PortRelay/core/protocol"
+)
+
+const (
+ PortMin = 33000
+ PortMax = 34000
+ // ControlPort 是中继服务器上 PortRelay 自身的控制端口,不能分配给通道
+ ControlPort = 33000
+
+ // DefaultIdleCloseMinutes 未设置时的闲置自动关闭时长
+ DefaultIdleCloseMinutes = 30
+
+ maxClientHistory = 200
+ // 连接历史的落盘延迟:短连接密集时合并写入,避免每断开一个连接就重写一次配置
+ historySaveDelay = 2 * time.Second
+)
+
+func generateAuthCode() string {
+ const chars = "23456789abcdefghjkmnpqrstuvwxyz"
+ b := make([]byte, 8)
+ for i := range b {
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
+ if err != nil {
+ panic(err)
+ }
+ b[i] = chars[n.Int64()]
+ }
+ return string(b)
+}
+
+// Config represents persistent client settings
+type Config struct {
+ ServerAddr string `json:"server_addr"` // 中继服务器控制地址 (host:port)
+ Token string `json:"token"`
+ PublicHost string `json:"public_host,omitempty"` // 访问者连接通道时使用的网关地址
+ Tunnels []protocol.TunnelConfig `json:"tunnels"`
+ ClientHistory []protocol.ClientHistoryRecord `json:"client_history,omitempty"`
+}
+
+// ConfigManager handles loading and saving configuration
+type ConfigManager struct {
+ path string
+ mu sync.RWMutex
+ cfg Config
+
+ historyTimer *time.Timer
+ closed bool
+}
+
+// DefaultConfig returns the first-run config: no server and no tunnels.
+// 服务器地址、令牌与通道都属于用户数据,由用户在界面中填写,不能写死在程序里。
+func DefaultConfig() Config {
+ return Config{
+ Tunnels: make([]protocol.TunnelConfig, 0),
+ ClientHistory: make([]protocol.ClientHistoryRecord, 0),
+ }
+}
+
+// NewConfigManager initializes or loads config from ~/.portrelay/config.json
+func NewConfigManager() (*ConfigManager, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil, err
+ }
+ return NewConfigManagerAt(filepath.Join(home, ".portrelay", "config.json"))
+}
+
+// NewConfigManagerAt initializes or loads config from the given path
+func NewConfigManagerAt(filePath string) (*ConfigManager, error) {
+ if err := os.MkdirAll(filepath.Dir(filePath), 0700); err != nil {
+ return nil, err
+ }
+ cm := &ConfigManager{path: filePath}
+ err := cm.load()
+ switch {
+ case err == nil:
+ return cm, nil
+ case errors.Is(err, fs.ErrNotExist):
+ cm.cfg = DefaultConfig()
+ default:
+ // 配置损坏时先备份原文件,绝不直接覆盖用户数据
+ backup := fmt.Sprintf("%s.corrupt-%s", filePath, time.Now().Format("20060102-150405"))
+ if rerr := os.Rename(filePath, backup); rerr != nil {
+ return nil, fmt.Errorf("config %s is unreadable (%v) and could not be backed up: %w", filePath, err, rerr)
+ }
+ log.Printf("[Config] 配置文件损坏 (%v),已备份到 %s 并使用默认配置", err, backup)
+ cm.cfg = DefaultConfig()
+ }
+ if err := cm.save(); err != nil {
+ return nil, err
+ }
+ return cm, nil
+}
+
+func (cm *ConfigManager) load() error {
+ data, err := os.ReadFile(cm.path)
+ if err != nil {
+ return err
+ }
+ var cfg Config
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return err
+ }
+ // Ensure every tunnel has an auth code
+ for i := range cfg.Tunnels {
+ if cfg.Tunnels[i].AuthCode == "" {
+ cfg.Tunnels[i].AuthCode = generateAuthCode()
+ }
+ }
+ cm.cfg = cfg
+ return nil
+}
+
+// save writes the config atomically (temp file + rename). Caller must hold cm.mu.
+func (cm *ConfigManager) save() error {
+ if cm.closed {
+ return nil
+ }
+ data, err := json.MarshalIndent(cm.cfg, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(cm.path), ".config-*.json")
+ if err != nil {
+ return err
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName)
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Sync(); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Chmod(tmpName, 0600); err != nil {
+ return err
+ }
+ return os.Rename(tmpName, cm.path)
+}
+
+// Close persists pending writes; afterwards the manager no longer touches the file (used on shutdown)
+func (cm *ConfigManager) Close() error {
+ err := cm.Flush()
+ cm.mu.Lock()
+ cm.closed = true
+ cm.mu.Unlock()
+ return err
+}
+
+// Flush persists any pending (debounced) writes
+func (cm *ConfigManager) Flush() error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ if cm.historyTimer != nil {
+ cm.historyTimer.Stop()
+ cm.historyTimer = nil
+ }
+ return cm.save()
+}
+
+func cloneTunnel(t protocol.TunnelConfig) protocol.TunnelConfig {
+ // 空白名单(仅授权链接)必须序列化为 [],不能是 null:App 端按非可选数组解析
+ t.Whitelist = append(make([]string, 0, len(t.Whitelist)), t.Whitelist...)
+ t.DynamicWhitelist = append([]string(nil), t.DynamicWhitelist...)
+ if t.StartedAt != nil {
+ v := *t.StartedAt
+ t.StartedAt = &v
+ }
+ if t.ExpiresAt != nil {
+ v := *t.ExpiresAt
+ t.ExpiresAt = &v
+ }
+ return t
+}
+
+// Get returns a deep copy snapshot (safe to read without holding the lock)
+func (cm *ConfigManager) Get() Config {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+ out := cm.cfg
+ out.Tunnels = make([]protocol.TunnelConfig, len(cm.cfg.Tunnels))
+ for i, t := range cm.cfg.Tunnels {
+ out.Tunnels[i] = cloneTunnel(t)
+ }
+ out.ClientHistory = append([]protocol.ClientHistoryRecord(nil), cm.cfg.ClientHistory...)
+ return out
+}
+
+// FindTunnel returns a copy of the tunnel with the given ID
+func (cm *ConfigManager) FindTunnel(id string) (protocol.TunnelConfig, bool) {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+ for _, t := range cm.cfg.Tunnels {
+ if t.ID == id {
+ return cloneTunnel(t), true
+ }
+ }
+ return protocol.TunnelConfig{}, false
+}
+
+// ValidateServerAddr checks a host:port address
+func ValidateServerAddr(addr string) error {
+ host, port, err := net.SplitHostPort(strings.TrimSpace(addr))
+ if err != nil || host == "" {
+ return fmt.Errorf("服务器地址格式应为 IP:端口")
+ }
+ if p, err := strconv.Atoi(port); err != nil || p <= 0 || p > 65535 {
+ return fmt.Errorf("服务器端口无效")
+ }
+ return nil
+}
+
+// PublicHostOrDefault returns the host LAN users connect to;
+// 未单独配置时使用服务器控制地址中的主机部分
+func (c Config) PublicHostOrDefault() string {
+ if h := strings.TrimSpace(c.PublicHost); h != "" {
+ return h
+ }
+ if h, _, err := net.SplitHostPort(c.ServerAddr); err == nil {
+ return h
+ }
+ return ""
+}
+
+// UpdateServer sets server address, token and the public access host.
+// 返回 true 表示连接参数(地址/令牌)发生变化,需要重连。
+func (cm *ConfigManager) UpdateServer(addr, token, publicHost string) (bool, error) {
+ addr = strings.TrimSpace(addr)
+ token = strings.TrimSpace(token)
+ publicHost = strings.TrimSpace(publicHost)
+ if err := ValidateServerAddr(addr); err != nil {
+ return false, err
+ }
+ if publicHost != "" && (strings.ContainsAny(publicHost, " /:") || strings.Contains(publicHost, "://")) {
+ return false, fmt.Errorf("对外访问地址只填主机名或 IP,不要带端口或协议")
+ }
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ changed := cm.cfg.ServerAddr != addr || cm.cfg.Token != token
+ cm.cfg.ServerAddr = addr
+ cm.cfg.Token = token
+ cm.cfg.PublicHost = publicHost
+ return changed, cm.save()
+}
+
+// normalizeAndValidate trims user input and checks the user-editable fields
+func normalizeAndValidate(t *protocol.TunnelConfig) error {
+ t.Name = strings.TrimSpace(t.Name)
+ t.Target = strings.TrimSpace(t.Target)
+ t.Owner = strings.TrimSpace(t.Owner)
+ if t.Name == "" || t.Target == "" {
+ return fmt.Errorf("名称与目标地址为必填项")
+ }
+ if host, port, err := net.SplitHostPort(t.Target); err != nil || host == "" {
+ return fmt.Errorf("目标地址格式应为 主机:端口,例如 10.0.0.10:3306")
+ } else if p, err := strconv.Atoi(port); err != nil || p <= 0 || p > 65535 {
+ return fmt.Errorf("目标端口无效")
+ }
+ if t.ListenPort < PortMin || t.ListenPort > PortMax {
+ return fmt.Errorf("监听端口须在 %d-%d 范围内", PortMin, PortMax)
+ }
+ if t.ListenPort == ControlPort {
+ return fmt.Errorf("端口 %d 是服务器控制端口,不能用于通道", ControlPort)
+ }
+ if t.DurationMinutes < 0 {
+ return fmt.Errorf("开启时长不能为负数")
+ }
+ if t.MaxBandwidthMB < 0 {
+ return fmt.Errorf("限速不能为负数")
+ }
+ if t.IdleCloseMinutes != nil && (*t.IdleCloseMinutes < 0 || *t.IdleCloseMinutes > 7*24*60) {
+ return fmt.Errorf("闲置自动关闭时长须在 0(不自动关闭)到 7 天之间")
+ }
+
+ // 白名单为空表示“仅授权链接”:只有通过邀请链接放通的设备可以访问。
+ // 放通全部必须显式填写 "*",不能因为留空而意外对所有人开放。
+ wl := make([]string, 0, len(t.Whitelist))
+ for _, r := range t.Whitelist {
+ r = strings.TrimSpace(r)
+ if r == "" {
+ continue
+ }
+ if err := acl.ValidateRule(r); err != nil {
+ return err
+ }
+ wl = append(wl, r)
+ }
+ t.Whitelist = wl
+
+ // 放通时长与是否审批已改为在每个邀请上设置,通道级字段不再使用
+ t.LeaseDuration = ""
+ t.RequireApproval = false
+ return nil
+}
+
+// checkPortLocked returns an error if another tunnel already uses the port. Caller must hold cm.mu.
+func (cm *ConfigManager) checkPortLocked(id string, port int) error {
+ for _, existing := range cm.cfg.Tunnels {
+ if existing.ID != id && existing.ListenPort == port {
+ return fmt.Errorf("端口 %d 已被「%s」使用", port, existing.Name)
+ }
+ }
+ return nil
+}
+
+// AddTunnel adds a new (enabled) tunnel
+func (cm *ConfigManager) AddTunnel(t protocol.TunnelConfig) (protocol.TunnelConfig, error) {
+ if err := normalizeAndValidate(&t); err != nil {
+ return t, err
+ }
+
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ t.ID = uuid.New().String()
+ t.AuthCode = generateAuthCode()
+ t.HistoricalBytesIn, t.HistoricalBytesOut, t.LastDurationSec = 0, 0, 0
+ t.CloseReason, t.FirstConnectedAt, t.ConnectedSec, t.LastConnectedSec = "", nil, 0, 0
+ if t.IdleCloseMinutes == nil {
+ v := DefaultIdleCloseMinutes
+ t.IdleCloseMinutes = &v
+ }
+ t.DynamicWhitelist = nil
+ if err := cm.checkPortLocked(t.ID, t.ListenPort); err != nil {
+ return t, err
+ }
+
+ t.Enabled = true
+ now := time.Now()
+ t.StartedAt = &now
+ t.ExpiresAt = nil
+ if t.DurationMinutes > 0 {
+ exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
+ t.ExpiresAt = &exp
+ }
+
+ cm.cfg.Tunnels = append(cm.cfg.Tunnels, t)
+ return cloneTunnel(t), cm.save()
+}
+
+// PatchTunnel applies a partial JSON update: only fields present in patch change.
+// 启用状态、计时、流量统计、授权码由服务端逻辑维护,不能通过编辑修改。
+// Returns the updated tunnel and its previous listen port.
+func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelConfig, int, error) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ idx := -1
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID == id {
+ idx = i
+ break
+ }
+ }
+ if idx < 0 {
+ return protocol.TunnelConfig{}, 0, fmt.Errorf("通道 %s 未找到", id)
+ }
+
+ existing := cm.cfg.Tunnels[idx]
+ updated := cloneTunnel(existing)
+ if err := json.Unmarshal(patch, &updated); err != nil {
+ return existing, existing.ListenPort, fmt.Errorf("Invalid JSON")
+ }
+
+ // 受保护字段
+ updated.ID = existing.ID
+ updated.Enabled = existing.Enabled
+ updated.StartedAt = existing.StartedAt
+ updated.ExpiresAt = existing.ExpiresAt
+ updated.HistoricalBytesIn = existing.HistoricalBytesIn
+ updated.HistoricalBytesOut = existing.HistoricalBytesOut
+ updated.LastDurationSec = existing.LastDurationSec
+ updated.CloseReason = existing.CloseReason
+ updated.FirstConnectedAt = existing.FirstConnectedAt
+ updated.ConnectedSec = existing.ConnectedSec
+ updated.LastConnectedSec = existing.LastConnectedSec
+ updated.AuthCode = existing.AuthCode
+ updated.DynamicWhitelist = nil
+
+ if err := normalizeAndValidate(&updated); err != nil {
+ return existing, existing.ListenPort, err
+ }
+ if err := cm.checkPortLocked(id, updated.ListenPort); err != nil {
+ return existing, existing.ListenPort, err
+ }
+
+ // 运行中修改时长:从本次开启时间重新计算到期时间
+ if updated.Enabled && updated.DurationMinutes != existing.DurationMinutes {
+ updated.ExpiresAt = nil
+ if updated.DurationMinutes > 0 {
+ start := time.Now()
+ if updated.StartedAt != nil {
+ start = *updated.StartedAt
+ }
+ exp := start.Add(time.Duration(updated.DurationMinutes) * time.Minute)
+ updated.ExpiresAt = &exp
+ }
+ }
+
+ cm.cfg.Tunnels[idx] = updated
+ return cloneTunnel(updated), existing.ListenPort, cm.save()
+}
+
+// RefreshAuthCode generates a new auth code for a tunnel
+func (cm *ConfigManager) RefreshAuthCode(id string) (protocol.TunnelConfig, error) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID == id {
+ t.AuthCode = generateAuthCode()
+ cm.cfg.Tunnels[i] = t
+ return cloneTunnel(t), cm.save()
+ }
+ }
+ return protocol.TunnelConfig{}, fmt.Errorf("通道 %s 未找到", id)
+}
+
+// CurrentEnabled reports whether the tunnel is enabled
+func (cm *ConfigManager) CurrentEnabled(id string) (bool, error) {
+ t, ok := cm.FindTunnel(id)
+ if !ok {
+ return false, fmt.Errorf("未找到通道 %s", id)
+ }
+ return t.Enabled, nil
+}
+
+// IdleCloseMinutes returns the effective idle auto-close setting (0 = never)
+func IdleCloseMinutes(t protocol.TunnelConfig) int {
+ if t.IdleCloseMinutes == nil {
+ return DefaultIdleCloseMinutes
+ }
+ return *t.IdleCloseMinutes
+}
+
+// stopLocked marks a tunnel stopped: records run time, real connected time, traffic and the reason
+func stopLocked(t *protocol.TunnelConfig, now time.Time, sessionIn, sessionOut int64, reason string) {
+ t.Enabled = false
+ if t.StartedAt != nil {
+ t.LastDurationSec = int64(now.Sub(*t.StartedAt).Seconds())
+ }
+ t.LastConnectedSec = t.ConnectedSec
+ t.HistoricalBytesIn += sessionIn
+ t.HistoricalBytesOut += sessionOut
+ t.StartedAt = nil
+ t.ExpiresAt = nil
+ t.CloseReason = reason
+}
+
+// CloseTunnel stops an enabled tunnel with the given reason (e.g. idle auto-close)
+func (cm *ConfigManager) CloseTunnel(id, reason string, sessionBytes func(id string) (int64, int64)) (protocol.TunnelConfig, bool) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID != id || !t.Enabled {
+ continue
+ }
+ in, out := sessionBytes(t.ID)
+ stopLocked(&t, time.Now(), in, out, reason)
+ cm.cfg.Tunnels[i] = t
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ return cloneTunnel(t), true
+ }
+ return protocol.TunnelConfig{}, false
+}
+
+// MarkFirstConnected records the first connection of the current session (once)
+func (cm *ConfigManager) MarkFirstConnected(id string, at time.Time) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID == id && t.Enabled && t.FirstConnectedAt == nil {
+ cm.cfg.Tunnels[i].FirstConnectedAt = &at
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ return
+ }
+ }
+}
+
+// AddConnectedSeconds accumulates the real connected time of the current session
+func (cm *ConfigManager) AddConnectedSeconds(id string, secs int64) {
+ if secs <= 0 {
+ return
+ }
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID == id && t.Enabled {
+ cm.cfg.Tunnels[i].ConnectedSec += secs
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ return
+ }
+ }
+}
+
+// SetTunnelEnabled turns a tunnel on/off, handling TTL and merging session traffic on stop.
+// changed=false means it was already in the requested state (idempotent).
+func (cm *ConfigManager) SetTunnelEnabled(id string, enabled bool, sessionIn, sessionOut int64) (protocol.TunnelConfig, bool, error) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID != id {
+ continue
+ }
+ if t.Enabled == enabled {
+ return cloneTunnel(t), false, nil
+ }
+ now := time.Now()
+ if enabled {
+ t.Enabled = true
+ t.StartedAt = &now
+ t.ExpiresAt = nil
+ if t.DurationMinutes > 0 {
+ exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
+ t.ExpiresAt = &exp
+ }
+ t.CloseReason, t.FirstConnectedAt, t.ConnectedSec = "", nil, 0
+ } else {
+ stopLocked(&t, now, sessionIn, sessionOut, "手动关闭")
+ }
+ cm.cfg.Tunnels[i] = t
+ return cloneTunnel(t), true, cm.save()
+ }
+ return protocol.TunnelConfig{}, false, fmt.Errorf("未找到通道 %s", id)
+}
+
+// ExpireDue disables every enabled tunnel whose TTL has passed and returns them.
+// sessionBytes returns (and consumes) the current session traffic for a tunnel.
+func (cm *ConfigManager) ExpireDue(sessionBytes func(id string) (int64, int64)) []protocol.TunnelConfig {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ now := time.Now()
+ var expired []protocol.TunnelConfig
+ for i, t := range cm.cfg.Tunnels {
+ if !t.Enabled || t.ExpiresAt == nil || !now.After(*t.ExpiresAt) {
+ continue
+ }
+ in, out := sessionBytes(t.ID)
+ stopLocked(&t, now, in, out, fmt.Sprintf("达到最长开启时长(%s)", formatMinutes(t.DurationMinutes)))
+ cm.cfg.Tunnels[i] = t
+ expired = append(expired, cloneTunnel(t))
+ }
+ if len(expired) > 0 {
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ }
+ return expired
+}
+
+func formatMinutes(m int) string {
+ switch {
+ case m >= 60*24 && m%(60*24) == 0:
+ return fmt.Sprintf("%d 天", m/(60*24))
+ case m >= 60 && m%60 == 0:
+ return fmt.Sprintf("%d 小时", m/60)
+ default:
+ return fmt.Sprintf("%d 分钟", m)
+ }
+}
+
+// AddHistoricalTraffic merges session traffic that is about to be lost (disconnect / port move)
+func (cm *ConfigManager) AddHistoricalTraffic(traffic map[string][2]int64) {
+ if len(traffic) == 0 {
+ return
+ }
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ changed := false
+ for i, t := range cm.cfg.Tunnels {
+ if v, ok := traffic[t.ID]; ok && (v[0] != 0 || v[1] != 0) {
+ cm.cfg.Tunnels[i].HistoricalBytesIn += v[0]
+ cm.cfg.Tunnels[i].HistoricalBytesOut += v[1]
+ changed = true
+ }
+ }
+ if changed {
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ }
+}
+
+// DeleteTunnel removes a tunnel
+func (cm *ConfigManager) DeleteTunnel(id string) error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ newTunnels := make([]protocol.TunnelConfig, 0, len(cm.cfg.Tunnels))
+ found := false
+ for _, t := range cm.cfg.Tunnels {
+ if t.ID == id {
+ found = true
+ continue
+ }
+ newTunnels = append(newTunnels, t)
+ }
+ if !found {
+ return fmt.Errorf("通道 %s 未找到", id)
+ }
+ cm.cfg.Tunnels = newTunnels
+ return cm.save()
+}
+
+// AddClientHistory appends a client connection log record (persisted with a short delay)
+func (cm *ConfigManager) AddClientHistory(rec protocol.ClientHistoryRecord) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ hist := make([]protocol.ClientHistoryRecord, 0, min(len(cm.cfg.ClientHistory)+1, maxClientHistory))
+ hist = append(hist, rec)
+ hist = append(hist, cm.cfg.ClientHistory...)
+ if len(hist) > maxClientHistory {
+ hist = hist[:maxClientHistory]
+ }
+ cm.cfg.ClientHistory = hist
+
+ if cm.historyTimer == nil && !cm.closed {
+ cm.historyTimer = time.AfterFunc(historySaveDelay, func() {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ cm.historyTimer = nil
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ })
+ }
+}
+
+// GetClientHistory returns client connection records (optionally filtered by tunnelId)
+func (cm *ConfigManager) GetClientHistory(tunnelID string) []protocol.ClientHistoryRecord {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+
+ filtered := make([]protocol.ClientHistoryRecord, 0)
+ for _, r := range cm.cfg.ClientHistory {
+ if tunnelID == "" || r.TunnelID == tunnelID {
+ filtered = append(filtered, r)
+ }
+ }
+ return filtered
+}
+
+// RecommendPort finds an unused port in 33000-34000
+func (cm *ConfigManager) RecommendPort() int {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+
+ used := make(map[int]bool)
+ for _, t := range cm.cfg.Tunnels {
+ used[t.ListenPort] = true
+ }
+
+ for p := 33660; p <= PortMax; p++ {
+ if !used[p] {
+ return p
+ }
+ }
+ for p := PortMin; p < 33660; p++ {
+ if p == ControlPort {
+ continue
+ }
+ if !used[p] {
+ return p
+ }
+ }
+ return 0
+}
diff --git a/client/probe.go b/client/probe.go
new file mode 100644
index 0000000..ea8ccc2
--- /dev/null
+++ b/client/probe.go
@@ -0,0 +1,45 @@
+package client
+
+import (
+ "fmt"
+ "net"
+ "time"
+)
+
+// ProbeResult holds ping/reachability info
+type ProbeResult struct {
+ Target string `json:"target"`
+ Reachable bool `json:"reachable"`
+ RTT time.Duration `json:"rtt"`
+ Error string `json:"error,omitempty"`
+}
+
+// ProbeTarget checks if this machine can reach target
+func ProbeTarget(target string, timeout time.Duration) ProbeResult {
+ if timeout <= 0 {
+ timeout = 2 * time.Second
+ }
+
+ start := time.Now()
+ conn, err := net.DialTimeout("tcp", target, timeout)
+ if err != nil {
+ return ProbeResult{
+ Target: target,
+ Reachable: false,
+ RTT: time.Since(start),
+ Error: err.Error(),
+ }
+ }
+ defer conn.Close()
+
+ return ProbeResult{
+ Target: target,
+ Reachable: true,
+ RTT: time.Since(start),
+ }
+}
+
+// FormatRTT formats duration in ms
+func (pr ProbeResult) FormatRTT() string {
+ return fmt.Sprintf("%.1f ms", float64(pr.RTT.Microseconds())/1000.0)
+}
diff --git a/client/ratehistory.go b/client/ratehistory.go
new file mode 100644
index 0000000..a011e63
--- /dev/null
+++ b/client/ratehistory.go
@@ -0,0 +1,127 @@
+package client
+
+import (
+ "sync"
+ "time"
+)
+
+const (
+ rateStep = 5 * time.Second
+ ratePoints = 120 // 10 分钟
+ totalSeries = "__total__"
+)
+
+// RateSeries is a fixed-length rate history (oldest first). The last point is the
+// current, still-accumulating bucket so the chart moves every second.
+type RateSeries struct {
+ End int64 `json:"end"` // 最后一个点所在 5 秒桶的起始时间(Unix 秒)
+ Step int `json:"step"` // 每个点的秒数
+ Up []int64 `json:"up"` // 上行 bytes/s(客户端 → 目标)
+ Down []int64 `json:"down"` // 下行 bytes/s(目标 → 客户端)
+}
+
+type rateBucket struct {
+ start time.Time
+ up, down int64
+}
+
+type rateHistory struct {
+ done []rateBucket // completed buckets, oldest first, at most ratePoints
+ curStart time.Time
+ curUp, curDown int64
+ curSamples int64
+ lastSampleBucket time.Time
+}
+
+func (h *rateHistory) add(now time.Time, up, down int64) {
+ bs := now.Truncate(rateStep)
+ if h.curStart.IsZero() {
+ h.curStart = bs
+ }
+ if bs.After(h.curStart) {
+ if h.curSamples > 0 {
+ h.done = append(h.done, rateBucket{h.curStart, h.curUp / h.curSamples, h.curDown / h.curSamples})
+ }
+ if len(h.done) > ratePoints {
+ h.done = h.done[len(h.done)-ratePoints:]
+ }
+ h.curStart, h.curUp, h.curDown, h.curSamples = bs, 0, 0, 0
+ }
+ h.curUp += up
+ h.curDown += down
+ h.curSamples++
+}
+
+// series renders the last ratePoints buckets ending at now's bucket; missing buckets are zero
+func (h *rateHistory) series(now time.Time) RateSeries {
+ end := now.Truncate(rateStep)
+ s := RateSeries{End: end.Unix(), Step: int(rateStep / time.Second), Up: make([]int64, ratePoints), Down: make([]int64, ratePoints)}
+ first := end.Add(-time.Duration(ratePoints-1) * rateStep)
+ put := func(start time.Time, up, down int64) {
+ if start.Before(first) || start.After(end) {
+ return
+ }
+ i := int(start.Sub(first) / rateStep)
+ s.Up[i], s.Down[i] = up, down
+ }
+ for _, b := range h.done {
+ put(b.start, b.up, b.down)
+ }
+ if h.curSamples > 0 {
+ put(h.curStart, h.curUp/h.curSamples, h.curDown/h.curSamples)
+ }
+ return s
+}
+
+// rateRecorder keeps per-tunnel and total rate histories in memory (近 10 分钟,不落盘)
+type rateRecorder struct {
+ mu sync.Mutex
+ hist map[string]*rateHistory
+}
+
+func newRateRecorder() *rateRecorder {
+ return &rateRecorder{hist: make(map[string]*rateHistory)}
+}
+
+// record adds one sample per tunnel (rates in bytes/s) plus the total
+func (r *rateRecorder) record(now time.Time, rates map[string][2]int64) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ var tu, td int64
+ for id, v := range rates {
+ h := r.hist[id]
+ if h == nil {
+ h = &rateHistory{}
+ r.hist[id] = h
+ }
+ h.add(now, v[0], v[1])
+ tu += v[0]
+ td += v[1]
+ }
+ t := r.hist[totalSeries]
+ if t == nil {
+ t = &rateHistory{}
+ r.hist[totalSeries] = t
+ }
+ t.add(now, tu, td)
+}
+
+func (r *rateRecorder) series(id string, now time.Time) RateSeries {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if h := r.hist[id]; h != nil {
+ return h.series(now)
+ }
+ return (&rateHistory{}).series(now)
+}
+
+// forget drops the history of tunnels that are no longer enabled
+func (r *rateRecorder) retain(enabled map[string]bool) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ for id := range r.hist {
+ if id != totalSeries && !enabled[id] {
+ delete(r.hist, id)
+ }
+ }
+}
diff --git a/client/ratehistory_test.go b/client/ratehistory_test.go
new file mode 100644
index 0000000..56b6875
--- /dev/null
+++ b/client/ratehistory_test.go
@@ -0,0 +1,52 @@
+package client
+
+import (
+ "testing"
+ "time"
+)
+
+func TestRateHistoryBucketsAndWindow(t *testing.T) {
+ r := newRateRecorder()
+ base := time.Unix(1_700_000_000, 0).Truncate(rateStep)
+
+ // 同一个 5 秒桶内的多个采样取平均
+ r.record(base, map[string][2]int64{"a": {100, 1000}})
+ r.record(base.Add(1*time.Second), map[string][2]int64{"a": {300, 3000}})
+ // 跳过 3 个桶(中间无数据,应为 0)
+ r.record(base.Add(20*time.Second), map[string][2]int64{"a": {50, 500}})
+
+ s := r.series("a", base.Add(21*time.Second))
+ if len(s.Up) != ratePoints || len(s.Down) != ratePoints || s.Step != 5 {
+ t.Fatalf("series must be fixed length %d with step 5: %d/%d step %d", ratePoints, len(s.Up), len(s.Down), s.Step)
+ }
+ last := ratePoints - 1
+ if s.Up[last] != 50 || s.Down[last] != 500 {
+ t.Fatalf("last point should be the current bucket: %d/%d", s.Up[last], s.Down[last])
+ }
+ if s.Up[last-4] != 200 || s.Down[last-4] != 2000 {
+ t.Fatalf("first bucket should average samples: %d/%d", s.Up[last-4], s.Down[last-4])
+ }
+ for i := last - 3; i < last; i++ {
+ if s.Up[i] != 0 || s.Down[i] != 0 {
+ t.Fatalf("gap bucket %d should be zero: %d/%d", i, s.Up[i], s.Down[i])
+ }
+ }
+
+ // 合计曲线
+ total := r.series(totalSeries, base.Add(21*time.Second))
+ if total.Down[last] != 500 {
+ t.Fatalf("total should sum tunnels: %d", total.Down[last])
+ }
+
+ // 11 分钟后旧数据滑出窗口
+ later := base.Add(11 * time.Minute)
+ if s2 := r.series("a", later); s2.Up[0] != 0 || s2.Down[last] != 0 {
+ t.Fatalf("old data should slide out of the 10 minute window: first=%d last=%d", s2.Up[0], s2.Down[last])
+ }
+
+ // 关闭的通道清除曲线
+ r.retain(map[string]bool{})
+ if s3 := r.series("a", base.Add(21*time.Second)); s3.Down[last] != 0 {
+ t.Fatal("history of a disabled tunnel should be dropped")
+ }
+}
diff --git a/cmd/portrelay-agent/main.go b/cmd/portrelay-agent/main.go
new file mode 100644
index 0000000..2aed2b4
--- /dev/null
+++ b/cmd/portrelay-agent/main.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "embed"
+ "flag"
+ "fmt"
+ "io/fs"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/signal"
+ "runtime"
+ "syscall"
+
+ "github.com/DeepFal/PortRelay/client"
+)
+
+//go:embed all:web
+var webFS embed.FS
+
+// version 由发布构建通过 -ldflags "-X main.version=..." 注入
+var version = "dev"
+
+func main() {
+ httpPort := flag.Int("port", 33080, "Local dashboard HTTP port")
+ noBrowser := flag.Bool("no-browser", false, "Do not auto-open browser")
+ showVersion := flag.Bool("version", false, "Print version and exit")
+ flag.Parse()
+
+ if *showVersion {
+ fmt.Println("portrelay-agent", version)
+ return
+ }
+
+ log.Printf("==================================================")
+ log.Printf(" PortRelay Agent %s", version)
+ log.Printf(" Dashboard: http://127.0.0.1:%d", *httpPort)
+ log.Printf("==================================================")
+
+ // 先占住面板端口:已有实例在运行时直接退出,避免第二个 Agent 连上服务端顶替会话
+ addr := fmt.Sprintf("127.0.0.1:%d", *httpPort)
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ log.Fatalf("Dashboard port %s unavailable (is another PortRelay agent running?): %v", addr, err)
+ }
+
+ cm, err := client.NewConfigManager()
+ if err != nil {
+ log.Fatalf("Failed to init config manager: %v", err)
+ }
+
+ agent := client.NewAgent(cm)
+ agent.Start()
+
+ // Strip 'web' prefix for embed fs
+ subFS, err := fs.Sub(webFS, "web")
+ if err != nil {
+ log.Fatalf("Failed to load embedded web assets: %v", err)
+ }
+
+ apiServer := client.NewAPIServer(cm, agent, http.FS(subFS))
+ apiServer.OnShutdown = func() {
+ log.Printf("Shutdown requested via API, stopping PortRelay Agent...")
+ agent.Stop()
+ _ = cm.Close()
+ os.Exit(0)
+ }
+
+ // Graceful shutdown
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+ go func() {
+ <-sigChan
+ log.Printf("\nShutting down PortRelay Agent...")
+ agent.Stop()
+ _ = cm.Close()
+ os.Exit(0)
+ }()
+
+ if !*noBrowser {
+ go openBrowser(fmt.Sprintf("http://127.0.0.1:%d", *httpPort))
+ }
+
+ log.Printf("Serving local web control panel at http://%s", addr)
+ if err := http.Serve(ln, apiServer.Handler()); err != nil {
+ agent.Stop()
+ _ = cm.Close()
+ log.Fatalf("HTTP server failed: %v", err)
+ }
+}
+
+func openBrowser(url string) {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = exec.Command("open", url)
+ case "windows":
+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
+ default:
+ cmd = exec.Command("xdg-open", url)
+ }
+ _ = cmd.Start()
+}
diff --git a/cmd/portrelay-agent/web/index.html b/cmd/portrelay-agent/web/index.html
new file mode 100644
index 0000000..8a19c62
--- /dev/null
+++ b/cmd/portrelay-agent/web/index.html
@@ -0,0 +1,1374 @@
+
+
+
+
+
+ PortRelay - 端口中继与安全管控
+
+
+
+
+
+
+
+
⚡
+
+
PortRelay 控制台
+
高性能定向反向中继与安全审计
+
+
+
+
+
+
+
+
+
活跃通道 / 总数
+
0 / 0
+
放通端口池: 33001-34000
+
+
+
+
实时速率 (上行 / 下行)
+
0 KB/s
+
+
累计传输: 0 MB
+
+
+
安全拦截未授权尝试
+
0
+
边缘白名单实时防护
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 时间
+ 目标端口
+ 非法客户端 IP
+ 拦截原因
+ 防护策略
+
+
+
+ 暂无拦截事件,系统运行安全
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 分享文案(可直接发给同事)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/portrelay-server/main.go b/cmd/portrelay-server/main.go
new file mode 100644
index 0000000..b68939e
--- /dev/null
+++ b/cmd/portrelay-server/main.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+ "bufio"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/DeepFal/PortRelay/server"
+)
+
+type peekedConn struct {
+ net.Conn
+ r io.Reader
+}
+
+func (c *peekedConn) Read(b []byte) (int, error) {
+ return c.r.Read(b)
+}
+
+// version 由发布构建通过 -ldflags "-X main.version=..." 注入
+var version = "dev"
+
+func envOr(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+
+func main() {
+ listenAddr := flag.String("listen", ":33000", "Control/relay listen address")
+ portMin := flag.Int("port-min", 33000, "Minimum tunnel port")
+ portMax := flag.Int("port-max", 34000, "Maximum tunnel port")
+ token := flag.String("token", envOr("PORTRELAY_TOKEN", ""), "Shared secret the agent must present (or env PORTRELAY_TOKEN); required")
+ publicHost := flag.String("public-host", "", "Host shown on the web auth page (default: the host the visitor used to open the link)")
+ stateFile := flag.String("state", "/var/lib/portrelay/leases.json", "File that keeps temporary (web auth) leases across restarts; empty disables")
+ trustProxy := flag.Bool("trust-proxy", false, "Trust X-Forwarded-For/X-Real-IP on /auth (only behind a trusted reverse proxy)")
+ showVersion := flag.Bool("version", false, "Print version and exit")
+ flag.Parse()
+
+ if *showVersion {
+ fmt.Println("portrelay-server", version)
+ return
+ }
+
+ if *token == "" {
+ log.Fatalf("Fatal: no token configured; set -token or PORTRELAY_TOKEN (e.g. openssl rand -hex 24)")
+ }
+ if len(*token) < 16 {
+ log.Printf("WARNING: token is shorter than 16 characters; use a long random value")
+ }
+
+ log.Printf("==================================================")
+ log.Printf(" PortRelay Relay Server %s", version)
+ log.Printf(" Control/Web Port: %s", *listenAddr)
+ log.Printf(" Allowed Range: %d - %d", *portMin, *portMax)
+ log.Printf("==================================================")
+
+ mgr := server.NewManager(*portMin, *portMax, *token)
+ mgr.PublicHost = *publicHost
+ mgr.TrustProxyHeaders = *trustProxy
+ if *stateFile != "" {
+ if err := mgr.EnableLeasePersistence(*stateFile); err != nil {
+ log.Printf("WARNING: lease persistence disabled: %v", err)
+ }
+ }
+
+ ln, err := net.Listen("tcp", *listenAddr)
+ if err != nil {
+ log.Fatalf("Fatal: failed to listen on %s: %v", *listenAddr, err)
+ }
+ defer ln.Close()
+
+ // Graceful shutdown
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+ go func() {
+ <-sigChan
+ log.Printf("\nShutting down PortRelay Server...")
+ _ = ln.Close()
+ os.Exit(0)
+ }()
+
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ select {
+ case <-sigChan:
+ return
+ default:
+ log.Printf("Accept error: %v", err)
+ time.Sleep(50 * time.Millisecond)
+ continue
+ }
+ }
+
+ go func(c net.Conn) {
+ // 不发数据的空连接最多占用 10 秒
+ _ = c.SetReadDeadline(time.Now().Add(10 * time.Second))
+ br := bufio.NewReader(c)
+ peek, err := br.Peek(3)
+ if err != nil {
+ _ = c.Close()
+ return
+ }
+ _ = c.SetReadDeadline(time.Time{})
+ if string(peek) == "GET" || string(peek) == "POS" || string(peek) == "HEA" {
+ mgr.HandleHTTP(&peekedConn{Conn: c, r: br})
+ return
+ }
+
+ log.Printf("Incoming relay agent connection from %s", c.RemoteAddr())
+ if err := mgr.HandleSession(&peekedConn{Conn: c, r: br}); err != nil {
+ log.Printf("Session closed for %s: %v", c.RemoteAddr(), err)
+ }
+ }(conn)
+ }
+}
diff --git a/core/acl/whitelist.go b/core/acl/whitelist.go
new file mode 100644
index 0000000..1a61adb
--- /dev/null
+++ b/core/acl/whitelist.go
@@ -0,0 +1,193 @@
+package acl
+
+import (
+ "fmt"
+ "net"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Whitelist checks if an IP is allowed based on rules and dynamic leases
+type Whitelist struct {
+ rules []string
+ allowAll bool
+ ips map[string]bool
+ subnets []*net.IPNet
+ dynamic map[string]time.Time // dynamic IP -> expiration
+ mu sync.RWMutex
+}
+
+// NewWhitelist parses a list of rules
+func NewWhitelist(rules []string) *Whitelist {
+ w := &Whitelist{
+ rules: rules,
+ ips: make(map[string]bool),
+ subnets: make([]*net.IPNet, 0),
+ dynamic: make(map[string]time.Time),
+ }
+
+ for _, r := range rules {
+ r = strings.TrimSpace(r)
+ if r == "" {
+ continue
+ }
+ if r == "*" || r == "0.0.0.0/0" || r == "::/0" {
+ w.allowAll = true
+ continue
+ }
+ if strings.Contains(r, "/") {
+ _, ipNet, err := net.ParseCIDR(r)
+ if err == nil {
+ w.subnets = append(w.subnets, ipNet)
+ }
+ } else {
+ ip := net.ParseIP(r)
+ if ip != nil {
+ w.ips[ip.String()] = true
+ }
+ }
+ }
+ return w
+}
+
+// ValidateRule reports whether a whitelist rule is "*", a valid IP or a valid CIDR
+func ValidateRule(r string) error {
+ r = strings.TrimSpace(r)
+ switch {
+ case r == "":
+ return fmt.Errorf("白名单规则不能为空")
+ case r == "*":
+ return nil
+ case strings.Contains(r, "/"):
+ if _, _, err := net.ParseCIDR(r); err != nil {
+ return fmt.Errorf("无效的网段 %q", r)
+ }
+ default:
+ if net.ParseIP(r) == nil {
+ return fmt.Errorf("无效的 IP 地址 %q", r)
+ }
+ }
+ return nil
+}
+
+// DynamicLeases returns a copy of unexpired dynamic leases
+func (w *Whitelist) DynamicLeases() map[string]time.Time {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ now := time.Now()
+ out := make(map[string]time.Time, len(w.dynamic))
+ for ip, exp := range w.dynamic {
+ if now.Before(exp) {
+ out[ip] = exp
+ }
+ }
+ return out
+}
+
+// RestoreDynamic merges previously granted leases (e.g. after a config update)
+func (w *Whitelist) RestoreDynamic(leases map[string]time.Time) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for ip, exp := range leases {
+ if cur, ok := w.dynamic[ip]; !ok || exp.After(cur) {
+ w.dynamic[ip] = exp
+ }
+ }
+}
+
+// RemoveDynamic revokes the lease for ip (all leases when ip is empty) and returns how many were removed
+func (w *Whitelist) RemoveDynamic(ip string) int {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if ip == "" {
+ n := len(w.dynamic)
+ w.dynamic = make(map[string]time.Time)
+ return n
+ }
+ if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil {
+ if _, ok := w.dynamic[parsed.String()]; ok {
+ delete(w.dynamic, parsed.String())
+ return 1
+ }
+ }
+ return 0
+}
+
+// AddDynamic adds an IP with an expiration timestamp
+func (w *Whitelist) AddDynamic(ipStr string, expireAt time.Time) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ host := ipStr
+ if h, _, err := net.SplitHostPort(ipStr); err == nil {
+ host = h
+ }
+ parsed := net.ParseIP(host)
+ if parsed != nil {
+ w.dynamic[parsed.String()] = expireAt
+ }
+}
+
+// GetDynamic returns active unexpired dynamic IPs
+func (w *Whitelist) GetDynamic() []string {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ now := time.Now()
+ var list []string
+ for ip, exp := range w.dynamic {
+ if now.Before(exp) {
+ list = append(list, ip)
+ } else {
+ delete(w.dynamic, ip)
+ }
+ }
+ return list
+}
+
+// IsAllowed checks if remoteAddr (either "IP" or "IP:Port") is permitted
+func (w *Whitelist) IsAllowed(remoteAddr string) (bool, string) {
+ if w.allowAll {
+ return true, ""
+ }
+
+ host := remoteAddr
+ if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
+ host = h
+ }
+
+ parsedIP := net.ParseIP(host)
+ if parsedIP == nil {
+ return false, "invalid client IP"
+ }
+
+ ipStr := parsedIP.String()
+
+ // Canonical IP string check
+ if w.ips[ipStr] {
+ return true, ""
+ }
+
+ // Dynamic leases check
+ w.mu.RLock()
+ if exp, ok := w.dynamic[ipStr]; ok {
+ if time.Now().Before(exp) {
+ w.mu.RUnlock()
+ return true, ""
+ }
+ }
+ w.mu.RUnlock()
+
+ // CIDR subnets check
+ for _, subnet := range w.subnets {
+ if subnet.Contains(parsedIP) {
+ return true, ""
+ }
+ }
+
+ return false, "IP not in whitelist"
+}
+
+// Rules returns the raw rules
+func (w *Whitelist) Rules() []string {
+ return w.rules
+}
diff --git a/core/acl/whitelist_test.go b/core/acl/whitelist_test.go
new file mode 100644
index 0000000..decc954
--- /dev/null
+++ b/core/acl/whitelist_test.go
@@ -0,0 +1,114 @@
+package acl
+
+import (
+ "testing"
+ "time"
+)
+
+func TestWhitelist(t *testing.T) {
+ tests := []struct {
+ name string
+ rules []string
+ ip string
+ expected bool
+ }{
+ {
+ name: "allow all wildcard",
+ rules: []string{"*"},
+ ip: "192.168.1.50:12345",
+ expected: true,
+ },
+ {
+ name: "single ip match",
+ rules: []string{"192.168.1.50"},
+ ip: "192.168.1.50:12345",
+ expected: true,
+ },
+ {
+ name: "single ip mismatch",
+ rules: []string{"192.168.1.50"},
+ ip: "192.168.1.51:12345",
+ expected: false,
+ },
+ {
+ name: "cidr match",
+ rules: []string{"192.168.1.0/24"},
+ ip: "192.168.1.77:45678",
+ expected: true,
+ },
+ {
+ name: "cidr mismatch",
+ rules: []string{"192.168.1.0/24"},
+ ip: "192.168.2.190:45678",
+ expected: false,
+ },
+ {
+ name: "multiple rules mixed",
+ rules: []string{"10.0.0.1", "192.168.1.0/24"},
+ ip: "10.0.0.1:80",
+ expected: true,
+ },
+ {
+ name: "empty rules reject all",
+ rules: []string{},
+ ip: "127.0.0.1:80",
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ wl := NewWhitelist(tt.rules)
+ allowed, _ := wl.IsAllowed(tt.ip)
+ if allowed != tt.expected {
+ t.Errorf("expected %v for IP %s with rules %v, got %v", tt.expected, tt.ip, tt.rules, allowed)
+ }
+ })
+ }
+}
+
+func TestValidateRule(t *testing.T) {
+ for _, ok := range []string{"*", "192.168.1.100", "192.168.1.0/24", "::1", " 10.0.0.1 "} {
+ if err := ValidateRule(ok); err != nil {
+ t.Errorf("ValidateRule(%q) = %v, want nil", ok, err)
+ }
+ }
+ for _, bad := range []string{"", "192.168.1", "10.0.0.0/33", "abc", "192.168.1.*"} {
+ if err := ValidateRule(bad); err == nil {
+ t.Errorf("ValidateRule(%q) = nil, want error", bad)
+ }
+ }
+}
+
+func TestDynamicLeasesRoundTrip(t *testing.T) {
+ w1 := NewWhitelist([]string{"10.0.0.1"})
+ w1.AddDynamic("192.168.1.5", time.Now().Add(time.Hour))
+ w1.AddDynamic("192.168.1.6", time.Now().Add(-time.Second))
+
+ w2 := NewWhitelist([]string{"10.0.0.2"})
+ w2.RestoreDynamic(w1.DynamicLeases())
+ if ok, _ := w2.IsAllowed("192.168.1.5:1234"); !ok {
+ t.Error("restored lease not honoured")
+ }
+ if ok, _ := w2.IsAllowed("192.168.1.6"); ok {
+ t.Error("expired lease should not be restored")
+ }
+}
+
+func TestRemoveDynamic(t *testing.T) {
+ w := NewWhitelist(nil)
+ w.AddDynamic("10.0.0.1", time.Now().Add(time.Hour))
+ w.AddDynamic("10.0.0.2", time.Now().Add(time.Hour))
+ if n := w.RemoveDynamic("10.0.0.1"); n != 1 {
+ t.Fatalf("removed %d, want 1", n)
+ }
+ if ok, _ := w.IsAllowed("10.0.0.1"); ok {
+ t.Error("revoked IP still allowed")
+ }
+ if n := w.RemoveDynamic(""); n != 1 {
+ t.Fatalf("remove all removed %d, want 1", n)
+ }
+ if ok, _ := w.IsAllowed("10.0.0.2"); ok {
+ t.Error("IP still allowed after revoke-all")
+ }
+}
diff --git a/core/protocol/messages.go b/core/protocol/messages.go
new file mode 100644
index 0000000..fa32211
--- /dev/null
+++ b/core/protocol/messages.go
@@ -0,0 +1,305 @@
+package protocol
+
+import "time"
+
+// Message types
+const (
+ TypeAuth = "auth"
+ TypeAuthResp = "auth_resp"
+ TypeRegisterTunnel = "register_tunnel"
+ TypeRegisterResp = "register_resp"
+ TypeUnregister = "unregister_tunnel"
+ TypeUnregisterResp = "unregister_resp"
+ TypeUpdateTunnel = "update_tunnel"
+ TypeKillConn = "kill_connection"
+ TypeRevokeLease = "revoke_lease"
+ TypeCreateInvite = "create_invite"
+ TypeInviteResp = "invite_resp"
+ TypeRevokeInvite = "revoke_invite"
+ TypeDecideRequest = "decide_request"
+ TypeMetrics = "metrics_update"
+ TypePing = "ping"
+ TypePong = "pong"
+)
+
+// BaseMessage represents any control message
+type BaseMessage struct {
+ Type string `json:"type"`
+}
+
+// AuthMessage sent by agent to authenticate
+type AuthMessage struct {
+ Type string `json:"type"`
+ Token string `json:"token"`
+}
+
+// AuthRespMessage returned by server
+type AuthRespMessage struct {
+ Type string `json:"type"`
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+}
+
+// TunnelConfig is the definition of a tunnel
+type TunnelConfig struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Target string `json:"target"` // Target host:port (accessed by Mac agent, e.g. 10.0.1.16:3306)
+ ListenPort int `json:"listen_port"`
+ Whitelist []string `json:"whitelist"` // e.g. ["192.168.1.0/24", "192.168.1.100", "*"]
+ Enabled bool `json:"enabled"`
+ Owner string `json:"owner,omitempty"` // 责任人
+ DurationMinutes int `json:"duration_minutes,omitempty"` // 最长开启时长,到时无论是否在用都关闭 (0: 不限制)
+ // IdleCloseMinutes 闲置多久自动关闭(无在线连接、无有效授权、无待审批、无可用邀请);nil 表示默认 30 分钟,0 表示不自动关闭
+ IdleCloseMinutes *int `json:"idle_close_minutes,omitempty"`
+ // 以下由程序维护
+ CloseReason string `json:"close_reason,omitempty"` // 上次关闭原因
+ FirstConnectedAt *time.Time `json:"first_connected_at,omitempty"` // 本次开启后首次有连接的时间
+ ConnectedSec int64 `json:"connected_sec,omitempty"` // 本次开启后有连接在线的累计秒数
+ LastConnectedSec int64 `json:"last_connected_sec,omitempty"` // 上次开启期间有连接在线的累计秒数
+ StartedAt *time.Time `json:"started_at,omitempty"` // 本次开启时间
+ ExpiresAt *time.Time `json:"expires_at,omitempty"` // 到期自动结束时间
+ HistoricalBytesIn int64 `json:"historical_bytes_in,omitempty"` // 历史累计入流量
+ HistoricalBytesOut int64 `json:"historical_bytes_out,omitempty"` // 历史累计出流量
+ LastDurationSec int64 `json:"last_duration_sec,omitempty"` // 上次开启累计运行秒数
+ MaxBandwidthMB float64 `json:"max_bandwidth_mb,omitempty"` // 最大带宽限速 (MB/s, 0 为不限制)
+ AuthCode string `json:"auth_code,omitempty"` // 网页放通授权识别码
+ DynamicWhitelist []string `json:"dynamic_whitelist,omitempty"` // 动态放通中的客户端 IP 列表
+ Notes string `json:"notes,omitempty"`
+ LeaseDuration string `json:"lease_duration,omitempty"` // 已废弃:放通时长改为在每个邀请上设置,保留字段仅为兼容旧配置
+ RequireApproval bool `json:"require_approval,omitempty"` // 已废弃:是否审批改为在每个邀请上设置,保留字段仅为兼容旧配置
+}
+
+// RegisterTunnelMessage registers a tunnel on server
+type RegisterTunnelMessage struct {
+ Type string `json:"type"`
+ Tunnel TunnelConfig `json:"tunnel"`
+}
+
+// RegisterRespMessage response to register
+type RegisterRespMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+ Port int `json:"port"`
+}
+
+// UnregisterMessage removes a tunnel
+type UnregisterMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+}
+
+// UnregisterRespMessage response to unregister
+type UnregisterRespMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+ Success bool `json:"success"`
+}
+
+// UpdateTunnelMessage updates tunnel configuration (e.g. whitelist or name)
+type UpdateTunnelMessage struct {
+ Type string `json:"type"`
+ Tunnel TunnelConfig `json:"tunnel"`
+}
+
+// KillConnMessage requests server to terminate a specific client connection
+type KillConnMessage struct {
+ Type string `json:"type"`
+ ConnID string `json:"conn_id"`
+}
+
+// RevokeLeaseMessage removes a temporary (web-auth) whitelist lease; empty IP revokes all leases of the tunnel
+type RevokeLeaseMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+ IP string `json:"ip,omitempty"`
+}
+
+// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久
+const (
+ LeaseDuration1h = "1h"
+ LeaseDuration2h = "2h"
+ LeaseDuration8h = "8h"
+ LeaseDurationToday = "today" // 当天 23:59:59 前有效
+ LeaseDurationTunnel = "tunnel" // 通道开启期间有效
+)
+
+// 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:
+ return true
+ }
+ return false
+}
+
+// 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 小时"
+ }
+}
+
+// DynamicLease is a temporary whitelist entry granted through an invite link
+type DynamicLease struct {
+ IP string `json:"ip"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Name string `json:"name,omitempty"` // 申请人填写的名字
+ Invite string `json:"invite,omitempty"` // 来源邀请码
+ // Duration 授权时长;计时从该设备首次连接开始
+ Duration string `json:"duration,omitempty"`
+ // AwaitingConnect 为 true 表示已授权但尚未首次连接:计时未开始,ExpiresAt 是必须首次连接的截止时间
+ AwaitingConnect bool `json:"awaiting_connect,omitempty"`
+ StartedAt *time.Time `json:"started_at,omitempty"` // 首次连接(开始计时)的时间
+}
+
+// InviteUse records one device that used an invite
+type InviteUse struct {
+ Name string `json:"name"`
+ IP string `json:"ip"`
+ At time.Time `json:"at"`
+}
+
+// Invite is a shareable link that lets a limited number of devices request access
+type Invite struct {
+ Code string `json:"code"`
+ Note string `json:"note,omitempty"` // 管理员备注(发给谁)
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt time.Time `json:"expires_at"` // 链接可被打开的截止时间
+ MaxUses int `json:"max_uses"` // 0 表示不限设备数
+ // LeaseDuration 通过该邀请获得授权后可访问多久(每个邀请单独设置)
+ LeaseDuration string `json:"lease_duration,omitempty"`
+ // RequireApproval 通过该邀请提交的申请需管理员批准后才放通
+ RequireApproval bool `json:"require_approval,omitempty"`
+ Uses []InviteUse `json:"uses,omitempty"`
+}
+
+// AccessRequest is an access application on a tunnel that requires approval
+type AccessRequest struct {
+ ID string `json:"id"`
+ TunnelID string `json:"tunnel_id"`
+ Name string `json:"name"`
+ IP string `json:"ip"`
+ Invite string `json:"invite,omitempty"`
+ At time.Time `json:"at"`
+ Status string `json:"status"` // pending / approved / rejected
+}
+
+// CreateInviteMessage asks the server to create an invite link
+type CreateInviteMessage struct {
+ Type string `json:"type"`
+ RequestID string `json:"request_id"`
+ TunnelID string `json:"tunnel_id"`
+ Note string `json:"note,omitempty"`
+ ValidMinutes int `json:"valid_minutes"`
+ MaxUses int `json:"max_uses"`
+ // LeaseDuration 授权后可访问时长,见 LeaseDuration* 常量
+ LeaseDuration string `json:"lease_duration,omitempty"`
+ // RequireApproval 打开链接后需管理员批准才放通
+ RequireApproval bool `json:"require_approval,omitempty"`
+}
+
+// InviteRespMessage answers CreateInviteMessage
+type InviteRespMessage struct {
+ Type string `json:"type"`
+ RequestID string `json:"request_id"`
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+ Invite Invite `json:"invite"`
+}
+
+// RevokeInviteMessage invalidates an invite and the leases it granted
+type RevokeInviteMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+ Code string `json:"code"`
+}
+
+// DecideRequestMessage approves or rejects a pending access request
+type DecideRequestMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+ RequestID string `json:"request_id"`
+ Approve bool `json:"approve"`
+}
+
+// ActiveConnInfo details about currently connected client
+type ActiveConnInfo struct {
+ ConnID string `json:"conn_id"`
+ TunnelID string `json:"tunnel_id"`
+ ClientIP string `json:"client_ip"`
+ ConnectedAt time.Time `json:"connected_at"`
+ BytesIn int64 `json:"bytes_in"` // Upload from client to target
+ BytesOut int64 `json:"bytes_out"` // Download from target to client
+ RateIn int64 `json:"rate_in"` // Current bytes/sec
+ RateOut int64 `json:"rate_out"` // Current bytes/sec
+}
+
+// ClientHistoryRecord historical record of a client connection
+type ClientHistoryRecord struct {
+ ID string `json:"id"`
+ TunnelID string `json:"tunnel_id"`
+ TunnelName string `json:"tunnel_name"`
+ Port int `json:"port"`
+ ClientIP string `json:"client_ip"`
+ ConnectedAt time.Time `json:"connected_at"`
+ DisconnectedAt time.Time `json:"disconnected_at"`
+ DurationSec int64 `json:"duration_sec"`
+ BytesIn int64 `json:"bytes_in"`
+ BytesOut int64 `json:"bytes_out"`
+}
+
+// TunnelMetrics summary statistics for a tunnel
+type TunnelMetrics struct {
+ TunnelID string `json:"tunnel_id"`
+ ListenPort int `json:"listen_port"`
+ ActiveConns int `json:"active_conns"`
+ TotalConns int64 `json:"total_conns"`
+ BytesIn int64 `json:"bytes_in"` // 本次会话入流量
+ BytesOut int64 `json:"bytes_out"` // 本次会话出流量
+ RateIn int64 `json:"rate_in"`
+ RateOut int64 `json:"rate_out"`
+ BlockedAttemps int64 `json:"blocked_attempts"`
+ Status string `json:"status"` // "online", "error", "disabled"
+ Error string `json:"error,omitempty"`
+ DynamicWhitelist []string `json:"dynamic_whitelist,omitempty"`
+ DynamicLeases []DynamicLease `json:"dynamic_leases,omitempty"`
+ Invites []Invite `json:"invites,omitempty"`
+ Pending []AccessRequest `json:"pending,omitempty"`
+}
+
+// SecurityEvent records blocked or flagged connection attempts
+type SecurityEvent struct {
+ ID string `json:"id"`
+ TunnelID string `json:"tunnel_id"`
+ Port int `json:"port"`
+ ClientIP string `json:"client_ip"`
+ Timestamp time.Time `json:"timestamp"`
+ Reason string `json:"reason"`
+}
+
+// MetricsMessage pushed by server to agent periodically (e.g. every 1s)
+type MetricsMessage struct {
+ Type string `json:"type"`
+ Tunnels []TunnelMetrics `json:"tunnels"`
+ ActiveConns []ActiveConnInfo `json:"active_conns"`
+ SecurityEvents []SecurityEvent `json:"security_events,omitempty"`
+}
+
+// StreamHandshake sent as first line when a data stream is opened
+type StreamHandshake struct {
+ TunnelID string `json:"tunnel_id"`
+ ConnID string `json:"conn_id"`
+ ClientIP string `json:"client_ip"`
+}
diff --git a/core/traffic/meter.go b/core/traffic/meter.go
new file mode 100644
index 0000000..0a66a08
--- /dev/null
+++ b/core/traffic/meter.go
@@ -0,0 +1,210 @@
+package traffic
+
+import (
+ "io"
+ "net"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// Meter tracks bandwidth and total bytes
+type Meter struct {
+ bytesIn atomic.Int64 // Read from client (upload)
+ bytesOut atomic.Int64 // Written to client (download)
+ lastBytesIn int64
+ lastBytesOut int64
+ rateIn atomic.Int64 // Bytes per second
+ rateOut atomic.Int64 // Bytes per second
+ lastTick time.Time
+}
+
+// NewMeter creates a new meter
+func NewMeter() *Meter {
+ return &Meter{
+ lastTick: time.Now(),
+ }
+}
+
+// AddIn adds incoming bytes
+func (m *Meter) AddIn(n int64) {
+ m.bytesIn.Add(n)
+}
+
+// AddOut adds outgoing bytes
+func (m *Meter) AddOut(n int64) {
+ m.bytesOut.Add(n)
+}
+
+// Tick calculates current transfer rate
+func (m *Meter) Tick() {
+ now := time.Now()
+ elapsed := now.Sub(m.lastTick).Seconds()
+ if elapsed <= 0 {
+ elapsed = 1
+ }
+
+ currIn := m.bytesIn.Load()
+ currOut := m.bytesOut.Load()
+
+ diffIn := currIn - m.lastBytesIn
+ diffOut := currOut - m.lastBytesOut
+
+ m.rateIn.Store(int64(float64(diffIn) / elapsed))
+ m.rateOut.Store(int64(float64(diffOut) / elapsed))
+
+ m.lastBytesIn = currIn
+ m.lastBytesOut = currOut
+ m.lastTick = now
+}
+
+// Stats returns snapshot values
+func (m *Meter) Stats() (bytesIn, bytesOut, rateIn, rateOut int64) {
+ return m.bytesIn.Load(), m.bytesOut.Load(), m.rateIn.Load(), m.rateOut.Load()
+}
+
+// Conn wraps a net.Conn with traffic metering
+type Conn struct {
+ net.Conn
+ meter *Meter
+}
+
+// WrapConn wraps a net.Conn
+func WrapConn(c net.Conn, meter *Meter) *Conn {
+ return &Conn{
+ Conn: c,
+ meter: meter,
+ }
+}
+
+func (c *Conn) Read(b []byte) (n int, err error) {
+ n, err = c.Conn.Read(b)
+ if n > 0 {
+ c.meter.AddIn(int64(n))
+ }
+ return
+}
+
+func (c *Conn) Write(b []byte) (n int, err error) {
+ n, err = c.Conn.Write(b)
+ if n > 0 {
+ c.meter.AddOut(int64(n))
+ }
+ return
+}
+
+// Pipe copies data bidirectionally between two ReadWriteClosers with buffer pooling
+func Pipe(c1, c2 io.ReadWriteCloser) {
+ PipeWithLimiter(c1, c2, nil)
+}
+
+// Limiter implements a token-bucket rate limiter for bandwidth QoS.
+// 令牌可以透支,调用方在锁外按欠额休眠,多个连接共享同一个桶时不会互相串行阻塞。
+type Limiter struct {
+ mbPerSec float64
+ rate float64 // bytes per second
+ capacity float64 // burst capacity
+ tokens float64
+ lastUpdate time.Time
+ mu sync.Mutex
+}
+
+// NewLimiter creates a limiter with MB/s target
+func NewLimiter(mbPerSec float64) *Limiter {
+ if mbPerSec <= 0 {
+ return nil
+ }
+ bytesPerSec := mbPerSec * 1024 * 1024
+ return &Limiter{
+ mbPerSec: mbPerSec,
+ rate: bytesPerSec,
+ capacity: bytesPerSec * 2,
+ tokens: bytesPerSec,
+ lastUpdate: time.Now(),
+ }
+}
+
+// MBPerSec returns the configured limit (0 for a nil limiter)
+func (l *Limiter) MBPerSec() float64 {
+ if l == nil {
+ return 0
+ }
+ return l.mbPerSec
+}
+
+// reserve takes n tokens and returns how long the caller must wait before proceeding
+func (l *Limiter) reserve(n int) time.Duration {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ now := time.Now()
+ if elapsed := now.Sub(l.lastUpdate).Seconds(); elapsed > 0 {
+ l.tokens += elapsed * l.rate
+ if l.tokens > l.capacity {
+ l.tokens = l.capacity
+ }
+ l.lastUpdate = now
+ }
+
+ l.tokens -= float64(n)
+ if l.tokens >= 0 {
+ return 0
+ }
+ return time.Duration(-l.tokens / l.rate * float64(time.Second))
+}
+
+// Allow limits byte throughput
+func (l *Limiter) Allow(n int) {
+ if l == nil || n <= 0 {
+ return
+ }
+ if wait := l.reserve(n); wait > 0 {
+ time.Sleep(wait)
+ }
+}
+
+// LimitedReader wraps an io.Reader with a Limiter
+type LimitedReader struct {
+ R io.Reader
+ Limiter *Limiter
+}
+
+func (lr *LimitedReader) Read(p []byte) (n int, err error) {
+ n, err = lr.R.Read(p)
+ if n > 0 && lr.Limiter != nil {
+ lr.Limiter.Allow(n)
+ }
+ return
+}
+
+// PipeWithLimiter copies data bidirectionally between two ReadWriteClosers with optional rate limiting
+func PipeWithLimiter(c1, c2 io.ReadWriteCloser, limiter *Limiter) {
+ done := make(chan struct{}, 2)
+
+ go func() {
+ buf := make([]byte, 32*1024)
+ var r io.Reader = c2
+ if limiter != nil {
+ r = &LimitedReader{R: c2, Limiter: limiter}
+ }
+ _, _ = io.CopyBuffer(c1, r, buf)
+ _ = c1.Close()
+ _ = c2.Close()
+ done <- struct{}{}
+ }()
+
+ go func() {
+ buf := make([]byte, 32*1024)
+ var r io.Reader = c1
+ if limiter != nil {
+ r = &LimitedReader{R: c1, Limiter: limiter}
+ }
+ _, _ = io.CopyBuffer(c2, r, buf)
+ _ = c1.Close()
+ _ = c2.Close()
+ done <- struct{}{}
+ }()
+
+ <-done
+ <-done
+}
diff --git a/core/traffic/meter_test.go b/core/traffic/meter_test.go
new file mode 100644
index 0000000..7b259db
--- /dev/null
+++ b/core/traffic/meter_test.go
@@ -0,0 +1,36 @@
+package traffic
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestLimiterDoesNotSerializeCallers(t *testing.T) {
+ // 1 MB/s,初始令牌 1 MB;两个调用方各取 1 MB,总耗时应约 1 秒而不是被锁串行化
+ l := NewLimiter(1)
+ start := time.Now()
+ var wg sync.WaitGroup
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ l.Allow(1024 * 1024)
+ }()
+ }
+ wg.Wait()
+ if d := time.Since(start); d < 800*time.Millisecond || d > 1500*time.Millisecond {
+ t.Fatalf("expected ~1s total wait, got %v", d)
+ }
+}
+
+func TestNilLimiter(t *testing.T) {
+ var l *Limiter
+ l.Allow(1 << 20)
+ if l.MBPerSec() != 0 {
+ t.Fatal("nil limiter should report 0")
+ }
+ if NewLimiter(0) != nil {
+ t.Fatal("NewLimiter(0) should be nil (unlimited)")
+ }
+}
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 0000000..b800443
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,17 @@
+# 用法:PORTRELAY_TOKEN=$(openssl rand -hex 24) docker compose up -d
+services:
+ portrelay:
+ image: ghcr.io/deepfal/portrelay-server:latest
+ # 或者从源码构建:
+ # build: ..
+ # host 网络保留访问者的真实 IP(白名单依赖它),也免去映射 1000 个端口
+ network_mode: host
+ environment:
+ PORTRELAY_TOKEN: ${PORTRELAY_TOKEN:?请设置 PORTRELAY_TOKEN}
+ # command: ["-listen", ":33000", "-port-min", "33000", "-port-max", "34000", "-state", "/var/lib/portrelay/leases.json", "-public-host", "gw.example.com"]
+ volumes:
+ - portrelay-state:/var/lib/portrelay
+ restart: unless-stopped
+
+volumes:
+ portrelay-state:
diff --git a/deploy/install-server.sh b/deploy/install-server.sh
new file mode 100755
index 0000000..79aa26d
--- /dev/null
+++ b/deploy/install-server.sh
@@ -0,0 +1,49 @@
+#!/bin/sh
+# 在 Linux 网关上安装 / 升级 PortRelay 服务端(systemd)。
+#
+# sudo ./install-server.sh ./portrelay-server
+#
+# 首次安装会生成随机 token 写入 /etc/portrelay-server.env;升级时保留已有配置。
+set -eu
+
+BIN="${1:-./portrelay-server}"
+HERE="$(cd "$(dirname "$0")" && pwd)"
+ENV_FILE=/etc/portrelay-server.env
+UNIT=/etc/systemd/system/portrelay-server.service
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo "请用 root 运行:sudo $0 $*" >&2
+ exit 1
+fi
+if [ ! -x "$BIN" ]; then
+ echo "找不到可执行文件 $BIN" >&2
+ exit 1
+fi
+
+if [ -f /usr/local/bin/portrelay-server ]; then
+ cp /usr/local/bin/portrelay-server "/usr/local/bin/portrelay-server.bak-$(date +%Y%m%d-%H%M%S)"
+fi
+install -m 755 "$BIN" /usr/local/bin/portrelay-server
+
+if [ ! -f "$ENV_FILE" ]; then
+ if command -v openssl >/dev/null 2>&1; then
+ TOKEN="$(openssl rand -hex 24)"
+ else
+ TOKEN="$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"
+ fi
+ umask 077
+ sed "s/^PORTRELAY_TOKEN=.*/PORTRELAY_TOKEN=$TOKEN/" "$HERE/portrelay-server.env.example" > "$ENV_FILE"
+ chmod 600 "$ENV_FILE"
+ echo "已生成 token 并写入 $ENV_FILE"
+fi
+
+install -m 644 "$HERE/portrelay-server.service" "$UNIT"
+systemctl daemon-reload
+systemctl enable portrelay-server >/dev/null
+systemctl restart portrelay-server
+sleep 1
+systemctl --no-pager --lines=5 status portrelay-server || true
+
+echo
+echo "完成。查看 token:sudo grep PORTRELAY_TOKEN $ENV_FILE"
+echo "防火墙需放通 TCP 33000-34000(控制端口 + 通道端口池)"
diff --git a/deploy/portrelay-server.env.example b/deploy/portrelay-server.env.example
new file mode 100644
index 0000000..2114ac3
--- /dev/null
+++ b/deploy/portrelay-server.env.example
@@ -0,0 +1,10 @@
+# 复制为 /etc/portrelay-server.env 并设为 0600:
+# sudo install -m 600 deploy/portrelay-server.env.example /etc/portrelay-server.env
+#
+# Agent 与网关之间的共享密钥(必填)。生成方式:openssl rand -hex 24
+PORTRELAY_TOKEN=change-me-to-a-long-random-string
+
+# 额外的启动参数(可选),例如:
+# -public-host gw.example.com 授权页上展示给访问者的地址(默认取访问者打开链接时用的地址)
+# -trust-proxy 网关前面有可信反向代理时才开启
+PORTRELAY_ARGS=
diff --git a/deploy/portrelay-server.service b/deploy/portrelay-server.service
new file mode 100644
index 0000000..8f02f3d
--- /dev/null
+++ b/deploy/portrelay-server.service
@@ -0,0 +1,30 @@
+[Unit]
+Description=PortRelay edge gateway
+Documentation=https://github.com/DeepFal/PortRelay
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+# PORTRELAY_TOKEN 等配置写在这里(权限 0600),参考 portrelay-server.env.example
+EnvironmentFile=/etc/portrelay-server.env
+ExecStart=/usr/local/bin/portrelay-server -listen :33000 -port-min 33000 -port-max 34000 -state /var/lib/portrelay/leases.json $PORTRELAY_ARGS
+Restart=always
+RestartSec=3
+LimitNOFILE=65536
+
+# 以临时系统用户运行,状态保存在 /var/lib/portrelay
+DynamicUser=yes
+StateDirectory=portrelay
+NoNewPrivileges=yes
+ProtectSystem=strict
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectControlGroups=yes
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+LockPersonality=yes
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/images/app-access.png b/docs/images/app-access.png
new file mode 100644
index 0000000..9a5a6fa
Binary files /dev/null and b/docs/images/app-access.png differ
diff --git a/docs/images/app-clients.png b/docs/images/app-clients.png
new file mode 100644
index 0000000..5861b60
Binary files /dev/null and b/docs/images/app-clients.png differ
diff --git a/docs/images/app-edit.png b/docs/images/app-edit.png
new file mode 100644
index 0000000..eeebb51
Binary files /dev/null and b/docs/images/app-edit.png differ
diff --git a/docs/images/app-invite.png b/docs/images/app-invite.png
new file mode 100644
index 0000000..a0c2f34
Binary files /dev/null and b/docs/images/app-invite.png differ
diff --git a/docs/images/app-main-dark.png b/docs/images/app-main-dark.png
new file mode 100644
index 0000000..e957d15
Binary files /dev/null and b/docs/images/app-main-dark.png differ
diff --git a/docs/images/app-main-light.png b/docs/images/app-main-light.png
new file mode 100644
index 0000000..5a4c36d
Binary files /dev/null and b/docs/images/app-main-light.png differ
diff --git a/docs/images/architecture.svg b/docs/images/architecture.svg
new file mode 100644
index 0000000..114cc4f
--- /dev/null
+++ b/docs/images/architecture.svg
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+ 访问者所在网络 · 办公网 / 局域网 / 公网
+
+ 目标内网
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 张三的电脑
+ 192.168.1.21 已授权
+
+
+
+ 手机扫码申请
+ /i/k7m2xq9fpa
+
+
+
+ 陌生设备
+ 未授权 · 直接拦截
+
+
+
+ 边缘网关
+ portrelay-server
+
+ :33000 控制 · 授权页
+
+
+
+ :33660 → MySQL
+
+ :33661 → Grafana
+
+ :33662 → SSH
+
+ 白名单 · 授权租约
+
+
+
+
+
+
+
+ Agent 主动外连
+ 任意连通方式
+ 局域网 · VPN · Tailscale
+ ZeroTier · WireGuard · 公网
+
+
+
+
+
+
+ Agent
+ macOS App
+ Linux / Windows
+ 命令行同样可用
+
+
+
+ MySQL
+ 10.0.1.16:3306
+
+ Grafana
+ 10.0.2.30:3000
+
+ SSH
+ 10.0.8.3:22
+
+
diff --git a/docs/images/auth-form.png b/docs/images/auth-form.png
new file mode 100644
index 0000000..4a3e3f8
Binary files /dev/null and b/docs/images/auth-form.png differ
diff --git a/docs/images/auth-ok.png b/docs/images/auth-ok.png
new file mode 100644
index 0000000..89ab2a5
Binary files /dev/null and b/docs/images/auth-ok.png differ
diff --git a/docs/images/auth-pending.png b/docs/images/auth-pending.png
new file mode 100644
index 0000000..a2eec5f
Binary files /dev/null and b/docs/images/auth-pending.png differ
diff --git a/docs/images/invite-flow.svg b/docs/images/invite-flow.svg
new file mode 100644
index 0000000..2cbbc5d
--- /dev/null
+++ b/docs/images/invite-flow.svg
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+ 1
+
+
+ 1
+
+ 生成邀请链接
+ 有效期 · 设备数
+ 访问时长 · 是否审批
+
+ 2
+
+
+ 2
+
+ 同事打开链接
+ 填写姓名即可
+ 手机扫码也可以
+
+ 3
+
+
+ 3
+
+ 管理员审批
+ App 弹出通知提醒
+ 一键批准或拒绝
+ 可选
+
+ 4
+
+
+ 4
+
+ 首次连接起计时
+ 2 小时 / 8 小时 / 当天
+ 或直到通道关闭
+
+ 5
+
+
+ 5
+
+ 到期自动断开
+ 已建立的连接一并断开
+ 闲置的通道自动关闭
+
diff --git a/docs/images/live-traffic.gif b/docs/images/live-traffic.gif
new file mode 100644
index 0000000..624d9cd
Binary files /dev/null and b/docs/images/live-traffic.gif differ
diff --git a/docs/images/web-panel.png b/docs/images/web-panel.png
new file mode 100644
index 0000000..9fef9ae
Binary files /dev/null and b/docs/images/web-panel.png differ
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..46d54ee
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,8 @@
+module github.com/DeepFal/PortRelay
+
+go 1.23.12
+
+require (
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/hashicorp/yamux v0.1.2 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..dd53763
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,4 @@
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
+github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
diff --git a/mac-native/AppIcon.icns b/mac-native/AppIcon.icns
new file mode 100644
index 0000000..c449343
Binary files /dev/null and b/mac-native/AppIcon.icns differ
diff --git a/mac-native/AppIcon.iconset/icon_128x128.png b/mac-native/AppIcon.iconset/icon_128x128.png
new file mode 100644
index 0000000..270e2c6
Binary files /dev/null and b/mac-native/AppIcon.iconset/icon_128x128.png differ
diff --git a/mac-native/AppIcon.iconset/icon_128x128@2x.png b/mac-native/AppIcon.iconset/icon_128x128@2x.png
new file mode 100644
index 0000000..d1c82f1
Binary files /dev/null and b/mac-native/AppIcon.iconset/icon_128x128@2x.png differ
diff --git a/mac-native/AppIcon.iconset/icon_16x16.png b/mac-native/AppIcon.iconset/icon_16x16.png
new file mode 100644
index 0000000..3d6d65e
Binary files /dev/null and b/mac-native/AppIcon.iconset/icon_16x16.png differ
diff --git a/mac-native/AppIcon.iconset/icon_16x16@2x.png b/mac-native/AppIcon.iconset/icon_16x16@2x.png
new file mode 100644
index 0000000..93d7f5e
Binary files /dev/null and b/mac-native/AppIcon.iconset/icon_16x16@2x.png differ
diff --git a/mac-native/AppIcon.iconset/icon_256x256.png b/mac-native/AppIcon.iconset/icon_256x256.png
new file mode 100644
index 0000000..d1c82f1
Binary files /dev/null and b/mac-native/AppIcon.iconset/icon_256x256.png differ
diff --git a/mac-native/AppIcon.iconset/icon_256x256@2x.png b/mac-native/AppIcon.iconset/icon_256x256@2x.png
new file mode 100644
index 0000000..4cafd72
Binary files /dev/null and b/mac-native/AppIcon.iconset/icon_256x256@2x.png differ
diff --git a/mac-native/AppIcon.iconset/icon_32x32.png b/mac-native/AppIcon.iconset/icon_32x32.png
new file mode 100644
index 0000000..93d7f5e
Binary files /dev/null and b/mac-native/AppIcon.iconset/icon_32x32.png differ
diff --git a/mac-native/AppIcon.iconset/icon_32x32@2x.png b/mac-native/AppIcon.iconset/icon_32x32@2x.png
new file mode 100644
index 0000000..69c04da
Binary files /dev/null and b/mac-native/AppIcon.iconset/icon_32x32@2x.png differ
diff --git a/mac-native/AppIcon.iconset/icon_512x512.png b/mac-native/AppIcon.iconset/icon_512x512.png
new file mode 100644
index 0000000..4cafd72
Binary files /dev/null and b/mac-native/AppIcon.iconset/icon_512x512.png differ
diff --git a/mac-native/AppIcon.iconset/icon_512x512@2x.png b/mac-native/AppIcon.iconset/icon_512x512@2x.png
new file mode 100644
index 0000000..c5b88bc
Binary files /dev/null and b/mac-native/AppIcon.iconset/icon_512x512@2x.png differ
diff --git a/mac-native/PortRelayApp.swift b/mac-native/PortRelayApp.swift
new file mode 100644
index 0000000..19bf4f3
--- /dev/null
+++ b/mac-native/PortRelayApp.swift
@@ -0,0 +1,3426 @@
+import SwiftUI
+import AppKit
+import CoreImage.CIFilterBuiltins
+import Charts
+import UserNotifications
+
+// MARK: - Models
+
+struct TunnelItem: Identifiable, Codable {
+ var id: String
+ var name: String
+ var target: String
+ var listen_port: Int
+ var whitelist: [String]
+ var enabled: Bool
+ var owner: String?
+ var duration_minutes: Int?
+ var started_at: String?
+ var expires_at: String?
+ var historical_bytes_in: Int64?
+ var historical_bytes_out: Int64?
+ var last_duration_sec: Int64?
+ var max_bandwidth_mb: Double?
+ var auth_code: String?
+ var notes: String?
+ var metrics: TunnelMetrics?
+ var idle_close_minutes: Int?
+ var effective_idle_close_minutes: Int?
+ var close_reason: String?
+ var first_connected_at: String?
+ var connected_sec: Int64?
+ var last_connected_sec: Int64?
+ var activity: TunnelActivityItem?
+ var rate_history: RateSeriesItem?
+}
+
+/// 近 10 分钟流量曲线:固定 120 个点(5 秒一个),最后一点为当前
+struct RateSeriesItem: Codable {
+ var end: Int64
+ var step: Int
+ var up: [Int64]
+ var down: [Int64]
+
+ var peak: Int64 { max(up.max() ?? 0, down.max() ?? 0) }
+}
+
+/// 通道当前使用情况:是否在用、闲置起点、预计自动关闭时间、实际连接时长
+struct TunnelActivityItem: Codable {
+ var in_use: Bool
+ var idle_since: String?
+ var auto_close_at: String?
+ var connected_sec: Int64
+}
+
+struct TunnelMetrics: Codable {
+ var tunnel_id: String?
+ var listen_port: Int?
+ var active_conns: Int?
+ var total_conns: Int64?
+ var bytes_in: Int64?
+ var bytes_out: Int64?
+ var rate_in: Int64?
+ var rate_out: Int64?
+ var blocked_attempts: Int64?
+ var status: String?
+ var error: String?
+ var dynamic_whitelist: [String]?
+ var dynamic_leases: [DynamicLeaseItem]?
+ var invites: [InviteItem]?
+ var pending: [AccessRequestItem]?
+}
+
+struct DynamicLeaseItem: Identifiable, Codable {
+ var id: String { ip }
+ var ip: String
+ var expires_at: String
+ var name: String?
+ var invite: String?
+ var duration: String?
+ /// 已授权但尚未首次连接:计时未开始,expires_at 为首次连接截止时间
+ var awaiting_connect: Bool?
+ var started_at: String?
+}
+
+struct InviteUseItem: Codable, Hashable {
+ var name: String
+ var ip: String
+ var at: String
+}
+
+struct InviteItem: Identifiable, Codable {
+ var id: String { code }
+ var code: String
+ var note: String?
+ var created_at: String
+ var expires_at: String
+ var max_uses: Int
+ /// 通过该邀请授权后可访问多久(每个邀请单独设置)
+ var lease_duration: String?
+ /// 通过该邀请提交的申请是否需要审批
+ var require_approval: Bool?
+ var uses: [InviteUseItem]?
+}
+
+struct AccessRequestItem: Identifiable, Codable {
+ var id: String
+ var tunnel_id: String
+ var name: String
+ var ip: String
+ var invite: String?
+ var at: String
+ var status: String
+}
+
+struct InviteShareResp: Codable {
+ var invite: InviteItem
+ var link: String
+ var share_text: String
+}
+
+struct ConnectionsResp: Codable {
+ var active_conns: [ActiveClientInfo]
+ var security_events: [SecurityEventItem]
+}
+
+struct ActiveClientInfo: Identifiable, Codable {
+ var id: String { conn_id }
+ var conn_id: String
+ var tunnel_id: String
+ var client_ip: String
+ var connected_at: String
+ var bytes_in: Int64
+ var bytes_out: Int64
+ var rate_in: Int64
+ var rate_out: Int64
+}
+
+struct ClientHistoryRecord: Identifiable, Codable {
+ var id: String
+ var tunnel_id: String
+ var tunnel_name: String
+ var port: Int
+ var client_ip: String
+ var connected_at: String
+ var disconnected_at: String
+ var duration_sec: Int64
+ var bytes_in: Int64
+ var bytes_out: Int64
+}
+
+struct SecurityEventItem: Identifiable, Codable {
+ var id: String
+ var tunnel_id: String
+ var port: Int
+ var client_ip: String
+ var timestamp: String
+ var reason: String
+}
+
+struct ServerStatusResp: Codable {
+ var connected: Bool
+ var server_addr: String
+ var port_range: String
+ var public_host: String?
+ var last_error: String?
+ var invite_base: String?
+ var total_rate_history: RateSeriesItem?
+}
+
+struct ProbeResp: Codable {
+ var target: String
+ var reachable: Bool
+ var rtt: String
+ var error: String?
+}
+
+struct RecommendPortResp: Codable {
+ var recommended_port: Int
+}
+
+struct SettingsResp: Codable {
+ var server_addr: String
+ var token: String
+ var public_host: String?
+}
+
+// Diagnostic Item Model
+enum DiagnosticStatus {
+ case ok
+ case warning
+ case error
+ case checking
+}
+
+struct DiagnosticItem: Identifiable {
+ let id = UUID()
+ let title: String
+ let desc: String
+ let status: DiagnosticStatus
+ let statusText: String
+ let actionTitle: String?
+ let action: (() -> Void)?
+}
+
+// MARK: - Helper Formatting
+
+func formatBytes(_ bytes: Int64) -> String {
+ if bytes <= 0 { return "0 B" }
+ let d = Double(bytes)
+ let k = 1024.0
+ let sizes = ["B", "KB", "MB", "GB", "TB"]
+ let i = Int(log(d) / log(k))
+ let clampedI = min(i, sizes.count - 1)
+ let val = d / pow(k, Double(clampedI))
+ return String(format: "%.1f %@", val, sizes[clampedI])
+}
+
+func formatRate(_ bytesPerSec: Int64) -> String {
+ return "\(formatBytes(bytesPerSec))/s"
+}
+
+func parseISO8601(_ str: String?) -> Date? {
+ guard let s = str, !s.isEmpty else { return nil }
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let d = formatter.date(from: s) { return d }
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.date(from: s)
+}
+
+func formatRemainingTime(expiresAt: String?) -> (text: String, isExpiringSoon: Bool) {
+ guard let exp = parseISO8601(expiresAt) else {
+ return ("持续开启 (无限制)", false)
+ }
+ let diff = exp.timeIntervalSince(Date())
+ if diff <= 0 {
+ return ("已自动到期停止", true)
+ }
+ let totalSec = Int(diff)
+ let hours = totalSec / 3600
+ let mins = (totalSec % 3600) / 60
+ let secs = totalSec % 60
+ let isSoon = diff < 300 // less than 5 min
+ if hours > 0 {
+ return (String(format: "剩余 %02d:%02d:%02d", hours, mins, secs), isSoon)
+ } else {
+ return (String(format: "剩余 %02d:%02d", mins, secs), isSoon)
+ }
+}
+
+func formatElapsedTime(startedAt: String?) -> String {
+ guard let start = parseISO8601(startedAt) else { return "-" }
+ let diff = Date().timeIntervalSince(start)
+ if diff < 0 { return "00:00" }
+ let totalSec = Int(diff)
+ let hours = totalSec / 3600
+ let mins = (totalSec % 3600) / 60
+ let secs = totalSec % 60
+ if hours > 0 {
+ return String(format: "%02d:%02d:%02d", hours, mins, secs)
+ } else {
+ return String(format: "%02d:%02d", mins, secs)
+ }
+}
+
+func formatSecondsToDuration(_ totalSec: Int64) -> String {
+ if totalSec <= 0 { return "0秒" }
+ let hours = totalSec / 3600
+ let mins = (totalSec % 3600) / 60
+ let secs = totalSec % 60
+ if hours > 0 {
+ return "\(hours)小时\(mins)分"
+ } else if mins > 0 {
+ return "\(mins)分\(secs)秒"
+ } else {
+ return "\(secs)秒"
+ }
+}
+
+/// 生成二维码图片
+func makeQRCode(_ text: String, size: CGFloat = 180) -> NSImage? {
+ let filter = CIFilter.qrCodeGenerator()
+ filter.message = Data(text.utf8)
+ filter.correctionLevel = "M"
+ guard let output = filter.outputImage else { return nil }
+ let scale = size / output.extent.width
+ let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
+ let rep = NSCIImageRep(ciImage: scaled)
+ let image = NSImage(size: rep.size)
+ image.addRepresentation(rep)
+ return image
+}
+
+let leaseDurationOptions: [(value: String, title: String)] = [
+ ("1h", "1 小时"), ("2h", "2 小时"), ("8h", "8 小时"), ("today", "当天有效"), ("tunnel", "通道开启期间")
+]
+
+func leaseDurationTitle(_ v: String?) -> String {
+ leaseDurationOptions.first { $0.value == (v ?? "2h") }?.title ?? "2 小时"
+}
+
+func copyToPasteboard(_ text: String) {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+}
+
+// MARK: - Shared UI Components
+
+/// 列表行统一卡片样式(tint 用于需要强调的行,如待审批)
+struct RowCard: ViewModifier {
+ var tint: Color? = nil
+ func body(content: Content) -> some View {
+ content
+ .background(RoundedRectangle(cornerRadius: 8).fill(tint.map { $0.opacity(0.08) } ?? Color.primary.opacity(0.035)))
+ .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(tint.map { $0.opacity(0.35) } ?? Color.primary.opacity(0.08), lineWidth: 1))
+ }
+}
+
+extension View {
+ func rowCard(tint: Color? = nil) -> some View { modifier(RowCard(tint: tint)) }
+}
+
+/// 流式布局:一行放不下时自动换行(时间口径与状态标签共用一行,空间不够再折行)
+struct FlowLayout: Layout {
+ var spacing: CGFloat = 14
+ var lineSpacing: CGFloat = 6
+
+ private func rows(_ subviews: Subviews, maxWidth: CGFloat) -> [[(Int, CGSize)]] {
+ var rows: [[(Int, CGSize)]] = [[]]
+ var x: CGFloat = 0
+ for (i, v) in subviews.enumerated() {
+ let size = v.sizeThatFits(.unspecified)
+ let needed = rows[rows.count - 1].isEmpty ? size.width : x + spacing + size.width
+ if !rows[rows.count - 1].isEmpty && needed > maxWidth {
+ rows.append([])
+ x = size.width
+ } else {
+ x = needed
+ }
+ rows[rows.count - 1].append((i, size))
+ }
+ return rows
+ }
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let maxWidth = proposal.width ?? .infinity
+ let rs = rows(subviews, maxWidth: maxWidth)
+ var width: CGFloat = 0
+ var height: CGFloat = 0
+ for (n, row) in rs.enumerated() where !row.isEmpty {
+ let w = row.map { $0.1.width }.reduce(0, +) + spacing * CGFloat(row.count - 1)
+ width = max(width, w)
+ height += (row.map { $0.1.height }.max() ?? 0) + (n > 0 ? lineSpacing : 0)
+ }
+ return CGSize(width: proposal.width ?? width, height: height)
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ var y = bounds.minY
+ for row in rows(subviews, maxWidth: bounds.width) where !row.isEmpty {
+ let h = row.map { $0.1.height }.max() ?? 0
+ var x = bounds.minX
+ for (i, size) in row {
+ subviews[i].place(at: CGPoint(x: x, y: y + (h - size.height) / 2), proposal: ProposedViewSize(size))
+ x += size.width + spacing
+ }
+ y += h + lineSpacing
+ }
+ }
+}
+
+let downColor = Color.blue
+let upColor = Color.teal
+
+/// 流量曲线(面积 + 平滑线),纵轴随峰值自动缩放
+struct RateSparkline: View {
+ let series: RateSeriesItem?
+ var height: CGFloat = 40
+ var showsArea: Bool = true
+
+ struct Point: Identifiable {
+ let id: String
+ let i: Int
+ let v: Double
+ let dir: String
+ }
+
+ var points: [Point] {
+ guard let s = series else { return [] }
+ var out: [Point] = []
+ for (i, v) in s.down.enumerated() { out.append(Point(id: "d\(i)", i: i, v: Double(v), dir: "down")) }
+ for (i, v) in s.up.enumerated() { out.append(Point(id: "u\(i)", i: i, v: Double(v), dir: "up")) }
+ return out
+ }
+
+ var body: some View {
+ let peak = Double(series?.peak ?? 0)
+ let count = max((series?.down.count ?? 120) - 1, 1)
+ Chart(points) { p in
+ if showsArea {
+ AreaMark(x: .value("t", p.i), y: .value("rate", p.v), series: .value("dir", p.dir), stacking: .unstacked)
+ .interpolationMethod(.monotone)
+ .foregroundStyle(LinearGradient(
+ colors: [(p.dir == "down" ? downColor : upColor).opacity(0.30), (p.dir == "down" ? downColor : upColor).opacity(0.02)],
+ startPoint: .top, endPoint: .bottom))
+ }
+ LineMark(x: .value("t", p.i), y: .value("rate", p.v), series: .value("dir", p.dir))
+ .interpolationMethod(.monotone)
+ .lineStyle(StrokeStyle(lineWidth: 1.4, lineCap: .round, lineJoin: .round))
+ .foregroundStyle(p.dir == "down" ? downColor : upColor)
+ }
+ .chartXScale(domain: 0...count)
+ .chartYScale(domain: 0...max(peak * 1.18, 1024))
+ .chartXAxis(.hidden)
+ .chartYAxis(.hidden)
+ .chartLegend(.hidden)
+ .frame(height: height)
+ }
+}
+
+/// 当前速率图例:● ↓ 下行 ● ↑ 上行
+struct RateLegend: View {
+ let down: Int64
+ let up: Int64
+
+ var body: some View {
+ HStack(spacing: 10) {
+ item(downColor, "↓ \(formatRate(down))")
+ item(upColor, "↑ \(formatRate(up))")
+ }
+ .lineLimit(1)
+ }
+
+ func item(_ color: Color, _ text: String) -> some View {
+ HStack(spacing: 4) {
+ Circle().fill(color).frame(width: 6, height: 6)
+ Text(verbatim: text)
+ .font(.system(size: 11, weight: .medium, design: .monospaced))
+ }
+ }
+}
+
+/// 卡片内的流量曲线:不加背景,直接融入卡片;底部一条淡基线 + 时间范围与峰值
+struct InlineTrafficChart: View {
+ let series: RateSeriesItem?
+
+ var body: some View {
+ let peak = series?.peak ?? 0
+ VStack(spacing: 3) {
+ ZStack(alignment: .bottom) {
+ Rectangle()
+ .fill(Color.primary.opacity(0.10))
+ .frame(height: 1)
+ if peak > 0 {
+ RateSparkline(series: series, height: 44)
+ } else {
+ Text("近 10 分钟无流量")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ .frame(maxHeight: .infinity)
+ }
+ }
+ .frame(height: 44)
+ HStack {
+ Text("近 10 分钟")
+ Spacer(minLength: 0)
+ if peak > 0 {
+ Text(verbatim: "峰值 \(formatRate(peak))")
+ }
+ }
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ .monospacedDigit()
+ .lineLimit(1)
+ }
+ }
+}
+
+struct CardWidthKey: PreferenceKey {
+ static var defaultValue: CGFloat = 0
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
+}
+
+/// 小号胶囊标签
+struct TagView: View {
+ let text: String
+ var color: Color = .secondary
+
+ init(_ text: String, color: Color = .secondary) {
+ self.text = text
+ self.color = color
+ }
+
+ var body: some View {
+ Text(verbatim: text)
+ .font(.system(size: 10, weight: .medium))
+ .lineLimit(1)
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1.5)
+ .background(color.opacity(0.13))
+ .foregroundColor(color == .secondary ? .primary.opacity(0.75) : color)
+ .cornerRadius(4)
+ }
+}
+
+/// 上下行速率:固定宽度 + 等宽数字,避免数值变化时挤压左侧内容导致换行或跳动
+struct RateText: View {
+ let up: Int64
+ let down: Int64
+ var size: CGFloat = 11
+
+ var body: some View {
+ Text(verbatim: "↑ \(formatRate(up)) ↓ \(formatRate(down))")
+ .font(.system(size: size, design: .monospaced))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ .frame(width: size * 18.5, alignment: .trailing)
+ }
+}
+
+/// 统一的选项按钮(开启时长、限速、授权时长、邀请选项)
+struct ChipLabel: View {
+ let title: String
+ let selected: Bool
+ var tint: Color = .accentColor
+
+ var body: some View {
+ Text(verbatim: title)
+ .font(.system(size: 11, weight: selected ? .semibold : .regular))
+ .lineLimit(1)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .background(selected ? tint.opacity(0.15) : Color(NSColor.controlBackgroundColor))
+ .foregroundColor(selected ? tint : .primary)
+ .cornerRadius(4)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(selected ? tint.opacity(0.8) : Color(NSColor.separatorColor), lineWidth: 1)
+ )
+ }
+}
+
+struct StringChip: View {
+ let title: String
+ let value: String
+ @Binding var selection: String
+
+ var body: some View {
+ Button(action: { selection = value }) {
+ ChipLabel(title: title, selected: selection == value)
+ }
+ .buttonStyle(.borderless)
+ }
+}
+
+// MARK: - Tunnel Search
+
+/// 通道搜索:多个关键字(空格分隔)需全部命中;不区分大小写;中文支持全拼与拼音首字母
+@MainActor
+enum TunnelSearch {
+ private static var pinyinCache: [String: (full: String, initials: String)] = [:]
+
+ /// "MySQL 数据库" -> ("mysqlshujuku", "msjk")
+ static func pinyin(_ text: String) -> (full: String, initials: String) {
+ if let hit = pinyinCache[text] { return hit }
+ let latin = (text.applyingTransform(.toLatin, reverse: false) ?? text)
+ .applyingTransform(.stripDiacritics, reverse: false)?.lowercased() ?? text.lowercased()
+ let words = latin.split(whereSeparator: { !$0.isLetter && !$0.isNumber })
+ let result = (words.joined(), String(words.compactMap { $0.first }))
+ if pinyinCache.count > 2000 { pinyinCache.removeAll() }
+ pinyinCache[text] = result
+ return result
+ }
+
+ static func fields(_ t: TunnelItem, publicHost: String) -> [String] {
+ var f: [String] = [t.name, t.target, "\(t.listen_port)", "\(publicHost):\(t.listen_port)",
+ t.owner ?? "", t.notes ?? "", t.whitelist.joined(separator: " ")]
+ f.append(t.enabled ? "运行中 开启" : "已暂停 关闭")
+ f.append(t.whitelist.isEmpty ? "仅授权链接" : (t.whitelist.contains("*") ? "全部放通" : "固定ip 白名单"))
+ f += (t.metrics?.dynamic_leases ?? []).compactMap { $0.name }
+ f += (t.metrics?.dynamic_leases ?? []).map { $0.ip }
+ f += (t.metrics?.invites ?? []).compactMap { $0.note }
+ return f.filter { !$0.isEmpty }
+ }
+
+ static func matches(_ t: TunnelItem, query: String, publicHost: String) -> Bool {
+ let tokens = query.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init)
+ if tokens.isEmpty { return true }
+ let fs = fields(t, publicHost: publicHost)
+ let plain = fs.joined(separator: " ").lowercased()
+ // 拼音只对可能含中文的字段计算(名称、责任人、备注、授权人姓名)
+ let py = ([t.name, t.owner ?? "", t.notes ?? ""] + (t.metrics?.dynamic_leases ?? []).compactMap { $0.name })
+ .filter { !$0.isEmpty }
+ .map { pinyin($0) }
+ return tokens.allSatisfy { tok in
+ plain.contains(tok) || py.contains { $0.full.contains(tok) || $0.initials.contains(tok) }
+ }
+ }
+}
+
+enum TunnelStatusFilter: String, CaseIterable {
+ case all = "全部", running = "运行中", paused = "已暂停"
+
+ func accepts(_ t: TunnelItem) -> Bool {
+ switch self {
+ case .all: return true
+ case .running: return t.enabled
+ case .paused: return !t.enabled
+ }
+ }
+}
+
+/// 从 "ip:port" / "[ipv6]:port" 中取出 IP
+func hostOf(_ addr: String) -> String {
+ if addr.hasPrefix("["), let end = addr.firstIndex(of: "]") {
+ return String(addr[addr.index(after: addr.startIndex).. String {
+ guard let idx = addr.lastIndex(of: ":"), addr.filter({ $0 == ":" }).count == 1 || addr.hasPrefix("[") else { return "" }
+ return String(addr[addr.index(after: idx)...])
+}
+
+/// 同一 IP 的在线连接合并展示
+struct ClientIPGroup: Identifiable {
+ let ip: String
+ let conns: [ActiveClientInfo]
+ var id: String { ip }
+ var bytesIn: Int64 { conns.reduce(0) { $0 + $1.bytes_in } }
+ var bytesOut: Int64 { conns.reduce(0) { $0 + $1.bytes_out } }
+ var rateIn: Int64 { conns.reduce(0) { $0 + $1.rate_in } }
+ var rateOut: Int64 { conns.reduce(0) { $0 + $1.rate_out } }
+ var firstConnectedAt: String? { conns.map { $0.connected_at }.min() }
+}
+
+/// 距现在的秒数(正数表示过去)
+func secondsSince(_ str: String?) -> Int64 {
+ guard let d = parseISO8601(str) else { return 0 }
+ return Int64(Date().timeIntervalSince(d))
+}
+
+let idleCloseOptions: [(Int, String)] = [(15, "15 分钟"), (30, "30 分钟"), (60, "1 小时"), (120, "2 小时"), (0, "不自动关闭")]
+
+func idleCloseTitle(_ minutes: Int) -> String {
+ idleCloseOptions.first { $0.0 == minutes }?.1 ?? "\(minutes) 分钟"
+}
+
+/// 今天显示 HH:mm:ss,否则显示 MM-dd HH:mm
+func formatDateTimeShort(_ str: String?) -> String {
+ guard let d = parseISO8601(str) else { return "-" }
+ let df = DateFormatter()
+ df.dateFormat = Calendar.current.isDateInToday(d) ? "HH:mm:ss" : "MM-dd HH:mm"
+ return df.string(from: d)
+}
+
+/// 同一 IP 的历史连接合并展示
+struct HistoryIPGroup: Identifiable {
+ let ip: String
+ let records: [ClientHistoryRecord] // 按接入时间倒序
+ var id: String { ip }
+ var bytesIn: Int64 { records.reduce(0) { $0 + $1.bytes_in } }
+ var bytesOut: Int64 { records.reduce(0) { $0 + $1.bytes_out } }
+ var totalDuration: Int64 { records.reduce(0) { $0 + $1.duration_sec } }
+ var lastSeen: String? { records.map { $0.disconnected_at }.max() }
+ var firstSeen: String? { records.map { $0.connected_at }.min() }
+}
+
+func formatTimeHHmm(_ str: String?) -> String {
+ guard let d = parseISO8601(str) else { return "-" }
+ let df = DateFormatter()
+ df.dateFormat = "HH:mm:ss"
+ return df.string(from: d)
+}
+
+// MARK: - ViewModel
+
+@MainActor
+class PortRelayViewModel: ObservableObject {
+ static let shared = PortRelayViewModel()
+ weak var appDelegate: AppDelegate?
+
+ @Published var isConnected: Bool = false
+ @Published var agentReachable: Bool = true
+ @Published var lastError: String = ""
+ @Published var serverAddr: String = ""
+ /// 访问者连接通道时使用的网关地址(设置中可修改)
+ @Published var publicHost: String = ""
+ /// 邀请链接前缀 http://<对外访问地址>:<控制端口>/i/
+ @Published var inviteBase: String = ""
+ /// 所有通道合计的近 10 分钟流量曲线
+ @Published var totalRateHistory: RateSeriesItem? = nil
+ /// 已提醒过的审批申请,避免重复通知
+ private var notifiedRequests: Set = []
+
+ var pendingRequests: [(tunnel: TunnelItem, request: AccessRequestItem)] {
+ tunnels.flatMap { t in (t.metrics?.pending ?? []).map { (t, $0) } }
+ }
+
+ /// 服务器控制端口(网页授权链接使用),取自控制地址
+ var controlPort: String {
+ if let idx = serverAddr.lastIndex(of: ":") {
+ let port = serverAddr[serverAddr.index(after: idx)...]
+ if !port.isEmpty { return String(port) }
+ }
+ return "33000"
+ }
+ @Published var tunnels: [TunnelItem] = []
+ @Published var activeConns: [ActiveClientInfo] = []
+ @Published var securityEvents: [SecurityEventItem] = []
+
+ @Published var totalActiveConns: Int = 0
+ @Published var totalHistoricalConns: Int64 = 0
+ @Published var totalRateIn: Int64 = 0
+ @Published var totalRateOut: Int64 = 0
+ @Published var totalTransferred: Int64 = 0
+ @Published var totalBlockedAttempts: Int64 = 0
+
+ // Diagnostics
+ @Published var diagnostics: [DiagnosticItem] = []
+ @Published var isRunningDiagnostics: Bool = false
+
+ private var timer: Timer?
+ private var agentProcess: Process?
+ private var agentFailures: Int = 0
+ private var lastAgentLaunch: Date = .distantPast
+ private var lastMenuSignature: String = ""
+
+ /// preview = true 时不启动后台引擎、不轮询、不请求通知权限(用于界面预览与离屏渲染检查)
+ /// 预览模式:网络调用返回下面的模拟数据
+ private(set) var isPreview = false
+ var previewHistory: [ClientHistoryRecord] = []
+
+ init(preview: Bool = false) {
+ isPreview = preview
+ guard !preview else { return }
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
+ startAgentIfNeeded()
+ startPolling()
+ runDiagnostics()
+ setupSleepWakeNotifications()
+ }
+
+ private func setupSleepWakeNotifications() {
+ NSWorkspace.shared.notificationCenter.addObserver(
+ forName: NSWorkspace.didWakeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ self?.handleSystemWake()
+ }
+ }
+ NSWorkspace.shared.notificationCenter.addObserver(
+ forName: NSWorkspace.screensDidWakeNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ self?.handleSystemWake()
+ }
+ }
+ }
+
+ func handleSystemWake() {
+ Task {
+ // 唤醒时只在会话失效时重连;屏幕唤醒等场景不再切断所有活跃连接
+ await triggerReconnect(force: false)
+ await refreshData()
+ runDiagnostics()
+ }
+ }
+
+ /// 彻底退出:通知后台引擎结束(包括不是由本 App 拉起的实例)
+ func shutdownAgent() async {
+ if let url = URL(string: "http://127.0.0.1:33080/api/shutdown") {
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.timeoutInterval = 2
+ _ = try? await URLSession.shared.data(for: req)
+ }
+ if let p = agentProcess, p.isRunning {
+ p.terminate()
+ }
+ }
+
+ func triggerReconnect(force: Bool = true) async {
+ let endpoint = force ? "http://127.0.0.1:33080/api/reconnect" : "http://127.0.0.1:33080/api/reconnect?mode=check"
+ guard let url = URL(string: endpoint) else { return }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ _ = try? await URLSession.shared.data(for: req)
+ }
+
+ func startAgentIfNeeded() {
+ let url = URL(string: "http://127.0.0.1:33080/api/status")!
+ var req = URLRequest(url: url)
+ req.timeoutInterval = 0.5
+ URLSession.shared.dataTask(with: req) { [weak self] _, _, err in
+ if err != nil {
+ Task { @MainActor in
+ self?.launchAgentProcess()
+ }
+ }
+ }.resume()
+ }
+
+ private func launchAgentProcess() {
+ if let p = agentProcess, p.isRunning { return }
+ lastAgentLaunch = Date()
+ let bundleURL = Bundle.main.bundleURL
+ let candidatePaths = [
+ bundleURL.appendingPathComponent("Contents/Resources/portrelay-agent").path,
+ bundleURL.deletingLastPathComponent().appendingPathComponent("bin/portrelay-agent").path
+ ]
+
+ var execPath: String?
+ for path in candidatePaths {
+ if FileManager.default.fileExists(atPath: path) {
+ execPath = path
+ break
+ }
+ }
+
+ guard let agentPath = execPath else { return }
+
+ let p = Process()
+ p.executableURL = URL(fileURLWithPath: agentPath)
+ p.arguments = ["-no-browser"]
+ try? p.run()
+ self.agentProcess = p
+ }
+
+ func startPolling() {
+ timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ await self?.refreshData()
+ }
+ }
+ Task {
+ await refreshData()
+ }
+ }
+
+ func refreshData() async {
+ await fetchStatus()
+ await fetchTunnels()
+ await fetchConnections()
+ let onlineCount = tunnels.filter { $0.enabled }.count
+ appDelegate?.updateStatusItemTitle(activeCount: onlineCount)
+ }
+
+ /// 新的审批申请弹出系统通知
+ private func notifyNewRequests() {
+ for (t, r) in pendingRequests where !notifiedRequests.contains(r.id) {
+ notifiedRequests.insert(r.id)
+ let content = UNMutableNotificationContent()
+ content.title = "访问申请:\(t.name)"
+ content.body = "\(r.name)(\(r.ip))申请访问,打开 PortRelay 菜单栏或授权管理进行审批"
+ content.sound = .default
+ UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: r.id, content: content, trigger: nil))
+ }
+ }
+
+ /// 菜单内容有变化时才需要重建(避免每秒重建打断已展开的菜单)
+ func menuNeedsRebuild() -> Bool {
+ let sig = "\(isConnected)|" + tunnels.map { "\($0.id):\($0.name):\($0.listen_port):\($0.enabled)" }.joined(separator: ",")
+ + "|" + pendingRequests.map { $0.request.id }.joined(separator: ",")
+ if sig == lastMenuSignature { return false }
+ lastMenuSignature = sig
+ return true
+ }
+
+ func fetchStatus() async {
+ guard let url = URL(string: "http://127.0.0.1:33080/api/status") else { return }
+ do {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ if let resp = try? JSONDecoder().decode(ServerStatusResp.self, from: data) {
+ let wasConnected = self.isConnected
+ self.isConnected = resp.connected
+ self.serverAddr = resp.server_addr
+ if let host = resp.public_host, !host.isEmpty { self.publicHost = host }
+ self.inviteBase = resp.invite_base ?? ""
+ self.totalRateHistory = resp.total_rate_history
+ self.lastError = resp.last_error ?? ""
+ self.agentReachable = true
+ self.agentFailures = 0
+ if wasConnected != resp.connected {
+ runDiagnostics()
+ }
+ }
+ } catch {
+ let wasConnected = self.isConnected
+ self.isConnected = false
+ self.agentReachable = false
+ self.lastError = "本地后台引擎 (portrelay-agent) 未响应"
+ if wasConnected { runDiagnostics() }
+ // 后台引擎崩溃或被结束:连续失败后自动拉起(10 秒内最多一次)
+ agentFailures += 1
+ if agentFailures >= 3 && Date().timeIntervalSince(lastAgentLaunch) > 10 {
+ launchAgentProcess()
+ }
+ }
+ }
+
+ func fetchTunnels() async {
+ guard let url = URL(string: "http://127.0.0.1:33080/api/tunnels") else { return }
+ do {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ let items = try JSONDecoder().decode([TunnelItem].self, from: data)
+ self.tunnels = items
+
+ var aConns = 0
+ var hConns: Int64 = 0
+ var rIn: Int64 = 0
+ var rOut: Int64 = 0
+ var bytes: Int64 = 0
+ var blocked: Int64 = 0
+
+ for t in items {
+ let histBytes = (t.historical_bytes_in ?? 0) + (t.historical_bytes_out ?? 0)
+ bytes += histBytes
+ if let m = t.metrics {
+ aConns += (m.active_conns ?? 0)
+ hConns += (m.total_conns ?? 0)
+ rIn += (m.rate_in ?? 0)
+ rOut += (m.rate_out ?? 0)
+ bytes += ((m.bytes_in ?? 0) + (m.bytes_out ?? 0))
+ blocked += (m.blocked_attempts ?? 0)
+ }
+ }
+
+ self.totalActiveConns = aConns
+ self.totalHistoricalConns = hConns
+ self.totalRateIn = rIn
+ self.totalRateOut = rOut
+ self.totalTransferred = bytes
+ self.totalBlockedAttempts = blocked
+ notifyNewRequests()
+ } catch {
+ // 拿不到最新数据时不能继续展示旧的“运行中”和速率
+ self.tunnels = self.tunnels.map { var t = $0; t.metrics = nil; return t }
+ self.activeConns = []
+ self.totalActiveConns = 0
+ self.totalRateIn = 0
+ self.totalRateOut = 0
+ }
+ }
+
+ /// enabled 为 nil 时取反;传入目标状态时幂等(避免重复点击来回翻转)
+ func toggleTunnel(id: String, enabled: Bool? = nil) async {
+ guard let url = URL(string: "http://127.0.0.1:33080/api/tunnels/\(id)/toggle") else { return }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ if let enabled = enabled {
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try? JSONSerialization.data(withJSONObject: ["enabled": enabled])
+ }
+ _ = try? await URLSession.shared.data(for: req)
+ await refreshData()
+ }
+
+ func deleteTunnel(id: String) async {
+ guard let url = URL(string: "http://127.0.0.1:33080/api/tunnels/\(id)") else { return }
+ var req = URLRequest(url: url)
+ req.httpMethod = "DELETE"
+ _ = try? await URLSession.shared.data(for: req)
+ await refreshData()
+ }
+
+ func saveTunnel(
+ id: String?,
+ name: String,
+ target: String,
+ port: Int,
+ whitelist: [String],
+ owner: String,
+ durationMinutes: Int,
+ idleCloseMinutes: Int,
+ maxBandwidthMB: Double
+ ) async -> (Bool, String?, String?) {
+ let isEdit = (id != nil && !id!.isEmpty)
+ let endpoint = isEdit ? "http://127.0.0.1:33080/api/tunnels/\(id!)" : "http://127.0.0.1:33080/api/tunnels"
+ guard let url = URL(string: endpoint) else { return (false, "无效地址", nil) }
+
+ var req = URLRequest(url: url)
+ req.httpMethod = isEdit ? "PUT" : "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let body: [String: Any] = [
+ "name": name,
+ "target": target,
+ "listen_port": port,
+ // 空数组表示“仅授权链接”
+ "whitelist": whitelist,
+ "owner": owner,
+ "duration_minutes": durationMinutes,
+ "idle_close_minutes": idleCloseMinutes,
+ "max_bandwidth_mb": maxBandwidthMB
+ ]
+
+ guard let httpBody = try? JSONSerialization.data(withJSONObject: body) else {
+ return (false, "JSON 序列化失败", nil)
+ }
+ req.httpBody = httpBody
+
+ do {
+ let (data, resp) = try await URLSession.shared.data(for: req)
+ if let httpResp = resp as? HTTPURLResponse, httpResp.statusCode >= 400 {
+ if let errJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let errMsg = errJson["error"] as? String {
+ return (false, errMsg, nil)
+ }
+ return (false, "保存失败", nil)
+ }
+ let savedId = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["id"] as? String
+ await refreshData()
+ return (true, nil, savedId)
+ } catch {
+ return (false, error.localizedDescription, nil)
+ }
+ }
+
+ func probeTarget(target: String) async -> (Bool, String) {
+ guard let url = URL(string: "http://127.0.0.1:33080/api/probe") else { return (false, "无效地址") }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ let body = ["target": target]
+ req.httpBody = try? JSONSerialization.data(withJSONObject: body)
+
+ do {
+ let (data, _) = try await URLSession.shared.data(for: req)
+ if let res = try? JSONDecoder().decode(ProbeResp.self, from: data) {
+ if res.reachable {
+ return (true, "连接成功 (\(res.rtt))")
+ } else {
+ return (false, "连接失败: \(res.error ?? "超时")")
+ }
+ }
+ return (false, "解析失败")
+ } catch {
+ return (false, error.localizedDescription)
+ }
+ }
+
+ func recommendPort() async -> Int {
+ if isPreview { return 33662 }
+ guard let url = URL(string: "http://127.0.0.1:33080/api/recommend-port") else { return 33660 }
+ do {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ if let res = try? JSONDecoder().decode(RecommendPortResp.self, from: data) {
+ return res.recommended_port
+ }
+ } catch {}
+ return 33660
+ }
+
+ func fetchHistory(tunnelId: String) async -> [ClientHistoryRecord] {
+ if isPreview { return previewHistory.filter { $0.tunnel_id == tunnelId } }
+ guard let url = URL(string: "http://127.0.0.1:33080/api/history?tunnel_id=\(tunnelId)") else { return [] }
+ do {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ return (try? JSONDecoder().decode([ClientHistoryRecord].self, from: data)) ?? []
+ } catch {}
+ return []
+ }
+
+ /// 在线连接与安全事件(之前从未拉取,导致“当前在线客户端”始终为空)
+ func fetchConnections() async {
+ guard let url = URL(string: "http://127.0.0.1:33080/api/connections") else { return }
+ do {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ let resp = try JSONDecoder().decode(ConnectionsResp.self, from: data)
+ self.activeConns = resp.active_conns
+ self.securityEvents = resp.security_events
+ } catch {
+ self.activeConns = []
+ }
+ }
+
+ private func apiCall(_ method: String, _ path: String, body: [String: Any]? = nil) async -> (Data?, String?) {
+ guard let url = URL(string: "http://127.0.0.1:33080" + path) else { return (nil, "无效地址") }
+ var req = URLRequest(url: url)
+ req.httpMethod = method
+ if let body = body {
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try? JSONSerialization.data(withJSONObject: body)
+ }
+ do {
+ let (data, resp) = try await URLSession.shared.data(for: req)
+ if let httpResp = resp as? HTTPURLResponse, httpResp.statusCode >= 400 {
+ let errJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ return (nil, (errJson?["error"] as? String) ?? "操作失败 (HTTP \(httpResp.statusCode))")
+ }
+ return (data, nil)
+ } catch {
+ return (nil, error.localizedDescription)
+ }
+ }
+
+ /// 生成邀请链接(validMinutes / maxUses 为 0 时 maxUses 表示不限设备)
+ func createInvite(tunnelId: String, note: String, validMinutes: Int, maxUses: Int, leaseDuration: String, requireApproval: Bool) async -> (InviteShareResp?, String?) {
+ if isPreview {
+ let link = inviteBase.isEmpty ? "http://192.168.1.10:33000/i/k7m2xq9fpa" : inviteBase + "k7m2xq9fpa"
+ let t = tunnels.first { $0.id == tunnelId }
+ let deadline = Date().addingTimeInterval(Double(validMinutes) * 60)
+ let fmt = DateFormatter(); fmt.dateFormat = "MM-dd HH:mm"
+ let text = "【PortRelay 访问邀请】" + (note.isEmpty ? "" : "(给 \(note))") + "\n通道:\(t?.name ?? "MySQL 数据库")\n访问地址:\(publicHost.isEmpty ? "192.168.1.10" : publicHost):\(t?.listen_port ?? 33660)\n请在 \(fmt.string(from: deadline)) 前用浏览器打开下面的链接," + (requireApproval ? "填写姓名提交申请,管理员批准后即可访问" : "填写姓名后即可访问") + "(访问有效期:\(leaseDurationTitle(leaseDuration))):\n\(link)\n此链接仅限 1 台设备使用,请勿转发。"
+ return (InviteShareResp(invite: InviteItem(code: "k7m2xq9fpa", note: note, created_at: "", expires_at: "", max_uses: maxUses, lease_duration: leaseDuration, require_approval: requireApproval, uses: nil), link: link, share_text: text), nil)
+ }
+ let (data, err) = await apiCall("POST", "/api/tunnels/\(tunnelId)/invites",
+ body: ["note": note, "valid_minutes": validMinutes, "max_uses": maxUses,
+ "lease_duration": leaseDuration, "require_approval": requireApproval])
+ guard let data = data else { return (nil, err) }
+ let resp = try? JSONDecoder().decode(InviteShareResp.self, from: data)
+ await refreshData()
+ return (resp, resp == nil ? "解析失败" : nil)
+ }
+
+ func inviteShare(tunnelId: String, code: String) async -> (InviteShareResp?, String?) {
+ let (data, err) = await apiCall("GET", "/api/tunnels/\(tunnelId)/invites/\(code)/share")
+ guard let data = data else { return (nil, err) }
+ return (try? JSONDecoder().decode(InviteShareResp.self, from: data), nil)
+ }
+
+ func revokeInvite(tunnelId: String, code: String) async -> String? {
+ let (_, err) = await apiCall("DELETE", "/api/tunnels/\(tunnelId)/invites/\(code)")
+ try? await Task.sleep(nanoseconds: 1_200_000_000)
+ await refreshData()
+ return err
+ }
+
+ func decideRequest(tunnelId: String, requestId: String, approve: Bool) async -> String? {
+ let (_, err) = await apiCall("POST", "/api/tunnels/\(tunnelId)/requests/\(requestId)", body: ["approve": approve])
+ UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [requestId])
+ try? await Task.sleep(nanoseconds: 1_200_000_000)
+ await refreshData()
+ return err
+ }
+
+ /// 撤销临时放通;ip 为 nil 时撤销该通道全部临时放通。返回错误信息,成功为 nil
+ func revokeLease(tunnelId: String, ip: String?) async -> String? {
+ guard let url = URL(string: "http://127.0.0.1:33080/api/tunnels/\(tunnelId)/leases/revoke") else { return "无效地址" }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try? JSONSerialization.data(withJSONObject: ["ip": ip ?? ""])
+ do {
+ let (data, resp) = try await URLSession.shared.data(for: req)
+ if let httpResp = resp as? HTTPURLResponse, httpResp.statusCode >= 400 {
+ let errJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ return (errJson?["error"] as? String) ?? "撤销失败"
+ }
+ } catch {
+ return error.localizedDescription
+ }
+ // 服务端下一次指标推送(约 1 秒)后列表才会更新
+ try? await Task.sleep(nanoseconds: 1_200_000_000)
+ await refreshData()
+ return nil
+ }
+
+ func killClients(connIds: [String]) async {
+ for id in connIds {
+ guard let url = URL(string: "http://127.0.0.1:33080/api/connections/kill") else { return }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try? JSONSerialization.data(withJSONObject: ["conn_id": id])
+ _ = try? await URLSession.shared.data(for: req)
+ }
+ await refreshData()
+ }
+
+ func killClient(connId: String) async {
+ guard let url = URL(string: "http://127.0.0.1:33080/api/connections/kill") else { return }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ let body = ["conn_id": connId]
+ req.httpBody = try? JSONSerialization.data(withJSONObject: body)
+ _ = try? await URLSession.shared.data(for: req)
+ await refreshData()
+ }
+
+ func fetchSettings() async -> (String, String, String) {
+ if isPreview { return ("192.168.1.10:33000", "preview-token", "192.168.1.10") }
+ guard let url = URL(string: "http://127.0.0.1:33080/api/settings") else { return ("", "", "") }
+ do {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ if let s = try? JSONDecoder().decode(SettingsResp.self, from: data) {
+ return (s.server_addr, s.token, s.public_host ?? self.publicHost)
+ }
+ } catch {}
+ return (self.serverAddr, "", self.publicHost)
+ }
+
+ /// 返回错误信息,成功时为 nil(后端保存后会自动重连)
+ func saveSettings(serverAddr: String, token: String, publicHost: String) async -> String? {
+ guard let url = URL(string: "http://127.0.0.1:33080/api/settings") else { return "无效地址" }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ let body = ["server_addr": serverAddr, "token": token, "public_host": publicHost]
+ req.httpBody = try? JSONSerialization.data(withJSONObject: body)
+ do {
+ let (data, resp) = try await URLSession.shared.data(for: req)
+ if let httpResp = resp as? HTTPURLResponse, httpResp.statusCode >= 400 {
+ let errJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ return (errJson?["error"] as? String) ?? "保存失败"
+ }
+ } catch {
+ return error.localizedDescription
+ }
+ await refreshData()
+ runDiagnostics()
+ return nil
+ }
+
+ // Live Diagnostics Check
+ /// 粗略判断服务器地址所在的网络类型,仅用于诊断展示
+ var serverNetworkKind: String {
+ let host = hostOf(serverAddr)
+ let parts = host.split(separator: ".").compactMap { Int($0) }
+ guard parts.count == 4 else { return host.isEmpty ? "未配置" : "域名" }
+ switch (parts[0], parts[1]) {
+ case (127, _): return "本机"
+ case (10, _), (192, 168), (172, 16...31): return "私有网络"
+ case (100, 64...127): return "虚拟组网"
+ default: return "公网"
+ }
+ }
+
+ func runDiagnostics() {
+ self.isRunningDiagnostics = true
+ var items: [DiagnosticItem] = []
+
+ if self.isConnected {
+ items.append(DiagnosticItem(
+ title: "本地网络访问权限 (Local Network)",
+ desc: "macOS 隐私与安全性授权状态正常,可与本地及内网节点通信。",
+ status: .ok,
+ statusText: "已授权",
+ actionTitle: nil,
+ action: nil
+ ))
+ } else {
+ items.append(DiagnosticItem(
+ title: "本地网络访问权限 (Local Network)",
+ desc: "若曾误点击拒绝,系统会阻止本应用访问局域网内的网关或目标服务。",
+ status: .warning,
+ statusText: "需确认授权",
+ actionTitle: "打开系统设置 > 本地网络",
+ action: {
+ if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+ ))
+ }
+
+ if self.isConnected {
+ items.append(DiagnosticItem(
+ title: "中继服务器通信",
+ desc: "已与 \(self.serverAddr) 建立长连接,端口池处于就绪状态。",
+ status: .ok,
+ statusText: "连通正常",
+ actionTitle: nil,
+ action: nil
+ ))
+ } else {
+ items.append(DiagnosticItem(
+ title: "中继服务器通信",
+ desc: "无法与 \(self.serverAddr) 握手。" + (self.lastError.isEmpty ? "请确保中继服务器上的服务正在运行且防火墙已放通控制端口。" : "原因:\(self.lastError)"),
+ status: .error,
+ statusText: "无法连接",
+ actionTitle: "重试连接",
+ action: {
+ Task { @MainActor in
+ await self.refreshData()
+ self.runDiagnostics()
+ }
+ }
+ ))
+ }
+
+ items.append(DiagnosticItem(
+ title: "网络路径",
+ desc: "经\(serverNetworkKind)连接边缘网关 \(self.serverAddr)。连接方式不限:局域网、VPN、Tailscale / ZeroTier 等组网或公网均可,只要本机同时能访问目标网段即可。",
+ status: .ok,
+ statusText: serverNetworkKind,
+ actionTitle: nil,
+ action: nil
+ ))
+
+ items.append(DiagnosticItem(
+ title: "后台中继引擎 (PortRelay Core)",
+ desc: self.agentReachable ? "负责与服务器保持连接,并把访问流量转发到内网目标服务。" : "本地 127.0.0.1:33080 无响应,正在尝试自动拉起后台引擎。",
+ status: self.agentReachable ? .ok : .error,
+ statusText: self.agentReachable ? "运行中" : "未运行",
+ actionTitle: nil,
+ action: nil
+ ))
+
+ self.diagnostics = items
+ self.isRunningDiagnostics = false
+ }
+}
+
+// MARK: - Native Views
+
+@MainActor
+struct ContentView: View {
+ @ObservedObject var vm: PortRelayViewModel
+ @State private var showingAddSheet = false
+ @State private var showingSettingsSheet = false
+ @State private var showingDiagnosticsSheet = false
+ // 用 sheet(item:) 直接把要操作的通道传给弹窗;
+ // 之前 isPresented + selectedTunnel 的写法会读到旧值,导致编辑弹窗偶尔为空或显示别的通道
+ @State private var editingTunnel: TunnelItem?
+ /// 邀请弹窗:通道 + 是否打开即生成(新建通道后直接出分享卡片)
+ @State private var inviteTarget: InviteSheetTarget?
+ @State private var clientsTarget: ClientsSheetTarget?
+ // 通道搜索与状态筛选
+ @State private var searchText: String = ""
+ @State private var statusFilter: TunnelStatusFilter = .all
+ @FocusState private var searchFocused: Bool
+
+ init() {
+ self.init(vm: PortRelayViewModel.shared)
+ }
+
+ init(vm: PortRelayViewModel, initialSearch: String = "", initialFilter: TunnelStatusFilter = .all) {
+ _vm = ObservedObject(wrappedValue: vm)
+ _searchText = State(initialValue: initialSearch)
+ _statusFilter = State(initialValue: initialFilter)
+ }
+
+ var filteredTunnels: [TunnelItem] {
+ vm.tunnels.filter { statusFilter.accepts($0) && TunnelSearch.matches($0, query: searchText, publicHost: vm.publicHost) }
+ }
+
+ var isFiltering: Bool { !searchText.trimmingCharacters(in: .whitespaces).isEmpty || statusFilter != .all }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // macOS Standard Top Bar
+ topBarView
+ .padding(.horizontal, 18)
+ .padding(.vertical, 12)
+ .background(Color(NSColor.windowBackgroundColor))
+
+ Divider()
+
+ if !vm.isConnected {
+ HStack(spacing: 10) {
+ Image(systemName: "exclamationmark.circle.fill")
+ .foregroundColor(.orange)
+ .font(.system(size: 13))
+ Text("未连接至中继服务器 (\(vm.serverAddr))" + (vm.lastError.isEmpty ? "" : " · \(vm.lastError)"))
+ .font(.system(size: 12))
+ .foregroundColor(.primary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Spacer()
+ Button("查看授权与诊断") {
+ showingDiagnosticsSheet = true
+ }
+ .buttonStyle(.link)
+ .font(.system(size: 12))
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 7)
+ .background(Color.orange.opacity(0.08))
+ Divider()
+ }
+
+ ScrollView {
+ VStack(spacing: 16) {
+ // Minimalist Metrics Strip (Activity Monitor style)
+ metricsStripView
+
+ // Tunnel Section Header: 标题 / 数量 ……… 状态筛选 / 搜索
+ HStack(spacing: 12) {
+ Text("中继通道")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.secondary)
+ Text(verbatim: isFiltering ? "匹配 \(filteredTunnels.count) / 共 \(vm.tunnels.count) 个" : "\(vm.tunnels.count) 个通道")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ .monospacedDigit()
+ Spacer()
+ if !vm.tunnels.isEmpty {
+ Picker("", selection: $statusFilter) {
+ ForEach(TunnelStatusFilter.allCases, id: \.self) { f in
+ Text(f.rawValue).tag(f)
+ }
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ .fixedSize()
+ .controlSize(.small)
+
+ searchField
+ }
+ }
+ .padding(.top, 4)
+
+ // Tunnel Cards
+ if vm.tunnels.isEmpty {
+ emptyStateView
+ } else if filteredTunnels.isEmpty {
+ noMatchView
+ } else {
+ VStack(spacing: 10) {
+ ForEach(filteredTunnels) { t in
+ TunnelCardView(
+ tunnel: t,
+ vm: vm,
+ onEdit: {
+ editingTunnel = t
+ },
+ onViewClients: {
+ clientsTarget = ClientsSheetTarget(tunnel: t, tab: 0)
+ },
+ onViewLeases: {
+ clientsTarget = ClientsSheetTarget(tunnel: t, tab: 2)
+ },
+ onInvite: {
+ inviteTarget = InviteSheetTarget(tunnel: t, autoGenerate: false)
+ }
+ )
+ }
+ }
+ }
+ }
+ .padding(18)
+ }
+ }
+ .frame(minWidth: 920, minHeight: 640)
+ .sheet(isPresented: $showingAddSheet) {
+ TunnelEditSheet(vm: vm, tunnel: nil, isPresented: $showingAddSheet, onCreated: { id in
+ // 新建完成后打开邀请设置(时长、审批在邀请中选择)
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 600_000_000)
+ if let t = vm.tunnels.first(where: { $0.id == id }) {
+ inviteTarget = InviteSheetTarget(tunnel: t, autoGenerate: false)
+ }
+ }
+ })
+ }
+ .sheet(item: $inviteTarget) { target in
+ InviteSheet(vm: vm, tunnel: target.tunnel, autoGenerate: target.autoGenerate, isPresented: Binding(
+ get: { inviteTarget != nil },
+ set: { if !$0 { inviteTarget = nil } }
+ ))
+ }
+ .sheet(item: $editingTunnel) { t in
+ TunnelEditSheet(vm: vm, tunnel: t, isPresented: Binding(
+ get: { editingTunnel != nil },
+ set: { if !$0 { editingTunnel = nil } }
+ ))
+ }
+ .sheet(isPresented: $showingSettingsSheet) {
+ SettingsSheet(vm: vm, isPresented: $showingSettingsSheet)
+ }
+ .sheet(isPresented: $showingDiagnosticsSheet) {
+ DiagnosticsSheet(vm: vm, isPresented: $showingDiagnosticsSheet)
+ }
+ .sheet(item: $clientsTarget) { target in
+ ClientDetailsSheet(vm: vm, tunnel: target.tunnel, initialTab: target.tab, isPresented: Binding(
+ get: { clientsTarget != nil },
+ set: { if !$0 { clientsTarget = nil } }
+ ))
+ }
+ }
+
+ var topBarView: some View {
+ HStack(spacing: 14) {
+ HStack(spacing: 8) {
+ Image(systemName: "arrow.left.arrow.right.square.fill")
+ .font(.system(size: 20))
+ .foregroundColor(.accentColor)
+ VStack(alignment: .leading, spacing: 1) {
+ Text("PortRelay")
+ .font(.system(size: 14, weight: .bold))
+ Text("端口中继控制台")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ }
+
+ Spacer()
+
+ HStack(spacing: 6) {
+ Circle()
+ .fill(vm.isConnected ? Color.green : Color.orange)
+ .frame(width: 7, height: 7)
+ Text(vm.isConnected ? "已就绪" : "连接中断")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 4)
+ .background(Color(NSColor.controlBackgroundColor))
+ .cornerRadius(6)
+
+ Button(action: {
+ vm.runDiagnostics()
+ showingDiagnosticsSheet = true
+ }) {
+ Label("诊断与权限", systemImage: "shield")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+
+ Button(action: { showingAddSheet = true }) {
+ Label("添加通道", systemImage: "plus")
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+
+ Button(action: { showingSettingsSheet = true }) {
+ Image(systemName: "gearshape")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+ }
+
+ var metricsStripView: some View {
+ HStack(spacing: 1) {
+ MetricStripItem(
+ title: "活跃通道",
+ value: "\(vm.tunnels.filter { $0.enabled }.count) / \(vm.tunnels.count)",
+ caption: "端口池 33001-34000"
+ )
+ Divider().frame(height: 36)
+ HStack(alignment: .center, spacing: 10) {
+ MetricStripItem(
+ title: "实时速率",
+ value: "↑ \(formatRate(vm.totalRateIn)) ↓ \(formatRate(vm.totalRateOut))",
+ caption: "上行 / 下行 · 近 10 分钟"
+ )
+ .fixedSize()
+ RateSparkline(series: vm.totalRateHistory, height: 34)
+ .frame(minWidth: 60, maxWidth: 140)
+ .padding(.trailing, 10)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Divider().frame(height: 36)
+ MetricStripItem(
+ title: "活跃连接",
+ value: "\(vm.totalActiveConns)",
+ caption: "历史累计 \(vm.totalHistoricalConns)"
+ )
+ Divider().frame(height: 36)
+ MetricStripItem(
+ title: "拦截未授权",
+ value: "\(vm.totalBlockedAttempts)",
+ caption: "白名单保护",
+ highlight: vm.totalBlockedAttempts > 0
+ )
+ }
+ .padding(.vertical, 10)
+ .padding(.horizontal, 12)
+ .background(RoundedRectangle(cornerRadius: 10).fill(Color(NSColor.controlBackgroundColor)))
+ .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.09), lineWidth: 1))
+ }
+
+ var searchField: some View {
+ HStack(spacing: 6) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ TextField("搜索名称、地址、端口、责任人…", text: $searchText)
+ .textFieldStyle(.plain)
+ .font(.system(size: 12))
+ .focused($searchFocused)
+ .onExitCommand { searchText = "" }
+ if !searchText.isEmpty {
+ Button(action: { searchText = "" }) {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ .buttonStyle(.plain)
+ .help("清除搜索 (Esc)")
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 5)
+ .frame(width: 250)
+ .background(RoundedRectangle(cornerRadius: 7).fill(Color(NSColor.controlBackgroundColor)))
+ .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(searchFocused ? Color.accentColor.opacity(0.7) : Color.primary.opacity(0.12), lineWidth: 1))
+ .help("支持多个关键字(空格分隔),中文可用拼音或首字母,例如 sjk。快捷键 ⌘F")
+ .background(
+ // ⌘F 聚焦搜索框
+ Button("") { searchFocused = true }
+ .keyboardShortcut("f", modifiers: .command)
+ .opacity(0)
+ )
+ }
+
+ var noMatchView: some View {
+ VStack(spacing: 8) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 26))
+ .foregroundColor(.secondary)
+ Text("没有匹配的通道")
+ .font(.system(size: 13, weight: .medium))
+ Text(verbatim: searchText.isEmpty ? "当前筛选:\(statusFilter.rawValue)" : "关键字:\(searchText)" + (statusFilter == .all ? "" : " · 筛选:\(statusFilter.rawValue)"))
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ Button("清除搜索与筛选") {
+ searchText = ""
+ statusFilter = .all
+ }
+ .controlSize(.small)
+ .padding(.top, 2)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 40)
+ .rowCard()
+ }
+
+ var emptyStateView: some View {
+ VStack(spacing: 8) {
+ Image(systemName: "tray")
+ .font(.system(size: 32))
+ .foregroundColor(.secondary)
+ Text("无活动通道")
+ .font(.system(size: 13, weight: .medium))
+ Text("点击右上角「添加通道」创建反向中继端口")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 48)
+ .background(Color(NSColor.controlBackgroundColor).opacity(0.4))
+ .cornerRadius(8)
+ }
+}
+
+struct MetricStripItem: View {
+ let title: String
+ let value: String
+ let caption: String
+ var highlight: Bool = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ Text(value)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundColor(highlight ? .red : .primary)
+ Text(caption)
+ .font(.system(size: 10))
+ .foregroundColor(.secondary.opacity(0.8))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 10)
+ }
+}
+
+// MARK: - Tunnel Card View (With TTL, Owner, Session/History Traffic)
+
+struct TunnelCardView: View {
+ let tunnel: TunnelItem
+ @ObservedObject var vm: PortRelayViewModel
+ let onEdit: () -> Void
+ let onViewClients: () -> Void
+ let onViewLeases: () -> Void
+ let onInvite: () -> Void
+
+ @State private var showDeleteConfirm: Bool = false
+
+ var isOnline: Bool {
+ tunnel.enabled && (tunnel.metrics?.status == "online")
+ }
+
+ var statusBadge: (text: String, color: Color) {
+ guard tunnel.enabled else { return ("已暂停", .secondary) }
+ switch tunnel.metrics?.status {
+ case "online": return ("运行中", .green)
+ case "error": return ("异常", .red)
+ case "offline": return ("等待连接服务器", .orange)
+ default: return ("同步中", .secondary)
+ }
+ }
+
+ @State private var cardWidth: CGFloat = 0
+
+ /// 卡片足够宽时在右侧显示流量面板
+ var showsTraffic: Bool { cardWidth == 0 || cardWidth >= 700 }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ // 主体左右两栏:左侧 名称/地址/时间口径,右侧 速率 + 开关 + 流量曲线(与左侧等高)
+ HStack(alignment: .top, spacing: 20) {
+ VStack(alignment: .leading, spacing: 9) {
+ HStack(alignment: .center, spacing: 8) {
+ Text(verbatim: tunnel.name)
+ .font(.system(size: 14, weight: .semibold))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .layoutPriority(1)
+
+ if let owner = tunnel.owner, !owner.isEmpty {
+ HStack(spacing: 3) {
+ Image(systemName: "person.fill")
+ .font(.system(size: 9))
+ Text(verbatim: owner)
+ .font(.system(size: 11, weight: .medium))
+ .lineLimit(1)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.blue.opacity(0.12))
+ .foregroundColor(.blue)
+ .cornerRadius(4)
+ }
+
+ Text(verbatim: statusBadge.text)
+ .font(.system(size: 10, weight: .medium))
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1.5)
+ .background(statusBadge.color.opacity(0.12))
+ .foregroundColor(statusBadge.color)
+ .cornerRadius(4)
+ .help(tunnel.metrics?.error ?? "")
+
+ accessTag
+ }
+
+ if tunnel.enabled, tunnel.metrics?.status == "error", let err = tunnel.metrics?.error, !err.isEmpty {
+ Label {
+ Text(verbatim: "服务器注册失败:\(err)")
+ } icon: {
+ Image(systemName: "exclamationmark.triangle.fill")
+ }
+ .font(.system(size: 11))
+ .foregroundColor(.red)
+ .lineLimit(2)
+ }
+
+ routeRow
+
+ FlowLayout(spacing: 14, lineSpacing: 7) {
+ lifecycleItems
+ badgeItems
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if tunnel.enabled && showsTraffic {
+ VStack(alignment: .trailing, spacing: 6) {
+ HStack(spacing: 10) {
+ RateLegend(down: tunnel.metrics?.rate_out ?? 0, up: tunnel.metrics?.rate_in ?? 0)
+ Spacer(minLength: 0)
+ enableToggle
+ }
+ InlineTrafficChart(series: tunnel.rate_history)
+ }
+ .frame(width: 250)
+ } else {
+ // 无曲线时右栏只放开关,固定尺寸,不挤占左侧
+ enableToggle
+ .fixedSize()
+ }
+ }
+
+ Divider()
+
+ // Footer: 连接 / 本次 / 历史 ……… 操作
+ HStack(spacing: 12) {
+ let m = tunnel.metrics
+ let sessionBytes = (m?.bytes_in ?? 0) + (m?.bytes_out ?? 0)
+ let histBytes = (tunnel.historical_bytes_in ?? 0) + (tunnel.historical_bytes_out ?? 0) + (tunnel.enabled ? sessionBytes : 0)
+ HStack(spacing: 16) {
+ footerStat("连接", "\(m?.active_conns ?? 0)")
+ footerStat("本次", formatBytes(sessionBytes))
+ footerStat("历史", formatBytes(histBytes))
+ if !showsTraffic && tunnel.enabled {
+ footerStat("速率", "↑\(formatRate(m?.rate_in ?? 0)) ↓\(formatRate(m?.rate_out ?? 0))")
+ }
+ }
+ .lineLimit(1)
+
+ Spacer(minLength: 8)
+
+ HStack(spacing: 6) {
+ Button(action: onInvite) {
+ Label("邀请", systemImage: "square.and.arrow.up")
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ .disabled(!tunnel.enabled)
+ .help(tunnel.enabled ? "生成邀请链接,发给同事临时访问" : "请先开启通道")
+
+ Button("连接与授权", action: onViewClients)
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+
+ Button("编辑", action: onEdit)
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+
+ Button("删除", role: .destructive) {
+ showDeleteConfirm = true
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+ }
+ }
+ .padding(14)
+ .background(GeometryReader { g in Color.clear.preference(key: CardWidthKey.self, value: g.size.width) })
+ .onPreferenceChange(CardWidthKey.self) { cardWidth = $0 }
+ .background(RoundedRectangle(cornerRadius: 10).fill(Color(NSColor.controlBackgroundColor)))
+ .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.09), lineWidth: 1))
+ .shadow(color: .black.opacity(0.04), radius: 2, y: 1)
+ .opacity(tunnel.enabled ? 1.0 : 0.6)
+ .alert("确认删除通道?", isPresented: $showDeleteConfirm) {
+ Button("取消", role: .cancel) {}
+ Button("确认删除", role: .destructive) {
+ Task { await vm.deleteTunnel(id: tunnel.id) }
+ }
+ } message: {
+ Text(verbatim: "确定要删除通道「\(tunnel.name)」吗?\n\n• 对外访问地址:\(vm.publicHost):\(tunnel.listen_port)\n• 目标服务:\(tunnel.target)\n\n删除后将立即注销中转监听并切断所有活跃连接。此操作不可撤销。")
+ }
+ }
+
+ // MARK: Card parts
+
+ var enableToggle: some View {
+ Toggle("", isOn: Binding(
+ get: { tunnel.enabled },
+ set: { newValue in
+ Task { await vm.toggleTunnel(id: tunnel.id, enabled: newValue) }
+ }
+ ))
+ .toggleStyle(.switch)
+ .controlSize(.mini)
+ .labelsHidden()
+ .help(tunnel.enabled ? "关闭通道" : "开启通道")
+ }
+
+ var routeRow: some View {
+ HStack(spacing: 8) {
+ Text(verbatim: "\(vm.publicHost):\(tunnel.listen_port)")
+ .font(.system(size: 12, weight: .medium, design: .monospaced))
+ .foregroundColor(.accentColor)
+ Image(systemName: "arrow.right")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ Text(verbatim: tunnel.target)
+ .font(.system(size: 12, weight: .medium, design: .monospaced))
+ .foregroundColor(.secondary)
+ Menu {
+ ForEach(quickConnectSnippets(), id: \.cmd) { item in
+ Button(action: { copyToPasteboard(item.cmd) }) {
+ Label(item.title, systemImage: item.icon)
+ }
+ }
+ } label: {
+ HStack(spacing: 3) {
+ Image(systemName: "doc.on.doc")
+ .font(.system(size: 10))
+ Text("一键复制")
+ .font(.system(size: 11))
+ }
+ .foregroundColor(.secondary)
+ }
+ .menuStyle(.borderlessButton)
+ .fixedSize()
+ .help("一键复制客户端连接命令或访问地址")
+ }
+ .lineLimit(1)
+ }
+
+ var accessTag: some View {
+ Group {
+ if tunnel.whitelist.isEmpty {
+ capsule("lock.fill", "仅授权链接", .green)
+ } else if tunnel.whitelist.contains("*") {
+ capsule("exclamationmark.triangle.fill", "全部放通 *", .red)
+ .help("任何能访问服务器的设备都可以连接,建议改为“仅授权链接”")
+ } else {
+ capsule("network", tunnel.whitelist.count == 1 ? "\(tunnel.whitelist[0]) + 授权链接" : "\(tunnel.whitelist.count) 个 IP/网段 + 授权链接", .secondary)
+ .help("固定白名单:" + tunnel.whitelist.joined(separator: ", "))
+ }
+ }
+ }
+
+ func capsule(_ icon: String, _ text: String, _ color: Color) -> some View {
+ HStack(spacing: 3) {
+ Image(systemName: icon)
+ .font(.system(size: 9))
+ Text(verbatim: text)
+ .font(.system(size: 10, weight: .medium))
+ .lineLimit(1)
+ }
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1.5)
+ .background(color.opacity(0.12))
+ .foregroundColor(color == .secondary ? .primary.opacity(0.7) : color)
+ .cornerRadius(4)
+ }
+
+ @ViewBuilder
+ var lifecycleItems: some View {
+ if tunnel.enabled {
+ lifecycleItem("play.circle", "开启 \(formatDateTimeShort(tunnel.started_at))")
+ if let first = tunnel.first_connected_at {
+ lifecycleItem("person.wave.2", "首次连接 \(formatDateTimeShort(first))")
+ } else {
+ lifecycleItem("person.wave.2", "尚无连接")
+ }
+ lifecycleItem("stopwatch", "实际连接 \(formatSecondsToDuration(tunnel.activity?.connected_sec ?? tunnel.connected_sec ?? 0))",
+ color: .primary, weight: .medium)
+ .help("本次开启后,至少有一个连接在线的累计时长")
+ if let act = tunnel.activity {
+ if act.in_use {
+ lifecycleItem("circle.fill", "使用中", color: .green, weight: .medium, iconSize: 7)
+ .help("有在线连接、有效授权、待审批申请或可用的邀请链接")
+ } else if let closeAt = act.auto_close_at {
+ let left = max(0, -secondsSince(closeAt))
+ lifecycleItem("moon.zzz", "已闲置 \(formatSecondsToDuration(secondsSince(act.idle_since))),\(formatSecondsToDuration(left)) 后自动关闭",
+ color: left < 300 ? .orange : .secondary)
+ } else {
+ lifecycleItem("moon.zzz", "已闲置 \(formatSecondsToDuration(secondsSince(act.idle_since)))(不自动关闭)")
+ }
+ }
+ if tunnel.expires_at != nil {
+ let remaining = formatRemainingTime(expiresAt: tunnel.expires_at)
+ lifecycleItem("hourglass", "最长开启至 \(formatDateTimeShort(tunnel.expires_at))",
+ color: remaining.isExpiringSoon ? .orange : .secondary)
+ }
+ } else {
+ if let lastSec = tunnel.last_duration_sec, lastSec > 0 {
+ lifecycleItem("clock.arrow.circlepath", "上次运行 \(formatSecondsToDuration(lastSec)) · 实际连接 \(formatSecondsToDuration(tunnel.last_connected_sec ?? 0))")
+ }
+ if let reason = tunnel.close_reason, !reason.isEmpty {
+ lifecycleItem("power", "关闭原因:\(reason)")
+ }
+ let idle = tunnel.effective_idle_close_minutes ?? 30
+ lifecycleItem("moon.zzz", "闲置自动关闭:" + (idle > 0 ? idleCloseTitle(idle) : "关"))
+ if let d = tunnel.duration_minutes, d > 0 {
+ lifecycleItem("hourglass", "最长开启 \(formatSecondsToDuration(Int64(d) * 60))")
+ }
+ }
+ }
+
+ @ViewBuilder
+ var badgeItems: some View {
+ if let mb = tunnel.max_bandwidth_mb, mb > 0 {
+ capsule("gauge.with.needle", "限速 \(Int(mb)) MB/s", .purple)
+ }
+ if let pending = tunnel.metrics?.pending, !pending.isEmpty {
+ Button(action: onViewLeases) {
+ capsule("bell.badge.fill", "待审批 \(pending.count)", .orange)
+ }
+ .buttonStyle(.plain)
+ .help("有同事申请访问,点击审批")
+ }
+ if let leases = tunnel.metrics?.dynamic_leases, !leases.isEmpty {
+ Button(action: onViewLeases) {
+ HStack(spacing: 2) {
+ capsule("person.crop.circle.badge.checkmark", "已授权 \(leases.count) 台", .green)
+ }
+ }
+ .buttonStyle(.plain)
+ .help("查看与撤销已授权的设备")
+ }
+ }
+
+ func footerStat(_ label: String, _ value: String) -> some View {
+ HStack(spacing: 4) {
+ Text(verbatim: label)
+ .foregroundColor(.secondary)
+ Text(verbatim: value)
+ .fontWeight(.medium)
+ .foregroundColor(.primary.opacity(0.85))
+ }
+ .font(.system(size: 11))
+ .monospacedDigit()
+ }
+
+ func lifecycleItem(_ icon: String, _ text: String, color: Color = .secondary, weight: Font.Weight = .regular, iconSize: CGFloat = 11) -> some View {
+ HStack(spacing: 4) {
+ Image(systemName: icon)
+ .font(.system(size: iconSize))
+ Text(verbatim: text)
+ .font(.system(size: 11, weight: weight))
+ .monospacedDigit()
+ }
+ .foregroundColor(color)
+ }
+
+ func quickConnectSnippets() -> [(title: String, cmd: String, icon: String)] {
+ let p = tunnel.listen_port
+ let host = vm.publicHost
+ let targetLower = tunnel.target.lowercased()
+ let nameLower = tunnel.name.lowercased()
+ var list: [(String, String, String)] = []
+
+ if targetLower.hasSuffix(":22") || nameLower.contains("ssh") {
+ list.append(("复制 SSH 命令: ssh -p \(p) root@\(host)", "ssh -p \(p) root@\(host)", "terminal"))
+ } else if targetLower.hasSuffix(":3306") || nameLower.contains("mysql") {
+ list.append(("复制 MySQL 命令: mysql -h \(host) -P \(p) -u root -p", "mysql -h \(host) -P \(p) -u root -p", "cylinder"))
+ } else if targetLower.hasSuffix(":6379") || nameLower.contains("redis") {
+ list.append(("复制 Redis 命令: redis-cli -h \(host) -p \(p)", "redis-cli -h \(host) -p \(p)", "server.rack"))
+ } else if targetLower.hasSuffix(":80") || targetLower.hasSuffix(":443") || targetLower.hasSuffix(":8080") || nameLower.contains("web") || nameLower.contains("http") {
+ list.append(("复制 Web URL: http://\(host):\(p)", "http://\(host):\(p)", "globe"))
+ }
+
+ list.append(("复制 访问地址 (\(host):\(p))", "\(host):\(p)", "network"))
+ return list
+ }
+}
+
+// MARK: - Client Details & Historical Records Sheet
+
+/// 客户明细弹窗的目标(通道 + 初始页签),整体作为 sheet(item:) 的参数
+struct ClientsSheetTarget: Identifiable {
+ let tunnel: TunnelItem
+ let tab: Int
+ var id: String { "\(tunnel.id)#\(tab)" }
+}
+
+struct ClientDetailsSheet: View {
+ @ObservedObject var vm: PortRelayViewModel
+ let tunnel: TunnelItem
+ @Binding var isPresented: Bool
+
+ @State private var selectedTab: Int
+ @State private var revokeError: String? = nil
+ @State private var expandedIPs: Set = []
+ @State private var expandedHistoryIPs: Set = []
+ @State private var isRevoking: Bool = false
+
+ init(vm: PortRelayViewModel, tunnel: TunnelItem, initialTab: Int = 0, isPresented: Binding) {
+ _vm = ObservedObject(wrappedValue: vm)
+ self.tunnel = tunnel
+ _isPresented = isPresented
+ _selectedTab = State(initialValue: initialTab)
+ }
+
+ /// 弹窗打开期间使用最新数据(临时放通列表每秒刷新)
+ var liveTunnel: TunnelItem {
+ vm.tunnels.first { $0.id == tunnel.id } ?? tunnel
+ }
+
+ var leases: [DynamicLeaseItem] {
+ liveTunnel.metrics?.dynamic_leases ?? []
+ }
+
+ var invites: [InviteItem] {
+ liveTunnel.metrics?.invites ?? []
+ }
+
+ var pendingRequests: [AccessRequestItem] {
+ liveTunnel.metrics?.pending ?? []
+ }
+
+ @State private var showingInviteSheet = false
+ @State private var copiedCode: String? = nil
+ @State private var historyRecords: [ClientHistoryRecord] = []
+ @State private var isLoadingHistory: Bool = false
+
+ var activeClientsForTunnel: [ActiveClientInfo] {
+ vm.activeConns.filter { $0.tunnel_id == tunnel.id }
+ }
+
+ var historyGroups: [HistoryIPGroup] {
+ Dictionary(grouping: historyRecords) { hostOf($0.client_ip) }
+ .map { HistoryIPGroup(ip: $0.key, records: $0.value.sorted { $0.connected_at > $1.connected_at }) }
+ .sorted { ($0.lastSeen ?? "") > ($1.lastSeen ?? "") }
+ }
+
+ var clientGroups: [ClientIPGroup] {
+ Dictionary(grouping: activeClientsForTunnel) { hostOf($0.client_ip) }
+ .map { ClientIPGroup(ip: $0.key, conns: $0.value.sorted { $0.connected_at < $1.connected_at }) }
+ .sorted { $0.conns.count != $1.conns.count ? $0.conns.count > $1.conns.count : $0.ip < $1.ip }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ // Header
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(verbatim: "\(tunnel.name) · 连接与授权")
+ .font(.system(size: 15, weight: .bold))
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Text(verbatim: "\(vm.publicHost):\(tunnel.listen_port) → \(tunnel.target)")
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ }
+ Spacer()
+ Button("完成") { isPresented = false }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+
+ // Tab Picker
+ Picker("", selection: $selectedTab) {
+ Text(verbatim: clientGroups.isEmpty ? "当前在线客户端" : "当前在线 (\(clientGroups.count) 台 · \(activeClientsForTunnel.count) 连接)").tag(0)
+ Text(verbatim: historyGroups.isEmpty ? "历史接入台账" : "历史接入台账 (\(historyGroups.count) 台)").tag(1)
+ Text(verbatim: "授权管理 (\(leases.count + pendingRequests.count))").tag(2)
+ }
+ .pickerStyle(.segmented)
+ .padding(.vertical, 4)
+
+ Divider()
+
+ // Tab Content
+ if selectedTab == 0 {
+ // Live Connected Clients
+ if activeClientsForTunnel.isEmpty {
+ VStack(spacing: 8) {
+ Image(systemName: "person.slash")
+ .font(.system(size: 28))
+ .foregroundColor(.secondary)
+ Text("当前暂无客户端连接此端口")
+ .font(.system(size: 13))
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ScrollView {
+ VStack(spacing: 8) {
+ ForEach(clientGroups) { g in
+ clientGroupRow(g)
+ }
+ }
+ .padding(.bottom, 8)
+ }
+ }
+ } else if selectedTab == 1 {
+ // Historical Connections Log Table
+ if isLoadingHistory {
+ ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if historyRecords.isEmpty {
+ VStack(spacing: 8) {
+ Image(systemName: "doc.text")
+ .font(.system(size: 28))
+ .foregroundColor(.secondary)
+ Text("暂无已断开的连接历史记录")
+ .font(.system(size: 13))
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ScrollView {
+ VStack(spacing: 8) {
+ ForEach(historyGroups) { g in
+ historyGroupRow(g)
+ }
+ }
+ .padding(.bottom, 8)
+ }
+ }
+ } else {
+ leasesTab
+ }
+ }
+ .padding(20)
+ .frame(minWidth: 720, idealWidth: 760, maxWidth: .infinity, minHeight: 540, idealHeight: 600, maxHeight: .infinity)
+ .onAppear {
+ loadHistory()
+ }
+ .onChange(of: selectedTab) { newTab in
+ if newTab == 1 { loadHistory() }
+ }
+ }
+
+ /// 只有一个 IP 时默认展开;点击切换展开状态
+ func isExpanded(_ ip: String, in set: Set, groupCount: Int) -> Bool {
+ (groupCount == 1) != set.contains(ip)
+ }
+
+ func toggle(_ ip: String, in set: inout Set) {
+ if set.contains(ip) { set.remove(ip) } else { set.insert(ip) }
+ }
+
+ func chevron(_ expanded: Bool, visible: Bool = true) -> some View {
+ Image(systemName: "chevron.right")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(.secondary)
+ .rotationEffect(.degrees(expanded ? 90 : 0))
+ .frame(width: 12)
+ .opacity(visible ? 1 : 0)
+ }
+
+ @ViewBuilder
+ func historyGroupRow(_ g: HistoryIPGroup) -> some View {
+ let expanded = isExpanded(g.ip, in: expandedHistoryIPs, groupCount: historyGroups.count)
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(spacing: 8) {
+ chevron(expanded)
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 6) {
+ Text(verbatim: g.ip)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .lineLimit(1)
+ .truncationMode(.middle)
+ TagView("\(g.records.count) 次连接")
+ }
+ Text(verbatim: "最近 \(formatDateTimeShort(g.lastSeen)) · 首次 \(formatDateTimeShort(g.firstSeen)) · 累计 \(formatSecondsToDuration(g.totalDuration))")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ }
+ .layoutPriority(1)
+ Spacer(minLength: 8)
+ VStack(alignment: .trailing, spacing: 3) {
+ Text(verbatim: formatBytes(g.bytesIn + g.bytesOut))
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ Text(verbatim: "↑ \(formatBytes(g.bytesIn)) ↓ \(formatBytes(g.bytesOut))")
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(.secondary)
+ }
+ .lineLimit(1)
+ .frame(width: 190, alignment: .trailing)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .contentShape(Rectangle())
+ .onTapGesture { toggle(g.ip, in: &expandedHistoryIPs) }
+
+ if expanded {
+ Divider().padding(.leading, 32).padding(.trailing, 12)
+ VStack(spacing: 0) {
+ ForEach(g.records) { rec in
+ HStack(spacing: 8) {
+ Color.clear.frame(width: 12)
+ Text(verbatim: ":\(portOf(rec.client_ip))")
+ .font(.system(size: 11, design: .monospaced))
+ .frame(width: 56, alignment: .leading)
+ Text(verbatim: "\(formatDateTimeShort(rec.connected_at)) – \(formatTimeHHmm(rec.disconnected_at))")
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundColor(.secondary)
+ .frame(width: 180, alignment: .leading)
+ Text(verbatim: formatSecondsToDuration(rec.duration_sec))
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .frame(width: 80, alignment: .leading)
+ Spacer(minLength: 8)
+ Text(verbatim: "↑ \(formatBytes(rec.bytes_in)) ↓ \(formatBytes(rec.bytes_out))")
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(.secondary)
+ .frame(width: 190, alignment: .trailing)
+ }
+ .lineLimit(1)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 5)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ .rowCard()
+ }
+
+ func isLeased(_ ip: String) -> Bool {
+ leases.contains { $0.ip == ip }
+ }
+
+ func leaseName(_ ip: String) -> String? {
+ guard let n = leases.first(where: { $0.ip == ip })?.name, !n.isEmpty else { return nil }
+ return n
+ }
+
+ @ViewBuilder
+ func clientGroupRow(_ g: ClientIPGroup) -> some View {
+ let multi = g.conns.count > 1
+ let expanded = multi && isExpanded(g.ip, in: expandedIPs, groupCount: clientGroups.count)
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(spacing: 8) {
+ chevron(expanded, visible: multi)
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 6) {
+ Text(verbatim: g.ip)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundColor(.accentColor)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ TagView("\(g.conns.count) 个连接")
+ if isLeased(g.ip) {
+ TagView(leaseName(g.ip).map { "已授权 · \($0)" } ?? "已授权", color: .green)
+ }
+ }
+ Text(verbatim: "最早接入 \(formatDateTimeShort(g.firstConnectedAt)) · ↑ \(formatBytes(g.bytesIn)) ↓ \(formatBytes(g.bytesOut))")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ }
+ .layoutPriority(1)
+ Spacer(minLength: 8)
+ RateText(up: g.rateIn, down: g.rateOut)
+ Button(multi ? "全部断开" : "断开", role: .destructive) {
+ Task { await vm.killClients(connIds: g.conns.map { $0.conn_id }) }
+ }
+ .controlSize(.small)
+ .frame(width: 76, alignment: .trailing)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .contentShape(Rectangle())
+ .onTapGesture { if multi { toggle(g.ip, in: &expandedIPs) } }
+
+ if expanded {
+ Divider().padding(.leading, 32).padding(.trailing, 12)
+ VStack(spacing: 0) {
+ ForEach(g.conns) { c in
+ HStack(spacing: 8) {
+ Color.clear.frame(width: 12)
+ Text(verbatim: ":\(portOf(c.client_ip))")
+ .font(.system(size: 11, design: .monospaced))
+ .frame(width: 56, alignment: .leading)
+ Text(verbatim: "接入 \(formatTimeHHmm(c.connected_at))")
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundColor(.secondary)
+ .frame(width: 104, alignment: .leading)
+ Text(verbatim: "↑ \(formatBytes(c.bytes_in)) ↓ \(formatBytes(c.bytes_out))")
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundColor(.secondary)
+ Spacer(minLength: 8)
+ RateText(up: c.rate_in, down: c.rate_out, size: 10)
+ Button("断开") {
+ Task { await vm.killClient(connId: c.conn_id) }
+ }
+ .buttonStyle(.borderless)
+ .foregroundColor(.red)
+ .font(.system(size: 11))
+ .frame(width: 76, alignment: .trailing)
+ }
+ .lineLimit(1)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 5)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ .rowCard()
+ }
+
+ func sectionHeader(_ title: String, _ count: Int) -> some View {
+ Text(verbatim: "\(title) · \(count)")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundColor(.secondary)
+ .padding(.top, 6)
+ }
+
+ func emptyHint(_ text: String) -> some View {
+ Text(verbatim: text)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .padding(.vertical, 2)
+ }
+
+ var leasesTab: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .center, spacing: 8) {
+ Label {
+ Text(verbatim: "同事打开邀请链接并填写姓名后获得访问权限;可访问时长与是否需要审批在每个邀请中设置")
+ } icon: {
+ Image(systemName: "link")
+ }
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ Spacer()
+ if !leases.isEmpty {
+ Button("全部撤销", role: .destructive) { revoke(ip: nil) }
+ .controlSize(.small)
+ .disabled(isRevoking)
+ }
+ Button {
+ showingInviteSheet = true
+ } label: {
+ Label("新建邀请", systemImage: "plus")
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ .disabled(!liveTunnel.enabled)
+ .help(liveTunnel.enabled ? "生成新的邀请链接" : "请先开启通道")
+ }
+
+ if let err = revokeError {
+ Label(err, systemImage: "exclamationmark.triangle.fill")
+ .font(.system(size: 11))
+ .foregroundColor(.red)
+ }
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ if !pendingRequests.isEmpty {
+ sectionHeader("待审批", pendingRequests.count)
+ ForEach(pendingRequests) { r in
+ HStack(spacing: 10) {
+ Image(systemName: "bell.badge.fill")
+ .foregroundColor(.orange)
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 6) {
+ Text(verbatim: r.name)
+ .font(.system(size: 13, weight: .semibold))
+ .lineLimit(1)
+ Text(verbatim: r.ip)
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundColor(.accentColor)
+ }
+ Text(verbatim: "申请于 \(formatDateTimeShort(r.at)) · 批准后可访问 \(leaseDurationTitle(invites.first { $0.code == r.invite }?.lease_duration))")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ }
+ .layoutPriority(1)
+ Spacer(minLength: 8)
+ Button("拒绝") { decide(r, approve: false) }
+ .controlSize(.small)
+ .disabled(isRevoking)
+ Button("批准") { decide(r, approve: true) }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ .disabled(isRevoking)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .rowCard(tint: .orange)
+ }
+ }
+
+ sectionHeader("已授权设备", leases.count)
+ if leases.isEmpty {
+ emptyHint("暂无。点击“新建邀请”把链接发给同事,对方打开并填写姓名后即可访问。")
+ }
+ ForEach(leases) { lease in
+ let remaining = formatRemainingTime(expiresAt: lease.expires_at)
+ let online = vm.activeConns.filter { $0.tunnel_id == tunnel.id && hostOf($0.client_ip) == lease.ip }.count
+ HStack(spacing: 10) {
+ Circle()
+ .fill(online > 0 ? Color.green : Color.secondary.opacity(0.35))
+ .frame(width: 7, height: 7)
+ .help(online > 0 ? "在线" : "当前没有连接")
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 6) {
+ Text(verbatim: lease.name?.isEmpty == false ? lease.name! : "未署名")
+ .font(.system(size: 13, weight: .semibold))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Text(verbatim: lease.ip)
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundColor(.accentColor)
+ .lineLimit(1)
+ if online > 0 {
+ TagView("在线 \(online) 个连接", color: .green)
+ }
+ }
+ if lease.awaiting_connect == true {
+ Text(verbatim: "已授权,尚未连接 · 首次连接后可访问 \(leaseDurationTitle(lease.duration)) · 请在 \(formatDateTimeShort(lease.expires_at)) 前连接")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ } else {
+ Text(verbatim: (lease.started_at.map { "首次连接 \(formatDateTimeShort($0)) · " } ?? "") + "有效期至 \(formatDateTimeShort(lease.expires_at)) · \(remaining.text)")
+ .font(.system(size: 11))
+ .foregroundColor(remaining.isExpiringSoon ? .orange : .secondary)
+ .lineLimit(1)
+ }
+ }
+ .layoutPriority(1)
+ Spacer(minLength: 8)
+ Button("撤销", role: .destructive) { revoke(ip: lease.ip) }
+ .controlSize(.small)
+ .disabled(isRevoking)
+ .help("立即收回该设备的访问权限并断开连接")
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .rowCard()
+ }
+
+ sectionHeader("有效的邀请链接", invites.count)
+ if invites.isEmpty {
+ emptyHint("暂无有效的邀请链接")
+ }
+ ForEach(invites) { inv in
+ let used = inv.uses ?? []
+ let full = inv.max_uses > 0 && used.count >= inv.max_uses
+ HStack(spacing: 10) {
+ Image(systemName: "link")
+ .foregroundColor(.secondary)
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 6) {
+ Text(verbatim: inv.note?.isEmpty == false ? "给 \(inv.note!)" : "未备注的邀请")
+ .font(.system(size: 13, weight: .semibold))
+ .lineLimit(1)
+ TagView("已用 \(used.count)/\(inv.max_uses > 0 ? "\(inv.max_uses)" : "不限")", color: full ? .orange : .secondary)
+ TagView("授权后可访问 \(leaseDurationTitle(inv.lease_duration))", color: .blue)
+ if inv.require_approval == true {
+ TagView("需审批", color: .orange)
+ }
+ }
+ Text(verbatim: "链接有效至 \(formatDateTimeShort(inv.expires_at))" + (used.isEmpty ? " · 尚未使用" : " · \(used.map { $0.name }.joined(separator: "、"))"))
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+ .layoutPriority(1)
+ Spacer(minLength: 8)
+ Button(copiedCode == inv.code ? "已复制 ✓" : "复制分享文案") {
+ Task {
+ let (resp, _) = await vm.inviteShare(tunnelId: tunnel.id, code: inv.code)
+ copyToPasteboard(resp?.share_text ?? (vm.inviteBase + inv.code))
+ copiedCode = inv.code
+ }
+ }
+ .controlSize(.small)
+ .disabled(full)
+ .help(full ? "该链接已用满,请新建邀请" : "复制可直接发给同事的文案")
+ Button("作废", role: .destructive) {
+ isRevoking = true
+ Task {
+ revokeError = await vm.revokeInvite(tunnelId: tunnel.id, code: inv.code)
+ isRevoking = false
+ }
+ }
+ .controlSize(.small)
+ .disabled(isRevoking)
+ .help("作废后链接失效,通过它获得的访问权限也会被收回")
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .rowCard()
+ }
+ }
+ .padding(.bottom, 8)
+ }
+ }
+ .sheet(isPresented: $showingInviteSheet) {
+ InviteSheet(vm: vm, tunnel: liveTunnel, autoGenerate: false, isPresented: $showingInviteSheet)
+ }
+ }
+
+ func decide(_ r: AccessRequestItem, approve: Bool) {
+ isRevoking = true
+ Task {
+ revokeError = await vm.decideRequest(tunnelId: tunnel.id, requestId: r.id, approve: approve)
+ isRevoking = false
+ }
+ }
+
+ func revoke(ip: String?) {
+ isRevoking = true
+ revokeError = nil
+ Task {
+ revokeError = await vm.revokeLease(tunnelId: tunnel.id, ip: ip)
+ isRevoking = false
+ }
+ }
+
+ func loadHistory() {
+ isLoadingHistory = true
+ Task {
+ let list = await vm.fetchHistory(tunnelId: tunnel.id)
+ historyRecords = list
+ isLoadingHistory = false
+ }
+ }
+}
+
+// MARK: - Diagnostics & Permission Status Sheet (Live Check)
+
+struct DiagnosticsSheet: View {
+ @ObservedObject var vm: PortRelayViewModel
+ @Binding var isPresented: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ // Header
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("系统权限与网络环境诊断")
+ .font(.system(size: 15, weight: .bold))
+ Text("检测本机网络授权、中继服务器通信与后台引擎状态")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ Button(action: { vm.runDiagnostics() }) {
+ Label("重新检测", systemImage: "arrow.clockwise")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+
+ Button("完成") { isPresented = false }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+
+ Divider()
+
+ // Diagnostic Items List
+ VStack(spacing: 10) {
+ ForEach(vm.diagnostics) { item in
+ HStack(alignment: .top, spacing: 12) {
+ Group {
+ switch item.status {
+ case .ok:
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.green)
+ case .warning:
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.orange)
+ case .error:
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.red)
+ case .checking:
+ ProgressView().controlSize(.small)
+ }
+ }
+ .font(.system(size: 16))
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(item.title)
+ .font(.system(size: 13, weight: .semibold))
+ Spacer()
+ Text(item.statusText)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundColor(item.status == .ok ? .green : (item.status == .error ? .red : .orange))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 1.5)
+ .background((item.status == .ok ? Color.green : (item.status == .error ? Color.red : Color.orange)).opacity(0.12))
+ .cornerRadius(4)
+ }
+
+ Text(item.desc)
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ .lineSpacing(2)
+
+ if let actionTitle = item.actionTitle, let action = item.action {
+ Button(actionTitle, action: action)
+ .buttonStyle(.link)
+ .font(.system(size: 12))
+ .padding(.top, 2)
+ }
+ }
+ }
+ .padding(12)
+ .rowCard(tint: item.status == .error ? .red : (item.status == .warning ? .orange : nil))
+ }
+ }
+
+ Spacer()
+
+ Text("说明:若 macOS 弹出「PortRelay 想要查找并连接本地网络上的设备」提示,请选择「允许」。")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ .padding(20)
+ .frame(width: 580, height: 460)
+ }
+}
+
+// MARK: - Edit / Create Sheet (With Owner & Duration TTL)
+
+struct TunnelEditSheet: View {
+ @ObservedObject var vm: PortRelayViewModel
+ let tunnel: TunnelItem?
+ @Binding var isPresented: Bool
+
+ @State private var name: String
+ @State private var owner: String
+ @State private var target: String
+ @State private var portString: String
+ @State private var durationMinutes: Int
+ @State private var idleCloseMinutes: Int
+ @State private var maxBandwidthMB: Double // 0 为不限速
+ @State private var whitelistString: String
+ @State private var accessMode: AccessMode
+ @State private var probeMessage: String = ""
+ @State private var isProbing: Bool = false
+ @State private var errorMessage: String? = nil
+
+ var onCreated: ((String) -> Void)? = nil
+
+ enum AccessMode: String, CaseIterable {
+ case linkOnly, specific, all
+ }
+
+ var isEdit: Bool { tunnel != nil }
+
+ /// 表单字段在创建时就用通道数据填好,不依赖 onAppear(避免第一帧显示空表单)
+ init(vm: PortRelayViewModel, tunnel: TunnelItem?, isPresented: Binding, onCreated: ((String) -> Void)? = nil) {
+ _vm = ObservedObject(wrappedValue: vm)
+ self.tunnel = tunnel
+ self.onCreated = onCreated
+ _isPresented = isPresented
+ let wl = tunnel?.whitelist ?? []
+ let mode: AccessMode = wl.isEmpty ? .linkOnly : (wl.contains("*") ? .all : .specific)
+ _accessMode = State(initialValue: mode)
+ _name = State(initialValue: tunnel?.name ?? "")
+ _owner = State(initialValue: tunnel.map { $0.owner ?? "" } ?? NSUserName())
+ _target = State(initialValue: tunnel?.target ?? "")
+ _portString = State(initialValue: tunnel.map { "\($0.listen_port)" } ?? "")
+ // duration_minutes 为 0 时 JSON 中会省略,缺省即“不限制”;新建默认 2 小时
+ // 最长开启时长:新建默认不限制(主要依靠闲置自动关闭)
+ _durationMinutes = State(initialValue: tunnel.map { $0.duration_minutes ?? 0 } ?? 0)
+ _idleCloseMinutes = State(initialValue: tunnel?.effective_idle_close_minutes ?? 30)
+ _maxBandwidthMB = State(initialValue: tunnel?.max_bandwidth_mb ?? 0)
+ _whitelistString = State(initialValue: mode == .specific ? wl.joined(separator: ", ") : "")
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Text(isEdit ? "编辑通道" : "添加通道")
+ .font(.system(size: 15, weight: .bold))
+
+ HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("通道名称")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ TextField("例如:MySQL 实习库", text: $name)
+ .textFieldStyle(.roundedBorder)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("关联责任人")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ TextField("例如:张三 / 数据组", text: $owner)
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("目标服务 (内网主机 IP:端口)")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ HStack {
+ TextField("例如 10.0.0.10:3306", text: $target)
+ .textFieldStyle(.roundedBorder)
+ Button(isProbing ? "测试中" : "连通性测试") {
+ Task {
+ isProbing = true
+ let (_, msg) = await vm.probeTarget(target: target)
+ probeMessage = msg
+ isProbing = false
+ }
+ }
+ .disabled(target.isEmpty || isProbing)
+ .controlSize(.small)
+ }
+ if !probeMessage.isEmpty {
+ Text(probeMessage)
+ .font(.system(size: 11))
+ .foregroundColor(probeMessage.contains("成功") ? .green : .red)
+ }
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("对外监听端口 (范围 33001-34000)")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ HStack {
+ TextField("33660", text: $portString)
+ .textFieldStyle(.roundedBorder)
+ Button("自动分配") {
+ Task {
+ let p = await vm.recommendPort()
+ portString = "\(p)"
+ }
+ }
+ .controlSize(.small)
+ }
+ }
+
+ // Auto close: idle (primary) + optional hard cap
+ VStack(alignment: .leading, spacing: 6) {
+ Text("闲置自动关闭(没有连接、有效授权、待审批和可用邀请时开始计时)")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ HStack(spacing: 6) {
+ ForEach(idleCloseOptions, id: \.0) { opt in
+ DurationChip(title: opt.1, minutes: opt.0, selectedMinutes: $idleCloseMinutes)
+ }
+ }
+ Text("最长开启时长(可选,到时无论是否在用都会关闭)")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ .padding(.top, 2)
+ HStack(spacing: 6) {
+ DurationChip(title: "不限制", minutes: 0, selectedMinutes: $durationMinutes)
+ DurationChip(title: "2 小时", minutes: 120, selectedMinutes: $durationMinutes)
+ DurationChip(title: "8 小时", minutes: 480, selectedMinutes: $durationMinutes)
+ DurationChip(title: "1 天", minutes: 1440, selectedMinutes: $durationMinutes)
+ DurationChip(title: "7 天", minutes: 10080, selectedMinutes: $durationMinutes)
+ // 兼容旧配置中的其他时长
+ if ![0, 120, 480, 1440, 10080].contains(durationMinutes) {
+ DurationChip(title: formatSecondsToDuration(Int64(durationMinutes) * 60), minutes: durationMinutes, selectedMinutes: $durationMinutes)
+ }
+ }
+ }
+
+ // QoS Bandwidth Limit Option
+ VStack(alignment: .leading, spacing: 4) {
+ Text("单通道带宽限速 (QoS 防挤占)")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+
+ HStack(spacing: 6) {
+ BandwidthChip(title: "不限速", mb: 0, selectedMB: $maxBandwidthMB)
+ BandwidthChip(title: "2 MB/s", mb: 2, selectedMB: $maxBandwidthMB)
+ BandwidthChip(title: "5 MB/s", mb: 5, selectedMB: $maxBandwidthMB)
+ BandwidthChip(title: "10 MB/s", mb: 10, selectedMB: $maxBandwidthMB)
+ BandwidthChip(title: "20 MB/s", mb: 20, selectedMB: $maxBandwidthMB)
+ }
+ }
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text("访问控制")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ Picker("", selection: $accessMode) {
+ Text("仅授权链接(推荐)").tag(AccessMode.linkOnly)
+ Text("固定 IP / 网段").tag(AccessMode.specific)
+ Text("全部放通 *").tag(AccessMode.all)
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+
+ switch accessMode {
+ case .linkOnly:
+ Text("只有通过邀请链接授权的设备可以访问,保存后可以直接设置并生成邀请链接。")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ case .specific:
+ TextField("例如 192.168.1.100, 192.168.1.0/24(逗号分隔)", text: $whitelistString)
+ .textFieldStyle(.roundedBorder)
+ Text("名单内的设备始终可以访问;其他人仍可通过邀请链接临时授权。")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ case .all:
+ Label("任何能访问服务器的设备都可以连接,不建议用于数据库等敏感服务。", systemImage: "exclamationmark.triangle.fill")
+ .font(.system(size: 11))
+ .foregroundColor(.red)
+ }
+ }
+
+ if let err = errorMessage {
+ Text(err)
+ .font(.system(size: 12))
+ .foregroundColor(.red)
+ }
+
+ HStack {
+ Spacer()
+ Button("取消") { isPresented = false }
+ .controlSize(.small)
+ Button("保存") {
+ guard let p = Int(portString), p > 33000 && p <= 34000 else {
+ errorMessage = "端口须在 33001-34000 范围内"
+ return
+ }
+ var wl: [String] = []
+ switch accessMode {
+ case .linkOnly:
+ wl = []
+ case .all:
+ wl = ["*"]
+ case .specific:
+ wl = whitelistString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
+ if wl.isEmpty {
+ errorMessage = "请填写允许访问的 IP 或网段,或选择“仅授权链接”"
+ return
+ }
+ if wl.contains("*") {
+ errorMessage = "如需放通全部,请选择“全部放通 *”"
+ return
+ }
+ }
+ Task {
+ let (ok, err, savedId) = await vm.saveTunnel(
+ id: tunnel?.id,
+ name: name,
+ target: target,
+ port: p,
+ whitelist: wl,
+ owner: owner.trimmingCharacters(in: .whitespaces),
+ durationMinutes: durationMinutes,
+ idleCloseMinutes: idleCloseMinutes,
+ maxBandwidthMB: maxBandwidthMB
+ )
+ if ok {
+ isPresented = false
+ if !isEdit, accessMode != .all, let id = savedId {
+ onCreated?(id)
+ }
+ } else {
+ errorMessage = err ?? "保存失败"
+ }
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ .disabled(name.isEmpty || target.isEmpty)
+ }
+ .padding(.top, 4)
+ }
+ .padding(20)
+ .frame(width: 490)
+ .onAppear {
+ if tunnel == nil {
+ Task {
+ let p = await vm.recommendPort()
+ if portString.isEmpty { portString = "\(p)" }
+ }
+ }
+ }
+ }
+}
+
+struct InviteSheetTarget: Identifiable {
+ let tunnel: TunnelItem
+ let autoGenerate: Bool
+ var id: String { tunnel.id }
+}
+
+/// 生成邀请链接并展示分享卡片(二维码 + 可直接发送的文案)
+struct InviteSheet: View {
+ @ObservedObject var vm: PortRelayViewModel
+ let tunnel: TunnelItem
+ let autoGenerate: Bool
+ @Binding var isPresented: Bool
+
+ @State private var note: String = ""
+ @State private var validMinutes: Int = 24 * 60
+ @State private var maxUses: Int = 1
+ @State private var leaseDuration: String = "2h"
+ @State private var requireApproval: Bool = false
+ @State private var result: InviteShareResp? = nil
+ @State private var errorMessage: String? = nil
+ @State private var isWorking = false
+ @State private var copied: String? = nil
+
+ let validOptions: [(Int, String)] = [(60, "1 小时"), (24 * 60, "24 小时"), (7 * 24 * 60, "7 天")]
+ let useOptions: [(Int, String)] = [(1, "1 台"), (3, "3 台"), (0, "不限")]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(verbatim: "邀请访问「\(tunnel.name)」")
+ .font(.system(size: 15, weight: .bold))
+ Text(verbatim: result == nil
+ ? "同事打开链接并填写姓名后获得访问权限"
+ : "授权后可访问 \(leaseDurationTitle(result?.invite.lease_duration))" + (result?.invite.require_approval == true ? ",需你审批" : ",无需审批"))
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ Button("完成") { isPresented = false }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+
+ if let r = result {
+ HStack(alignment: .top, spacing: 16) {
+ if let qr = makeQRCode(r.link) {
+ VStack(spacing: 4) {
+ Image(nsImage: qr)
+ .interpolation(.none)
+ .resizable()
+ .frame(width: 150, height: 150)
+ .background(Color.white)
+ .cornerRadius(6)
+ Text("手机扫码打开")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ }
+ }
+ VStack(alignment: .leading, spacing: 8) {
+ ScrollView {
+ Text(verbatim: r.share_text)
+ .font(.system(size: 12))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(height: 130)
+ .padding(8)
+ .background(Color(NSColor.textBackgroundColor))
+ .cornerRadius(6)
+ .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color(NSColor.separatorColor), lineWidth: 1))
+ HStack {
+ Button(copied == "text" ? "已复制" : "复制分享文案") {
+ copyToPasteboard(r.share_text)
+ copied = "text"
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ Button(copied == "link" ? "已复制" : "仅复制链接") {
+ copyToPasteboard(r.link)
+ copied = "link"
+ }
+ .controlSize(.small)
+ Spacer()
+ Button("再生成一个") {
+ result = nil
+ copied = nil
+ }
+ .buttonStyle(.link)
+ .font(.system(size: 11))
+ }
+ }
+ }
+ } else {
+ VStack(alignment: .leading, spacing: 10) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("备注(发给谁,可选)")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ TextField("例如:张三", text: $note)
+ .textFieldStyle(.roundedBorder)
+ }
+ HStack(spacing: 6) {
+ Text("链接有效期")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ .frame(width: 84, alignment: .leading)
+ ForEach(validOptions, id: \.0) { opt in
+ DurationChip(title: opt.1, minutes: opt.0, selectedMinutes: $validMinutes)
+ }
+ }
+ HStack(spacing: 6) {
+ Text("可用设备数")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ .frame(width: 84, alignment: .leading)
+ ForEach(useOptions, id: \.0) { opt in
+ DurationChip(title: opt.1, minutes: opt.0, selectedMinutes: $maxUses)
+ }
+ }
+ HStack(spacing: 6) {
+ Text("授权后可访问")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ .frame(width: 84, alignment: .leading)
+ ForEach(leaseDurationOptions, id: \.value) { opt in
+ StringChip(title: opt.title, value: opt.value, selection: $leaseDuration)
+ }
+ }
+ HStack(spacing: 6) {
+ Text("审批")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ .frame(width: 84, alignment: .leading)
+ Toggle("同事打开链接后需要我批准才放通", isOn: $requireApproval)
+ .font(.system(size: 12))
+ }
+ Text("“链接有效期”是同事必须在多久内打开链接;“授权后可访问”是打开并授权后能使用多久。链接用满设备数后自动失效,也可随时在“授权管理”中作废。")
+ .fixedSize(horizontal: false, vertical: true)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ HStack {
+ Spacer()
+ Button(isWorking ? "生成中…" : "生成邀请链接") { generate() }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ .disabled(isWorking)
+ }
+ }
+ }
+
+ if let err = errorMessage {
+ Text(err)
+ .font(.system(size: 12))
+ .foregroundColor(.red)
+ }
+ }
+ .padding(20)
+ .frame(width: 560)
+ .onAppear {
+ if autoGenerate { generate() }
+ }
+ }
+
+ func generate() {
+ isWorking = true
+ errorMessage = nil
+ Task {
+ let (resp, err) = await vm.createInvite(tunnelId: tunnel.id, note: note.trimmingCharacters(in: .whitespaces),
+ validMinutes: validMinutes, maxUses: maxUses, leaseDuration: leaseDuration,
+ requireApproval: requireApproval)
+ result = resp
+ errorMessage = err
+ isWorking = false
+ }
+ }
+}
+
+struct DurationChip: View {
+ let title: String
+ let minutes: Int
+ @Binding var selectedMinutes: Int
+
+ var isSelected: Bool { selectedMinutes == minutes }
+
+ var body: some View {
+ Button(action: { selectedMinutes = minutes }) {
+ ChipLabel(title: title, selected: isSelected)
+ }
+ .buttonStyle(.borderless)
+ }
+}
+
+struct BandwidthChip: View {
+ let title: String
+ let mb: Double
+ @Binding var selectedMB: Double
+
+ var isSelected: Bool { abs(selectedMB - mb) < 0.01 }
+
+ var body: some View {
+ Button(action: { selectedMB = mb }) {
+ ChipLabel(title: title, selected: isSelected, tint: .purple)
+ }
+ .buttonStyle(.borderless)
+ }
+}
+
+// MARK: - Settings Sheet
+
+struct SettingsSheet: View {
+ @ObservedObject var vm: PortRelayViewModel
+ @Binding var isPresented: Bool
+
+ @State private var serverAddr: String = ""
+ @State private var token: String = ""
+ @State private var publicHost: String = ""
+ @State private var errorMessage: String? = nil
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("设置")
+ .font(.system(size: 15, weight: .bold))
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("服务器控制地址 (地址:端口)")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ TextField("例如 gw.example.com:33000", text: $serverAddr)
+ .textFieldStyle(.roundedBorder)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("安全令牌")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ SecureField("与服务器一致的令牌", text: $token)
+ .textFieldStyle(.roundedBorder)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("对外访问地址 (访问者连接通道时使用)")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ TextField("留空则使用服务器地址", text: $publicHost)
+ .textFieldStyle(.roundedBorder)
+ }
+
+ if let err = errorMessage {
+ Text(err)
+ .font(.system(size: 12))
+ .foregroundColor(.red)
+ }
+
+ HStack {
+ Spacer()
+ Button("取消") { isPresented = false }
+ .controlSize(.small)
+ Button("保存并重连") {
+ Task {
+ if let err = await vm.saveSettings(serverAddr: serverAddr, token: token, publicHost: publicHost) {
+ errorMessage = err
+ } else {
+ isPresented = false
+ }
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ .padding(.top, 6)
+ }
+ .padding(20)
+ .frame(width: 400)
+ .onAppear {
+ Task {
+ let (addr, t, host) = await vm.fetchSettings()
+ serverAddr = addr
+ token = t
+ publicHost = host
+ }
+ }
+ }
+}
+
+// MARK: - App Delegate & Menu Bar Extra
+
+@MainActor
+class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
+ var statusItem: NSStatusItem?
+ private var mainWindow: NSWindow?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ PortRelayViewModel.shared.appDelegate = self
+ setupStatusItem()
+
+ // Hook window close button so it hides to menu bar instead of quitting
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
+ if let window = NSApp.windows.first(where: { $0.title == "PortRelay" || $0.canBecomeMain }) {
+ self?.mainWindow = window
+ window.delegate = self
+ }
+ }
+ }
+
+ /// 关闭窗口:隐藏到菜单栏,同时从 Dock 移除图标
+ func windowShouldClose(_ sender: NSWindow) -> Bool {
+ sender.orderOut(nil)
+ NSApp.setActivationPolicy(.accessory)
+ return false
+ }
+
+ /// 彻底退出:先结束后台引擎再退出 App
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ Task { @MainActor in
+ await PortRelayViewModel.shared.shutdownAgent()
+ sender.reply(toApplicationShouldTerminate: true)
+ }
+ return .terminateLater
+ }
+
+ func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+ if !flag {
+ showMainWindow()
+ }
+ return true
+ }
+
+ private func setupStatusItem() {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ if let button = statusItem?.button {
+ button.image = NSImage(systemSymbolName: "arrow.left.arrow.right.circle", accessibilityDescription: "PortRelay")
+ button.imagePosition = .imageLeading
+ button.title = " 0"
+ }
+ rebuildMenu()
+ }
+
+ func updateStatusItemTitle(activeCount: Int) {
+ guard let button = self.statusItem?.button else { return }
+ let isConnected = PortRelayViewModel.shared.isConnected
+ let pendingCount = PortRelayViewModel.shared.pendingRequests.count
+ var iconName = isConnected ? (activeCount > 0 ? "arrow.left.arrow.right.circle.fill" : "arrow.left.arrow.right.circle") : "exclamationmark.triangle"
+ if pendingCount > 0 { iconName = "bell.badge.fill" }
+ button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: "PortRelay")
+ button.title = pendingCount > 0 ? " 待审批 \(pendingCount)" : " \(activeCount)"
+ if PortRelayViewModel.shared.menuNeedsRebuild() {
+ self.rebuildMenu()
+ }
+ }
+
+ func rebuildMenu() {
+ let menu = NSMenu()
+ let vm = PortRelayViewModel.shared
+
+ // Status Title
+ let header = NSMenuItem(title: "PortRelay 端口中继", action: nil, keyEquivalent: "")
+ header.isEnabled = false
+ menu.addItem(header)
+
+ let connStatus = vm.isConnected ? "🟢 服务器连接正常" : "🔴 服务器未连接 / 重试中"
+ let statusItem = NSMenuItem(title: connStatus, action: nil, keyEquivalent: "")
+ statusItem.isEnabled = false
+ menu.addItem(statusItem)
+
+ menu.addItem(NSMenuItem.separator())
+
+ // Pending approvals
+ let pending = vm.pendingRequests
+ if !pending.isEmpty {
+ let title = NSMenuItem(title: "待审批的访问申请 (\(pending.count)):", action: nil, keyEquivalent: "")
+ title.isEnabled = false
+ menu.addItem(title)
+ for (t, r) in pending {
+ let item = NSMenuItem(title: "\(r.name)(\(r.ip))→ \(t.name)", action: nil, keyEquivalent: "")
+ let sub = NSMenu()
+ let approve = NSMenuItem(title: "批准", action: #selector(decideAction(_:)), keyEquivalent: "")
+ approve.representedObject = [t.id, r.id, "1"]
+ approve.target = self
+ let reject = NSMenuItem(title: "拒绝", action: #selector(decideAction(_:)), keyEquivalent: "")
+ reject.representedObject = [t.id, r.id, "0"]
+ reject.target = self
+ sub.addItem(approve)
+ sub.addItem(reject)
+ item.submenu = sub
+ menu.addItem(item)
+ }
+ menu.addItem(NSMenuItem.separator())
+ }
+
+ // Tunnels section
+ if vm.tunnels.isEmpty {
+ let emptyItem = NSMenuItem(title: "暂无通道", action: nil, keyEquivalent: "")
+ emptyItem.isEnabled = false
+ menu.addItem(emptyItem)
+ } else {
+ let tunnelSection = NSMenuItem(title: "中继通道列表 (\(vm.tunnels.filter { $0.enabled }.count)/\(vm.tunnels.count) 开启):", action: nil, keyEquivalent: "")
+ tunnelSection.isEnabled = false
+ menu.addItem(tunnelSection)
+
+ for t in vm.tunnels {
+ let check = t.enabled ? "✓ " : " "
+ let title = "\(check)\(t.name) (:\(t.listen_port))"
+ let item = NSMenuItem(title: title, action: #selector(toggleTunnelAction(_:)), keyEquivalent: "")
+ item.representedObject = t.id
+ item.target = self
+ menu.addItem(item)
+ }
+ }
+
+ menu.addItem(NSMenuItem.separator())
+
+ // Quick Actions
+ let showItem = NSMenuItem(title: "打开主控制台", action: #selector(showMainWindowAction), keyEquivalent: "o")
+ showItem.target = self
+ menu.addItem(showItem)
+
+ let reconnectItem = NSMenuItem(title: "立即重连网关", action: #selector(reconnectAction), keyEquivalent: "r")
+ reconnectItem.target = self
+ menu.addItem(reconnectItem)
+
+ menu.addItem(NSMenuItem.separator())
+
+ let quitItem = NSMenuItem(title: "彻底退出 PortRelay (含后台引擎)", action: #selector(quitAction), keyEquivalent: "q")
+ quitItem.target = self
+ menu.addItem(quitItem)
+
+ self.statusItem?.menu = menu
+ }
+
+ @objc func toggleTunnelAction(_ sender: NSMenuItem) {
+ guard let id = sender.representedObject as? String else { return }
+ Task {
+ await PortRelayViewModel.shared.toggleTunnel(id: id)
+ }
+ }
+
+ @objc func decideAction(_ sender: NSMenuItem) {
+ guard let v = sender.representedObject as? [String], v.count == 3 else { return }
+ Task {
+ _ = await PortRelayViewModel.shared.decideRequest(tunnelId: v[0], requestId: v[1], approve: v[2] == "1")
+ }
+ }
+
+ @objc func showMainWindowAction() {
+ showMainWindow()
+ }
+
+ @objc func reconnectAction() {
+ Task {
+ await PortRelayViewModel.shared.triggerReconnect(force: true)
+ }
+ }
+
+ @objc func quitAction() {
+ NSApp.terminate(nil)
+ }
+
+ func showMainWindow() {
+ // 打开主窗口时恢复 Dock 图标
+ NSApp.setActivationPolicy(.regular)
+ NSApp.activate(ignoringOtherApps: true)
+ if let window = mainWindow {
+ window.makeKeyAndOrderFront(nil)
+ } else if let window = NSApp.windows.first(where: { $0.title == "PortRelay" || $0.canBecomeMain }) {
+ mainWindow = window
+ window.delegate = self
+ window.makeKeyAndOrderFront(nil)
+ }
+ }
+}
+
+// MARK: - App Main
+
+@main
+struct PortRelayApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ var body: some Scene {
+ WindowGroup("PortRelay") {
+ ContentView()
+ }
+ .windowStyle(.titleBar)
+ .defaultSize(width: 920, height: 640)
+ }
+}
+
diff --git a/mac-native/generate_icon.swift b/mac-native/generate_icon.swift
new file mode 100644
index 0000000..74faf98
--- /dev/null
+++ b/mac-native/generate_icon.swift
@@ -0,0 +1,106 @@
+import AppKit
+
+let size: CGFloat = 1024
+let img = NSImage(size: NSSize(width: size, height: size))
+img.lockFocus()
+
+guard let ctx = NSGraphicsContext.current?.cgContext else {
+ exit(1)
+}
+
+// 1. Draw rounded squircle background (Apple style macOS icon radius)
+let rect = NSRect(x: 80, y: 80, width: 864, height: 864)
+let path = NSBezierPath(roundedRect: rect, xRadius: 190, yRadius: 190)
+
+// Shadow
+ctx.saveGState()
+ctx.setShadow(offset: CGSize(width: 0, height: -20), blur: 50, color: NSColor.black.withAlphaComponent(0.6).cgColor)
+NSColor.black.setFill()
+path.fill()
+ctx.restoreGState()
+
+// Gradient Fill
+let colorSpace = CGColorSpaceCreateDeviceRGB()
+let gradientColors = [
+ NSColor(red: 0.05, green: 0.10, blue: 0.22, alpha: 1.0).cgColor,
+ NSColor(red: 0.08, green: 0.16, blue: 0.35, alpha: 1.0).cgColor,
+ NSColor(red: 0.02, green: 0.45, blue: 0.75, alpha: 1.0).cgColor
+] as CFArray
+let locations: [CGFloat] = [0.0, 0.5, 1.0]
+if let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: locations) {
+ ctx.saveGState()
+ path.addClip()
+ ctx.drawLinearGradient(gradient, start: CGPoint(x: 80, y: 944), end: CGPoint(x: 944, y: 80), options: [])
+
+ // Border highlight
+ ctx.setStrokeColor(NSColor.white.withAlphaComponent(0.2).cgColor)
+ ctx.setLineWidth(4)
+ ctx.stroke(rect.insetBy(dx: 2, dy: 2))
+
+ ctx.restoreGState()
+}
+
+// 2. Outer glowing ring (Network relay portal)
+ctx.saveGState()
+ctx.setShadow(offset: .zero, blur: 40, color: NSColor(red: 0.22, green: 0.74, blue: 0.97, alpha: 0.8).cgColor)
+let ringRect = NSRect(x: 232, y: 232, width: 560, height: 560)
+let ringPath = NSBezierPath(ovalIn: ringRect)
+ringPath.lineWidth = 28
+NSColor(red: 0.22, green: 0.74, blue: 0.97, alpha: 0.9).setStroke()
+ringPath.stroke()
+ctx.restoreGState()
+
+// 3. Inner lightning bolt & relay arrow
+let boltPath = NSBezierPath()
+// Center coordinates
+let cx: CGFloat = 512
+let cy: CGFloat = 512
+
+boltPath.move(to: NSPoint(x: cx + 30, y: cy + 240))
+boltPath.line(to: NSPoint(x: cx - 140, y: cy + 20))
+boltPath.line(to: NSPoint(x: cx - 10, y: cy + 20))
+boltPath.line(to: NSPoint(x: cx - 50, y: cy - 240))
+boltPath.line(to: NSPoint(x: cx + 150, y: cy - 20))
+boltPath.line(to: NSPoint(x: cx + 15, y: cy - 20))
+boltPath.close()
+
+ctx.saveGState()
+ctx.setShadow(offset: .zero, blur: 50, color: NSColor.white.withAlphaComponent(0.9).cgColor)
+NSColor.white.setFill()
+boltPath.fill()
+ctx.restoreGState()
+
+img.unlockFocus()
+
+// Save iconset
+// 在仓库根目录运行(build-app.sh 会先 cd 到仓库根目录)
+let iconsetDir = "mac-native/AppIcon.iconset"
+try? FileManager.default.createDirectory(atPath: iconsetDir, withIntermediateDirectories: true)
+
+let sizes: [(String, Int)] = [
+ ("icon_16x16.png", 16),
+ ("icon_16x16@2x.png", 32),
+ ("icon_32x32.png", 32),
+ ("icon_32x32@2x.png", 64),
+ ("icon_128x128.png", 128),
+ ("icon_128x128@2x.png", 256),
+ ("icon_256x256.png", 256),
+ ("icon_256x256@2x.png", 512),
+ ("icon_512x512.png", 512),
+ ("icon_512x512@2x.png", 1024)
+]
+
+for (name, sz) in sizes {
+ let destImg = NSImage(size: NSSize(width: sz, height: sz))
+ destImg.lockFocus()
+ img.draw(in: NSRect(x: 0, y: 0, width: sz, height: sz))
+ destImg.unlockFocus()
+ if let tiff = destImg.tiffRepresentation,
+ let rep = NSBitmapImageRep(data: tiff),
+ let png = rep.representation(using: .png, properties: [:]) {
+ let filePath = "\(iconsetDir)/\(name)"
+ try? png.write(to: URL(fileURLWithPath: filePath))
+ }
+}
+
+print("Iconset PNGs generated successfully.")
diff --git a/mac-native/set_icon.swift b/mac-native/set_icon.swift
new file mode 100644
index 0000000..2060b27
--- /dev/null
+++ b/mac-native/set_icon.swift
@@ -0,0 +1,10 @@
+import AppKit
+
+// 用法: swift mac-native/set_icon.swift [PortRelay.app](默认相对当前目录)
+let appPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "PortRelay.app"
+let iconPath = appPath + "/Contents/Resources/AppIcon.icns"
+
+if let img = NSImage(contentsOfFile: iconPath) {
+ let ok = NSWorkspace.shared.setIcon(img, forFile: appPath, options: [])
+ print("NSWorkspace setIcon result:", ok)
+}
diff --git a/server/manager.go b/server/manager.go
new file mode 100644
index 0000000..5663e3c
--- /dev/null
+++ b/server/manager.go
@@ -0,0 +1,611 @@
+package server
+
+import (
+ "bufio"
+ "crypto/subtle"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/hashicorp/yamux"
+
+ "github.com/DeepFal/PortRelay/core/protocol"
+)
+
+const (
+ DefaultPortMin = 33000
+ DefaultPortMax = 34000
+
+ // authTimeout 是新连接完成鉴权的最长等待时间,超时直接断开
+ authTimeout = 10 * time.Second
+)
+
+// agentSession 表示一个已建立的 Agent 连接,控制流写入与指标推送都绑定在该会话上
+type agentSession struct {
+ sess *yamux.Session
+ writer *bufio.Writer
+ writeMu sync.Mutex
+ done chan struct{}
+}
+
+func (s *agentSession) send(v any) {
+ data, err := json.Marshal(v)
+ if err != nil {
+ return
+ }
+ data = append(data, '\n')
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ _, _ = s.writer.Write(data)
+ _ = s.writer.Flush()
+}
+
+// Manager manages all active tunnels on the server node
+type Manager struct {
+ portMin int
+ portMax int
+ token string
+
+ // PublicHost 显示在授权页面上的中转访问地址;为空时使用访问者打开链接时的主机名
+ PublicHost string
+ // TrustProxyHeaders 为 true 时 /auth 信任 X-Forwarded-For / X-Real-IP(仅在前置可信反向代理时开启)
+ TrustProxyHeaders bool
+
+ mu sync.RWMutex
+ active *agentSession
+ tunnels map[string]*Tunnel
+ portMap map[int]string // port -> tunnelID
+
+ secEvents []protocol.SecurityEvent
+ secEventsMu sync.Mutex
+
+ state *stateStore // 临时放通、邀请链接、审批申请
+}
+
+// NewManager creates a manager
+func NewManager(portMin, portMax int, token string) *Manager {
+ if portMin <= 0 {
+ portMin = DefaultPortMin
+ }
+ if portMax <= 0 {
+ portMax = DefaultPortMax
+ }
+ return &Manager{
+ portMin: portMin,
+ portMax: portMax,
+ token: token,
+ tunnels: make(map[string]*Tunnel),
+ portMap: make(map[int]string),
+ secEvents: make([]protocol.SecurityEvent, 0),
+ state: newStateStore(),
+ }
+}
+
+// HandleSession handles an incoming connection from the Mac agent.
+// 只有在控制流上通过 token 鉴权后,该连接才会成为当前活跃会话。
+func (m *Manager) HandleSession(rawConn net.Conn) error {
+ defer rawConn.Close()
+
+ cfg := yamux.DefaultConfig()
+ cfg.EnableKeepAlive = true
+ cfg.KeepAliveInterval = 10 * time.Second
+ cfg.LogOutput = io.Discard
+
+ session, err := yamux.Server(rawConn, cfg)
+ if err != nil {
+ return fmt.Errorf("yamux server init error: %w", err)
+ }
+ defer session.Close()
+
+ // 非 Agent 连接(端口扫描、探活等)在超时内打不开控制流,直接丢弃
+ type acceptResult struct {
+ stream net.Conn
+ err error
+ }
+ acceptCh := make(chan acceptResult, 1)
+ go func() {
+ st, err := session.AcceptStream()
+ acceptCh <- acceptResult{st, err}
+ }()
+ var controlStream net.Conn
+ select {
+ case res := <-acceptCh:
+ if res.err != nil {
+ return fmt.Errorf("failed to accept control stream: %w", res.err)
+ }
+ controlStream = res.stream
+ case <-time.After(authTimeout):
+ return errors.New("timed out waiting for control stream")
+ }
+ defer controlStream.Close()
+
+ as := &agentSession{
+ sess: session,
+ writer: bufio.NewWriter(controlStream),
+ done: make(chan struct{}),
+ }
+ defer close(as.done)
+
+ reader := bufio.NewReader(controlStream)
+
+ // 1. 第一条消息必须是鉴权
+ _ = controlStream.SetReadDeadline(time.Now().Add(authTimeout))
+ line, err := reader.ReadBytes('\n')
+ if err != nil {
+ return fmt.Errorf("read auth message: %w", err)
+ }
+ _ = controlStream.SetReadDeadline(time.Time{})
+
+ var auth protocol.AuthMessage
+ if err := json.Unmarshal(line, &auth); err != nil || auth.Type != protocol.TypeAuth {
+ as.send(protocol.AuthRespMessage{Type: protocol.TypeAuthResp, Success: false, Error: "first message must be auth"})
+ return errors.New("first control message is not auth")
+ }
+ if !m.checkToken(auth.Token) {
+ as.send(protocol.AuthRespMessage{Type: protocol.TypeAuthResp, Success: false, Error: "invalid authentication token"})
+ return errors.New("invalid authentication token")
+ }
+
+ // 2. 鉴权通过:替换旧会话,旧会话的通道全部释放,由新 Agent 重新注册
+ m.activate(as)
+ defer m.deactivate(as)
+ as.send(protocol.AuthRespMessage{Type: protocol.TypeAuthResp, Success: true})
+ log.Printf("[Server] Agent %s authenticated, control stream established", rawConn.RemoteAddr())
+
+ go m.metricsLoop(as)
+
+ // 3. 控制消息循环
+ for {
+ line, err := reader.ReadBytes('\n')
+ if err != nil {
+ if err != io.EOF {
+ log.Printf("[Server] control stream read error: %v", err)
+ }
+ return nil
+ }
+ if len(line) == 0 {
+ continue
+ }
+
+ var base protocol.BaseMessage
+ if err := json.Unmarshal(line, &base); err != nil {
+ log.Printf("[Server] malformed control message: %v", err)
+ continue
+ }
+ m.handleControlMessage(as, base.Type, line)
+ }
+}
+
+func (m *Manager) checkToken(token string) bool {
+ if m.token == "" {
+ return true
+ }
+ return subtle.ConstantTimeCompare([]byte(token), []byte(m.token)) == 1
+}
+
+func (m *Manager) activate(as *agentSession) {
+ m.mu.Lock()
+ old := m.active
+ m.active = as
+ m.closeAllTunnelsLocked()
+ m.mu.Unlock()
+
+ if old != nil {
+ log.Printf("[Server] New agent session replaces the previous one")
+ _ = old.sess.Close()
+ }
+}
+
+// deactivate 只在该会话仍是活跃会话时清理,避免旧会话迟到的断开把新会话的通道清掉
+func (m *Manager) deactivate(as *agentSession) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.active != as {
+ return
+ }
+ m.active = nil
+ m.closeAllTunnelsLocked()
+ log.Printf("[Server] Agent disconnected, cleaned up all tunnels")
+}
+
+func (m *Manager) closeAllTunnelsLocked() {
+ for _, t := range m.tunnels {
+ t.Close()
+ }
+ m.tunnels = make(map[string]*Tunnel)
+ m.portMap = make(map[int]string)
+}
+
+func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []byte) {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[Server] PANIC while handling control message %q: %v", msgType, r)
+ }
+ }()
+ switch msgType {
+ case protocol.TypeRegisterTunnel:
+ var msg protocol.RegisterTunnelMessage
+ if json.Unmarshal(raw, &msg) == nil {
+ as.send(m.registerTunnel(as, msg.Tunnel))
+ }
+
+ case protocol.TypeUnregister:
+ var msg protocol.UnregisterMessage
+ if json.Unmarshal(raw, &msg) == nil {
+ m.unregisterTunnel(as, msg.TunnelID)
+ as.send(protocol.UnregisterRespMessage{Type: protocol.TypeUnregisterResp, TunnelID: msg.TunnelID, Success: true})
+ }
+
+ case protocol.TypeUpdateTunnel:
+ var msg protocol.UpdateTunnelMessage
+ if json.Unmarshal(raw, &msg) == nil {
+ as.send(m.updateTunnel(as, msg.Tunnel))
+ }
+
+ case protocol.TypeRevokeLease:
+ var msg protocol.RevokeLeaseMessage
+ if json.Unmarshal(raw, &msg) == nil {
+ m.revokeLease(msg.TunnelID, msg.IP)
+ }
+
+ case protocol.TypeCreateInvite:
+ var msg protocol.CreateInviteMessage
+ if json.Unmarshal(raw, &msg) == nil {
+ as.send(m.createInvite(msg))
+ }
+
+ case protocol.TypeRevokeInvite:
+ var msg protocol.RevokeInviteMessage
+ if json.Unmarshal(raw, &msg) == nil {
+ m.revokeInvite(msg.TunnelID, msg.Code)
+ }
+
+ case protocol.TypeDecideRequest:
+ var msg protocol.DecideRequestMessage
+ if json.Unmarshal(raw, &msg) == nil {
+ m.decideRequest(msg.TunnelID, msg.RequestID, msg.Approve)
+ }
+
+ case protocol.TypeKillConn:
+ var msg protocol.KillConnMessage
+ if json.Unmarshal(raw, &msg) == nil {
+ m.killConn(msg.ConnID)
+ }
+
+ case protocol.TypePing:
+ as.send(protocol.BaseMessage{Type: protocol.TypePong})
+ }
+}
+
+func registerResp(cfg protocol.TunnelConfig, err error) protocol.RegisterRespMessage {
+ resp := protocol.RegisterRespMessage{
+ Type: protocol.TypeRegisterResp,
+ TunnelID: cfg.ID,
+ Success: err == nil,
+ Port: cfg.ListenPort,
+ }
+ if err != nil {
+ resp.Error = err.Error()
+ }
+ return resp
+}
+
+// checkPortLocked 校验端口范围以及是否被其它通道占用
+func (m *Manager) checkPortLocked(cfg protocol.TunnelConfig) error {
+ if cfg.ListenPort < m.portMin || cfg.ListenPort > m.portMax {
+ return fmt.Errorf("port %d out of allowed range [%d, %d]", cfg.ListenPort, m.portMin, m.portMax)
+ }
+ if existingID, inUse := m.portMap[cfg.ListenPort]; inUse && existingID != cfg.ID {
+ return fmt.Errorf("port %d already in use by tunnel %s", cfg.ListenPort, existingID)
+ }
+ return nil
+}
+
+func (m *Manager) registerTunnel(as *agentSession, cfg protocol.TunnelConfig) protocol.RegisterRespMessage {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.active != as {
+ return registerResp(cfg, errors.New("agent session is no longer active"))
+ }
+ if err := m.checkPortLocked(cfg); err != nil {
+ return registerResp(cfg, err)
+ }
+
+ // 同 ID 重新注册:先关闭旧监听(端口可能相同,必须先释放),统计与临时白名单沿用
+ old := m.tunnels[cfg.ID]
+ if old != nil {
+ old.Close()
+ delete(m.portMap, old.ListenPort())
+ delete(m.tunnels, cfg.ID)
+ }
+
+ t, err := newTunnel(cfg, as.sess, m.addSecEvent, m.onClientConnect, old)
+ if err != nil {
+ return registerResp(cfg, err)
+ }
+ if old == nil {
+ // 服务端重启或 Agent 重连后,恢复该通道尚未过期的临时放通
+ t.restoreDynamicLeases(m.state.leaseExpiries(cfg.ID))
+ }
+ m.tunnels[cfg.ID] = t
+ m.portMap[cfg.ListenPort] = cfg.ID
+
+ log.Printf("[Server] Registered tunnel '%s' listening on 0.0.0.0:%d", cfg.Name, cfg.ListenPort)
+ return registerResp(cfg, nil)
+}
+
+func (m *Manager) unregisterTunnel(as *agentSession, tunnelID string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.active != as {
+ return
+ }
+ // 主动关闭/删除通道时,放通、邀请链接与待审批申请一并失效(断线重连不会走到这里)
+ m.state.forgetTunnel(tunnelID)
+ if t, exists := m.tunnels[tunnelID]; exists {
+ t.Close()
+ delete(m.portMap, t.ListenPort())
+ delete(m.tunnels, tunnelID)
+ log.Printf("[Server] Unregistered tunnel '%s' (port %d freed)", t.Name(), t.ListenPort())
+ }
+}
+
+// updateTunnel 端口不变时原地更新(不断开现有连接);端口变化时先绑定新端口再关闭旧端口,失败则保留旧通道
+func (m *Manager) updateTunnel(as *agentSession, cfg protocol.TunnelConfig) protocol.RegisterRespMessage {
+ m.mu.Lock()
+ t, exists := m.tunnels[cfg.ID]
+ if !exists || m.active != as {
+ m.mu.Unlock()
+ return m.registerTunnel(as, cfg)
+ }
+ defer m.mu.Unlock()
+
+ if t.ListenPort() == cfg.ListenPort {
+ t.UpdateConfig(cfg)
+ log.Printf("[Server] Updated tunnel '%s' whitelist/metadata", cfg.Name)
+ return registerResp(cfg, nil)
+ }
+
+ if err := m.checkPortLocked(cfg); err != nil {
+ return registerResp(cfg, err)
+ }
+ nt, err := newTunnel(cfg, as.sess, m.addSecEvent, m.onClientConnect, t)
+ if err != nil {
+ return registerResp(cfg, err)
+ }
+ t.Close()
+ delete(m.portMap, t.ListenPort())
+ m.tunnels[cfg.ID] = nt
+ m.portMap[cfg.ListenPort] = cfg.ID
+ log.Printf("[Server] Moved tunnel '%s' from port %d to %d", cfg.Name, t.ListenPort(), cfg.ListenPort)
+ return registerResp(cfg, nil)
+}
+
+func (m *Manager) killConn(connID string) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ for _, t := range m.tunnels {
+ if t.KillConn(connID) {
+ log.Printf("[Server] Killed connection %s", connID)
+ break
+ }
+ }
+}
+
+func (m *Manager) revokeLease(tunnelID, ip string) {
+ m.mu.RLock()
+ t := m.tunnels[tunnelID]
+ m.mu.RUnlock()
+ if t == nil {
+ return
+ }
+ revoked, kicked := t.RevokeDynamic(ip)
+ m.state.removeLease(tunnelID, ip)
+ target := ip
+ if target == "" {
+ target = "all"
+ }
+ log.Printf("[WebAuth] Revoked %d temporary lease(s) (%s) on tunnel %s, disconnected %d client(s)",
+ revoked, target, t.Name(), kicked)
+}
+
+func (m *Manager) tunnel(id string) *Tunnel {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.tunnels[id]
+}
+
+func (m *Manager) createInvite(msg protocol.CreateInviteMessage) protocol.InviteRespMessage {
+ resp := protocol.InviteRespMessage{Type: protocol.TypeInviteResp, RequestID: msg.RequestID}
+ t := m.tunnel(msg.TunnelID)
+ switch {
+ case t == nil:
+ resp.Error = "通道未开启,无法生成邀请链接"
+ case msg.ValidMinutes <= 0 || msg.ValidMinutes > 30*24*60:
+ resp.Error = "邀请有效期须在 1 分钟到 30 天之间"
+ case msg.MaxUses < 0:
+ resp.Error = "可用设备数不能为负数"
+ case !protocol.ValidLeaseDuration(msg.LeaseDuration):
+ resp.Error = "无效的授权时长"
+ default:
+ resp.Invite = m.state.createInvite(msg.TunnelID, truncateRunes(msg.Note, 64), time.Duration(msg.ValidMinutes)*time.Minute, msg.MaxUses, msg.LeaseDuration, msg.RequireApproval)
+ resp.Success = true
+ log.Printf("[WebAuth] Created invite for tunnel %s (valid %dm, max %d device(s))", t.Name(), msg.ValidMinutes, msg.MaxUses)
+ }
+ return resp
+}
+
+func (m *Manager) revokeInvite(tunnelID, code string) {
+ ips := m.state.revokeInvite(tunnelID, code)
+ kicked := 0
+ if t := m.tunnel(tunnelID); t != nil {
+ for _, ip := range ips {
+ _, k := t.RevokeDynamic(ip)
+ kicked += k
+ }
+ }
+ log.Printf("[WebAuth] Revoked invite on tunnel %s: %d lease(s) removed, %d client(s) disconnected", tunnelID, len(ips), kicked)
+}
+
+func (m *Manager) decideRequest(tunnelID, requestID string, approve bool) {
+ t := m.tunnel(tunnelID)
+ if t == nil {
+ return
+ }
+ r, exp, err := m.state.decide(tunnelID, requestID, approve, t.Snapshot().ExpiresAt)
+ if err != nil {
+ return
+ }
+ if approve {
+ t.AddDynamicWhitelist(r.IP, exp)
+ }
+ log.Printf("[WebAuth] Request from %s (%s) on tunnel %s: %s", r.Name, r.IP, t.Name(), r.Status)
+}
+
+// onClientConnect starts the countdown of a device's lease on its first connection
+func (m *Manager) onClientConnect(tunnelID, ip string) {
+ t := m.tunnel(tunnelID)
+ if t == nil {
+ return
+ }
+ if exp, started := m.state.startLease(tunnelID, ip, t.Snapshot().ExpiresAt, time.Now()); started {
+ t.AddDynamicWhitelist(ip, exp)
+ log.Printf("[WebAuth] %s connected for the first time on tunnel %s, access valid until %s", ip, t.Name(), exp.Format("01-02 15:04"))
+ }
+}
+
+func truncateRunes(s string, n int) string {
+ r := []rune(strings.TrimSpace(s))
+ if len(r) > n {
+ r = r[:n]
+ }
+ return string(r)
+}
+
+func (m *Manager) addSecEvent(ev protocol.SecurityEvent) {
+ m.secEventsMu.Lock()
+ defer m.secEventsMu.Unlock()
+ m.secEvents = append(m.secEvents, ev)
+ if len(m.secEvents) > 100 {
+ m.secEvents = m.secEvents[len(m.secEvents)-100:]
+ }
+}
+
+func (m *Manager) metricsLoop(as *agentSession) {
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-as.done:
+ return
+ case <-ticker.C:
+ }
+
+ m.mu.RLock()
+ if m.active != as {
+ m.mu.RUnlock()
+ return
+ }
+ tunnelMetricsList := make([]protocol.TunnelMetrics, 0, len(m.tunnels))
+ allActiveConns := make([]protocol.ActiveConnInfo, 0)
+ for _, t := range m.tunnels {
+ // 授权到期(或白名单变更)后,已建立的连接也要断开,“授权后可访问 X”才名副其实
+ for _, addr := range t.EnforceAccess() {
+ log.Printf("[Tunnel %d] access expired, disconnected %s", t.ListenPort(), addr)
+ }
+ t.Tick()
+ tm, conns := t.Metrics()
+ tm.DynamicLeases = m.state.leaseList(tm.TunnelID)
+ tm.Invites = m.state.inviteList(tm.TunnelID)
+ tm.Pending = m.state.pendingList(tm.TunnelID)
+ tunnelMetricsList = append(tunnelMetricsList, tm)
+ allActiveConns = append(allActiveConns, conns...)
+ }
+ m.mu.RUnlock()
+
+ m.secEventsMu.Lock()
+ secCopy := make([]protocol.SecurityEvent, len(m.secEvents))
+ copy(secCopy, m.secEvents)
+ m.secEventsMu.Unlock()
+
+ as.send(protocol.MetricsMessage{
+ Type: protocol.TypeMetrics,
+ Tunnels: tunnelMetricsList,
+ ActiveConns: allActiveConns,
+ SecurityEvents: secCopy,
+ })
+ }
+}
+
+type httpResponseWriter struct {
+ conn net.Conn
+ header http.Header
+ statusCode int
+ wroteHdr bool
+}
+
+func (w *httpResponseWriter) Header() http.Header {
+ return w.header
+}
+
+func (w *httpResponseWriter) WriteHeader(statusCode int) {
+ if !w.wroteHdr {
+ w.statusCode = statusCode
+ w.wroteHdr = true
+ fmt.Fprintf(w.conn, "HTTP/1.1 %d %s\r\n", statusCode, http.StatusText(statusCode))
+ _ = w.header.Write(w.conn)
+ fmt.Fprintf(w.conn, "\r\n")
+ }
+}
+
+func (w *httpResponseWriter) Write(b []byte) (int, error) {
+ if !w.wroteHdr {
+ w.WriteHeader(http.StatusOK)
+ }
+ return w.conn.Write(b)
+}
+
+// HandleHTTP handles incoming HTTP requests (such as /auth?code=... and /health)
+func (m *Manager) HandleHTTP(conn net.Conn) {
+ defer conn.Close()
+ // 单个请求出错不能拖垮整个服务进程
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[Server] PANIC while handling HTTP request: %v", r)
+ }
+ }()
+ _ = conn.SetDeadline(time.Now().Add(15 * time.Second))
+
+ br := bufio.NewReader(conn)
+ req, err := http.ReadRequest(br)
+ if err != nil {
+ return
+ }
+ req.RemoteAddr = conn.RemoteAddr().String()
+ if m.TrustProxyHeaders {
+ if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
+ if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" {
+ req.RemoteAddr = first
+ }
+ } else if xrip := strings.TrimSpace(req.Header.Get("X-Real-IP")); xrip != "" {
+ req.RemoteAddr = xrip
+ }
+ }
+
+ w := &httpResponseWriter{
+ conn: conn,
+ header: make(http.Header),
+ }
+ w.header.Set("Server", "PortRelay")
+ w.header.Set("Connection", "close")
+
+ m.handleHTTPRequest(w, req)
+}
diff --git a/server/manager_test.go b/server/manager_test.go
new file mode 100644
index 0000000..9dfd2c6
--- /dev/null
+++ b/server/manager_test.go
@@ -0,0 +1,887 @@
+package server
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math/rand"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/hashicorp/yamux"
+
+ "github.com/DeepFal/PortRelay/core/protocol"
+)
+
+const testToken = "secret"
+
+type peekedConn struct {
+ net.Conn
+ r io.Reader
+}
+
+func (c *peekedConn) Read(b []byte) (int, error) { return c.r.Read(b) }
+
+// startServer 复刻 cmd/portrelay-server 的分流逻辑
+func startServer(t *testing.T, m *Manager) string {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func(c net.Conn) {
+ _ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
+ br := bufio.NewReader(c)
+ p, err := br.Peek(3)
+ if err != nil {
+ c.Close()
+ return
+ }
+ _ = c.SetReadDeadline(time.Time{})
+ if s := string(p); s == "GET" || s == "POS" || s == "HEA" {
+ m.HandleHTTP(&peekedConn{c, br})
+ return
+ }
+ _ = m.HandleSession(&peekedConn{c, br})
+ }(c)
+ }
+ }()
+ return ln.Addr().String()
+}
+
+type testAgent struct {
+ sess *yamux.Session
+ w *bufio.Writer
+ msgs chan []byte
+}
+
+// dialAgent 连接服务端、发送鉴权,并对每条数据流回复 "OK"
+func dialAgent(t *testing.T, addr, token string) *testAgent {
+ t.Helper()
+ c, err := net.Dial("tcp", addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := yamux.DefaultConfig()
+ cfg.LogOutput = io.Discard
+ s, err := yamux.Client(c, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cs, err := s.OpenStream()
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := &testAgent{sess: s, w: bufio.NewWriter(cs), msgs: make(chan []byte, 64)}
+ t.Cleanup(func() { s.Close() })
+ go func() {
+ r := bufio.NewReader(cs)
+ for {
+ line, err := r.ReadBytes('\n')
+ if err != nil {
+ close(a.msgs)
+ return
+ }
+ var base protocol.BaseMessage
+ if json.Unmarshal(line, &base) == nil && base.Type != protocol.TypeMetrics {
+ a.msgs <- line
+ }
+ }
+ }()
+ go func() {
+ for {
+ st, err := s.AcceptStream()
+ if err != nil {
+ return
+ }
+ go func() {
+ // 回复 OK 后保持连接,直到对端关闭(便于验证服务端主动断开)
+ r := bufio.NewReader(st)
+ _, _ = r.ReadBytes('\n')
+ _, _ = st.Write([]byte("OK"))
+ _, _ = io.Copy(io.Discard, r)
+ st.Close()
+ }()
+ }
+ }()
+ a.send(t, protocol.AuthMessage{Type: protocol.TypeAuth, Token: token})
+ return a
+}
+
+func (a *testAgent) send(t *testing.T, v any) {
+ t.Helper()
+ b, _ := json.Marshal(v)
+ _, _ = a.w.Write(append(b, '\n'))
+ if err := a.w.Flush(); err != nil {
+ t.Fatalf("send: %v", err)
+ }
+}
+
+func (a *testAgent) next(t *testing.T) []byte {
+ t.Helper()
+ select {
+ case m, ok := <-a.msgs:
+ if !ok {
+ return nil
+ }
+ return m
+ case <-time.After(3 * time.Second):
+ t.Fatal("timed out waiting for control message")
+ return nil
+ }
+}
+
+func (a *testAgent) expectAuth(t *testing.T) protocol.AuthRespMessage {
+ t.Helper()
+ var resp protocol.AuthRespMessage
+ if line := a.next(t); line != nil {
+ _ = json.Unmarshal(line, &resp)
+ }
+ return resp
+}
+
+func (a *testAgent) register(t *testing.T, msgType string, cfg protocol.TunnelConfig) protocol.RegisterRespMessage {
+ t.Helper()
+ if msgType == protocol.TypeUpdateTunnel {
+ a.send(t, protocol.UpdateTunnelMessage{Type: msgType, Tunnel: cfg})
+ } else {
+ a.send(t, protocol.RegisterTunnelMessage{Type: msgType, Tunnel: cfg})
+ }
+ var resp protocol.RegisterRespMessage
+ _ = json.Unmarshal(a.next(t), &resp)
+ return resp
+}
+
+// createInvite asks the server for an invite through the control stream
+func (a *testAgent) createInvite(t *testing.T, tunnelID string, maxUses int) protocol.Invite {
+ t.Helper()
+ return a.createInviteWith(t, tunnelID, maxUses, "")
+}
+
+func (a *testAgent) createInviteWith(t *testing.T, tunnelID string, maxUses int, lease string) protocol.Invite {
+ t.Helper()
+ return a.createInviteFull(t, tunnelID, maxUses, lease, false)
+}
+
+func (a *testAgent) createInviteFull(t *testing.T, tunnelID string, maxUses int, lease string, approval bool) protocol.Invite {
+ t.Helper()
+ a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "r1", TunnelID: tunnelID, ValidMinutes: 60, MaxUses: maxUses, LeaseDuration: lease, RequireApproval: approval})
+ var resp protocol.InviteRespMessage
+ _ = json.Unmarshal(a.next(t), &resp)
+ if !resp.Success {
+ t.Fatalf("create invite failed: %s", resp.Error)
+ }
+ return resp.Invite
+}
+
+var noRedirect = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+
+func postForm(t *testing.T, addr, code, name string) (int, string, string) {
+ t.Helper()
+ resp, err := noRedirect.PostForm("http://"+addr+"/auth", url.Values{"code": {code}, "name": {name}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, resp.Header.Get("Location"), string(b)
+}
+
+// freePort picks a port outside the OS ephemeral range (so outgoing connections cannot
+// grab it between the check and the tunnel's bind) and verifies it is bindable on 0.0.0.0.
+func freePort(t *testing.T) int {
+ t.Helper()
+ start := 20000 + rand.Intn(9000)
+ for i := 0; i < 1000; i++ {
+ p := 20000 + (start-20000+i)%9000
+ if ln, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", p)); err == nil {
+ ln.Close()
+ return p
+ }
+ }
+ t.Fatal("no free port")
+ return 0
+}
+
+func newTestManager() *Manager {
+ return NewManager(1024, 65535, testToken)
+}
+
+func tunnelCfg(id string, port int) protocol.TunnelConfig {
+ return protocol.TunnelConfig{ID: id, Name: id, ListenPort: port, Whitelist: []string{"*"}, Enabled: true, AuthCode: "code-" + id}
+}
+
+func hit(port int) string {
+ c, err := net.DialTimeout("tcp", "127.0.0.1:"+strconv.Itoa(port), time.Second)
+ if err != nil {
+ return "dial error"
+ }
+ defer c.Close()
+ _ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
+ b := make([]byte, 2)
+ n, _ := io.ReadFull(c, b)
+ return string(b[:n])
+}
+
+func waitFor(t *testing.T, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(3 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatal("condition not met in time")
+}
+
+func TestWrongTokenIsRejected(t *testing.T) {
+ m := newTestManager()
+ addr := startServer(t, m)
+ a := dialAgent(t, addr, "WRONG")
+ if resp := a.expectAuth(t); resp.Success {
+ t.Fatal("auth with wrong token succeeded")
+ }
+ port := freePort(t)
+ // 服务端在鉴权失败后会关闭连接,这里的写入允许失败
+ b, _ := json.Marshal(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: tunnelCfg("evil", port)})
+ _, _ = a.w.Write(append(b, '\n'))
+ _ = a.w.Flush()
+ time.Sleep(200 * time.Millisecond)
+ if got := hit(port); got == "OK" {
+ t.Fatal("unauthenticated agent managed to open a tunnel")
+ }
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ if len(m.tunnels) != 0 {
+ t.Fatalf("expected no tunnels, got %d", len(m.tunnels))
+ }
+}
+
+func TestPortProbeDoesNotBreakActiveSession(t *testing.T) {
+ m := newTestManager()
+ addr := startServer(t, m)
+ a := dialAgent(t, addr, testToken)
+ if !a.expectAuth(t).Success {
+ t.Fatal("auth failed")
+ }
+
+ // 模拟 nc -z、端口扫描、非 HTTP 垃圾数据
+ c, _ := net.Dial("tcp", addr)
+ c.Close()
+ c, _ = net.Dial("tcp", addr)
+ _, _ = c.Write([]byte("SSH-2.0-scanner\r\n"))
+ c.Close()
+ time.Sleep(200 * time.Millisecond)
+
+ port := freePort(t)
+ if resp := a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("b", port)); !resp.Success {
+ t.Fatalf("register failed: %s", resp.Error)
+ }
+ if got := hit(port); got != "OK" {
+ t.Fatalf("tunnel registered after port probe is broken: got %q", got)
+ }
+}
+
+func TestStaleSessionTeardownKeepsNewSessionTunnels(t *testing.T) {
+ m := newTestManager()
+ addr := startServer(t, m)
+
+ old := dialAgent(t, addr, testToken)
+ if !old.expectAuth(t).Success {
+ t.Fatal("auth failed")
+ }
+ port := freePort(t)
+ if resp := old.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port)); !resp.Success {
+ t.Fatal(resp.Error)
+ }
+
+ // 唤醒后 Agent 立刻重连并重新注册,旧会话随后才断开
+ fresh := dialAgent(t, addr, testToken)
+ if !fresh.expectAuth(t).Success {
+ t.Fatal("auth failed")
+ }
+ if resp := fresh.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port)); !resp.Success {
+ t.Fatalf("re-register on new session failed: %s", resp.Error)
+ }
+ old.sess.Close()
+ time.Sleep(300 * time.Millisecond)
+
+ if got := hit(port); got != "OK" {
+ t.Fatalf("tunnel lost after stale session teardown: got %q", got)
+ }
+}
+
+func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
+ m := newTestManager()
+ addr := startServer(t, m)
+ a := dialAgent(t, addr, testToken)
+ a.expectAuth(t)
+
+ port := freePort(t)
+ cfg := tunnelCfg("a", port)
+ a.register(t, protocol.TypeRegisterTunnel, cfg)
+
+ m.mu.RLock()
+ tun := m.tunnels["a"]
+ m.mu.RUnlock()
+ tun.AddDynamicWhitelist("10.1.2.3", time.Now().Add(time.Hour))
+
+ cfg.Name = "renamed"
+ cfg.Whitelist = []string{"127.0.0.1"}
+ if resp := a.register(t, protocol.TypeUpdateTunnel, cfg); !resp.Success {
+ t.Fatal(resp.Error)
+ }
+ m.mu.RLock()
+ same := m.tunnels["a"] == tun
+ m.mu.RUnlock()
+ if !same {
+ t.Fatal("metadata update recreated the tunnel (would drop active connections)")
+ }
+ if got := tun.GetDynamicWhitelist(); len(got) != 1 || got[0] != "10.1.2.3" {
+ t.Fatalf("dynamic lease lost after update: %v", got)
+ }
+ if hit(port) != "OK" {
+ t.Fatal("tunnel unusable after update")
+ }
+}
+
+func TestUpdatePortConflictKeepsOldTunnel(t *testing.T) {
+ m := newTestManager()
+ addr := startServer(t, m)
+ a := dialAgent(t, addr, testToken)
+ a.expectAuth(t)
+
+ p1, p2 := freePort(t), freePort(t)
+ a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", p1))
+ a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("b", p2))
+
+ moved := tunnelCfg("a", p2)
+ if resp := a.register(t, protocol.TypeUpdateTunnel, moved); resp.Success {
+ t.Fatal("moving onto an occupied port should fail")
+ }
+ if hit(p1) != "OK" {
+ t.Fatal("failed port change destroyed the original tunnel")
+ }
+
+ p3 := freePort(t)
+ if resp := a.register(t, protocol.TypeUpdateTunnel, tunnelCfg("a", p3)); !resp.Success {
+ t.Fatal(resp.Error)
+ }
+ if hit(p3) != "OK" {
+ t.Fatal("tunnel not reachable on new port")
+ }
+ if hit(p1) == "OK" {
+ t.Fatal("old port still served after move")
+ }
+}
+
+func TestUpdateOfUnknownTunnelRegistersIt(t *testing.T) {
+ m := newTestManager()
+ addr := startServer(t, m)
+ a := dialAgent(t, addr, testToken)
+ a.expectAuth(t)
+ port := freePort(t)
+ if resp := a.register(t, protocol.TypeUpdateTunnel, tunnelCfg("x", port)); !resp.Success {
+ t.Fatal(resp.Error)
+ }
+ if hit(port) != "OK" {
+ t.Fatal("update of unknown tunnel did not register it")
+ }
+}
+
+func TestDisconnectCleansUpTunnels(t *testing.T) {
+ m := newTestManager()
+ addr := startServer(t, m)
+ a := dialAgent(t, addr, testToken)
+ a.expectAuth(t)
+ port := freePort(t)
+ a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port))
+ a.sess.Close()
+ waitFor(t, func() bool {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.active == nil && len(m.tunnels) == 0
+ })
+}
+
+func httpGet(t *testing.T, addr, path string, hdr map[string]string) (int, string) {
+ t.Helper()
+ req, _ := http.NewRequest("GET", "http://"+addr+path, nil)
+ for k, v := range hdr {
+ req.Header.Set(k, v)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, string(b)
+}
+
+func TestInviteFlowIgnoresForwardedHeadersAndEscapesHTML(t *testing.T) {
+ m := newTestManager()
+ addr := startServer(t, m)
+ a := dialAgent(t, addr, testToken)
+ a.expectAuth(t)
+ cfg := tunnelCfg("a", freePort(t))
+ cfg.Name = ``
+ cfg.Whitelist = nil // 仅授权链接
+ a.register(t, protocol.TypeRegisterTunnel, cfg)
+ inv := a.createInvite(t, "a", 1)
+
+ if hit(cfg.ListenPort) == "OK" {
+ t.Fatal("link-only tunnel must reject unauthorised clients")
+ }
+
+ status, body := httpGet(t, addr, "/i/"+inv.Code, map[string]string{"X-Forwarded-For": "8.8.8.8"})
+ if status != 200 || !strings.Contains(body, `name="name"`) {
+ t.Fatalf("invite form not shown: %d", status)
+ }
+ if strings.Contains(body, "