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 +

+ +

PortRelay

+ +

+ 把内网服务临时、可控地开放给同事:发一条邀请链接,到期自动收回。 +

+ +

+ CI + Release + MIT + Go + Platform +

+ +

+ 简体中文 · English +

+ +

+ PortRelay 架构 +

+ +你的电脑能连上某个内网网段:数据库、跳板机、内部面板,但同事连不上。常见的做法是开 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 分钟流量(动图为加速演示) +

+ + + + PortRelay macOS App 主界面 + + +**邀请与授权**:生成链接后得到一段可直接发送的文案和二维码。同事在手机或电脑上打开链接填写姓名,需要审批时进入等待页,批准后页面自动更新。 + +

+ 邀请分享卡片 +

+ + + + + + + +
填写姓名
① 打开邀请链接
等待审批
② 等待管理员批准(可选)
授权成功
③ 授权成功
+ +**连接与授权管理**:待审批的申请、已授权设备(在线状态与剩余时间),以及仍然有效的邀请链接,都在同一处管理。 + +

+ 授权管理 +

+ +
+网页面板与更多截图 +
+ +不使用 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 +

+ +

PortRelay

+ +

+ Share internal services with teammates temporarily and safely: send an invite link, and access expires on its own. +

+ +

+ CI + Release + MIT + Go + Platform +

+ +

+ 简体中文 · English +

+ +

+ PortRelay architecture +

+ +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 + +

+ Tunnel card with live traffic +
A tunnel card: access address, grants, connected time and the last 10 minutes of traffic (sped up) +

+ + + + PortRelay macOS app + + +**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. + +

+ Invite share card +

+ + + + + + + +
Request access
① Open the invite link
Waiting for approval
② Wait for approval (optional)
Access granted
③ Access granted
+ +**Access management.** Pending requests, authorized devices (with online state and remaining time) and active invite links are all managed in one place. + +

+ Access management +

+ +
+Web panel and more screenshots +
+ +Without the macOS app, the agent serves a web panel at `http://127.0.0.1:33080`: + +Web panel + + + + + + +
Edit tunnel
Edit tunnel: access mode, idle close, max open time
Online devices
Online devices, grouped by IP
+ +
+ +## How it works + +

+ Invite flow +

+ +- 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
+
历史累计连接: 0
+
+
+
实时速率 (上行 / 下行)
+
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, "