From 86e159b3f01fe6d6215342965c91318ff52cfa95 Mon Sep 17 00:00:00 2001
From: DeepFal <41166795+DeepFal@users.noreply.github.com>
Date: Tue, 13 Oct 2026 16:24:25 +0800
Subject: [PATCH] =?UTF-8?q?PortRelay=EF=BC=9A=E6=8A=8A=E5=86=85=E7=BD=91?=
=?UTF-8?q?=E6=9C=8D=E5=8A=A1=E4=B8=B4=E6=97=B6=E3=80=81=E5=8F=AF=E6=8E=A7?=
=?UTF-8?q?=E5=9C=B0=E5=BC=80=E6=94=BE=E7=BB=99=E5=90=8C=E4=BA=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上;
通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。
- portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页)
- portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计)
- mac-native:SwiftUI 菜单栏 App
---
.dockerignore | 10 +
.github/workflows/ci.yml | 86 +
.github/workflows/release.yml | 100 +
.gitignore | 19 +
Dockerfile | 21 +
LICENSE | 21 +
Makefile | 57 +
README.md | 310 ++
README_EN.md | 329 ++
SECURITY.md | 15 +
build-app.sh | 102 +
client/agent.go | 807 ++++
client/api.go | 647 ++++
client/client_test.go | 712 ++++
client/config.go | 702 ++++
client/probe.go | 45 +
client/ratehistory.go | 127 +
client/ratehistory_test.go | 52 +
cmd/portrelay-agent/main.go | 105 +
cmd/portrelay-agent/web/index.html | 1374 +++++++
cmd/portrelay-server/main.go | 125 +
core/acl/whitelist.go | 193 +
core/acl/whitelist_test.go | 114 +
core/protocol/messages.go | 305 ++
core/traffic/meter.go | 210 +
core/traffic/meter_test.go | 36 +
deploy/docker-compose.yml | 17 +
deploy/install-server.sh | 49 +
deploy/portrelay-server.env.example | 10 +
deploy/portrelay-server.service | 30 +
docs/images/app-access.png | Bin 0 -> 176177 bytes
docs/images/app-clients.png | Bin 0 -> 101179 bytes
docs/images/app-edit.png | Bin 0 -> 142164 bytes
docs/images/app-invite.png | Bin 0 -> 94325 bytes
docs/images/app-main-dark.png | Bin 0 -> 401224 bytes
docs/images/app-main-light.png | Bin 0 -> 436177 bytes
docs/images/architecture.svg | 146 +
docs/images/auth-form.png | Bin 0 -> 80992 bytes
docs/images/auth-ok.png | Bin 0 -> 98189 bytes
docs/images/auth-pending.png | Bin 0 -> 80236 bytes
docs/images/invite-flow.svg | 82 +
docs/images/live-traffic.gif | Bin 0 -> 573369 bytes
docs/images/web-panel.png | Bin 0 -> 727799 bytes
go.mod | 8 +
go.sum | 4 +
mac-native/AppIcon.icns | Bin 0 -> 2839570 bytes
mac-native/AppIcon.iconset/icon_128x128.png | Bin 0 -> 112007 bytes
.../AppIcon.iconset/icon_128x128@2x.png | Bin 0 -> 336284 bytes
mac-native/AppIcon.iconset/icon_16x16.png | Bin 0 -> 4698 bytes
mac-native/AppIcon.iconset/icon_16x16@2x.png | Bin 0 -> 12601 bytes
mac-native/AppIcon.iconset/icon_256x256.png | Bin 0 -> 336284 bytes
.../AppIcon.iconset/icon_256x256@2x.png | Bin 0 -> 1030418 bytes
mac-native/AppIcon.iconset/icon_32x32.png | Bin 0 -> 12601 bytes
mac-native/AppIcon.iconset/icon_32x32@2x.png | Bin 0 -> 36994 bytes
mac-native/AppIcon.iconset/icon_512x512.png | Bin 0 -> 1030418 bytes
.../AppIcon.iconset/icon_512x512@2x.png | Bin 0 -> 2411956 bytes
mac-native/PortRelayApp.swift | 3426 +++++++++++++++++
mac-native/generate_icon.swift | 106 +
mac-native/set_icon.swift | 10 +
server/manager.go | 611 +++
server/manager_test.go | 887 +++++
server/state.go | 604 +++
server/tunnel.go | 451 +++
server/webauth.go | 309 ++
tools/ui-snapshots/main.swift | 241 ++
tools/ui-snapshots/mkgif/main.go | 260 ++
tools/ui-snapshots/render.sh | 13 +
67 files changed, 13888 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .github/workflows/ci.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 LICENSE
create mode 100644 Makefile
create mode 100644 README.md
create mode 100644 README_EN.md
create mode 100644 SECURITY.md
create mode 100755 build-app.sh
create mode 100644 client/agent.go
create mode 100644 client/api.go
create mode 100644 client/client_test.go
create mode 100644 client/config.go
create mode 100644 client/probe.go
create mode 100644 client/ratehistory.go
create mode 100644 client/ratehistory_test.go
create mode 100644 cmd/portrelay-agent/main.go
create mode 100644 cmd/portrelay-agent/web/index.html
create mode 100644 cmd/portrelay-server/main.go
create mode 100644 core/acl/whitelist.go
create mode 100644 core/acl/whitelist_test.go
create mode 100644 core/protocol/messages.go
create mode 100644 core/traffic/meter.go
create mode 100644 core/traffic/meter_test.go
create mode 100644 deploy/docker-compose.yml
create mode 100755 deploy/install-server.sh
create mode 100644 deploy/portrelay-server.env.example
create mode 100644 deploy/portrelay-server.service
create mode 100644 docs/images/app-access.png
create mode 100644 docs/images/app-clients.png
create mode 100644 docs/images/app-edit.png
create mode 100644 docs/images/app-invite.png
create mode 100644 docs/images/app-main-dark.png
create mode 100644 docs/images/app-main-light.png
create mode 100644 docs/images/architecture.svg
create mode 100644 docs/images/auth-form.png
create mode 100644 docs/images/auth-ok.png
create mode 100644 docs/images/auth-pending.png
create mode 100644 docs/images/invite-flow.svg
create mode 100644 docs/images/live-traffic.gif
create mode 100644 docs/images/web-panel.png
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 mac-native/AppIcon.icns
create mode 100644 mac-native/AppIcon.iconset/icon_128x128.png
create mode 100644 mac-native/AppIcon.iconset/icon_128x128@2x.png
create mode 100644 mac-native/AppIcon.iconset/icon_16x16.png
create mode 100644 mac-native/AppIcon.iconset/icon_16x16@2x.png
create mode 100644 mac-native/AppIcon.iconset/icon_256x256.png
create mode 100644 mac-native/AppIcon.iconset/icon_256x256@2x.png
create mode 100644 mac-native/AppIcon.iconset/icon_32x32.png
create mode 100644 mac-native/AppIcon.iconset/icon_32x32@2x.png
create mode 100644 mac-native/AppIcon.iconset/icon_512x512.png
create mode 100644 mac-native/AppIcon.iconset/icon_512x512@2x.png
create mode 100644 mac-native/PortRelayApp.swift
create mode 100644 mac-native/generate_icon.swift
create mode 100644 mac-native/set_icon.swift
create mode 100644 server/manager.go
create mode 100644 server/manager_test.go
create mode 100644 server/state.go
create mode 100644 server/tunnel.go
create mode 100644 server/webauth.go
create mode 100644 tools/ui-snapshots/main.swift
create mode 100644 tools/ui-snapshots/mkgif/main.go
create mode 100755 tools/ui-snapshots/render.sh
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..776e8c2
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+.git
+.github
+bin
+build
+dist
+docs
+mac-native
+tools
+PortRelay.app
+*.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e6fb6d2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,86 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ go:
+ name: Go (vet / test / cross-build)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: gofmt
+ run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
+ - name: vet
+ run: go vet ./...
+ - name: test
+ run: go test -race -count=1 ./...
+ - name: cross-build
+ run: |
+ for p in linux/amd64 linux/arm64 darwin/arm64 windows/amd64; do
+ GOOS=${p%/*} GOARCH=${p#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/...
+ done
+
+ install:
+ name: Install script (systemd)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Build
+ run: CGO_ENABLED=0 go build -o portrelay-server ./cmd/portrelay-server
+ - name: Install
+ run: sudo deploy/install-server.sh ./portrelay-server
+ - name: Verify
+ run: |
+ sleep 2
+ systemctl is-active portrelay-server
+ sudo test -s /etc/portrelay-server.env
+ test "$(sudo stat -c %a /etc/portrelay-server.env)" = 600
+ curl -fsS http://127.0.0.1:33000/health
+ sudo deploy/install-server.sh ./portrelay-server # 升级路径:保留已有 token
+ sleep 2
+ systemctl is-active portrelay-server
+ ls /usr/local/bin/portrelay-server.bak-*
+ - name: Logs
+ if: always()
+ run: sudo journalctl -u portrelay-server --no-pager | tail -30
+
+ macos:
+ name: macOS app
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Build PortRelay.app (universal)
+ run: UNIVERSAL=1 ./build-app.sh
+ - name: Check bundle
+ run: |
+ lipo -info PortRelay.app/Contents/MacOS/PortRelay
+ lipo -info PortRelay.app/Contents/Resources/portrelay-agent
+ codesign --verify --deep PortRelay.app
+
+ docker:
+ name: Docker image
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: false
+ tags: portrelay-server:ci
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..535d4b2
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,100 @@
+name: Release
+
+# 推送 v* 标签时构建并发布;手动触发时只构建、上传 artifact,不发布
+on:
+ push:
+ tags: ['v*']
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ packages: write
+
+jobs:
+ binaries:
+ name: Server & agent binaries
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Build
+ run: make release VERSION=${GITHUB_REF_NAME#v}
+ - uses: actions/upload-artifact@v4
+ with:
+ name: binaries
+ path: |
+ dist/*.tar.gz
+ dist/*.zip
+
+ macos-app:
+ name: macOS app
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Build universal app
+ run: |
+ VERSION=${GITHUB_REF_NAME#v}
+ case "$VERSION" in [0-9]*) ;; *) VERSION=0.0.0-${GITHUB_SHA::7} ;; esac
+ UNIVERSAL=1 VERSION=$VERSION ./build-app.sh
+ mkdir -p dist
+ ditto -c -k --sequesterRsrc --keepParent PortRelay.app "dist/PortRelay-$VERSION-macos-universal.zip"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: macos-app
+ path: dist/*.zip
+
+ docker:
+ name: Docker image
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/v')
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-qemu-action@v3
+ - uses: docker/setup-buildx-action@v3
+ - name: Image name (lowercase)
+ run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/portrelay-server" >> "$GITHUB_ENV"
+ - uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.IMAGE }}
+ tags: |
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=raw,value=latest
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: true
+ build-args: VERSION=${{ steps.meta.outputs.version }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+
+ publish:
+ name: GitHub release
+ needs: [binaries, macos-app]
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/v')
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ path: dist
+ merge-multiple: true
+ - name: Checksums
+ run: cd dist && sha256sum * > SHA256SUMS
+ - name: Create release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --generate-notes
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4a5092e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
+# macOS
+.DS_Store
+
+# 构建产物
+bin/
+build/
+dist/
+PortRelay.app/
+mac-native/PortRelay
+mac-native/test_bin
+mac-native/test_app_bin
+
+# UI 快照工具的构建产物与输出
+tools/ui-snapshots/.build/
+tools/ui-snapshots/out/
+
+# 本地配置与密钥
+*.env
+!deploy/*.env.example
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0ea19b3
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,21 @@
+# PortRelay 边缘网关(服务端)镜像
+# docker build -t portrelay-server .
+# docker run -d --network host -e PORTRELAY_TOKEN=... -v portrelay:/var/lib/portrelay portrelay-server
+# 请使用 host 网络:白名单依赖访问者的真实来源 IP,端口映射可能把它变成网桥地址。
+FROM golang:1.23-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+ARG VERSION=dev
+RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
+ -o /out/portrelay-server ./cmd/portrelay-server \
+ && mkdir -p /out/state
+
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY --from=build /out/portrelay-server /usr/local/bin/portrelay-server
+COPY --from=build --chown=65532:65532 /out/state /var/lib/portrelay
+VOLUME ["/var/lib/portrelay"]
+EXPOSE 33000-34000
+ENTRYPOINT ["/usr/local/bin/portrelay-server"]
+CMD ["-listen", ":33000", "-port-min", "33000", "-port-max", "34000", "-state", "/var/lib/portrelay/leases.json"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..048441f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 DeepFal
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..55a9663
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,57 @@
+# PortRelay —— 常用命令
+# make test 运行 gofmt 检查、go vet 与全部测试(-race)
+# make server 交叉编译 Linux 服务端到 dist/
+# make agent 编译当前平台的 Agent 到 bin/
+# make app 构建 macOS 应用 PortRelay.app(需要 Xcode 命令行工具)
+# make release 打包所有平台的服务端 / Agent 到 dist/
+# make snapshots 离屏渲染 App 界面截图(tools/ui-snapshots/out)
+
+VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo dev)
+LDFLAGS := -s -w -X main.version=$(VERSION)
+GOBUILD := CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)"
+
+PLATFORMS := linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64
+
+.PHONY: all test fmt server agent app release snapshots clean
+
+all: test agent
+
+test:
+ @test -z "$$(gofmt -l .)" || (echo "需要 gofmt:" && gofmt -l . && exit 1)
+ go vet ./...
+ go test -race -count=1 ./...
+
+fmt:
+ gofmt -w .
+
+server:
+ GOOS=linux GOARCH=amd64 $(GOBUILD) -o dist/portrelay-server-linux-amd64 ./cmd/portrelay-server
+ GOOS=linux GOARCH=arm64 $(GOBUILD) -o dist/portrelay-server-linux-arm64 ./cmd/portrelay-server
+
+agent:
+ $(GOBUILD) -o bin/portrelay-agent ./cmd/portrelay-agent
+
+app:
+ VERSION=$(VERSION) ./build-app.sh
+
+release:
+ @rm -rf dist && mkdir -p dist
+ @for p in $(PLATFORMS); do \
+ os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \
+ name=portrelay-$(VERSION)-$$os-$$arch; mkdir -p dist/$$name; \
+ echo "==> $$name"; \
+ GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/portrelay-server$$ext ./cmd/portrelay-server || exit 1; \
+ GOOS=$$os GOARCH=$$arch $(GOBUILD) -o dist/$$name/portrelay-agent$$ext ./cmd/portrelay-agent || exit 1; \
+ cp LICENSE README.md README_EN.md dist/$$name/; \
+ if [ $$os = linux ]; then cp -R deploy dist/$$name/; fi; \
+ if [ $$os = windows ]; then (cd dist && zip -qr $$name.zip $$name); \
+ else tar -C dist -czf dist/$$name.tar.gz $$name; fi; \
+ rm -rf dist/$$name; \
+ done
+ @cd dist && shasum -a 256 * > SHA256SUMS && cat SHA256SUMS
+
+snapshots:
+ tools/ui-snapshots/render.sh
+
+clean:
+ rm -rf bin build dist PortRelay.app mac-native/PortRelay tools/ui-snapshots/.build tools/ui-snapshots/out
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..04e8a23
--- /dev/null
+++ b/README.md
@@ -0,0 +1,310 @@
+
+
+
+
+PortRelay
+
+
+ 把内网服务临时、可控地开放给同事:发一条邀请链接,到期自动收回。
+
+
+
+
+
+
+
+
+
+
+
+ 简体中文 · English
+
+
+
+
+
+
+你的电脑能连上某个内网网段:数据库、跳板机、内部面板,但同事连不上。常见的做法是开 VPN 账号、改防火墙,或者干脆把端口对所有人放开,事后还经常忘记关。
+
+PortRelay 把这件事变成三步:
+
+1. **Agent** 把内网目标挂到 **边缘网关** 的一个端口上。
+2. 你把**邀请链接**(或二维码)发给同事。
+3. 同事打开链接、填写姓名,他的设备就获得一段时间的访问权。到期、闲置或被你撤销后,连接立即断开。
+
+> [!TIP]
+> **不依赖任何特定组网方案。** 只要 Agent 所在的机器**同时能连到目标网段和边缘网关**就能工作。Agent 到网关的线路随你选:同一局域网、公司 VPN、Tailscale、ZeroTier、WireGuard,或者网关本身有公网地址。Agent 只需主动连出,本机不用开放任何端口。
+
+## 目录
+
+- [功能](#功能)
+- [界面一览](#界面一览)
+- [工作原理](#工作原理)
+- [快速开始](#快速开始)
+- [配置参考](#配置参考)
+- [安全模型](#安全模型)
+- [常见问题](#常见问题)
+- [开发](#开发)
+
+## 功能
+
+**访问控制**
+
+- 三种访问方式:**仅授权链接**(默认,最安全)、IP / 网段白名单(可与授权链接叠加),以及全部放通。
+- 邀请链接可以设置链接有效期、可用设备数、授权后可访问时长(1 小时、2 小时、8 小时、当天有效,或直到通道关闭),以及是否需要审批。
+- 需要审批时,App 会弹出系统通知,一键批准或拒绝;访问者的页面会自动刷新结果。
+- **计时从首次连接开始**:同事拿到授权后不用急着连,开始用的时候才开始计时。
+- 授权到期或被撤销后,**已建立的连接也会立刻断开**;未授权的连接直接被重置(TCP RST)并留下拦截记录。
+
+**通道生命周期**
+
+- **闲置自动关闭**:没有连接、没有有效授权、没有待处理申请,也没有还能使用的邀请链接时开始计时,默认 30 分钟后关闭。
+- 可选的**最长开启时长**,到点无论是否在用都关闭。
+- 记录首次连接时间、实际连接时长和每次关闭的原因。
+- 编辑通道不会打断现有连接;更换端口时先绑定新端口再释放旧端口。
+
+**可观测**
+
+- 每个通道近 10 分钟的流量曲线,以及实时速率、连接数、本次与历史累计流量。
+- 在线设备按 IP 合并展示,可逐条断开;历史接入台账同样按 IP 合并。
+- 按通道限速(MB/s)。
+
+**客户端**
+
+- 原生 macOS 菜单栏 App(SwiftUI):关闭窗口只保留菜单栏图标,从菜单栏退出时连同后台 Agent 一起退出。
+- 内嵌网页面板,Linux、Windows 上也能用同一套功能。
+- 通道搜索(支持拼音首字母,比如输入 `sjk` 就能找到“数据库”),以及按运行状态筛选。
+
+## 界面一览
+
+
+
+ 通道卡片:访问地址、授权状态、实际连接时长,以及近 10 分钟流量(动图为加速演示)
+
+
+
+
+
+
+
+**邀请与授权**:生成链接后得到一段可直接发送的文案和二维码。同事在手机或电脑上打开链接填写姓名,需要审批时进入等待页,批准后页面自动更新。
+
+
+
+
+
+
+
+ ① 打开邀请链接
+ ② 等待管理员批准(可选)
+ ③ 授权成功
+
+
+
+**连接与授权管理**:待审批的申请、已授权设备(在线状态与剩余时间),以及仍然有效的邀请链接,都在同一处管理。
+
+
+
+
+
+
+网页面板与更多截图
+
+
+不使用 macOS App 时,Agent 自带网页面板,地址为 `http://127.0.0.1:33080`:
+
+
+
+
+
+ 编辑通道:访问方式、闲置关闭、最长时长
+ 在线设备:按 IP 合并,可逐条断开
+
+
+
+
+
+## 工作原理
+
+
+
+
+
+- **边缘网关**(`portrelay-server`)运行在访问者能连到的机器上,管理一个端口池(默认 `33000-34000`)。`33000` 是控制端口:Agent 的隧道和访问者打开的授权网页共用这一个端口,网关根据请求开头自动分流(HTTP 请求进入授权网页,其余视为 Agent 会话)。
+- **Agent**(`portrelay-agent`,macOS App 内置)运行在能访问目标网段的机器上。它主动连接网关的控制端口,先用 token 认证,再通过 [yamux](https://github.com/hashicorp/yamux) 在这条 TCP 连接上多路复用所有通道。
+- 访问者连接 `网关:33660` 时,网关先检查白名单和授权租约。通过后在隧道里开一个流,由 Agent 连向 `10.0.1.16:3306`,双向转发。不通过则直接重置连接并记录。
+- 授权租约、邀请和审批请求保存在网关的状态文件里,重启后仍然有效。通道配置、流量历史和接入台账保存在 Agent 本机。
+
+## 快速开始
+
+### 1. 部署边缘网关(Linux)
+
+网关需要满足两点:**访问者能连到它**,**Agent 也能连到它**。防火墙放通 TCP `33000-34000`。
+
+**方式 A:二进制 + systemd(推荐)**
+
+```bash
+VERSION=1.0.0 # 见 Releases 页面
+curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-$VERSION-linux-amd64.tar.gz
+tar xzf portrelay-$VERSION-linux-amd64.tar.gz && cd portrelay-$VERSION-linux-amd64
+sudo ./deploy/install-server.sh ./portrelay-server
+
+# 安装脚本会生成随机 token,记下来填到 Agent 里
+sudo grep PORTRELAY_TOKEN /etc/portrelay-server.env
+```
+
+安装脚本会把程序装到 `/usr/local/bin`,写入 [systemd 单元](deploy/portrelay-server.service)(以临时系统用户运行,状态保存在 `/var/lib/portrelay`),并设置开机自启。再次运行同一个脚本即可升级,已有配置会保留。
+
+**方式 B:Docker**
+
+```bash
+TOKEN=$(openssl rand -hex 24); echo "token: $TOKEN"
+docker run -d --name portrelay --network host --restart unless-stopped \
+ -e PORTRELAY_TOKEN=$TOKEN -v portrelay:/var/lib/portrelay \
+ ghcr.io/deepfal/portrelay-server:latest
+```
+
+> [!IMPORTANT]
+> 请使用 `--network host`。白名单依赖访问者的真实 IP,端口映射可能会把来源地址换成网桥地址。另见 [docker-compose 示例](deploy/docker-compose.yml)。
+
+**方式 C:从源码构建**
+
+```bash
+git clone https://github.com/DeepFal/PortRelay.git && cd PortRelay
+make server # 产物:dist/portrelay-server-linux-{amd64,arm64}
+```
+
+### 2. 运行 Agent
+
+**macOS App**:从 [Releases](https://github.com/DeepFal/PortRelay/releases) 下载 `PortRelay-*-macos-universal.zip`,解压后拖进“应用程序”。
+
+> [!NOTE]
+> App 未经 Apple 公证。首次打开时,在 Finder 中右键选择“打开”,或者执行 `xattr -dr com.apple.quarantine /Applications/PortRelay.app`。也可以 `make app` 自行构建。
+
+打开后点右上角的 ⚙️ 设置:
+
+| 设置项 | 填什么 |
+| --- | --- |
+| 服务器控制地址 | Agent 连接网关用的地址,例如 `gw.example.com:33000`、`192.168.1.10:33000` 或 `100.x.y.z:33000` |
+| 安全令牌 | 网关上的 `PORTRELAY_TOKEN` |
+| 对外访问地址 | **同事**访问网关用的地址,留空则与上面相同 |
+
+> 举例:Agent 通过 Tailscale 地址 `100.64.0.10` 连网关,同事在办公室局域网里用 `192.168.1.10` 访问。这时“服务器控制地址”填 `100.64.0.10:33000`,“对外访问地址”填 `192.168.1.10`,邀请链接和分享文案都会使用后者。
+
+**Linux / Windows**:下载对应平台的压缩包,直接运行 Agent,在浏览器打开的面板里完成同样的设置。
+
+```bash
+./portrelay-agent # 面板:http://127.0.0.1:33080
+./portrelay-agent -no-browser # 不自动打开浏览器
+```
+
+### 3. 创建通道并邀请同事
+
+1. 点 **添加通道**,填写名称和目标地址(如 `10.0.1.16:3306`)。可以先“探测”一下本机能否连通;端口会自动推荐。
+2. 访问方式保持默认的 **仅授权链接**,然后点 **邀请**,选择授权后可访问时长、可用设备数,以及是否需要审批。
+3. 把生成的文案或二维码发给同事。同事打开链接、填写姓名后,就可以用数据库客户端或 SSH 连接 `192.168.1.10:33660`。
+
+## 配置参考
+
+**网关 `portrelay-server`**
+
+| 参数 | 默认值 | 说明 |
+| --- | --- | --- |
+| `-token` / `PORTRELAY_TOKEN` | 必填 | Agent 认证用的共享密钥,建议用 `openssl rand -hex 24` 生成 |
+| `-listen` | `:33000` | 控制端口(Agent 隧道与授权网页共用) |
+| `-port-min` / `-port-max` | `33000` / `34000` | 通道端口池 |
+| `-state` | `/var/lib/portrelay/leases.json` | 授权租约、邀请与审批的持久化文件,留空则只保存在内存 |
+| `-public-host` | 空 | 授权页上展示给访问者的地址,默认取访问者打开链接时使用的地址 |
+| `-trust-proxy` | `false` | 信任 `X-Forwarded-For` / `X-Real-IP`,**仅在网关前有可信反向代理时开启** |
+| `-version` | | 打印版本 |
+
+**Agent `portrelay-agent`**
+
+| 参数 | 默认值 | 说明 |
+| --- | --- | --- |
+| `-port` | `33080` | 本地面板 / API 端口(只监听 `127.0.0.1`) |
+| `-no-browser` | `false` | 启动时不自动打开面板 |
+| `-version` | | 打印版本 |
+
+Agent 的配置保存在 `~/.portrelay/config.json`(权限 0600,原子写入;文件损坏时会先备份再使用默认值)。
+
+## 安全模型
+
+- **Agent 认证**:连接建立后的第一条消息必须是 token 认证(常量时间比较),认证通过的会话才能注册通道。新会话会替换旧会话。
+- **默认拒绝**:新通道默认只允许持有授权的设备访问。授权按“通道 + 来源 IP”生效,并且有明确的到期时间。
+- **到期即断**:网关每秒检查一次存量连接,授权失效的连接立即断开。
+- **本地 API 只对本机开放**:面板只监听回环地址,并校验 `Host` 与 `Origin`,防止 DNS 重绑定和跨站请求。
+
+> [!WARNING]
+> - **Agent 与网关之间的链路目前没有加密**,token 与转发的数据都以明文传输。请让这段链路跑在可信网络或加密通道上(WireGuard、Tailscale、ZeroTier、IPSec VPN 等),或者在两端之间自行加一层 TLS 隧道。
+> - 授权以**来源 IP** 为准。如果多人通过同一个 NAT 出口访问网关,他们会共享同一份授权。
+> - 访问者到网关的流量就是目标服务本身的协议,加密与否取决于目标服务(例如 MySQL 是否启用 TLS)。
+
+## 常见问题
+
+
+必须用 Tailscale 吗?
+
+不需要。PortRelay 只要求 Agent 所在机器能访问目标网段,并且能通过 TCP 连到网关的控制端口。Tailscale 只是让 Agent 连上网关的方式之一,局域网直连、VPN 或公网地址都可以。
+
+
+
+和 frp、ssh -R 有什么区别?
+
+它们都能做端口转发,而且更通用。PortRelay 专注于**临时、按人授权**的场景:默认拒绝、通过邀请链接按设备授权、可选审批、首次连接起计时、到期或闲置自动收回,并配有图形界面和接入台账。如果只需要一条长期开着的转发,frp 或 SSH 更简单。
+
+
+
+一个网关能接多个 Agent 吗?
+
+目前同一时间只服务一个 Agent,新的认证会话会替换旧会话。需要多个 Agent 时,可以部署多个网关实例(使用不同的端口池和 token)。
+
+
+
+网关重启会怎样?
+
+正在进行的连接会断开。授权租约、邀请链接和审批请求保存在状态文件里,重启后依然有效。Agent 会自动重连并重新注册通道。
+
+
+
+访问者看到的 IP 不对 / 白名单不生效?
+
+检查网关前面是否有 NAT、端口映射或反向代理。Docker 部署请使用 `--network host`。只有在可信反向代理之后才开启 `-trust-proxy`,否则 `X-Forwarded-For` 可以被伪造。
+
+
+
+App 提示“无法验证开发者”?
+
+发布的 App 只做了 ad-hoc 签名,没有经过 Apple 公证。请右键选择“打开”,或者执行 `xattr -dr com.apple.quarantine /Applications/PortRelay.app`。
+
+
+## 开发
+
+需要 Go 1.23+;构建 macOS App 还需要 Xcode 命令行工具,运行环境为 macOS 13 及以上。
+
+```bash
+make test # gofmt 检查 + go vet + go test -race
+make agent # 编译当前平台的 Agent 到 bin/
+make app # 构建 PortRelay.app(UNIVERSAL=1 ./build-app.sh 构建通用二进制)
+make release # 打包全部平台的网关与 Agent 到 dist/
+make snapshots # 离屏渲染 App 各界面截图到 tools/ui-snapshots/out/
+```
+
+```text
+cmd/portrelay-server 边缘网关入口
+cmd/portrelay-agent Agent 入口(内嵌网页面板 web/)
+server/ 网关:Agent 会话、端口监听、白名单与授权租约、邀请与授权网页
+client/ Agent:配置、隧道、本地 API、闲置关闭、流量统计
+core/ 协议消息、ACL、流量计量与限速
+mac-native/ SwiftUI 菜单栏 App
+deploy/ systemd、Docker Compose 部署示例与安装脚本
+tools/ui-snapshots/ 离屏截图工具与 README 动图生成(mkgif)
+```
+
+README 里的截图和动图都用模拟数据生成:`tools/ui-snapshots/render.sh readme` 渲染截图,`FRAMES=40 tools/ui-snapshots/render.sh gif` 渲染逐帧画面,再用 `go run ./tools/ui-snapshots/mkgif` 合成 GIF。
+
+欢迎提交 Issue 和 Pull Request。提交前请先运行 `make test`。
+
+## 许可证
+
+[MIT](LICENSE) © DeepFal
diff --git a/README_EN.md b/README_EN.md
new file mode 100644
index 0000000..ca0b026
--- /dev/null
+++ b/README_EN.md
@@ -0,0 +1,329 @@
+
+
+
+
+PortRelay
+
+
+ Share internal services with teammates temporarily and safely: send an invite link, and access expires on its own.
+
+
+
+
+
+
+
+
+
+
+
+ 简体中文 · English
+
+
+
+
+
+
+Your machine can reach an internal network: a database, a jump host, an internal dashboard. Your teammates can't. The usual fixes are VPN accounts, firewall tickets, or opening the port to everyone and forgetting to close it again.
+
+PortRelay turns that into three steps:
+
+1. The **agent** exposes an internal target on a port of the **edge gateway**.
+2. You send a teammate an **invite link** (or QR code).
+3. They open it and enter their name. Their device can then connect for a limited time. Access ends when it expires, when the tunnel sits idle, or when you revoke it, and live connections are cut immediately.
+
+> [!TIP]
+> **No particular network setup is required.** The machine running the agent only needs to reach **both the target network and the edge gateway**. How the agent reaches the gateway is up to you: the same LAN, a corporate VPN, Tailscale, ZeroTier, WireGuard, or a gateway with a public address. The agent only dials out and opens no inbound ports.
+
+> [!NOTE]
+> The user interface (macOS app, web panel and invite pages) is currently in Simplified Chinese.
+
+## Contents
+
+- [Features](#features)
+- [Screenshots](#screenshots)
+- [How it works](#how-it-works)
+- [Quick start](#quick-start)
+- [Configuration](#configuration)
+- [Security model](#security-model)
+- [FAQ](#faq)
+- [Development](#development)
+
+## Features
+
+**Access control**
+
+- Three access modes: **invite-only** (the default and safest), an IP / CIDR allowlist (which can be combined with invites), and open to all.
+- Each invite link has its own expiry, device limit, access duration once granted (1 h, 2 h, 8 h, until end of day, or until the tunnel closes) and an optional approval step.
+- When approval is required, the app shows a system notification and you approve or deny with one click. The requester's page refreshes on its own.
+- **The access clock starts at the first connection**, so a teammate can accept an invite now and use it later.
+- When access expires or is revoked, **existing connections are dropped too**. Unauthorized connections are reset (TCP RST) and logged.
+
+**Tunnel lifecycle**
+
+- **Idle auto-close.** The idle timer starts when a tunnel has no connections, no active grants, no pending requests and no invite links with uses left. By default the tunnel closes after 30 minutes of idle time.
+- An optional **maximum open time** closes the tunnel at a fixed point, whether or not it is in use.
+- PortRelay records the first-connection time, the actual connected time and why each tunnel was closed.
+- Editing a tunnel keeps existing connections. Moving a tunnel binds the new port before releasing the old one.
+
+**Observability**
+
+- A 10-minute traffic chart for each tunnel, plus live rates, connection counts and session/total bytes.
+- Online devices are grouped by IP and can be disconnected one by one. The connection history is grouped the same way.
+- Per-tunnel bandwidth limit in MB/s.
+
+**Clients**
+
+- A native macOS menu-bar app (SwiftUI). Closing the window leaves only the menu-bar icon; quitting from the menu also stops the background agent.
+- An embedded web panel with the same features on Linux and Windows.
+- Tunnel search, including pinyin initials (for example `sjk` finds “数据库”), and a status filter.
+
+## Screenshots
+
+
+
+ A tunnel card: access address, grants, connected time and the last 10 minutes of traffic (sped up)
+
+
+
+
+
+
+
+**Invites.** Generating an invite gives you a ready-to-send message and a QR code. The teammate opens the link on a phone or laptop and enters their name. If approval is required, they wait on a status page that updates when you decide.
+
+
+
+
+
+
+
+ ① Open the invite link
+ ② Wait for approval (optional)
+ ③ Access granted
+
+
+
+**Access management.** Pending requests, authorized devices (with online state and remaining time) and active invite links are all managed in one place.
+
+
+
+
+
+
+Web panel and more screenshots
+
+
+Without the macOS app, the agent serves a web panel at `http://127.0.0.1:33080`:
+
+
+
+
+
+ Edit tunnel: access mode, idle close, max open time
+ Online devices, grouped by IP
+
+
+
+
+
+## How it works
+
+
+
+
+
+- The **edge gateway** (`portrelay-server`) runs where visitors can reach it and manages a port pool (default `33000-34000`). Port `33000` is the control port. The agent tunnel and the invite web pages share it: HTTP requests go to the invite pages, and anything else is treated as an agent session.
+- The **agent** (`portrelay-agent`, bundled in the macOS app) runs on a machine that can reach the target network. It dials the gateway's control port, authenticates with a token, and multiplexes all tunnels over that single TCP connection using [yamux](https://github.com/hashicorp/yamux).
+- When a visitor connects to `gateway:33660`, the gateway checks the allowlist and access grants.
+ - If the visitor is allowed, the gateway opens a stream through the tunnel, the agent dials `10.0.1.16:3306`, and bytes are relayed both ways.
+ - Otherwise the connection is reset and logged.
+- Grants, invites and approval requests live in a state file on the gateway and survive restarts. Tunnel settings, traffic history and the connection log are kept on the agent's machine.
+
+## Quick start
+
+### 1. Deploy the edge gateway (Linux)
+
+The gateway must be reachable by **visitors** and by **the agent**. Open TCP `33000-34000` in your firewall.
+
+**Option A: binary + systemd (recommended)**
+
+```bash
+VERSION=1.0.0 # see the Releases page
+curl -LO https://github.com/DeepFal/PortRelay/releases/download/v$VERSION/portrelay-$VERSION-linux-amd64.tar.gz
+tar xzf portrelay-$VERSION-linux-amd64.tar.gz && cd portrelay-$VERSION-linux-amd64
+sudo ./deploy/install-server.sh ./portrelay-server
+
+# The installer generates a random token; you will enter it in the agent
+sudo grep PORTRELAY_TOKEN /etc/portrelay-server.env
+```
+
+The installer does four things:
+- installs the binary into `/usr/local/bin`;
+- writes a [systemd unit](deploy/portrelay-server.service), which runs as a dynamic system user and keeps state in `/var/lib/portrelay`;
+- enables the service at boot;
+- keeps your existing configuration when you re-run it to upgrade.
+
+**Option B: Docker**
+
+```bash
+TOKEN=$(openssl rand -hex 24); echo "token: $TOKEN"
+docker run -d --name portrelay --network host --restart unless-stopped \
+ -e PORTRELAY_TOKEN=$TOKEN -v portrelay:/var/lib/portrelay \
+ ghcr.io/deepfal/portrelay-server:latest
+```
+
+> [!IMPORTANT]
+> Use `--network host`. Allowlists rely on the visitor's real source IP, and port publishing may replace it with a bridge address. See also the [docker-compose example](deploy/docker-compose.yml).
+
+**Option C: build from source**
+
+```bash
+git clone https://github.com/DeepFal/PortRelay.git && cd PortRelay
+make server # dist/portrelay-server-linux-{amd64,arm64}
+```
+
+### 2. Run the agent
+
+**macOS app.** Download `PortRelay-*-macos-universal.zip` from [Releases](https://github.com/DeepFal/PortRelay/releases), unzip it and move it to Applications.
+
+> [!NOTE]
+> The app is not notarized. On first launch, right-click it in Finder and choose Open, or run `xattr -dr com.apple.quarantine /Applications/PortRelay.app`. You can also build it yourself with `make app`.
+
+Open ⚙️ Settings and fill in:
+
+| Setting | Value |
+| --- | --- |
+| Server control address (服务器控制地址) | The address the **agent** uses to reach the gateway, e.g. `gw.example.com:33000`, `192.168.1.10:33000` or `100.x.y.z:33000` |
+| Token (安全令牌) | `PORTRELAY_TOKEN` from the gateway |
+| Public access address (对外访问地址) | The address **teammates** use to reach the gateway. Leave it empty to use the same host. |
+
+> Example: the agent reaches the gateway over Tailscale at `100.64.0.10`, while teammates in the office use the LAN address `192.168.1.10`. Set the control address to `100.64.0.10:33000` and the public access address to `192.168.1.10`. Invite links and share messages will use the LAN address.
+
+**Linux / Windows.** Download the archive for your platform, run the agent, and use the web panel that opens in your browser.
+
+```bash
+./portrelay-agent # panel at http://127.0.0.1:33080
+./portrelay-agent -no-browser # don't open a browser
+```
+
+### 3. Create a tunnel and invite someone
+
+1. Click **添加通道** (Add tunnel) and enter a name and a target such as `10.0.1.16:3306`. You can probe the target from your machine first, and a free port is suggested automatically.
+2. Keep the default access mode, **仅授权链接** (invite-only). Click **邀请** (Invite), then choose how long access lasts, how many devices may use the link, and whether approval is required.
+3. Send the generated message or QR code. Once the teammate has entered their name, they can connect their database client or SSH to `192.168.1.10:33660`.
+
+## Configuration
+
+**Gateway: `portrelay-server`**
+
+| Flag | Default | Description |
+| --- | --- | --- |
+| `-token` / `PORTRELAY_TOKEN` | required | Shared secret for agent authentication; generate it with `openssl rand -hex 24` |
+| `-listen` | `:33000` | Control port, shared by the agent tunnel and the invite pages |
+| `-port-min` / `-port-max` | `33000` / `34000` | Tunnel port pool |
+| `-state` | `/var/lib/portrelay/leases.json` | Where grants, invites and requests are persisted; leave it empty to keep them in memory only |
+| `-public-host` | empty | Address shown to visitors on the invite page; defaults to the host the visitor used to open the link |
+| `-trust-proxy` | `false` | Trust `X-Forwarded-For` / `X-Real-IP`. **Enable it only behind a trusted reverse proxy.** |
+| `-version` | | Print the version |
+
+**Agent: `portrelay-agent`**
+
+| Flag | Default | Description |
+| --- | --- | --- |
+| `-port` | `33080` | Local panel / API port; listens on `127.0.0.1` only |
+| `-no-browser` | `false` | Don't open the panel on start |
+| `-version` | | Print the version |
+
+Agent settings live in `~/.portrelay/config.json`. The file has mode 0600 and is written atomically. If it is corrupt, it is backed up and replaced with defaults.
+
+## Security model
+
+- **Agent authentication.** The first message on a new connection must be a token check, compared in constant time. Only authenticated sessions can register tunnels, and a new session replaces the old one.
+- **Deny by default.** New tunnels admit only devices that hold a grant. A grant applies to one tunnel and one source IP, and always has an expiry.
+- **Expiry is enforced live.** The gateway re-checks open connections every second and drops any whose grant has ended.
+- **The local API is loopback-only.** The panel binds to `127.0.0.1` and validates `Host` and `Origin` to block DNS rebinding and cross-site requests.
+
+> [!WARNING]
+> - **The agent ↔ gateway link is not encrypted yet.** The token and all relayed data travel in plain text. Run that hop over a trusted network or an encrypted overlay (WireGuard, Tailscale, ZeroTier, an IPsec VPN, …), or wrap it in your own TLS tunnel.
+> - Grants are tied to the **source IP**. Visitors behind the same NAT address share a grant.
+> - Traffic between visitors and the gateway uses the target service's own protocol. Whether it is encrypted depends on that service (for example, whether MySQL has TLS enabled).
+
+## FAQ
+
+
+Do I need Tailscale?
+
+No. PortRelay only needs the agent's machine to reach the target network and to open a TCP connection to the gateway's control port. Tailscale is just one way to provide that path; a LAN, a VPN or a public address work equally well.
+
+
+
+How is this different from frp or ssh -R?
+
+Those tools are more general port forwarders. PortRelay focuses on **temporary, per-person access**:
+- deny by default;
+- per-device grants through invite links, with optional approval;
+- clocks that start at the first connection;
+- automatic expiry and idle close;
+- a GUI and a connection log.
+
+If you only need one long-lived forward, frp or SSH is simpler.
+
+
+
+Can one gateway serve several agents?
+
+Not at the same time: a new authenticated session replaces the previous one. For several agents, run several gateway instances with different port pools and tokens.
+
+
+
+What happens when the gateway restarts?
+
+Live connections drop. Grants, invite links and approval requests are kept in the state file and stay valid. The agent reconnects and re-registers its tunnels automatically.
+
+
+
+Visitors show up with the wrong IP, or the allowlist doesn't work
+
+Check for NAT, port publishing or a reverse proxy in front of the gateway. With Docker, use `--network host`. Enable `-trust-proxy` only behind a trusted proxy; otherwise `X-Forwarded-For` can be spoofed.
+
+
+
+macOS says the developer cannot be verified
+
+Release builds are ad-hoc signed, not notarized. Right-click the app and choose Open, or run `xattr -dr com.apple.quarantine /Applications/PortRelay.app`.
+
+
+## Development
+
+Building requires Go 1.23+. The macOS app also needs the Xcode command-line tools and runs on macOS 13 or later.
+
+```bash
+make test # gofmt check + go vet + go test -race
+make agent # build the agent for this platform into bin/
+make app # build PortRelay.app (UNIVERSAL=1 ./build-app.sh for a universal binary)
+make release # package gateway + agent for all platforms into dist/
+make snapshots # render app screens offscreen into tools/ui-snapshots/out/
+```
+
+```text
+cmd/portrelay-server gateway entry point
+cmd/portrelay-agent agent entry point (embeds the web panel in web/)
+server/ gateway: agent sessions, listeners, allowlists and grants, invites and invite pages
+client/ agent: config, tunnels, local API, idle close, traffic stats
+core/ protocol messages, ACL, traffic metering and rate limiting
+mac-native/ SwiftUI menu-bar app
+deploy/ systemd and Docker Compose examples, install script
+tools/ui-snapshots/ offscreen screenshot renderer and README GIF builder (mkgif)
+```
+
+All screenshots and animations in this README come from mock data:
+- `tools/ui-snapshots/render.sh readme` renders the screenshots;
+- `FRAMES=40 tools/ui-snapshots/render.sh gif` renders the animation frames;
+- `go run ./tools/ui-snapshots/mkgif` encodes the frames as a GIF.
+
+Issues and pull requests are welcome. Please run `make test` before submitting.
+
+## License
+
+[MIT](LICENSE) © DeepFal
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..34beace
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,15 @@
+# 安全策略 / Security Policy
+
+如果你发现了安全漏洞,请**不要**公开提交 Issue,而是通过 GitHub 的
+[私密漏洞报告](https://github.com/DeepFal/PortRelay/security/advisories/new) 提交,
+我们会尽快确认并修复。
+
+If you find a security vulnerability, please do **not** open a public issue.
+Report it privately via [GitHub Security Advisories](https://github.com/DeepFal/PortRelay/security/advisories/new) instead.
+
+已知限制(不视为漏洞)/ Known limitations (not considered vulnerabilities):
+
+- Agent 与网关之间的链路未加密,请运行在可信网络或加密组网之上。
+ The agent ↔ gateway link is not encrypted; run it over a trusted network or an encrypted overlay.
+- 授权以来源 IP 为准,同一 NAT 出口后的设备共享授权。
+ Grants are tied to the source IP; devices behind the same NAT share a grant.
diff --git a/build-app.sh b/build-app.sh
new file mode 100755
index 0000000..2ce5dcc
--- /dev/null
+++ b/build-app.sh
@@ -0,0 +1,102 @@
+#!/bin/bash
+# 构建 macOS 菜单栏应用 PortRelay.app(内含 Go Agent)。
+#
+# ./build-app.sh # 当前架构
+# UNIVERSAL=1 ./build-app.sh # Apple Silicon + Intel 通用二进制
+# VERSION=1.2.0 ./build-app.sh # 写入版本号(默认 0.0.0-dev)
+set -euo pipefail
+
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$DIR"
+
+VERSION="${VERSION:-0.0.0-dev}"
+BUNDLE_VERSION="${VERSION%%-*}"
+[[ "$BUNDLE_VERSION" =~ ^[0-9]+(\.[0-9]+)*$ ]] || BUNDLE_VERSION="0.0.0"
+MIN_MACOS="13.0"
+APP="PortRelay.app"
+BUILD="build/app"
+mkdir -p "$BUILD" bin
+
+if [ "${UNIVERSAL:-0}" = "1" ]; then
+ ARCHS="arm64 x86_64"
+else
+ ARCHS="$(uname -m)"
+fi
+
+goarch() { [ "$1" = "x86_64" ] && echo amd64 || echo "$1"; }
+
+echo "==> 1. Building Go agent ($ARCHS)..."
+agents=()
+for a in $ARCHS; do
+ CGO_ENABLED=0 GOOS=darwin GOARCH="$(goarch "$a")" \
+ go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$BUILD/portrelay-agent-$a" ./cmd/portrelay-agent
+ agents+=("$BUILD/portrelay-agent-$a")
+done
+lipo -create -output bin/portrelay-agent "${agents[@]}"
+
+echo "==> 2. Generating AppIcon if needed..."
+if [ ! -f mac-native/AppIcon.icns ]; then
+ xcrun swift mac-native/generate_icon.swift
+ iconutil -c icns mac-native/AppIcon.iconset -o mac-native/AppIcon.icns
+fi
+
+echo "==> 3. Compiling native Swift app ($ARCHS)..."
+apps=()
+for a in $ARCHS; do
+ xcrun swiftc -parse-as-library -O -target "$a-apple-macos$MIN_MACOS" \
+ mac-native/PortRelayApp.swift -o "$BUILD/PortRelay-$a"
+ apps+=("$BUILD/PortRelay-$a")
+done
+lipo -create -output "$BUILD/PortRelay" "${apps[@]}"
+
+echo "==> 4. Assembling $APP..."
+rm -rf "$APP"
+mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
+cp "$BUILD/PortRelay" "$APP/Contents/MacOS/PortRelay"
+cp bin/portrelay-agent "$APP/Contents/Resources/portrelay-agent"
+cp mac-native/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns"
+chmod +x "$APP/Contents/MacOS/PortRelay" "$APP/Contents/Resources/portrelay-agent"
+
+cat << PLIST > "$APP/Contents/Info.plist"
+
+
+
+
+ CFBundleExecutable
+ PortRelay
+ CFBundleIdentifier
+ com.deepfal.portrelay
+ CFBundleName
+ PortRelay
+ CFBundleDisplayName
+ PortRelay
+ CFBundleIconFile
+ AppIcon.icns
+ CFBundleIconName
+ AppIcon
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ ${VERSION}
+ CFBundleVersion
+ ${BUNDLE_VERSION}
+ LSMinimumSystemVersion
+ ${MIN_MACOS}
+ NSHighResolutionCapable
+
+ NSLocalNetworkUsageDescription
+ PortRelay 需要访问本地网络,以连接边缘网关并把流量转发到内网目标服务。
+
+
+PLIST
+
+echo "==> 5. Ad-hoc signing..."
+xattr -cr "$APP"
+codesign --force --deep --sign - "$APP"
+touch "$APP"
+if [ -z "${CI:-}" ]; then
+ /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$APP" || true
+ xcrun swift mac-native/set_icon.swift "$APP" || true
+fi
+
+echo "✓ Build complete: $DIR/$APP ($VERSION, $ARCHS)"
diff --git a/client/agent.go b/client/agent.go
new file mode 100644
index 0000000..60e9b47
--- /dev/null
+++ b/client/agent.go
@@ -0,0 +1,807 @@
+package client
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/hashicorp/yamux"
+
+ "github.com/DeepFal/PortRelay/core/protocol"
+)
+
+const (
+ authRespTimeout = 10 * time.Second
+ reconnectDelay = 2 * time.Second
+)
+
+// Agent runs on Mac and connects to the relay server
+type Agent struct {
+ cm *ConfigManager
+
+ mu sync.RWMutex
+ session *yamux.Session
+ connected bool
+ lastError string
+ latestMetrics protocol.MetricsMessage
+ regErrors map[string]string // tunnelID -> 服务器返回的注册失败原因
+ inviteWaiters map[string]chan protocol.InviteRespMessage
+
+ controlWriteMu sync.Mutex
+ controlWriter *bufio.Writer
+
+ // 闲置自动关闭与实际连接时长统计(按通道)
+ usageMu sync.Mutex
+ usage map[string]*usageState
+ idleUnit time.Duration // 闲置时长单位,正常为分钟(测试可调小)
+ rates *rateRecorder // 近 10 分钟流量曲线
+
+ metricsSub chan struct{}
+ closed chan struct{}
+ supervisorDone chan struct{}
+ stopOnce sync.Once
+ reconnectSignal chan struct{}
+}
+
+// NewAgent initializes the agent
+func NewAgent(cm *ConfigManager) *Agent {
+ return &Agent{
+ cm: cm,
+ regErrors: make(map[string]string),
+ inviteWaiters: make(map[string]chan protocol.InviteRespMessage),
+ usage: make(map[string]*usageState),
+ idleUnit: time.Minute,
+ rates: newRateRecorder(),
+ metricsSub: make(chan struct{}, 1),
+ closed: make(chan struct{}),
+ supervisorDone: make(chan struct{}),
+ reconnectSignal: make(chan struct{}, 1),
+ }
+}
+
+// Reconnect drops the current session and reconnects immediately.
+// force=false 时先探测现有会话,健康则保留(避免屏幕唤醒等场景无谓地切断所有连接)。
+func (a *Agent) Reconnect(force bool) {
+ a.mu.RLock()
+ sess := a.session
+ a.mu.RUnlock()
+
+ if !force && sess != nil {
+ if healthy(sess) {
+ log.Printf("[Agent] 会话健康,无需重连")
+ return
+ }
+ }
+
+ log.Printf("[Agent] ⚡ 触发主动重连...")
+ if sess != nil {
+ _ = sess.Close()
+ }
+ select {
+ case a.reconnectSignal <- struct{}{}:
+ default:
+ }
+}
+
+func healthy(sess *yamux.Session) bool {
+ res := make(chan error, 1)
+ go func() {
+ _, err := sess.Ping()
+ res <- err
+ }()
+ select {
+ case err := <-res:
+ return err == nil
+ case <-time.After(3 * time.Second):
+ return false
+ }
+}
+
+// Start runs background connection supervisor and TTL checker
+func (a *Agent) Start() {
+ go a.supervisorLoop()
+ go a.ttlCheckerLoop()
+}
+
+// Stop shuts down and waits (briefly) for the session to wind down, so the final
+// traffic merge is in the config before the caller persists it. Safe to call more than once.
+func (a *Agent) Stop() {
+ a.stopOnce.Do(func() {
+ close(a.closed)
+ a.mu.Lock()
+ if a.session != nil {
+ _ = a.session.Close()
+ }
+ a.mu.Unlock()
+ select {
+ case <-a.supervisorDone:
+ case <-time.After(3 * time.Second):
+ }
+ })
+}
+
+func (a *Agent) ttlCheckerLoop() {
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-a.closed:
+ return
+ case <-ticker.C:
+ for _, t := range a.cm.ExpireDue(a.ConsumeSessionBytes) {
+ log.Printf("[Agent] ⏰ 通道「%s」(端口 %d) %s,已自动关闭", t.Name, t.ListenPort, t.CloseReason)
+ a.dropUsage(t.ID)
+ a.SyncTunnel(t, false)
+ }
+ a.trackUsage(time.Now())
+ }
+ }
+}
+
+// usageState tracks one enabled tunnel's activity during the current session
+type usageState struct {
+ lastActive time.Time // 最近一次“在用”的时间(闲置计时起点)
+ lastTick time.Time // 上次统计时间
+ inUse bool // 当前是否在用
+ pendingConn time.Duration // 尚未落盘的实际连接时长
+ firstMarked bool
+}
+
+// TunnelActivity is the live usage view of an enabled tunnel
+type TunnelActivity struct {
+ InUse bool `json:"in_use"`
+ IdleSince *time.Time `json:"idle_since,omitempty"`
+ AutoCloseAt *time.Time `json:"auto_close_at,omitempty"`
+ ConnectedSec int64 `json:"connected_sec"` // 本次开启后有连接在线的累计秒数
+}
+
+// inUse: 有在线连接、有效授权(含尚未首次连接的)、待审批申请或仍可使用的邀请
+func tunnelInUse(m protocol.TunnelMetrics) bool {
+ if m.ActiveConns > 0 || len(m.DynamicLeases) > 0 || len(m.Pending) > 0 {
+ return true
+ }
+ for _, inv := range m.Invites {
+ if inv.MaxUses <= 0 || len(inv.Uses) < inv.MaxUses {
+ return true
+ }
+ }
+ return false
+}
+
+const connectedFlushInterval = 10 * time.Second
+
+// trackUsage runs every second: updates idle timers and real connected time, and closes idle tunnels
+func (a *Agent) trackUsage(now time.Time) {
+ cfg := a.cm.Get()
+ metrics := a.GetLatestMetrics()
+ connected := a.IsConnected()
+ byID := make(map[string]protocol.TunnelMetrics, len(metrics.Tunnels))
+ for _, m := range metrics.Tunnels {
+ byID[m.TunnelID] = m
+ }
+
+ type closing struct {
+ id, reason string
+ flush int64
+ }
+ var toClose []closing
+ var firstConn []string
+ flush := map[string]int64{}
+
+ a.usageMu.Lock()
+ enabled := map[string]bool{}
+ for _, t := range cfg.Tunnels {
+ if !t.Enabled {
+ continue
+ }
+ enabled[t.ID] = true
+ u := a.usage[t.ID]
+ if u == nil {
+ u = &usageState{lastActive: now, lastTick: now, firstMarked: t.FirstConnectedAt != nil}
+ a.usage[t.ID] = u
+ }
+ dt := now.Sub(u.lastTick)
+ u.lastTick = now
+
+ m, ok := byID[t.ID]
+ if !connected || !ok || m.Status != "online" {
+ // 未连接服务器或尚未收到指标:暂停闲置计时,避免把网络中断误判为闲置
+ u.lastActive = now
+ u.inUse = false
+ continue
+ }
+ if m.ActiveConns > 0 {
+ u.pendingConn += dt
+ if !u.firstMarked {
+ u.firstMarked = true
+ firstConn = append(firstConn, t.ID)
+ }
+ }
+ u.inUse = tunnelInUse(m)
+ if u.inUse {
+ u.lastActive = now
+ }
+ // 即将因最长开启时长关闭时先落盘,避免丢失最后一段连接时长
+ expiring := t.ExpiresAt != nil && !now.Before(t.ExpiresAt.Add(-time.Second))
+ if u.pendingConn >= connectedFlushInterval || (expiring && u.pendingConn >= time.Second) {
+ secs := int64(u.pendingConn / time.Second)
+ u.pendingConn -= time.Duration(secs) * time.Second
+ flush[t.ID] = secs
+ }
+ if idle := IdleCloseMinutes(t); idle > 0 && now.Sub(u.lastActive) >= time.Duration(idle)*a.idleUnit {
+ secs := int64(u.pendingConn / time.Second)
+ toClose = append(toClose, closing{t.ID, fmt.Sprintf("闲置 %s 自动关闭", formatMinutes(idle)), secs})
+ delete(a.usage, t.ID)
+ }
+ }
+ for id := range a.usage {
+ if !enabled[id] {
+ delete(a.usage, id)
+ }
+ }
+ a.usageMu.Unlock()
+ a.rates.retain(enabled)
+
+ for _, id := range firstConn {
+ a.cm.MarkFirstConnected(id, now)
+ }
+ for id, secs := range flush {
+ a.cm.AddConnectedSeconds(id, secs)
+ }
+ for _, c := range toClose {
+ a.cm.AddConnectedSeconds(c.id, c.flush)
+ if t, ok := a.cm.CloseTunnel(c.id, c.reason, a.ConsumeSessionBytes); ok {
+ log.Printf("[Agent] 💤 通道「%s」(端口 %d) %s", t.Name, t.ListenPort, c.reason)
+ a.SyncTunnel(t, false)
+ }
+ }
+}
+
+// FlushUsage persists the not-yet-saved connected time of a tunnel (before it is stopped)
+func (a *Agent) FlushUsage(id string) {
+ a.usageMu.Lock()
+ var secs int64
+ if u := a.usage[id]; u != nil {
+ secs = int64(u.pendingConn / time.Second)
+ u.pendingConn -= time.Duration(secs) * time.Second
+ }
+ a.usageMu.Unlock()
+ a.cm.AddConnectedSeconds(id, secs)
+}
+
+func (a *Agent) dropUsage(id string) {
+ a.usageMu.Lock()
+ delete(a.usage, id)
+ a.usageMu.Unlock()
+}
+
+// RateSeries returns the last 10 minutes of a tunnel's rates (id "" = all tunnels)
+func (a *Agent) RateSeries(id string) RateSeries {
+ if id == "" {
+ id = totalSeries
+ }
+ return a.rates.series(id, time.Now())
+}
+
+// Activity returns the live usage of an enabled tunnel (nil when not tracked yet)
+func (a *Agent) Activity(t protocol.TunnelConfig) *TunnelActivity {
+ a.usageMu.Lock()
+ defer a.usageMu.Unlock()
+ u := a.usage[t.ID]
+ if u == nil || !t.Enabled {
+ return nil
+ }
+ act := &TunnelActivity{InUse: u.inUse, ConnectedSec: t.ConnectedSec + int64(u.pendingConn/time.Second)}
+ if !u.inUse {
+ since := u.lastActive
+ act.IdleSince = &since
+ if idle := IdleCloseMinutes(t); idle > 0 {
+ at := since.Add(time.Duration(idle) * a.idleUnit)
+ act.AutoCloseAt = &at
+ }
+ }
+ return act
+}
+
+func (a *Agent) supervisorLoop() {
+ defer close(a.supervisorDone)
+ for {
+ select {
+ case <-a.closed:
+ return
+ default:
+ }
+
+ cfg := a.cm.Get()
+ if cfg.ServerAddr == "" {
+ // 首次运行尚未配置服务器:等用户在设置中填写后再连接
+ a.mu.Lock()
+ a.lastError = "尚未配置服务器地址,请在设置中填写"
+ a.mu.Unlock()
+ select {
+ case <-a.closed:
+ return
+ case <-a.reconnectSignal:
+ case <-time.After(reconnectDelay):
+ }
+ continue
+ }
+ log.Printf("[Agent] 正在连接中继服务器 (%s)...", cfg.ServerAddr)
+
+ err := a.connectAndRun(cfg)
+ if err != nil {
+ log.Printf("[Agent] 连接中断或失败: %v, %v 后尝试重连...", err, reconnectDelay)
+ }
+ a.resetSessionState(err)
+
+ select {
+ case <-a.closed:
+ return
+ case <-a.reconnectSignal:
+ log.Printf("[Agent] 立即执行重连...")
+ case <-time.After(reconnectDelay):
+ }
+ }
+}
+
+// resetSessionState 会话结束:把本次会话流量并入历史(服务端会重置计数),清掉过期指标
+func (a *Agent) resetSessionState(err error) {
+ a.controlWriteMu.Lock()
+ a.controlWriter = nil
+ a.controlWriteMu.Unlock()
+
+ // 锁顺序固定为 cm.mu -> a.mu(ExpireDue 持 cm.mu 回调 ConsumeSessionBytes),这里先读配置再加锁
+ enabled := make(map[string]bool)
+ for _, t := range a.cm.Get().Tunnels {
+ enabled[t.ID] = t.Enabled
+ }
+ a.mu.Lock()
+ carry := make(map[string][2]int64)
+ for _, m := range a.latestMetrics.Tunnels {
+ if enabled[m.TunnelID] {
+ carry[m.TunnelID] = [2]int64{m.BytesIn, m.BytesOut}
+ }
+ }
+ a.connected = false
+ a.session = nil
+ a.latestMetrics = protocol.MetricsMessage{}
+ a.regErrors = make(map[string]string)
+ if err != nil && !errors.Is(err, io.EOF) {
+ a.lastError = err.Error()
+ } else {
+ a.lastError = "与中继服务器的连接已断开"
+ }
+ a.mu.Unlock()
+
+ a.cm.AddHistoricalTraffic(carry)
+}
+
+func (a *Agent) connectAndRun(cfg Config) error {
+ rawConn, err := net.DialTimeout("tcp", cfg.ServerAddr, 5*time.Second)
+ if err != nil {
+ return fmt.Errorf("dial server failed: %w", err)
+ }
+ defer rawConn.Close()
+
+ yCfg := yamux.DefaultConfig()
+ yCfg.EnableKeepAlive = true
+ yCfg.KeepAliveInterval = 10 * time.Second
+ yCfg.LogOutput = io.Discard
+
+ session, err := yamux.Client(rawConn, yCfg)
+ if err != nil {
+ return fmt.Errorf("yamux client init error: %w", err)
+ }
+ defer session.Close()
+
+ a.mu.Lock()
+ a.session = session
+ a.mu.Unlock()
+
+ controlStream, err := session.OpenStream()
+ if err != nil {
+ return fmt.Errorf("open control stream failed: %w", err)
+ }
+ defer controlStream.Close()
+
+ writer := bufio.NewWriter(controlStream)
+ reader := bufio.NewReader(controlStream)
+
+ // 1. Authenticate and wait for the verdict before declaring the link usable
+ if err := writeJSONLine(writer, protocol.AuthMessage{Type: protocol.TypeAuth, Token: cfg.Token}); err != nil {
+ return err
+ }
+ _ = controlStream.SetReadDeadline(time.Now().Add(authRespTimeout))
+ line, err := reader.ReadBytes('\n')
+ if err != nil {
+ return fmt.Errorf("等待鉴权结果失败: %w", err)
+ }
+ _ = controlStream.SetReadDeadline(time.Time{})
+ var authResp protocol.AuthRespMessage
+ if err := json.Unmarshal(line, &authResp); err != nil || authResp.Type != protocol.TypeAuthResp {
+ return fmt.Errorf("服务端返回了非预期的鉴权响应")
+ }
+ if !authResp.Success {
+ return fmt.Errorf("身份鉴权失败: %s", authResp.Error)
+ }
+
+ a.controlWriteMu.Lock()
+ a.controlWriter = writer
+ a.controlWriteMu.Unlock()
+
+ a.mu.Lock()
+ a.connected = true
+ a.lastError = ""
+ a.mu.Unlock()
+ log.Printf("[Agent] 成功与中继服务器握手并通过鉴权!")
+
+ // 2. Register all enabled tunnels
+ for _, t := range a.cm.Get().Tunnels {
+ if t.Enabled {
+ log.Printf("[Agent] 注册通道: %s (:%d -> %s) [责任人: %s]", t.Name, t.ListenPort, t.Target, t.Owner)
+ _ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: t})
+ }
+ }
+
+ // 3. Start data stream acceptor loop
+ go a.acceptDataStreams(session)
+
+ // 4. Read incoming control messages
+ for {
+ line, err := reader.ReadBytes('\n')
+ if err != nil {
+ if err != io.EOF {
+ log.Printf("[Agent] control stream read error: %v", err)
+ }
+ return err
+ }
+ if len(line) == 0 {
+ continue
+ }
+
+ var base protocol.BaseMessage
+ if err := json.Unmarshal(line, &base); err != nil {
+ continue
+ }
+
+ switch base.Type {
+ case protocol.TypeMetrics:
+ var msg protocol.MetricsMessage
+ if err := json.Unmarshal(line, &msg); err == nil {
+ a.storeMetrics(msg)
+ }
+ case protocol.TypeInviteResp:
+ var resp protocol.InviteRespMessage
+ if json.Unmarshal(line, &resp) != nil {
+ continue
+ }
+ a.mu.Lock()
+ if ch, ok := a.inviteWaiters[resp.RequestID]; ok {
+ ch <- resp
+ delete(a.inviteWaiters, resp.RequestID)
+ }
+ a.mu.Unlock()
+ case protocol.TypeRegisterResp:
+ var resp protocol.RegisterRespMessage
+ if json.Unmarshal(line, &resp) != nil {
+ continue
+ }
+ a.mu.Lock()
+ if resp.Success {
+ delete(a.regErrors, resp.TunnelID)
+ } else {
+ a.regErrors[resp.TunnelID] = resp.Error
+ log.Printf("[Agent] 通道 %s 注册失败: %s", resp.TunnelID, resp.Error)
+ }
+ a.mu.Unlock()
+ }
+ }
+}
+
+// storeMetrics 只保留当前处于开启状态的通道,避免已关闭通道的旧指标被重复计入历史流量
+func (a *Agent) storeMetrics(msg protocol.MetricsMessage) {
+ enabled := make(map[string]bool)
+ for _, t := range a.cm.Get().Tunnels {
+ if t.Enabled {
+ enabled[t.ID] = true
+ }
+ }
+ filtered := msg.Tunnels[:0]
+ for _, m := range msg.Tunnels {
+ if enabled[m.TunnelID] {
+ filtered = append(filtered, m)
+ }
+ }
+ msg.Tunnels = filtered
+
+ rates := make(map[string][2]int64, len(msg.Tunnels))
+ for _, m := range msg.Tunnels {
+ rates[m.TunnelID] = [2]int64{m.RateIn, m.RateOut}
+ }
+ a.rates.record(time.Now(), rates)
+
+ a.mu.Lock()
+ a.latestMetrics = msg
+ a.mu.Unlock()
+
+ select {
+ case a.metricsSub <- struct{}{}:
+ default:
+ }
+}
+
+// ConsumeSessionBytes returns the current session traffic of a tunnel and drops it from the
+// cached metrics so it cannot be merged into history twice.
+func (a *Agent) ConsumeSessionBytes(id string) (int64, int64) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ tunnels := a.latestMetrics.Tunnels
+ for i, m := range tunnels {
+ if m.TunnelID == id {
+ out := make([]protocol.TunnelMetrics, 0, len(tunnels)-1)
+ out = append(out, tunnels[:i]...)
+ out = append(out, tunnels[i+1:]...)
+ a.latestMetrics.Tunnels = out
+ return m.BytesIn, m.BytesOut
+ }
+ }
+ return 0, 0
+}
+
+func (a *Agent) acceptDataStreams(session *yamux.Session) {
+ for {
+ stream, err := session.AcceptStream()
+ if err != nil {
+ return
+ }
+ go a.handleDataStream(stream)
+ }
+}
+
+func (a *Agent) handleDataStream(stream net.Conn) {
+ defer stream.Close()
+
+ _ = stream.SetReadDeadline(time.Now().Add(10 * time.Second))
+ reader := bufio.NewReader(stream)
+ line, err := reader.ReadBytes('\n')
+ if err != nil {
+ return
+ }
+ _ = stream.SetReadDeadline(time.Time{})
+
+ var hs protocol.StreamHandshake
+ if err := json.Unmarshal(line, &hs); err != nil {
+ return
+ }
+
+ t, ok := a.cm.FindTunnel(hs.TunnelID)
+ if !ok || !t.Enabled {
+ return
+ }
+
+ connStartTime := time.Now()
+
+ targetConn, err := net.DialTimeout("tcp", t.Target, 3*time.Second)
+ if err != nil {
+ log.Printf("[Agent] 目标主机 %s 无法连接 (客户端 %s): %v", t.Target, hs.ClientIP, err)
+ return
+ }
+ defer targetConn.Close()
+
+ // Count bytes for connection history
+ var bytesIn, bytesOut atomic.Int64
+ done := make(chan struct{}, 2)
+
+ go func() {
+ buf := make([]byte, 32*1024)
+ for {
+ nr, er := reader.Read(buf)
+ if nr > 0 {
+ bytesIn.Add(int64(nr))
+ nw, ew := targetConn.Write(buf[:nr])
+ if ew != nil || nr != nw {
+ break
+ }
+ }
+ if er != nil {
+ break
+ }
+ }
+ _ = targetConn.Close()
+ _ = stream.Close()
+ done <- struct{}{}
+ }()
+
+ go func() {
+ buf := make([]byte, 32*1024)
+ for {
+ nr, er := targetConn.Read(buf)
+ if nr > 0 {
+ bytesOut.Add(int64(nr))
+ nw, ew := stream.Write(buf[:nr])
+ if ew != nil || nr != nw {
+ break
+ }
+ }
+ if er != nil {
+ break
+ }
+ }
+ _ = targetConn.Close()
+ _ = stream.Close()
+ done <- struct{}{}
+ }()
+
+ <-done
+ <-done
+
+ // Record client connection history
+ connEndTime := time.Now()
+ a.cm.AddClientHistory(protocol.ClientHistoryRecord{
+ ID: uuid.New().String(),
+ TunnelID: hs.TunnelID,
+ TunnelName: t.Name,
+ Port: t.ListenPort,
+ ClientIP: hs.ClientIP,
+ ConnectedAt: connStartTime,
+ DisconnectedAt: connEndTime,
+ DurationSec: int64(connEndTime.Sub(connStartTime).Seconds()),
+ BytesIn: bytesIn.Load(),
+ BytesOut: bytesOut.Load(),
+ })
+}
+
+func writeJSONLine(w *bufio.Writer, v any) error {
+ data, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+ if _, err := w.Write(data); err != nil {
+ return err
+ }
+ return w.Flush()
+}
+
+func (a *Agent) sendControl(v any) error {
+ a.controlWriteMu.Lock()
+ defer a.controlWriteMu.Unlock()
+
+ if a.controlWriter == nil {
+ return fmt.Errorf("control stream not connected")
+ }
+ return writeJSONLine(a.controlWriter, v)
+}
+
+// SyncTunnel pushes a tunnel's current state to the relay server.
+// edited=true 时使用 update_tunnel(端口不变则原地更新,不断开现有连接)。
+func (a *Agent) SyncTunnel(t protocol.TunnelConfig, edited bool) {
+ if !t.Enabled {
+ a.mu.Lock()
+ delete(a.regErrors, t.ID)
+ a.mu.Unlock()
+ a.RemoveTunnel(t.ID)
+ return
+ }
+ if !a.IsConnected() {
+ return
+ }
+ if edited {
+ _ = a.sendControl(protocol.UpdateTunnelMessage{Type: protocol.TypeUpdateTunnel, Tunnel: t})
+ } else {
+ _ = a.sendControl(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: t})
+ }
+}
+
+func (a *Agent) RemoveTunnel(id string) {
+ a.mu.Lock()
+ delete(a.regErrors, id)
+ a.mu.Unlock()
+ if !a.IsConnected() {
+ return
+ }
+ _ = a.sendControl(protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: id})
+}
+
+// CreateInvite asks the relay server for a new invite link and waits for the result
+func (a *Agent) CreateInvite(tunnelID, note string, validMinutes, maxUses int, leaseDuration string, requireApproval bool) (protocol.Invite, error) {
+ if !a.IsConnected() {
+ return protocol.Invite{}, fmt.Errorf("未连接中继服务器")
+ }
+ reqID := uuid.New().String()
+ ch := make(chan protocol.InviteRespMessage, 1)
+ a.mu.Lock()
+ a.inviteWaiters[reqID] = ch
+ a.mu.Unlock()
+ defer func() {
+ a.mu.Lock()
+ delete(a.inviteWaiters, reqID)
+ a.mu.Unlock()
+ }()
+
+ if err := a.sendControl(protocol.CreateInviteMessage{
+ Type: protocol.TypeCreateInvite, RequestID: reqID, TunnelID: tunnelID,
+ Note: note, ValidMinutes: validMinutes, MaxUses: maxUses, LeaseDuration: leaseDuration, RequireApproval: requireApproval,
+ }); err != nil {
+ return protocol.Invite{}, err
+ }
+ select {
+ case resp := <-ch:
+ if !resp.Success {
+ return protocol.Invite{}, fmt.Errorf("%s", resp.Error)
+ }
+ return resp.Invite, nil
+ case <-time.After(5 * time.Second):
+ return protocol.Invite{}, fmt.Errorf("服务器未响应,请检查是否已升级到支持邀请链接的版本")
+ }
+}
+
+// RevokeInvite invalidates an invite link and the leases it granted
+func (a *Agent) RevokeInvite(tunnelID, code string) error {
+ if !a.IsConnected() {
+ return fmt.Errorf("未连接中继服务器")
+ }
+ return a.sendControl(protocol.RevokeInviteMessage{Type: protocol.TypeRevokeInvite, TunnelID: tunnelID, Code: code})
+}
+
+// DecideRequest approves or rejects a pending access request
+func (a *Agent) DecideRequest(tunnelID, requestID string, approve bool) error {
+ if !a.IsConnected() {
+ return fmt.Errorf("未连接中继服务器")
+ }
+ return a.sendControl(protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: tunnelID, RequestID: requestID, Approve: approve})
+}
+
+// RevokeLease removes a temporary whitelist lease on the relay server (all leases when ip is empty)
+func (a *Agent) RevokeLease(tunnelID, ip string) error {
+ if !a.IsConnected() {
+ return fmt.Errorf("未连接中继服务器")
+ }
+ return a.sendControl(protocol.RevokeLeaseMessage{Type: protocol.TypeRevokeLease, TunnelID: tunnelID, IP: ip})
+}
+
+func (a *Agent) KillConn(connID string) {
+ if !a.IsConnected() {
+ return
+ }
+ _ = a.sendControl(protocol.KillConnMessage{Type: protocol.TypeKillConn, ConnID: connID})
+}
+
+func (a *Agent) IsConnected() bool {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ return a.connected
+}
+
+// LastError returns why the agent is currently not connected (empty when connected)
+func (a *Agent) LastError() string {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ return a.lastError
+}
+
+// TunnelError returns the last registration error reported by the relay server for a tunnel
+func (a *Agent) TunnelError(id string) string {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ return a.regErrors[id]
+}
+
+func (a *Agent) GetLatestMetrics() protocol.MetricsMessage {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+ m := a.latestMetrics
+ m.Tunnels = append([]protocol.TunnelMetrics(nil), m.Tunnels...)
+ m.ActiveConns = append([]protocol.ActiveConnInfo(nil), m.ActiveConns...)
+ m.SecurityEvents = append([]protocol.SecurityEvent(nil), m.SecurityEvents...)
+ return m
+}
diff --git a/client/api.go b/client/api.go
new file mode 100644
index 0000000..c6cb3c3
--- /dev/null
+++ b/client/api.go
@@ -0,0 +1,647 @@
+package client
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/DeepFal/PortRelay/core/protocol"
+)
+
+const maxBodyBytes = 64 * 1024
+
+// APIServer provides REST and SSE endpoints for the dashboard
+type APIServer struct {
+ cm *ConfigManager
+ agent *Agent
+ fs http.FileSystem
+
+ // OnShutdown 由 POST /api/shutdown 触发(菜单栏“退出”时彻底结束后台引擎)
+ OnShutdown func()
+}
+
+// NewAPIServer creates an API server
+func NewAPIServer(cm *ConfigManager, agent *Agent, fs http.FileSystem) *APIServer {
+ return &APIServer{
+ cm: cm,
+ agent: agent,
+ fs: fs,
+ }
+}
+
+// Handler returns the http handler with routing
+func (s *APIServer) Handler() http.Handler {
+ mux := http.NewServeMux()
+
+ // API endpoints
+ mux.HandleFunc("/api/status", s.handleStatus)
+ mux.HandleFunc("/api/tunnels", s.handleTunnels)
+ mux.HandleFunc("/api/tunnels/", s.handleTunnelItem)
+ mux.HandleFunc("/api/probe", s.handleProbe)
+ mux.HandleFunc("/api/recommend-port", s.handleRecommendPort)
+ mux.HandleFunc("/api/connections", s.handleConnections)
+ mux.HandleFunc("/api/connections/kill", s.handleKillConn)
+ mux.HandleFunc("/api/history", s.handleClientHistory)
+ mux.HandleFunc("/api/reconnect", s.handleReconnect)
+ mux.HandleFunc("/api/settings", s.handleSettings)
+ mux.HandleFunc("/api/stream", s.handleSSE)
+ mux.HandleFunc("/api/shutdown", s.handleShutdown)
+
+ // Static web files
+ if s.fs != nil {
+ fileServer := http.FileServer(s.fs)
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api") {
+ http.NotFound(w, r)
+ return
+ }
+ fileServer.ServeHTTP(w, r)
+ })
+ }
+
+ return localOnlyMiddleware(mux)
+}
+
+func isLoopbackHost(hostport string) bool {
+ host := hostport
+ if h, _, err := net.SplitHostPort(hostport); err == nil {
+ host = h
+ }
+ host = strings.Trim(host, "[]")
+ if strings.EqualFold(host, "localhost") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+// localOnlyMiddleware 只接受本机面板与原生 App 的请求:
+// - Host 必须是回环地址(防 DNS rebinding)
+// - 带 Origin 的请求必须同源(防任意网页通过浏览器跨站调用本地 API)
+func localOnlyMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !isLoopbackHost(r.Host) {
+ http.Error(w, "forbidden host", http.StatusForbidden)
+ return
+ }
+ if origin := r.Header.Get("Origin"); origin != "" {
+ u, err := url.Parse(origin)
+ if err != nil || !strings.EqualFold(u.Host, r.Host) {
+ http.Error(w, "cross-origin request rejected", http.StatusForbidden)
+ return
+ }
+ }
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (s *APIServer) statusPayload() map[string]any {
+ cfg := s.cm.Get()
+ return map[string]any{
+ "connected": s.agent.IsConnected(),
+ "server_addr": cfg.ServerAddr,
+ "public_host": cfg.PublicHostOrDefault(),
+ "last_error": s.agent.LastError(),
+ "invite_base": s.inviteBase(cfg),
+ "total_rate_history": s.agent.RateSeries(""),
+ "port_range": fmt.Sprintf("%d - %d", PortMin, PortMax),
+ "timestamp": time.Now(),
+ }
+}
+
+func (s *APIServer) handleStatus(w http.ResponseWriter, r *http.Request) {
+ jsonResponse(w, http.StatusOK, s.statusPayload())
+}
+
+// TunnelWithMetrics joins config with current metrics
+type TunnelWithMetrics struct {
+ protocol.TunnelConfig
+ Metrics protocol.TunnelMetrics `json:"metrics"`
+ Activity *TunnelActivity `json:"activity,omitempty"`
+ // EffectiveIdleCloseMinutes 生效的闲置自动关闭时长(0 表示不自动关闭)
+ EffectiveIdleCloseMinutes int `json:"effective_idle_close_minutes"`
+ // RateHistory 近 10 分钟流量曲线(仅开启中的通道)
+ RateHistory *RateSeries `json:"rate_history,omitempty"`
+}
+
+// tunnelsWithMetrics merges config, live metrics and registration errors into one consistent view.
+// status: disabled | offline (未连接服务器) | error (服务器拒绝注册) | syncing (等待指标) | online
+func (s *APIServer) tunnelsWithMetrics(metrics protocol.MetricsMessage) []TunnelWithMetrics {
+ cfg := s.cm.Get()
+ connected := s.agent.IsConnected()
+ metricsMap := make(map[string]protocol.TunnelMetrics)
+ for _, m := range metrics.Tunnels {
+ metricsMap[m.TunnelID] = m
+ }
+
+ result := make([]TunnelWithMetrics, 0, len(cfg.Tunnels))
+ for _, t := range cfg.Tunnels {
+ tm := protocol.TunnelMetrics{TunnelID: t.ID, ListenPort: t.ListenPort}
+ switch {
+ case !t.Enabled:
+ tm.Status = "disabled"
+ case !connected:
+ tm.Status = "offline"
+ case s.agent.TunnelError(t.ID) != "":
+ tm.Status = "error"
+ tm.Error = s.agent.TunnelError(t.ID)
+ default:
+ if m, ok := metricsMap[t.ID]; ok {
+ tm = m
+ } else {
+ tm.Status = "syncing"
+ }
+ }
+ item := TunnelWithMetrics{
+ TunnelConfig: t,
+ Metrics: tm,
+ Activity: s.agent.Activity(t),
+ EffectiveIdleCloseMinutes: IdleCloseMinutes(t),
+ }
+ if t.Enabled {
+ series := s.agent.RateSeries(t.ID)
+ item.RateHistory = &series
+ }
+ result = append(result, item)
+ }
+ return result
+}
+
+func (s *APIServer) handleTunnels(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ jsonResponse(w, http.StatusOK, s.tunnelsWithMetrics(s.agent.GetLatestMetrics()))
+
+ case http.MethodPost:
+ var t protocol.TunnelConfig
+ if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON payload")
+ return
+ }
+ created, err := s.cm.AddTunnel(t)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ s.agent.SyncTunnel(created, false)
+ jsonResponse(w, http.StatusCreated, created)
+
+ default:
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) {
+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+ if len(parts) < 3 || parts[2] == "" {
+ jsonError(w, http.StatusBadRequest, "Missing tunnel ID")
+ return
+ }
+ tunnelID := parts[2]
+
+ if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost {
+ s.handleToggle(w, r, tunnelID)
+ return
+ }
+
+ // POST /api/tunnels/{id}/leases/revoke {"ip": "..."};ip 为空表示撤销该通道全部临时放通
+ if len(parts) == 5 && parts[3] == "leases" && parts[4] == "revoke" && r.Method == http.MethodPost {
+ var req struct {
+ IP string `json:"ip"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+ if _, ok := s.cm.FindTunnel(tunnelID); !ok {
+ jsonError(w, http.StatusNotFound, "通道未找到")
+ return
+ }
+ if err := s.agent.RevokeLease(tunnelID, strings.TrimSpace(req.IP)); err != nil {
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
+ return
+ }
+
+ // 邀请链接:POST 新建;DELETE /invites/{code} 作废;GET /invites/{code}/share 获取分享文案
+ if len(parts) >= 4 && parts[3] == "invites" {
+ s.handleInvites(w, r, tunnelID, parts[4:])
+ return
+ }
+
+ // POST /api/tunnels/{id}/requests/{rid} {"approve": true|false}
+ if len(parts) == 5 && parts[3] == "requests" && r.Method == http.MethodPost {
+ var req struct {
+ Approve bool `json:"approve"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+ if err := s.agent.DecideRequest(tunnelID, parts[4], req.Approve); err != nil {
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"})
+ return
+ }
+
+ if len(parts) == 4 && parts[3] == "refresh-auth-code" && r.Method == http.MethodPost {
+ t, err := s.cm.RefreshAuthCode(tunnelID)
+ if err != nil {
+ jsonError(w, http.StatusNotFound, err.Error())
+ return
+ }
+ s.agent.SyncTunnel(t, true)
+ jsonResponse(w, http.StatusOK, t)
+ return
+ }
+
+ if len(parts) != 3 {
+ http.NotFound(w, r)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodPut:
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, "Invalid body")
+ return
+ }
+ updated, _, err := s.cm.PatchTunnel(tunnelID, body)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ s.agent.SyncTunnel(updated, true)
+ jsonResponse(w, http.StatusOK, updated)
+
+ case http.MethodDelete:
+ if err := s.cm.DeleteTunnel(tunnelID); err != nil {
+ jsonError(w, http.StatusNotFound, err.Error())
+ return
+ }
+ s.agent.ConsumeSessionBytes(tunnelID)
+ s.agent.RemoveTunnel(tunnelID)
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
+
+ default:
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ }
+}
+
+// handleToggle 支持 {"enabled": true|false} 指定目标状态(幂等,防止重复点击来回翻转);不带 body 时取反
+func (s *APIServer) handleToggle(w http.ResponseWriter, r *http.Request, tunnelID string) {
+ var req struct {
+ Enabled *bool `json:"enabled"`
+ }
+ if r.Body != nil {
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+ }
+
+ current, err := s.cm.CurrentEnabled(tunnelID)
+ if err != nil {
+ jsonError(w, http.StatusNotFound, err.Error())
+ return
+ }
+ want := !current
+ if req.Enabled != nil {
+ want = *req.Enabled
+ }
+
+ var sessIn, sessOut int64
+ if !want && current {
+ s.agent.FlushUsage(tunnelID)
+ }
+ if want != current {
+ // 关闭时把本次会话流量并入历史;开启时清掉可能残留的旧指标
+ sessIn, sessOut = s.agent.ConsumeSessionBytes(tunnelID)
+ }
+ t, changed, err := s.cm.SetTunnelEnabled(tunnelID, want, sessIn, sessOut)
+ if err != nil {
+ jsonError(w, http.StatusNotFound, err.Error())
+ return
+ }
+ if changed {
+ s.agent.dropUsage(tunnelID)
+ s.agent.SyncTunnel(t, false)
+ }
+ jsonResponse(w, http.StatusOK, t)
+}
+
+// inviteBase is the URL prefix of invite links: http://<对外访问地址>:<控制端口>/i/
+func (s *APIServer) inviteBase(cfg Config) string {
+ port := "33000"
+ if _, p, err := net.SplitHostPort(cfg.ServerAddr); err == nil {
+ port = p
+ }
+ host := cfg.PublicHostOrDefault()
+ if host == "" {
+ return ""
+ }
+ return "http://" + net.JoinHostPort(host, port) + "/i/"
+}
+
+// shareText is the ready-to-send message (e.g. for WeChat) for an invite
+func shareText(cfg Config, t protocol.TunnelConfig, inv protocol.Invite, link string) string {
+ var b strings.Builder
+ b.WriteString("【PortRelay 访问邀请】")
+ if inv.Note != "" {
+ b.WriteString("(给 " + inv.Note + ")")
+ }
+ b.WriteString("\n通道:" + t.Name)
+ b.WriteString(fmt.Sprintf("\n访问地址:%s:%d", cfg.PublicHostOrDefault(), t.ListenPort))
+ action := "填写姓名后即可访问"
+ if inv.RequireApproval {
+ action = "填写姓名提交申请,管理员批准后即可访问"
+ }
+ b.WriteString(fmt.Sprintf("\n请在 %s 前用浏览器打开下面的链接,%s(访问有效期:%s):\n%s",
+ inv.ExpiresAt.Local().Format("01-02 15:04"), action, protocol.DescribeLeaseDuration(inv.LeaseDuration), link))
+ if inv.MaxUses == 1 {
+ b.WriteString("\n此链接仅限 1 台设备使用,请勿转发。")
+ } else if inv.MaxUses > 1 {
+ b.WriteString(fmt.Sprintf("\n此链接最多 %d 台设备使用,请勿转发。", inv.MaxUses))
+ }
+ return b.String()
+}
+
+func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnelID string, rest []string) {
+ t, ok := s.cm.FindTunnel(tunnelID)
+ if !ok {
+ jsonError(w, http.StatusNotFound, "通道未找到")
+ return
+ }
+ cfg := s.cm.Get()
+
+ switch {
+ case len(rest) == 0 && r.Method == http.MethodPost:
+ var req struct {
+ Note string `json:"note"`
+ ValidMinutes int `json:"valid_minutes"`
+ MaxUses *int `json:"max_uses"`
+ LeaseDuration string `json:"lease_duration"` // 授权后可访问时长,默认 2 小时
+ RequireApproval bool `json:"require_approval"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+ // 默认:24 小时内有效、限 1 台设备
+ if req.ValidMinutes == 0 {
+ req.ValidMinutes = 24 * 60
+ }
+ maxUses := 1
+ if req.MaxUses != nil {
+ maxUses = *req.MaxUses
+ }
+ if !protocol.ValidLeaseDuration(req.LeaseDuration) {
+ jsonError(w, http.StatusBadRequest, "无效的授权时长")
+ return
+ }
+ if req.LeaseDuration == "" {
+ req.LeaseDuration = protocol.LeaseDuration2h
+ }
+ if !t.Enabled {
+ jsonError(w, http.StatusConflict, "通道未开启,请先开启通道再生成邀请链接")
+ return
+ }
+ inv, err := s.agent.CreateInvite(tunnelID, strings.TrimSpace(req.Note), req.ValidMinutes, maxUses, req.LeaseDuration, req.RequireApproval)
+ if err != nil {
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ link := s.inviteBase(cfg) + inv.Code
+ jsonResponse(w, http.StatusCreated, map[string]any{
+ "invite": inv,
+ "link": link,
+ "share_text": shareText(cfg, t, inv, link),
+ })
+
+ case len(rest) == 1 && r.Method == http.MethodDelete:
+ if err := s.agent.RevokeInvite(tunnelID, rest[0]); err != nil {
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
+
+ case len(rest) == 2 && rest[1] == "share" && r.Method == http.MethodGet:
+ for _, m := range s.agent.GetLatestMetrics().Tunnels {
+ if m.TunnelID != tunnelID {
+ continue
+ }
+ for _, inv := range m.Invites {
+ if inv.Code == rest[0] {
+ link := s.inviteBase(cfg) + inv.Code
+ jsonResponse(w, http.StatusOK, map[string]any{"invite": inv, "link": link, "share_text": shareText(cfg, t, inv, link)})
+ return
+ }
+ }
+ }
+ jsonError(w, http.StatusNotFound, "邀请链接不存在或已过期")
+
+ default:
+ http.NotFound(w, r)
+ }
+}
+
+func (s *APIServer) handleReconnect(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ // mode=check:仅在会话不健康时重连(系统唤醒时使用)
+ force := r.URL.Query().Get("mode") != "check"
+ go s.agent.Reconnect(force)
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "reconnecting"})
+}
+
+func (s *APIServer) handleProbe(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ var req struct {
+ Target string `json:"target"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ req.Target = strings.TrimSpace(req.Target)
+ if req.Target == "" {
+ jsonError(w, http.StatusBadRequest, "target is required")
+ return
+ }
+ res := ProbeTarget(req.Target, 2*time.Second)
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "target": res.Target,
+ "reachable": res.Reachable,
+ "rtt": res.FormatRTT(),
+ "error": res.Error,
+ })
+}
+
+func (s *APIServer) handleRecommendPort(w http.ResponseWriter, r *http.Request) {
+ port := s.cm.RecommendPort()
+ if port == 0 {
+ jsonError(w, http.StatusConflict, "端口池已无空闲端口")
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]int{"recommended_port": port})
+}
+
+// handleConnections returns live client connections (optionally for one tunnel) and recent security events
+func (s *APIServer) handleConnections(w http.ResponseWriter, r *http.Request) {
+ metrics := s.agent.GetLatestMetrics()
+ tunnelID := r.URL.Query().Get("tunnel_id")
+ conns := make([]protocol.ActiveConnInfo, 0, len(metrics.ActiveConns))
+ for _, c := range metrics.ActiveConns {
+ if tunnelID == "" || c.TunnelID == tunnelID {
+ conns = append(conns, c)
+ }
+ }
+ events := metrics.SecurityEvents
+ if events == nil {
+ events = []protocol.SecurityEvent{}
+ }
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "active_conns": conns,
+ "security_events": events,
+ })
+}
+
+func (s *APIServer) handleKillConn(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ var req struct {
+ ConnID string `json:"conn_id"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ if req.ConnID != "" {
+ s.agent.KillConn(req.ConnID)
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+func (s *APIServer) handleClientHistory(w http.ResponseWriter, r *http.Request) {
+ tunnelID := r.URL.Query().Get("tunnel_id")
+ jsonResponse(w, http.StatusOK, s.cm.GetClientHistory(tunnelID))
+}
+
+func (s *APIServer) handleSettings(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ cfg := s.cm.Get()
+ jsonResponse(w, http.StatusOK, map[string]string{
+ "server_addr": cfg.ServerAddr,
+ "token": cfg.Token,
+ "public_host": cfg.PublicHostOrDefault(),
+ })
+ case http.MethodPost:
+ var req struct {
+ ServerAddr string `json:"server_addr"`
+ Token string `json:"token"`
+ PublicHost *string `json:"public_host"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+ publicHost := s.cm.Get().PublicHost
+ if req.PublicHost != nil {
+ publicHost = *req.PublicHost
+ }
+ changed, err := s.cm.UpdateServer(req.ServerAddr, req.Token, publicHost)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ // 新地址/令牌必须重连才会生效;只改对外访问地址时不打断连接
+ if changed {
+ go s.agent.Reconnect(true)
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "updated"})
+ default:
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *APIServer) handleSSE(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+
+ send := func() bool {
+ metrics := s.agent.GetLatestMetrics()
+ payload := s.statusPayload()
+ payload["metrics"] = metrics
+ payload["tunnels"] = s.tunnelsWithMetrics(metrics)
+ data, _ := json.Marshal(payload)
+ if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
+ return false
+ }
+ flusher.Flush()
+ return true
+ }
+
+ if !send() {
+ return
+ }
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+ case <-ticker.C:
+ if !send() {
+ return
+ }
+ }
+ }
+}
+
+func (s *APIServer) handleShutdown(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ if s.OnShutdown == nil {
+ jsonError(w, http.StatusNotImplemented, "shutdown not supported")
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]string{"status": "shutting down"})
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+ go s.OnShutdown()
+}
+
+func jsonResponse(w http.ResponseWriter, status int, data any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(data)
+}
+
+func jsonError(w http.ResponseWriter, status int, msg string) {
+ jsonResponse(w, status, map[string]string{"error": msg})
+}
diff --git a/client/client_test.go b/client/client_test.go
new file mode 100644
index 0000000..a01198b
--- /dev/null
+++ b/client/client_test.go
@@ -0,0 +1,712 @@
+package client
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DeepFal/PortRelay/core/protocol"
+ "github.com/DeepFal/PortRelay/server"
+)
+
+const testToken = "secret"
+
+type peekedConn struct {
+ net.Conn
+ r io.Reader
+}
+
+func (c *peekedConn) Read(b []byte) (int, error) { return c.r.Read(b) }
+
+// startRelayServer runs an in-process relay server (same dispatch as cmd/portrelay-server)
+func startRelayServer(t *testing.T) (string, *server.Manager) {
+ t.Helper()
+ m := server.NewManager(PortMin, PortMax, testToken)
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func(c net.Conn) {
+ br := bufio.NewReader(c)
+ p, err := br.Peek(3)
+ if err != nil {
+ c.Close()
+ return
+ }
+ if s := string(p); s == "GET" || s == "POS" || s == "HEA" {
+ m.HandleHTTP(&peekedConn{c, br})
+ return
+ }
+ _ = m.HandleSession(&peekedConn{c, br})
+ }(c)
+ }
+ }()
+ return ln.Addr().String(), m
+}
+
+// startEcho runs a TCP echo server acting as the intranet target
+func startEcho(t *testing.T) string {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ go func() {
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go func() { _, _ = io.Copy(c, c); c.Close() }()
+ }
+ }()
+ return ln.Addr().String()
+}
+
+// freeRelayPort finds a free port inside the tunnel range
+func freeRelayPort(t *testing.T, exclude ...int) int {
+ t.Helper()
+ skip := map[int]bool{ControlPort: true}
+ for _, p := range exclude {
+ skip[p] = true
+ }
+ for p := 33900; p < PortMax; p++ {
+ if skip[p] {
+ continue
+ }
+ if ln, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", p)); err == nil {
+ ln.Close()
+ return p
+ }
+ }
+ t.Fatal("no free port in tunnel range")
+ return 0
+}
+
+func newTestConfig(t *testing.T, serverAddr, token string) *ConfigManager {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "config.json")
+ data, _ := json.Marshal(Config{ServerAddr: serverAddr, Token: token, Tunnels: []protocol.TunnelConfig{}})
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ cm, err := NewConfigManagerAt(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // 在临时目录删除前停止延迟写盘(Cleanup 按注册的逆序执行,Agent 会先停止)
+ t.Cleanup(func() { _ = cm.Close() })
+ return cm
+}
+
+type env struct {
+ cm *ConfigManager
+ agent *Agent
+ api *httptest.Server
+ addr string
+}
+
+func startEnv(t *testing.T, token string) *env {
+ t.Helper()
+ return startEnvWith(t, token, nil)
+}
+
+func startEnvWith(t *testing.T, token string, tweak func(*Agent)) *env {
+ t.Helper()
+ addr, _ := startRelayServer(t)
+ cm := newTestConfig(t, addr, token)
+ agent := NewAgent(cm)
+ if tweak != nil {
+ tweak(agent)
+ }
+ agent.Start()
+ t.Cleanup(agent.Stop)
+ api := httptest.NewServer(NewAPIServer(cm, agent, nil).Handler())
+ t.Cleanup(api.Close)
+ return &env{cm: cm, agent: agent, api: api, addr: addr}
+}
+
+func (e *env) call(t *testing.T, method, path string, body any) (int, []byte) {
+ t.Helper()
+ var r io.Reader
+ if body != nil {
+ b, _ := json.Marshal(body)
+ r = strings.NewReader(string(b))
+ }
+ req, _ := http.NewRequest(method, e.api.URL+path, r)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, out
+}
+
+func (e *env) tunnel(t *testing.T, id string) TunnelWithMetrics {
+ t.Helper()
+ _, body := e.call(t, "GET", "/api/tunnels", nil)
+ var list []TunnelWithMetrics
+ _ = json.Unmarshal(body, &list)
+ for _, x := range list {
+ if x.ID == id {
+ return x
+ }
+ }
+ t.Fatalf("tunnel %s not found", id)
+ return TunnelWithMetrics{}
+}
+
+func waitFor(t *testing.T, what string, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ t.Fatalf("timed out waiting for: %s", what)
+}
+
+func echoVia(port int, msg string) (string, error) {
+ c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
+ if err != nil {
+ return "", err
+ }
+ defer c.Close()
+ _ = c.SetDeadline(time.Now().Add(2 * time.Second))
+ if _, err := c.Write([]byte(msg)); err != nil {
+ return "", err
+ }
+ buf := make([]byte, len(msg))
+ _, err = io.ReadFull(c, buf)
+ return string(buf), err
+}
+
+func TestEndToEndEditKeepsStateAndConnections(t *testing.T) {
+ e := startEnv(t, testToken)
+ waitFor(t, "agent connected", e.agent.IsConnected)
+
+ target := startEcho(t)
+ port := freeRelayPort(t)
+ status, body := e.call(t, "POST", "/api/tunnels", map[string]any{
+ "name": "db", "target": target, "listen_port": port, "owner": "ops",
+ "duration_minutes": 0, "max_bandwidth_mb": 5, "whitelist": []string{"*"},
+ })
+ if status != http.StatusCreated {
+ t.Fatalf("create: %d %s", status, body)
+ }
+ var created protocol.TunnelConfig
+ _ = json.Unmarshal(body, &created)
+
+ waitFor(t, "tunnel online", func() bool { return e.tunnel(t, created.ID).Metrics.Status == "online" })
+ if got, err := echoVia(port, "hello"); err != nil || got != "hello" {
+ t.Fatalf("relay broken: %q %v", got, err)
+ }
+
+ // 编辑前生成一个邀请链接,编辑后应仍然有效
+ status, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/invites", map[string]any{"max_uses": 0})
+ if status != http.StatusCreated {
+ t.Fatalf("create invite: %d %s", status, body)
+ }
+ var invResp struct {
+ Link string `json:"link"`
+ }
+ _ = json.Unmarshal(body, &invResp)
+
+ // 打开一条长连接,编辑后应保持可用
+ long, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer long.Close()
+
+ // Web 端只提交部分字段
+ status, body = e.call(t, "PUT", "/api/tunnels/"+created.ID, map[string]any{
+ "name": "db-renamed", "target": target, "listen_port": port, "whitelist": []string{"*"}, "enabled": true,
+ })
+ if status != http.StatusOK {
+ t.Fatalf("edit: %d %s", status, body)
+ }
+ var edited protocol.TunnelConfig
+ _ = json.Unmarshal(body, &edited)
+ if edited.Owner != "ops" || edited.MaxBandwidthMB != 5 {
+ t.Fatalf("partial edit wiped other fields: owner=%q bw=%v", edited.Owner, edited.MaxBandwidthMB)
+ }
+
+ _ = long.SetDeadline(time.Now().Add(2 * time.Second))
+ if _, err := long.Write([]byte("ping")); err != nil {
+ t.Fatal(err)
+ }
+ buf := make([]byte, 4)
+ if _, err := io.ReadFull(long, buf); err != nil || string(buf) != "ping" {
+ t.Fatalf("existing connection dropped by metadata edit: %v", err)
+ }
+
+ // 编辑后邀请链接仍然有效
+ resp, err := http.Get(invResp.Link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("invite link rejected after edit: %d", resp.StatusCode)
+ }
+
+ // 关闭后再编辑,不能被重新打开
+ e.call(t, "POST", "/api/tunnels/"+created.ID+"/toggle", map[string]bool{"enabled": false})
+ e.call(t, "PUT", "/api/tunnels/"+created.ID, map[string]any{"name": "x", "enabled": true})
+ if got := e.tunnel(t, created.ID); got.Enabled || got.Metrics.Status != "disabled" {
+ t.Fatalf("editing a disabled tunnel re-enabled it: enabled=%v status=%s", got.Enabled, got.Metrics.Status)
+ }
+
+ // 幂等开关:重复提交 enabled=false 不会翻转
+ e.call(t, "POST", "/api/tunnels/"+created.ID+"/toggle", map[string]bool{"enabled": false})
+ if e.tunnel(t, created.ID).Enabled {
+ t.Fatal("toggle with explicit state is not idempotent")
+ }
+}
+
+func TestWrongTokenIsReportedAsDisconnected(t *testing.T) {
+ e := startEnv(t, "WRONG")
+ waitFor(t, "auth error surfaced", func() bool { return strings.Contains(e.agent.LastError(), "鉴权") })
+ if e.agent.IsConnected() {
+ t.Fatal("agent reports connected with a wrong token")
+ }
+
+ // 改成正确 token 后应自动重连
+ status, body := e.call(t, "POST", "/api/settings", map[string]string{"server_addr": e.addr, "token": testToken})
+ if status != http.StatusOK {
+ t.Fatalf("settings: %d %s", status, body)
+ }
+ waitFor(t, "reconnected after settings change", e.agent.IsConnected)
+ if e.agent.LastError() != "" {
+ t.Fatalf("last error not cleared: %s", e.agent.LastError())
+ }
+}
+
+func TestRegistrationErrorIsVisible(t *testing.T) {
+ e := startEnv(t, testToken)
+ waitFor(t, "agent connected", e.agent.IsConnected)
+
+ port := freeRelayPort(t)
+ blocker, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer blocker.Close()
+
+ _, body := e.call(t, "POST", "/api/tunnels", map[string]any{"name": "x", "target": "127.0.0.1:1", "listen_port": port})
+ var created protocol.TunnelConfig
+ _ = json.Unmarshal(body, &created)
+ waitFor(t, "error status", func() bool {
+ m := e.tunnel(t, created.ID).Metrics
+ return m.Status == "error" && strings.Contains(m.Error, "bind")
+ })
+}
+
+func TestAPIRejectsCrossOriginAndForeignHost(t *testing.T) {
+ cm := newTestConfig(t, "127.0.0.1:1", testToken)
+ h := NewAPIServer(cm, NewAgent(cm), nil).Handler()
+
+ cases := []struct {
+ name, host, origin string
+ want int
+ }{
+ {"same origin", "127.0.0.1:33080", "http://127.0.0.1:33080", 200},
+ {"native app (no origin)", "127.0.0.1:33080", "", 200},
+ {"localhost", "localhost:33080", "", 200},
+ {"evil site", "127.0.0.1:33080", "https://evil.example", 403},
+ {"dns rebinding", "evil.example:33080", "", 403},
+ }
+ for _, c := range cases {
+ req := httptest.NewRequest("GET", "/api/settings", nil)
+ req.Host = c.host
+ if c.origin != "" {
+ req.Header.Set("Origin", c.origin)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != c.want {
+ t.Errorf("%s: got %d want %d", c.name, rec.Code, c.want)
+ }
+ if rec.Header().Get("Access-Control-Allow-Origin") != "" {
+ t.Errorf("%s: CORS header must not be set", c.name)
+ }
+ }
+}
+
+func TestCorruptConfigIsBackedUp(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+ if err := os.WriteFile(path, []byte(`{"tunnels": [`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := NewConfigManagerAt(path); err != nil {
+ t.Fatal(err)
+ }
+ matches, _ := filepath.Glob(path + ".corrupt-*")
+ if len(matches) != 1 {
+ t.Fatalf("expected a backup of the corrupt config, got %v", matches)
+ }
+ if b, _ := os.ReadFile(matches[0]); string(b) != `{"tunnels": [` {
+ t.Fatal("backup content differs from original")
+ }
+}
+
+func TestPatchValidationAndTTL(t *testing.T) {
+ cm := newTestConfig(t, "127.0.0.1:1", testToken)
+ a, err := cm.AddTunnel(protocol.TunnelConfig{Name: "a", Target: "10.0.0.1:22", ListenPort: 33701, DurationMinutes: 60})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cm.AddTunnel(protocol.TunnelConfig{Name: "b", Target: "10.0.0.1:22", ListenPort: 33701}); err == nil {
+ t.Fatal("duplicate port accepted")
+ }
+ for _, bad := range []string{
+ `{"listen_port": 33000}`,
+ `{"listen_port": 40000}`,
+ `{"target": "10.0.0.1"}`,
+ `{"whitelist": ["192.168.1.*"]}`,
+ } {
+ if _, _, err := cm.PatchTunnel(a.ID, []byte(bad)); err == nil {
+ t.Errorf("patch %s accepted", bad)
+ }
+ }
+
+ // 改成不限时:到期时间应清除
+ upd, _, err := cm.PatchTunnel(a.ID, []byte(`{"duration_minutes": 0}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if upd.ExpiresAt != nil {
+ t.Fatal("expires_at kept after switching to unlimited")
+ }
+ // 受保护字段不能被编辑
+ upd, _, _ = cm.PatchTunnel(a.ID, []byte(`{"enabled": false, "auth_code": "hacked", "historical_bytes_in": 999}`))
+ if !upd.Enabled || upd.AuthCode != a.AuthCode || upd.HistoricalBytesIn != 0 {
+ t.Fatalf("protected fields modified: %+v", upd)
+ }
+}
+
+func TestGetReturnsDeepCopy(t *testing.T) {
+ cm := newTestConfig(t, "127.0.0.1:1", testToken)
+ if _, err := cm.AddTunnel(protocol.TunnelConfig{Name: "a", Target: "10.0.0.1:22", ListenPort: 33702, Whitelist: []string{"*"}}); err != nil {
+ t.Fatal(err)
+ }
+ snap := cm.Get()
+ snap.Tunnels[0].Name = "mutated"
+ snap.Tunnels[0].Whitelist[0] = "1.1.1.1"
+ if got := cm.Get().Tunnels[0]; got.Name != "a" || got.Whitelist[0] != "*" {
+ t.Fatal("Get() leaks internal state")
+ }
+}
+
+func TestFirstRunHasNoUserData(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.json")
+ cm, err := NewConfigManagerAt(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := cm.Get()
+ if cfg.ServerAddr != "" || cfg.Token != "" || len(cfg.Tunnels) != 0 {
+ t.Fatalf("first-run config must be empty, got addr=%q token=%q tunnels=%d", cfg.ServerAddr, cfg.Token, len(cfg.Tunnels))
+ }
+
+ agent := NewAgent(cm)
+ agent.Start()
+ defer agent.Stop()
+ waitFor(t, "not-configured hint", func() bool { return strings.Contains(agent.LastError(), "尚未配置") })
+ if agent.IsConnected() {
+ t.Fatal("agent connected without a configured server")
+ }
+
+ // 未单独设置对外访问地址时,取服务器地址的主机部分
+ if _, err := cm.UpdateServer("10.1.2.3:33000", "t", ""); err != nil {
+ t.Fatal(err)
+ }
+ if got := cm.Get().PublicHostOrDefault(); got != "10.1.2.3" {
+ t.Fatalf("public host fallback = %q", got)
+ }
+}
+
+func TestLeaseListAndRevoke(t *testing.T) {
+ e := startEnv(t, testToken)
+ waitFor(t, "agent connected", e.agent.IsConnected)
+
+ target := startEcho(t)
+ port := freeRelayPort(t)
+ // 固定白名单不包含 127.0.0.1,只有临时放通后才能访问
+ _, body := e.call(t, "POST", "/api/tunnels", map[string]any{
+ "name": "lease", "target": target, "listen_port": port, "whitelist": []string{"10.9.9.9"},
+ })
+ var created protocol.TunnelConfig
+ _ = json.Unmarshal(body, &created)
+ waitFor(t, "tunnel online", func() bool { return e.tunnel(t, created.ID).Metrics.Status == "online" })
+
+ if _, err := echoVia(port, "x"); err == nil {
+ t.Fatal("client outside whitelist should be blocked before web auth")
+ }
+
+ _, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/invites", map[string]any{})
+ var inv struct {
+ Invite protocol.Invite `json:"invite"`
+ }
+ _ = json.Unmarshal(body, &inv)
+ resp, err := http.PostForm(fmt.Sprintf("http://%s/auth", e.addr), url.Values{"code": {inv.Invite.Code}, "name": {"张三"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+
+ waitFor(t, "lease listed with expiry and name", func() bool {
+ leases := e.tunnel(t, created.ID).Metrics.DynamicLeases
+ return len(leases) == 1 && leases[0].IP == "127.0.0.1" && leases[0].Name == "张三" && time.Until(leases[0].ExpiresAt) > time.Hour
+ })
+
+ long, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer long.Close()
+ _ = long.SetDeadline(time.Now().Add(3 * time.Second))
+ _, _ = long.Write([]byte("hi"))
+ buf := make([]byte, 2)
+ if _, err := io.ReadFull(long, buf); err != nil {
+ t.Fatalf("leased client cannot use tunnel: %v", err)
+ }
+
+ // 在线连接接口应能看到这条连接
+ waitFor(t, "active connection listed", func() bool {
+ _, b := e.call(t, "GET", "/api/connections?tunnel_id="+created.ID, nil)
+ var out struct {
+ ActiveConns []protocol.ActiveConnInfo `json:"active_conns"`
+ }
+ _ = json.Unmarshal(b, &out)
+ return len(out.ActiveConns) == 1
+ })
+
+ status, b := e.call(t, "POST", "/api/tunnels/"+created.ID+"/leases/revoke", map[string]string{"ip": "127.0.0.1"})
+ if status != http.StatusOK {
+ t.Fatalf("revoke: %d %s", status, b)
+ }
+ waitFor(t, "lease removed", func() bool { return len(e.tunnel(t, created.ID).Metrics.DynamicLeases) == 0 })
+
+ // 撤销后已建立的连接被断开,新连接被拒绝
+ if _, err := long.Read(buf); err == nil {
+ t.Fatal("existing connection of revoked IP still alive")
+ }
+ if _, err := echoVia(port, "x"); err == nil {
+ t.Fatal("revoked IP can still connect")
+ }
+}
+
+func TestLinkOnlyInviteShareAndApproval(t *testing.T) {
+ e := startEnv(t, testToken)
+ waitFor(t, "agent connected", e.agent.IsConnected)
+
+ target := startEcho(t)
+ port := freeRelayPort(t)
+ status, body := e.call(t, "POST", "/api/tunnels", map[string]any{
+ "name": "db", "target": target, "listen_port": port,
+ })
+ if status != http.StatusCreated {
+ t.Fatalf("create: %d %s", status, body)
+ }
+ var created protocol.TunnelConfig
+ _ = json.Unmarshal(body, &created)
+ if len(created.Whitelist) != 0 {
+ t.Fatalf("empty whitelist must stay empty (link-only), got %v", created.Whitelist)
+ }
+ waitFor(t, "tunnel online", func() bool { return e.tunnel(t, created.ID).Metrics.Status == "online" })
+ if _, err := echoVia(port, "x"); err == nil {
+ t.Fatal("link-only tunnel must block clients without authorisation")
+ }
+
+ status, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/invites", map[string]any{"note": "张三", "lease_duration": "8h", "require_approval": true})
+ if status != http.StatusCreated {
+ t.Fatalf("create invite: %d %s", status, body)
+ }
+ var out struct {
+ Invite protocol.Invite `json:"invite"`
+ Link string `json:"link"`
+ ShareText string `json:"share_text"`
+ }
+ _ = json.Unmarshal(body, &out)
+ if out.Invite.MaxUses != 1 || time.Until(out.Invite.ExpiresAt) < 23*time.Hour || out.Invite.LeaseDuration != "8h" || !out.Invite.RequireApproval {
+ t.Fatalf("default invite should be 24h / 1 device: %+v", out.Invite)
+ }
+ for _, want := range []string{"给 张三", fmt.Sprintf("127.0.0.1:%d", port), "管理员批准", "8 小时", out.Link, "仅限 1 台设备"} {
+ if !strings.Contains(out.ShareText, want) {
+ t.Errorf("share text missing %q:\n%s", want, out.ShareText)
+ }
+ }
+ waitFor(t, "invite listed", func() bool { return len(e.tunnel(t, created.ID).Metrics.Invites) == 1 })
+ if status, _ := e.call(t, "GET", "/api/tunnels/"+created.ID+"/invites/"+out.Invite.Code+"/share", nil); status != http.StatusOK {
+ t.Fatalf("share text for existing invite: %d", status)
+ }
+
+ // 同事打开链接提交申请
+ resp, err := http.PostForm(fmt.Sprintf("http://%s/auth", e.addr), url.Values{"code": {out.Invite.Code}, "name": {"张三"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ var pending protocol.AccessRequest
+ waitFor(t, "pending request listed", func() bool {
+ p := e.tunnel(t, created.ID).Metrics.Pending
+ if len(p) == 1 {
+ pending = p[0]
+ }
+ return len(p) == 1
+ })
+ if _, err := echoVia(port, "x"); err == nil {
+ t.Fatal("pending request must not grant access")
+ }
+
+ status, body = e.call(t, "POST", "/api/tunnels/"+created.ID+"/requests/"+pending.ID, map[string]bool{"approve": true})
+ if status != http.StatusOK {
+ t.Fatalf("approve: %d %s", status, body)
+ }
+ waitFor(t, "lease granted after approval", func() bool {
+ l := e.tunnel(t, created.ID).Metrics.DynamicLeases
+ return len(l) == 1 && l[0].Name == "张三" && time.Until(l[0].ExpiresAt) > 7*time.Hour
+ })
+ if got, err := echoVia(port, "hi"); err != nil || got != "hi" {
+ t.Fatalf("approved client cannot use tunnel: %q %v", got, err)
+ }
+
+ // 作废邀请后放通被收回
+ if status, _ := e.call(t, "DELETE", "/api/tunnels/"+created.ID+"/invites/"+out.Invite.Code, nil); status != http.StatusOK {
+ t.Fatal("revoke invite failed")
+ }
+ waitFor(t, "lease removed with invite", func() bool { return len(e.tunnel(t, created.ID).Metrics.DynamicLeases) == 0 })
+}
+
+func TestLinkOnlyWhitelistSerializesAsEmptyArray(t *testing.T) {
+ cm := newTestConfig(t, "127.0.0.1:1", testToken)
+ if _, err := cm.AddTunnel(protocol.TunnelConfig{Name: "a", Target: "10.0.0.1:22", ListenPort: 33703}); err != nil {
+ t.Fatal(err)
+ }
+ h := NewAPIServer(cm, NewAgent(cm), nil).Handler()
+ req := httptest.NewRequest("GET", "/api/tunnels", nil)
+ req.Host = "127.0.0.1:33080"
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if !strings.Contains(rec.Body.String(), `"whitelist":[]`) {
+ t.Fatalf("link-only whitelist must be [] (not null): %s", rec.Body.String())
+ }
+}
+
+func TestIdleAutoClose(t *testing.T) {
+ // 闲置单位缩短为 3 秒,便于测试(配置里的“1 分钟”即 3 秒)
+ e := startEnvWith(t, testToken, func(a *Agent) { a.idleUnit = 3 * time.Second })
+ waitFor(t, "agent connected", e.agent.IsConnected)
+
+ // 1) 没有任何连接/授权/邀请:闲置到期后自动关闭并记录原因
+ port := freeRelayPort(t)
+ _, body := e.call(t, "POST", "/api/tunnels", map[string]any{"name": "idle", "target": "127.0.0.1:1", "listen_port": port, "idle_close_minutes": 1})
+ var idle protocol.TunnelConfig
+ _ = json.Unmarshal(body, &idle)
+ waitFor(t, "idle tunnel closed", func() bool { return !e.tunnel(t, idle.ID).Enabled })
+ if got := e.tunnel(t, idle.ID).CloseReason; !strings.Contains(got, "闲置") {
+ t.Fatalf("close reason should mention idle, got %q", got)
+ }
+
+ // 2) 有可用的邀请链接时保持开启;作废后闲置关闭
+ port2 := freeRelayPort(t, port)
+ _, body = e.call(t, "POST", "/api/tunnels", map[string]any{"name": "invited", "target": "127.0.0.1:1", "listen_port": port2, "idle_close_minutes": 1})
+ var inv protocol.TunnelConfig
+ _ = json.Unmarshal(body, &inv)
+ waitFor(t, "tunnel online", func() bool { return e.tunnel(t, inv.ID).Metrics.Status == "online" })
+ _, ib := e.call(t, "POST", "/api/tunnels/"+inv.ID+"/invites", map[string]any{})
+ var ir struct {
+ Invite protocol.Invite `json:"invite"`
+ }
+ _ = json.Unmarshal(ib, &ir)
+ time.Sleep(6 * time.Second)
+ if !e.tunnel(t, inv.ID).Enabled {
+ t.Fatal("tunnel with an unused invite must stay open")
+ }
+ if act := e.tunnel(t, inv.ID).Activity; act == nil || !act.InUse {
+ t.Fatalf("tunnel with an unused invite should be reported in use: %+v", act)
+ }
+ e.call(t, "DELETE", "/api/tunnels/"+inv.ID+"/invites/"+ir.Invite.Code, nil)
+ waitFor(t, "tunnel closed after invite revoked", func() bool { return !e.tunnel(t, inv.ID).Enabled })
+
+ // 3) 不自动关闭(0)
+ port3 := freeRelayPort(t, port, port2)
+ _, body = e.call(t, "POST", "/api/tunnels", map[string]any{"name": "keep", "target": "127.0.0.1:1", "listen_port": port3, "idle_close_minutes": 0})
+ var keep protocol.TunnelConfig
+ _ = json.Unmarshal(body, &keep)
+ time.Sleep(6 * time.Second)
+ if !e.tunnel(t, keep.ID).Enabled {
+ t.Fatal("idle_close_minutes=0 must never auto-close")
+ }
+}
+
+func TestFirstConnectionAndConnectedTime(t *testing.T) {
+ e := startEnv(t, testToken)
+ waitFor(t, "agent connected", e.agent.IsConnected)
+ target := startEcho(t)
+ port := freeRelayPort(t)
+ _, body := e.call(t, "POST", "/api/tunnels", map[string]any{"name": "db", "target": target, "listen_port": port, "whitelist": []string{"*"}})
+ var tun protocol.TunnelConfig
+ _ = json.Unmarshal(body, &tun)
+ waitFor(t, "tunnel online", func() bool { return e.tunnel(t, tun.ID).Metrics.Status == "online" })
+ if e.tunnel(t, tun.ID).FirstConnectedAt != nil {
+ t.Fatal("no connection yet")
+ }
+
+ c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
+ if err != nil {
+ t.Fatal(err)
+ }
+ waitFor(t, "first connection recorded", func() bool { return e.tunnel(t, tun.ID).FirstConnectedAt != nil })
+ if rh := e.tunnel(t, tun.ID).RateHistory; rh == nil || len(rh.Up) != ratePoints || len(rh.Down) != ratePoints {
+ t.Fatalf("enabled tunnel should expose a %d-point rate history: %+v", ratePoints, rh)
+ }
+ time.Sleep(3 * time.Second)
+ act := e.tunnel(t, tun.ID).Activity
+ if act == nil || act.ConnectedSec < 2 || !act.InUse {
+ t.Fatalf("connected time should accumulate while a client is connected: %+v", act)
+ }
+ c.Close()
+ time.Sleep(2 * time.Second)
+ before := e.tunnel(t, tun.ID).Activity.ConnectedSec
+ time.Sleep(2 * time.Second)
+ if after := e.tunnel(t, tun.ID).Activity.ConnectedSec; after != before {
+ t.Fatalf("connected time must not grow without connections: %d -> %d", before, after)
+ }
+
+ e.call(t, "POST", "/api/tunnels/"+tun.ID+"/toggle", map[string]bool{"enabled": false})
+ got := e.tunnel(t, tun.ID)
+ if got.LastConnectedSec < 2 || got.CloseReason != "手动关闭" {
+ t.Fatalf("stop should keep last connected time and reason: sec=%d reason=%q", got.LastConnectedSec, got.CloseReason)
+ }
+ // 重新开启:本次统计清零
+ e.call(t, "POST", "/api/tunnels/"+tun.ID+"/toggle", map[string]bool{"enabled": true})
+ if got := e.tunnel(t, tun.ID); got.FirstConnectedAt != nil || got.ConnectedSec != 0 || got.CloseReason != "" {
+ t.Fatalf("new session should reset counters: %+v", got)
+ }
+}
diff --git a/client/config.go b/client/config.go
new file mode 100644
index 0000000..6ac3b32
--- /dev/null
+++ b/client/config.go
@@ -0,0 +1,702 @@
+package client
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "log"
+ "math/big"
+ "net"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/DeepFal/PortRelay/core/acl"
+ "github.com/DeepFal/PortRelay/core/protocol"
+)
+
+const (
+ PortMin = 33000
+ PortMax = 34000
+ // ControlPort 是中继服务器上 PortRelay 自身的控制端口,不能分配给通道
+ ControlPort = 33000
+
+ // DefaultIdleCloseMinutes 未设置时的闲置自动关闭时长
+ DefaultIdleCloseMinutes = 30
+
+ maxClientHistory = 200
+ // 连接历史的落盘延迟:短连接密集时合并写入,避免每断开一个连接就重写一次配置
+ historySaveDelay = 2 * time.Second
+)
+
+func generateAuthCode() string {
+ const chars = "23456789abcdefghjkmnpqrstuvwxyz"
+ b := make([]byte, 8)
+ for i := range b {
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
+ if err != nil {
+ panic(err)
+ }
+ b[i] = chars[n.Int64()]
+ }
+ return string(b)
+}
+
+// Config represents persistent client settings
+type Config struct {
+ ServerAddr string `json:"server_addr"` // 中继服务器控制地址 (host:port)
+ Token string `json:"token"`
+ PublicHost string `json:"public_host,omitempty"` // 访问者连接通道时使用的网关地址
+ Tunnels []protocol.TunnelConfig `json:"tunnels"`
+ ClientHistory []protocol.ClientHistoryRecord `json:"client_history,omitempty"`
+}
+
+// ConfigManager handles loading and saving configuration
+type ConfigManager struct {
+ path string
+ mu sync.RWMutex
+ cfg Config
+
+ historyTimer *time.Timer
+ closed bool
+}
+
+// DefaultConfig returns the first-run config: no server and no tunnels.
+// 服务器地址、令牌与通道都属于用户数据,由用户在界面中填写,不能写死在程序里。
+func DefaultConfig() Config {
+ return Config{
+ Tunnels: make([]protocol.TunnelConfig, 0),
+ ClientHistory: make([]protocol.ClientHistoryRecord, 0),
+ }
+}
+
+// NewConfigManager initializes or loads config from ~/.portrelay/config.json
+func NewConfigManager() (*ConfigManager, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil, err
+ }
+ return NewConfigManagerAt(filepath.Join(home, ".portrelay", "config.json"))
+}
+
+// NewConfigManagerAt initializes or loads config from the given path
+func NewConfigManagerAt(filePath string) (*ConfigManager, error) {
+ if err := os.MkdirAll(filepath.Dir(filePath), 0700); err != nil {
+ return nil, err
+ }
+ cm := &ConfigManager{path: filePath}
+ err := cm.load()
+ switch {
+ case err == nil:
+ return cm, nil
+ case errors.Is(err, fs.ErrNotExist):
+ cm.cfg = DefaultConfig()
+ default:
+ // 配置损坏时先备份原文件,绝不直接覆盖用户数据
+ backup := fmt.Sprintf("%s.corrupt-%s", filePath, time.Now().Format("20060102-150405"))
+ if rerr := os.Rename(filePath, backup); rerr != nil {
+ return nil, fmt.Errorf("config %s is unreadable (%v) and could not be backed up: %w", filePath, err, rerr)
+ }
+ log.Printf("[Config] 配置文件损坏 (%v),已备份到 %s 并使用默认配置", err, backup)
+ cm.cfg = DefaultConfig()
+ }
+ if err := cm.save(); err != nil {
+ return nil, err
+ }
+ return cm, nil
+}
+
+func (cm *ConfigManager) load() error {
+ data, err := os.ReadFile(cm.path)
+ if err != nil {
+ return err
+ }
+ var cfg Config
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return err
+ }
+ // Ensure every tunnel has an auth code
+ for i := range cfg.Tunnels {
+ if cfg.Tunnels[i].AuthCode == "" {
+ cfg.Tunnels[i].AuthCode = generateAuthCode()
+ }
+ }
+ cm.cfg = cfg
+ return nil
+}
+
+// save writes the config atomically (temp file + rename). Caller must hold cm.mu.
+func (cm *ConfigManager) save() error {
+ if cm.closed {
+ return nil
+ }
+ data, err := json.MarshalIndent(cm.cfg, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(cm.path), ".config-*.json")
+ if err != nil {
+ return err
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName)
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Sync(); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Chmod(tmpName, 0600); err != nil {
+ return err
+ }
+ return os.Rename(tmpName, cm.path)
+}
+
+// Close persists pending writes; afterwards the manager no longer touches the file (used on shutdown)
+func (cm *ConfigManager) Close() error {
+ err := cm.Flush()
+ cm.mu.Lock()
+ cm.closed = true
+ cm.mu.Unlock()
+ return err
+}
+
+// Flush persists any pending (debounced) writes
+func (cm *ConfigManager) Flush() error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ if cm.historyTimer != nil {
+ cm.historyTimer.Stop()
+ cm.historyTimer = nil
+ }
+ return cm.save()
+}
+
+func cloneTunnel(t protocol.TunnelConfig) protocol.TunnelConfig {
+ // 空白名单(仅授权链接)必须序列化为 [],不能是 null:App 端按非可选数组解析
+ t.Whitelist = append(make([]string, 0, len(t.Whitelist)), t.Whitelist...)
+ t.DynamicWhitelist = append([]string(nil), t.DynamicWhitelist...)
+ if t.StartedAt != nil {
+ v := *t.StartedAt
+ t.StartedAt = &v
+ }
+ if t.ExpiresAt != nil {
+ v := *t.ExpiresAt
+ t.ExpiresAt = &v
+ }
+ return t
+}
+
+// Get returns a deep copy snapshot (safe to read without holding the lock)
+func (cm *ConfigManager) Get() Config {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+ out := cm.cfg
+ out.Tunnels = make([]protocol.TunnelConfig, len(cm.cfg.Tunnels))
+ for i, t := range cm.cfg.Tunnels {
+ out.Tunnels[i] = cloneTunnel(t)
+ }
+ out.ClientHistory = append([]protocol.ClientHistoryRecord(nil), cm.cfg.ClientHistory...)
+ return out
+}
+
+// FindTunnel returns a copy of the tunnel with the given ID
+func (cm *ConfigManager) FindTunnel(id string) (protocol.TunnelConfig, bool) {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+ for _, t := range cm.cfg.Tunnels {
+ if t.ID == id {
+ return cloneTunnel(t), true
+ }
+ }
+ return protocol.TunnelConfig{}, false
+}
+
+// ValidateServerAddr checks a host:port address
+func ValidateServerAddr(addr string) error {
+ host, port, err := net.SplitHostPort(strings.TrimSpace(addr))
+ if err != nil || host == "" {
+ return fmt.Errorf("服务器地址格式应为 IP:端口")
+ }
+ if p, err := strconv.Atoi(port); err != nil || p <= 0 || p > 65535 {
+ return fmt.Errorf("服务器端口无效")
+ }
+ return nil
+}
+
+// PublicHostOrDefault returns the host LAN users connect to;
+// 未单独配置时使用服务器控制地址中的主机部分
+func (c Config) PublicHostOrDefault() string {
+ if h := strings.TrimSpace(c.PublicHost); h != "" {
+ return h
+ }
+ if h, _, err := net.SplitHostPort(c.ServerAddr); err == nil {
+ return h
+ }
+ return ""
+}
+
+// UpdateServer sets server address, token and the public access host.
+// 返回 true 表示连接参数(地址/令牌)发生变化,需要重连。
+func (cm *ConfigManager) UpdateServer(addr, token, publicHost string) (bool, error) {
+ addr = strings.TrimSpace(addr)
+ token = strings.TrimSpace(token)
+ publicHost = strings.TrimSpace(publicHost)
+ if err := ValidateServerAddr(addr); err != nil {
+ return false, err
+ }
+ if publicHost != "" && (strings.ContainsAny(publicHost, " /:") || strings.Contains(publicHost, "://")) {
+ return false, fmt.Errorf("对外访问地址只填主机名或 IP,不要带端口或协议")
+ }
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ changed := cm.cfg.ServerAddr != addr || cm.cfg.Token != token
+ cm.cfg.ServerAddr = addr
+ cm.cfg.Token = token
+ cm.cfg.PublicHost = publicHost
+ return changed, cm.save()
+}
+
+// normalizeAndValidate trims user input and checks the user-editable fields
+func normalizeAndValidate(t *protocol.TunnelConfig) error {
+ t.Name = strings.TrimSpace(t.Name)
+ t.Target = strings.TrimSpace(t.Target)
+ t.Owner = strings.TrimSpace(t.Owner)
+ if t.Name == "" || t.Target == "" {
+ return fmt.Errorf("名称与目标地址为必填项")
+ }
+ if host, port, err := net.SplitHostPort(t.Target); err != nil || host == "" {
+ return fmt.Errorf("目标地址格式应为 主机:端口,例如 10.0.0.10:3306")
+ } else if p, err := strconv.Atoi(port); err != nil || p <= 0 || p > 65535 {
+ return fmt.Errorf("目标端口无效")
+ }
+ if t.ListenPort < PortMin || t.ListenPort > PortMax {
+ return fmt.Errorf("监听端口须在 %d-%d 范围内", PortMin, PortMax)
+ }
+ if t.ListenPort == ControlPort {
+ return fmt.Errorf("端口 %d 是服务器控制端口,不能用于通道", ControlPort)
+ }
+ if t.DurationMinutes < 0 {
+ return fmt.Errorf("开启时长不能为负数")
+ }
+ if t.MaxBandwidthMB < 0 {
+ return fmt.Errorf("限速不能为负数")
+ }
+ if t.IdleCloseMinutes != nil && (*t.IdleCloseMinutes < 0 || *t.IdleCloseMinutes > 7*24*60) {
+ return fmt.Errorf("闲置自动关闭时长须在 0(不自动关闭)到 7 天之间")
+ }
+
+ // 白名单为空表示“仅授权链接”:只有通过邀请链接放通的设备可以访问。
+ // 放通全部必须显式填写 "*",不能因为留空而意外对所有人开放。
+ wl := make([]string, 0, len(t.Whitelist))
+ for _, r := range t.Whitelist {
+ r = strings.TrimSpace(r)
+ if r == "" {
+ continue
+ }
+ if err := acl.ValidateRule(r); err != nil {
+ return err
+ }
+ wl = append(wl, r)
+ }
+ t.Whitelist = wl
+
+ // 放通时长与是否审批已改为在每个邀请上设置,通道级字段不再使用
+ t.LeaseDuration = ""
+ t.RequireApproval = false
+ return nil
+}
+
+// checkPortLocked returns an error if another tunnel already uses the port. Caller must hold cm.mu.
+func (cm *ConfigManager) checkPortLocked(id string, port int) error {
+ for _, existing := range cm.cfg.Tunnels {
+ if existing.ID != id && existing.ListenPort == port {
+ return fmt.Errorf("端口 %d 已被「%s」使用", port, existing.Name)
+ }
+ }
+ return nil
+}
+
+// AddTunnel adds a new (enabled) tunnel
+func (cm *ConfigManager) AddTunnel(t protocol.TunnelConfig) (protocol.TunnelConfig, error) {
+ if err := normalizeAndValidate(&t); err != nil {
+ return t, err
+ }
+
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ t.ID = uuid.New().String()
+ t.AuthCode = generateAuthCode()
+ t.HistoricalBytesIn, t.HistoricalBytesOut, t.LastDurationSec = 0, 0, 0
+ t.CloseReason, t.FirstConnectedAt, t.ConnectedSec, t.LastConnectedSec = "", nil, 0, 0
+ if t.IdleCloseMinutes == nil {
+ v := DefaultIdleCloseMinutes
+ t.IdleCloseMinutes = &v
+ }
+ t.DynamicWhitelist = nil
+ if err := cm.checkPortLocked(t.ID, t.ListenPort); err != nil {
+ return t, err
+ }
+
+ t.Enabled = true
+ now := time.Now()
+ t.StartedAt = &now
+ t.ExpiresAt = nil
+ if t.DurationMinutes > 0 {
+ exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
+ t.ExpiresAt = &exp
+ }
+
+ cm.cfg.Tunnels = append(cm.cfg.Tunnels, t)
+ return cloneTunnel(t), cm.save()
+}
+
+// PatchTunnel applies a partial JSON update: only fields present in patch change.
+// 启用状态、计时、流量统计、授权码由服务端逻辑维护,不能通过编辑修改。
+// Returns the updated tunnel and its previous listen port.
+func (cm *ConfigManager) PatchTunnel(id string, patch []byte) (protocol.TunnelConfig, int, error) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ idx := -1
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID == id {
+ idx = i
+ break
+ }
+ }
+ if idx < 0 {
+ return protocol.TunnelConfig{}, 0, fmt.Errorf("通道 %s 未找到", id)
+ }
+
+ existing := cm.cfg.Tunnels[idx]
+ updated := cloneTunnel(existing)
+ if err := json.Unmarshal(patch, &updated); err != nil {
+ return existing, existing.ListenPort, fmt.Errorf("Invalid JSON")
+ }
+
+ // 受保护字段
+ updated.ID = existing.ID
+ updated.Enabled = existing.Enabled
+ updated.StartedAt = existing.StartedAt
+ updated.ExpiresAt = existing.ExpiresAt
+ updated.HistoricalBytesIn = existing.HistoricalBytesIn
+ updated.HistoricalBytesOut = existing.HistoricalBytesOut
+ updated.LastDurationSec = existing.LastDurationSec
+ updated.CloseReason = existing.CloseReason
+ updated.FirstConnectedAt = existing.FirstConnectedAt
+ updated.ConnectedSec = existing.ConnectedSec
+ updated.LastConnectedSec = existing.LastConnectedSec
+ updated.AuthCode = existing.AuthCode
+ updated.DynamicWhitelist = nil
+
+ if err := normalizeAndValidate(&updated); err != nil {
+ return existing, existing.ListenPort, err
+ }
+ if err := cm.checkPortLocked(id, updated.ListenPort); err != nil {
+ return existing, existing.ListenPort, err
+ }
+
+ // 运行中修改时长:从本次开启时间重新计算到期时间
+ if updated.Enabled && updated.DurationMinutes != existing.DurationMinutes {
+ updated.ExpiresAt = nil
+ if updated.DurationMinutes > 0 {
+ start := time.Now()
+ if updated.StartedAt != nil {
+ start = *updated.StartedAt
+ }
+ exp := start.Add(time.Duration(updated.DurationMinutes) * time.Minute)
+ updated.ExpiresAt = &exp
+ }
+ }
+
+ cm.cfg.Tunnels[idx] = updated
+ return cloneTunnel(updated), existing.ListenPort, cm.save()
+}
+
+// RefreshAuthCode generates a new auth code for a tunnel
+func (cm *ConfigManager) RefreshAuthCode(id string) (protocol.TunnelConfig, error) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID == id {
+ t.AuthCode = generateAuthCode()
+ cm.cfg.Tunnels[i] = t
+ return cloneTunnel(t), cm.save()
+ }
+ }
+ return protocol.TunnelConfig{}, fmt.Errorf("通道 %s 未找到", id)
+}
+
+// CurrentEnabled reports whether the tunnel is enabled
+func (cm *ConfigManager) CurrentEnabled(id string) (bool, error) {
+ t, ok := cm.FindTunnel(id)
+ if !ok {
+ return false, fmt.Errorf("未找到通道 %s", id)
+ }
+ return t.Enabled, nil
+}
+
+// IdleCloseMinutes returns the effective idle auto-close setting (0 = never)
+func IdleCloseMinutes(t protocol.TunnelConfig) int {
+ if t.IdleCloseMinutes == nil {
+ return DefaultIdleCloseMinutes
+ }
+ return *t.IdleCloseMinutes
+}
+
+// stopLocked marks a tunnel stopped: records run time, real connected time, traffic and the reason
+func stopLocked(t *protocol.TunnelConfig, now time.Time, sessionIn, sessionOut int64, reason string) {
+ t.Enabled = false
+ if t.StartedAt != nil {
+ t.LastDurationSec = int64(now.Sub(*t.StartedAt).Seconds())
+ }
+ t.LastConnectedSec = t.ConnectedSec
+ t.HistoricalBytesIn += sessionIn
+ t.HistoricalBytesOut += sessionOut
+ t.StartedAt = nil
+ t.ExpiresAt = nil
+ t.CloseReason = reason
+}
+
+// CloseTunnel stops an enabled tunnel with the given reason (e.g. idle auto-close)
+func (cm *ConfigManager) CloseTunnel(id, reason string, sessionBytes func(id string) (int64, int64)) (protocol.TunnelConfig, bool) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID != id || !t.Enabled {
+ continue
+ }
+ in, out := sessionBytes(t.ID)
+ stopLocked(&t, time.Now(), in, out, reason)
+ cm.cfg.Tunnels[i] = t
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ return cloneTunnel(t), true
+ }
+ return protocol.TunnelConfig{}, false
+}
+
+// MarkFirstConnected records the first connection of the current session (once)
+func (cm *ConfigManager) MarkFirstConnected(id string, at time.Time) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID == id && t.Enabled && t.FirstConnectedAt == nil {
+ cm.cfg.Tunnels[i].FirstConnectedAt = &at
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ return
+ }
+ }
+}
+
+// AddConnectedSeconds accumulates the real connected time of the current session
+func (cm *ConfigManager) AddConnectedSeconds(id string, secs int64) {
+ if secs <= 0 {
+ return
+ }
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID == id && t.Enabled {
+ cm.cfg.Tunnels[i].ConnectedSec += secs
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ return
+ }
+ }
+}
+
+// SetTunnelEnabled turns a tunnel on/off, handling TTL and merging session traffic on stop.
+// changed=false means it was already in the requested state (idempotent).
+func (cm *ConfigManager) SetTunnelEnabled(id string, enabled bool, sessionIn, sessionOut int64) (protocol.TunnelConfig, bool, error) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ for i, t := range cm.cfg.Tunnels {
+ if t.ID != id {
+ continue
+ }
+ if t.Enabled == enabled {
+ return cloneTunnel(t), false, nil
+ }
+ now := time.Now()
+ if enabled {
+ t.Enabled = true
+ t.StartedAt = &now
+ t.ExpiresAt = nil
+ if t.DurationMinutes > 0 {
+ exp := now.Add(time.Duration(t.DurationMinutes) * time.Minute)
+ t.ExpiresAt = &exp
+ }
+ t.CloseReason, t.FirstConnectedAt, t.ConnectedSec = "", nil, 0
+ } else {
+ stopLocked(&t, now, sessionIn, sessionOut, "手动关闭")
+ }
+ cm.cfg.Tunnels[i] = t
+ return cloneTunnel(t), true, cm.save()
+ }
+ return protocol.TunnelConfig{}, false, fmt.Errorf("未找到通道 %s", id)
+}
+
+// ExpireDue disables every enabled tunnel whose TTL has passed and returns them.
+// sessionBytes returns (and consumes) the current session traffic for a tunnel.
+func (cm *ConfigManager) ExpireDue(sessionBytes func(id string) (int64, int64)) []protocol.TunnelConfig {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ now := time.Now()
+ var expired []protocol.TunnelConfig
+ for i, t := range cm.cfg.Tunnels {
+ if !t.Enabled || t.ExpiresAt == nil || !now.After(*t.ExpiresAt) {
+ continue
+ }
+ in, out := sessionBytes(t.ID)
+ stopLocked(&t, now, in, out, fmt.Sprintf("达到最长开启时长(%s)", formatMinutes(t.DurationMinutes)))
+ cm.cfg.Tunnels[i] = t
+ expired = append(expired, cloneTunnel(t))
+ }
+ if len(expired) > 0 {
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ }
+ return expired
+}
+
+func formatMinutes(m int) string {
+ switch {
+ case m >= 60*24 && m%(60*24) == 0:
+ return fmt.Sprintf("%d 天", m/(60*24))
+ case m >= 60 && m%60 == 0:
+ return fmt.Sprintf("%d 小时", m/60)
+ default:
+ return fmt.Sprintf("%d 分钟", m)
+ }
+}
+
+// AddHistoricalTraffic merges session traffic that is about to be lost (disconnect / port move)
+func (cm *ConfigManager) AddHistoricalTraffic(traffic map[string][2]int64) {
+ if len(traffic) == 0 {
+ return
+ }
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ changed := false
+ for i, t := range cm.cfg.Tunnels {
+ if v, ok := traffic[t.ID]; ok && (v[0] != 0 || v[1] != 0) {
+ cm.cfg.Tunnels[i].HistoricalBytesIn += v[0]
+ cm.cfg.Tunnels[i].HistoricalBytesOut += v[1]
+ changed = true
+ }
+ }
+ if changed {
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ }
+}
+
+// DeleteTunnel removes a tunnel
+func (cm *ConfigManager) DeleteTunnel(id string) error {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ newTunnels := make([]protocol.TunnelConfig, 0, len(cm.cfg.Tunnels))
+ found := false
+ for _, t := range cm.cfg.Tunnels {
+ if t.ID == id {
+ found = true
+ continue
+ }
+ newTunnels = append(newTunnels, t)
+ }
+ if !found {
+ return fmt.Errorf("通道 %s 未找到", id)
+ }
+ cm.cfg.Tunnels = newTunnels
+ return cm.save()
+}
+
+// AddClientHistory appends a client connection log record (persisted with a short delay)
+func (cm *ConfigManager) AddClientHistory(rec protocol.ClientHistoryRecord) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ hist := make([]protocol.ClientHistoryRecord, 0, min(len(cm.cfg.ClientHistory)+1, maxClientHistory))
+ hist = append(hist, rec)
+ hist = append(hist, cm.cfg.ClientHistory...)
+ if len(hist) > maxClientHistory {
+ hist = hist[:maxClientHistory]
+ }
+ cm.cfg.ClientHistory = hist
+
+ if cm.historyTimer == nil && !cm.closed {
+ cm.historyTimer = time.AfterFunc(historySaveDelay, func() {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+ cm.historyTimer = nil
+ if err := cm.save(); err != nil {
+ log.Printf("[Config] save failed: %v", err)
+ }
+ })
+ }
+}
+
+// GetClientHistory returns client connection records (optionally filtered by tunnelId)
+func (cm *ConfigManager) GetClientHistory(tunnelID string) []protocol.ClientHistoryRecord {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+
+ filtered := make([]protocol.ClientHistoryRecord, 0)
+ for _, r := range cm.cfg.ClientHistory {
+ if tunnelID == "" || r.TunnelID == tunnelID {
+ filtered = append(filtered, r)
+ }
+ }
+ return filtered
+}
+
+// RecommendPort finds an unused port in 33000-34000
+func (cm *ConfigManager) RecommendPort() int {
+ cm.mu.RLock()
+ defer cm.mu.RUnlock()
+
+ used := make(map[int]bool)
+ for _, t := range cm.cfg.Tunnels {
+ used[t.ListenPort] = true
+ }
+
+ for p := 33660; p <= PortMax; p++ {
+ if !used[p] {
+ return p
+ }
+ }
+ for p := PortMin; p < 33660; p++ {
+ if p == ControlPort {
+ continue
+ }
+ if !used[p] {
+ return p
+ }
+ }
+ return 0
+}
diff --git a/client/probe.go b/client/probe.go
new file mode 100644
index 0000000..ea8ccc2
--- /dev/null
+++ b/client/probe.go
@@ -0,0 +1,45 @@
+package client
+
+import (
+ "fmt"
+ "net"
+ "time"
+)
+
+// ProbeResult holds ping/reachability info
+type ProbeResult struct {
+ Target string `json:"target"`
+ Reachable bool `json:"reachable"`
+ RTT time.Duration `json:"rtt"`
+ Error string `json:"error,omitempty"`
+}
+
+// ProbeTarget checks if this machine can reach target
+func ProbeTarget(target string, timeout time.Duration) ProbeResult {
+ if timeout <= 0 {
+ timeout = 2 * time.Second
+ }
+
+ start := time.Now()
+ conn, err := net.DialTimeout("tcp", target, timeout)
+ if err != nil {
+ return ProbeResult{
+ Target: target,
+ Reachable: false,
+ RTT: time.Since(start),
+ Error: err.Error(),
+ }
+ }
+ defer conn.Close()
+
+ return ProbeResult{
+ Target: target,
+ Reachable: true,
+ RTT: time.Since(start),
+ }
+}
+
+// FormatRTT formats duration in ms
+func (pr ProbeResult) FormatRTT() string {
+ return fmt.Sprintf("%.1f ms", float64(pr.RTT.Microseconds())/1000.0)
+}
diff --git a/client/ratehistory.go b/client/ratehistory.go
new file mode 100644
index 0000000..a011e63
--- /dev/null
+++ b/client/ratehistory.go
@@ -0,0 +1,127 @@
+package client
+
+import (
+ "sync"
+ "time"
+)
+
+const (
+ rateStep = 5 * time.Second
+ ratePoints = 120 // 10 分钟
+ totalSeries = "__total__"
+)
+
+// RateSeries is a fixed-length rate history (oldest first). The last point is the
+// current, still-accumulating bucket so the chart moves every second.
+type RateSeries struct {
+ End int64 `json:"end"` // 最后一个点所在 5 秒桶的起始时间(Unix 秒)
+ Step int `json:"step"` // 每个点的秒数
+ Up []int64 `json:"up"` // 上行 bytes/s(客户端 → 目标)
+ Down []int64 `json:"down"` // 下行 bytes/s(目标 → 客户端)
+}
+
+type rateBucket struct {
+ start time.Time
+ up, down int64
+}
+
+type rateHistory struct {
+ done []rateBucket // completed buckets, oldest first, at most ratePoints
+ curStart time.Time
+ curUp, curDown int64
+ curSamples int64
+ lastSampleBucket time.Time
+}
+
+func (h *rateHistory) add(now time.Time, up, down int64) {
+ bs := now.Truncate(rateStep)
+ if h.curStart.IsZero() {
+ h.curStart = bs
+ }
+ if bs.After(h.curStart) {
+ if h.curSamples > 0 {
+ h.done = append(h.done, rateBucket{h.curStart, h.curUp / h.curSamples, h.curDown / h.curSamples})
+ }
+ if len(h.done) > ratePoints {
+ h.done = h.done[len(h.done)-ratePoints:]
+ }
+ h.curStart, h.curUp, h.curDown, h.curSamples = bs, 0, 0, 0
+ }
+ h.curUp += up
+ h.curDown += down
+ h.curSamples++
+}
+
+// series renders the last ratePoints buckets ending at now's bucket; missing buckets are zero
+func (h *rateHistory) series(now time.Time) RateSeries {
+ end := now.Truncate(rateStep)
+ s := RateSeries{End: end.Unix(), Step: int(rateStep / time.Second), Up: make([]int64, ratePoints), Down: make([]int64, ratePoints)}
+ first := end.Add(-time.Duration(ratePoints-1) * rateStep)
+ put := func(start time.Time, up, down int64) {
+ if start.Before(first) || start.After(end) {
+ return
+ }
+ i := int(start.Sub(first) / rateStep)
+ s.Up[i], s.Down[i] = up, down
+ }
+ for _, b := range h.done {
+ put(b.start, b.up, b.down)
+ }
+ if h.curSamples > 0 {
+ put(h.curStart, h.curUp/h.curSamples, h.curDown/h.curSamples)
+ }
+ return s
+}
+
+// rateRecorder keeps per-tunnel and total rate histories in memory (近 10 分钟,不落盘)
+type rateRecorder struct {
+ mu sync.Mutex
+ hist map[string]*rateHistory
+}
+
+func newRateRecorder() *rateRecorder {
+ return &rateRecorder{hist: make(map[string]*rateHistory)}
+}
+
+// record adds one sample per tunnel (rates in bytes/s) plus the total
+func (r *rateRecorder) record(now time.Time, rates map[string][2]int64) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ var tu, td int64
+ for id, v := range rates {
+ h := r.hist[id]
+ if h == nil {
+ h = &rateHistory{}
+ r.hist[id] = h
+ }
+ h.add(now, v[0], v[1])
+ tu += v[0]
+ td += v[1]
+ }
+ t := r.hist[totalSeries]
+ if t == nil {
+ t = &rateHistory{}
+ r.hist[totalSeries] = t
+ }
+ t.add(now, tu, td)
+}
+
+func (r *rateRecorder) series(id string, now time.Time) RateSeries {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if h := r.hist[id]; h != nil {
+ return h.series(now)
+ }
+ return (&rateHistory{}).series(now)
+}
+
+// forget drops the history of tunnels that are no longer enabled
+func (r *rateRecorder) retain(enabled map[string]bool) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ for id := range r.hist {
+ if id != totalSeries && !enabled[id] {
+ delete(r.hist, id)
+ }
+ }
+}
diff --git a/client/ratehistory_test.go b/client/ratehistory_test.go
new file mode 100644
index 0000000..56b6875
--- /dev/null
+++ b/client/ratehistory_test.go
@@ -0,0 +1,52 @@
+package client
+
+import (
+ "testing"
+ "time"
+)
+
+func TestRateHistoryBucketsAndWindow(t *testing.T) {
+ r := newRateRecorder()
+ base := time.Unix(1_700_000_000, 0).Truncate(rateStep)
+
+ // 同一个 5 秒桶内的多个采样取平均
+ r.record(base, map[string][2]int64{"a": {100, 1000}})
+ r.record(base.Add(1*time.Second), map[string][2]int64{"a": {300, 3000}})
+ // 跳过 3 个桶(中间无数据,应为 0)
+ r.record(base.Add(20*time.Second), map[string][2]int64{"a": {50, 500}})
+
+ s := r.series("a", base.Add(21*time.Second))
+ if len(s.Up) != ratePoints || len(s.Down) != ratePoints || s.Step != 5 {
+ t.Fatalf("series must be fixed length %d with step 5: %d/%d step %d", ratePoints, len(s.Up), len(s.Down), s.Step)
+ }
+ last := ratePoints - 1
+ if s.Up[last] != 50 || s.Down[last] != 500 {
+ t.Fatalf("last point should be the current bucket: %d/%d", s.Up[last], s.Down[last])
+ }
+ if s.Up[last-4] != 200 || s.Down[last-4] != 2000 {
+ t.Fatalf("first bucket should average samples: %d/%d", s.Up[last-4], s.Down[last-4])
+ }
+ for i := last - 3; i < last; i++ {
+ if s.Up[i] != 0 || s.Down[i] != 0 {
+ t.Fatalf("gap bucket %d should be zero: %d/%d", i, s.Up[i], s.Down[i])
+ }
+ }
+
+ // 合计曲线
+ total := r.series(totalSeries, base.Add(21*time.Second))
+ if total.Down[last] != 500 {
+ t.Fatalf("total should sum tunnels: %d", total.Down[last])
+ }
+
+ // 11 分钟后旧数据滑出窗口
+ later := base.Add(11 * time.Minute)
+ if s2 := r.series("a", later); s2.Up[0] != 0 || s2.Down[last] != 0 {
+ t.Fatalf("old data should slide out of the 10 minute window: first=%d last=%d", s2.Up[0], s2.Down[last])
+ }
+
+ // 关闭的通道清除曲线
+ r.retain(map[string]bool{})
+ if s3 := r.series("a", base.Add(21*time.Second)); s3.Down[last] != 0 {
+ t.Fatal("history of a disabled tunnel should be dropped")
+ }
+}
diff --git a/cmd/portrelay-agent/main.go b/cmd/portrelay-agent/main.go
new file mode 100644
index 0000000..2aed2b4
--- /dev/null
+++ b/cmd/portrelay-agent/main.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "embed"
+ "flag"
+ "fmt"
+ "io/fs"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/signal"
+ "runtime"
+ "syscall"
+
+ "github.com/DeepFal/PortRelay/client"
+)
+
+//go:embed all:web
+var webFS embed.FS
+
+// version 由发布构建通过 -ldflags "-X main.version=..." 注入
+var version = "dev"
+
+func main() {
+ httpPort := flag.Int("port", 33080, "Local dashboard HTTP port")
+ noBrowser := flag.Bool("no-browser", false, "Do not auto-open browser")
+ showVersion := flag.Bool("version", false, "Print version and exit")
+ flag.Parse()
+
+ if *showVersion {
+ fmt.Println("portrelay-agent", version)
+ return
+ }
+
+ log.Printf("==================================================")
+ log.Printf(" PortRelay Agent %s", version)
+ log.Printf(" Dashboard: http://127.0.0.1:%d", *httpPort)
+ log.Printf("==================================================")
+
+ // 先占住面板端口:已有实例在运行时直接退出,避免第二个 Agent 连上服务端顶替会话
+ addr := fmt.Sprintf("127.0.0.1:%d", *httpPort)
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ log.Fatalf("Dashboard port %s unavailable (is another PortRelay agent running?): %v", addr, err)
+ }
+
+ cm, err := client.NewConfigManager()
+ if err != nil {
+ log.Fatalf("Failed to init config manager: %v", err)
+ }
+
+ agent := client.NewAgent(cm)
+ agent.Start()
+
+ // Strip 'web' prefix for embed fs
+ subFS, err := fs.Sub(webFS, "web")
+ if err != nil {
+ log.Fatalf("Failed to load embedded web assets: %v", err)
+ }
+
+ apiServer := client.NewAPIServer(cm, agent, http.FS(subFS))
+ apiServer.OnShutdown = func() {
+ log.Printf("Shutdown requested via API, stopping PortRelay Agent...")
+ agent.Stop()
+ _ = cm.Close()
+ os.Exit(0)
+ }
+
+ // Graceful shutdown
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+ go func() {
+ <-sigChan
+ log.Printf("\nShutting down PortRelay Agent...")
+ agent.Stop()
+ _ = cm.Close()
+ os.Exit(0)
+ }()
+
+ if !*noBrowser {
+ go openBrowser(fmt.Sprintf("http://127.0.0.1:%d", *httpPort))
+ }
+
+ log.Printf("Serving local web control panel at http://%s", addr)
+ if err := http.Serve(ln, apiServer.Handler()); err != nil {
+ agent.Stop()
+ _ = cm.Close()
+ log.Fatalf("HTTP server failed: %v", err)
+ }
+}
+
+func openBrowser(url string) {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "darwin":
+ cmd = exec.Command("open", url)
+ case "windows":
+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
+ default:
+ cmd = exec.Command("xdg-open", url)
+ }
+ _ = cmd.Start()
+}
diff --git a/cmd/portrelay-agent/web/index.html b/cmd/portrelay-agent/web/index.html
new file mode 100644
index 0000000..8a19c62
--- /dev/null
+++ b/cmd/portrelay-agent/web/index.html
@@ -0,0 +1,1374 @@
+
+
+
+
+
+ PortRelay - 端口中继与安全管控
+
+
+
+
+
+
+
+
⚡
+
+
PortRelay 控制台
+
高性能定向反向中继与安全审计
+
+
+
+
+
+
+
+
+
活跃通道 / 总数
+
0 / 0
+
放通端口池: 33001-34000
+
+
+
+
实时速率 (上行 / 下行)
+
0 KB/s
+
+
累计传输: 0 MB
+
+
+
安全拦截未授权尝试
+
0
+
边缘白名单实时防护
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 时间
+ 目标端口
+ 非法客户端 IP
+ 拦截原因
+ 防护策略
+
+
+
+ 暂无拦截事件,系统运行安全
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 分享文案(可直接发给同事)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/portrelay-server/main.go b/cmd/portrelay-server/main.go
new file mode 100644
index 0000000..b68939e
--- /dev/null
+++ b/cmd/portrelay-server/main.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+ "bufio"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/DeepFal/PortRelay/server"
+)
+
+type peekedConn struct {
+ net.Conn
+ r io.Reader
+}
+
+func (c *peekedConn) Read(b []byte) (int, error) {
+ return c.r.Read(b)
+}
+
+// version 由发布构建通过 -ldflags "-X main.version=..." 注入
+var version = "dev"
+
+func envOr(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
+
+func main() {
+ listenAddr := flag.String("listen", ":33000", "Control/relay listen address")
+ portMin := flag.Int("port-min", 33000, "Minimum tunnel port")
+ portMax := flag.Int("port-max", 34000, "Maximum tunnel port")
+ token := flag.String("token", envOr("PORTRELAY_TOKEN", ""), "Shared secret the agent must present (or env PORTRELAY_TOKEN); required")
+ publicHost := flag.String("public-host", "", "Host shown on the web auth page (default: the host the visitor used to open the link)")
+ stateFile := flag.String("state", "/var/lib/portrelay/leases.json", "File that keeps temporary (web auth) leases across restarts; empty disables")
+ trustProxy := flag.Bool("trust-proxy", false, "Trust X-Forwarded-For/X-Real-IP on /auth (only behind a trusted reverse proxy)")
+ showVersion := flag.Bool("version", false, "Print version and exit")
+ flag.Parse()
+
+ if *showVersion {
+ fmt.Println("portrelay-server", version)
+ return
+ }
+
+ if *token == "" {
+ log.Fatalf("Fatal: no token configured; set -token or PORTRELAY_TOKEN (e.g. openssl rand -hex 24)")
+ }
+ if len(*token) < 16 {
+ log.Printf("WARNING: token is shorter than 16 characters; use a long random value")
+ }
+
+ log.Printf("==================================================")
+ log.Printf(" PortRelay Relay Server %s", version)
+ log.Printf(" Control/Web Port: %s", *listenAddr)
+ log.Printf(" Allowed Range: %d - %d", *portMin, *portMax)
+ log.Printf("==================================================")
+
+ mgr := server.NewManager(*portMin, *portMax, *token)
+ mgr.PublicHost = *publicHost
+ mgr.TrustProxyHeaders = *trustProxy
+ if *stateFile != "" {
+ if err := mgr.EnableLeasePersistence(*stateFile); err != nil {
+ log.Printf("WARNING: lease persistence disabled: %v", err)
+ }
+ }
+
+ ln, err := net.Listen("tcp", *listenAddr)
+ if err != nil {
+ log.Fatalf("Fatal: failed to listen on %s: %v", *listenAddr, err)
+ }
+ defer ln.Close()
+
+ // Graceful shutdown
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+ go func() {
+ <-sigChan
+ log.Printf("\nShutting down PortRelay Server...")
+ _ = ln.Close()
+ os.Exit(0)
+ }()
+
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ select {
+ case <-sigChan:
+ return
+ default:
+ log.Printf("Accept error: %v", err)
+ time.Sleep(50 * time.Millisecond)
+ continue
+ }
+ }
+
+ go func(c net.Conn) {
+ // 不发数据的空连接最多占用 10 秒
+ _ = c.SetReadDeadline(time.Now().Add(10 * time.Second))
+ br := bufio.NewReader(c)
+ peek, err := br.Peek(3)
+ if err != nil {
+ _ = c.Close()
+ return
+ }
+ _ = c.SetReadDeadline(time.Time{})
+ if string(peek) == "GET" || string(peek) == "POS" || string(peek) == "HEA" {
+ mgr.HandleHTTP(&peekedConn{Conn: c, r: br})
+ return
+ }
+
+ log.Printf("Incoming relay agent connection from %s", c.RemoteAddr())
+ if err := mgr.HandleSession(&peekedConn{Conn: c, r: br}); err != nil {
+ log.Printf("Session closed for %s: %v", c.RemoteAddr(), err)
+ }
+ }(conn)
+ }
+}
diff --git a/core/acl/whitelist.go b/core/acl/whitelist.go
new file mode 100644
index 0000000..1a61adb
--- /dev/null
+++ b/core/acl/whitelist.go
@@ -0,0 +1,193 @@
+package acl
+
+import (
+ "fmt"
+ "net"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Whitelist checks if an IP is allowed based on rules and dynamic leases
+type Whitelist struct {
+ rules []string
+ allowAll bool
+ ips map[string]bool
+ subnets []*net.IPNet
+ dynamic map[string]time.Time // dynamic IP -> expiration
+ mu sync.RWMutex
+}
+
+// NewWhitelist parses a list of rules
+func NewWhitelist(rules []string) *Whitelist {
+ w := &Whitelist{
+ rules: rules,
+ ips: make(map[string]bool),
+ subnets: make([]*net.IPNet, 0),
+ dynamic: make(map[string]time.Time),
+ }
+
+ for _, r := range rules {
+ r = strings.TrimSpace(r)
+ if r == "" {
+ continue
+ }
+ if r == "*" || r == "0.0.0.0/0" || r == "::/0" {
+ w.allowAll = true
+ continue
+ }
+ if strings.Contains(r, "/") {
+ _, ipNet, err := net.ParseCIDR(r)
+ if err == nil {
+ w.subnets = append(w.subnets, ipNet)
+ }
+ } else {
+ ip := net.ParseIP(r)
+ if ip != nil {
+ w.ips[ip.String()] = true
+ }
+ }
+ }
+ return w
+}
+
+// ValidateRule reports whether a whitelist rule is "*", a valid IP or a valid CIDR
+func ValidateRule(r string) error {
+ r = strings.TrimSpace(r)
+ switch {
+ case r == "":
+ return fmt.Errorf("白名单规则不能为空")
+ case r == "*":
+ return nil
+ case strings.Contains(r, "/"):
+ if _, _, err := net.ParseCIDR(r); err != nil {
+ return fmt.Errorf("无效的网段 %q", r)
+ }
+ default:
+ if net.ParseIP(r) == nil {
+ return fmt.Errorf("无效的 IP 地址 %q", r)
+ }
+ }
+ return nil
+}
+
+// DynamicLeases returns a copy of unexpired dynamic leases
+func (w *Whitelist) DynamicLeases() map[string]time.Time {
+ w.mu.RLock()
+ defer w.mu.RUnlock()
+ now := time.Now()
+ out := make(map[string]time.Time, len(w.dynamic))
+ for ip, exp := range w.dynamic {
+ if now.Before(exp) {
+ out[ip] = exp
+ }
+ }
+ return out
+}
+
+// RestoreDynamic merges previously granted leases (e.g. after a config update)
+func (w *Whitelist) RestoreDynamic(leases map[string]time.Time) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for ip, exp := range leases {
+ if cur, ok := w.dynamic[ip]; !ok || exp.After(cur) {
+ w.dynamic[ip] = exp
+ }
+ }
+}
+
+// RemoveDynamic revokes the lease for ip (all leases when ip is empty) and returns how many were removed
+func (w *Whitelist) RemoveDynamic(ip string) int {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if ip == "" {
+ n := len(w.dynamic)
+ w.dynamic = make(map[string]time.Time)
+ return n
+ }
+ if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil {
+ if _, ok := w.dynamic[parsed.String()]; ok {
+ delete(w.dynamic, parsed.String())
+ return 1
+ }
+ }
+ return 0
+}
+
+// AddDynamic adds an IP with an expiration timestamp
+func (w *Whitelist) AddDynamic(ipStr string, expireAt time.Time) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ host := ipStr
+ if h, _, err := net.SplitHostPort(ipStr); err == nil {
+ host = h
+ }
+ parsed := net.ParseIP(host)
+ if parsed != nil {
+ w.dynamic[parsed.String()] = expireAt
+ }
+}
+
+// GetDynamic returns active unexpired dynamic IPs
+func (w *Whitelist) GetDynamic() []string {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ now := time.Now()
+ var list []string
+ for ip, exp := range w.dynamic {
+ if now.Before(exp) {
+ list = append(list, ip)
+ } else {
+ delete(w.dynamic, ip)
+ }
+ }
+ return list
+}
+
+// IsAllowed checks if remoteAddr (either "IP" or "IP:Port") is permitted
+func (w *Whitelist) IsAllowed(remoteAddr string) (bool, string) {
+ if w.allowAll {
+ return true, ""
+ }
+
+ host := remoteAddr
+ if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
+ host = h
+ }
+
+ parsedIP := net.ParseIP(host)
+ if parsedIP == nil {
+ return false, "invalid client IP"
+ }
+
+ ipStr := parsedIP.String()
+
+ // Canonical IP string check
+ if w.ips[ipStr] {
+ return true, ""
+ }
+
+ // Dynamic leases check
+ w.mu.RLock()
+ if exp, ok := w.dynamic[ipStr]; ok {
+ if time.Now().Before(exp) {
+ w.mu.RUnlock()
+ return true, ""
+ }
+ }
+ w.mu.RUnlock()
+
+ // CIDR subnets check
+ for _, subnet := range w.subnets {
+ if subnet.Contains(parsedIP) {
+ return true, ""
+ }
+ }
+
+ return false, "IP not in whitelist"
+}
+
+// Rules returns the raw rules
+func (w *Whitelist) Rules() []string {
+ return w.rules
+}
diff --git a/core/acl/whitelist_test.go b/core/acl/whitelist_test.go
new file mode 100644
index 0000000..decc954
--- /dev/null
+++ b/core/acl/whitelist_test.go
@@ -0,0 +1,114 @@
+package acl
+
+import (
+ "testing"
+ "time"
+)
+
+func TestWhitelist(t *testing.T) {
+ tests := []struct {
+ name string
+ rules []string
+ ip string
+ expected bool
+ }{
+ {
+ name: "allow all wildcard",
+ rules: []string{"*"},
+ ip: "192.168.1.50:12345",
+ expected: true,
+ },
+ {
+ name: "single ip match",
+ rules: []string{"192.168.1.50"},
+ ip: "192.168.1.50:12345",
+ expected: true,
+ },
+ {
+ name: "single ip mismatch",
+ rules: []string{"192.168.1.50"},
+ ip: "192.168.1.51:12345",
+ expected: false,
+ },
+ {
+ name: "cidr match",
+ rules: []string{"192.168.1.0/24"},
+ ip: "192.168.1.77:45678",
+ expected: true,
+ },
+ {
+ name: "cidr mismatch",
+ rules: []string{"192.168.1.0/24"},
+ ip: "192.168.2.190:45678",
+ expected: false,
+ },
+ {
+ name: "multiple rules mixed",
+ rules: []string{"10.0.0.1", "192.168.1.0/24"},
+ ip: "10.0.0.1:80",
+ expected: true,
+ },
+ {
+ name: "empty rules reject all",
+ rules: []string{},
+ ip: "127.0.0.1:80",
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ wl := NewWhitelist(tt.rules)
+ allowed, _ := wl.IsAllowed(tt.ip)
+ if allowed != tt.expected {
+ t.Errorf("expected %v for IP %s with rules %v, got %v", tt.expected, tt.ip, tt.rules, allowed)
+ }
+ })
+ }
+}
+
+func TestValidateRule(t *testing.T) {
+ for _, ok := range []string{"*", "192.168.1.100", "192.168.1.0/24", "::1", " 10.0.0.1 "} {
+ if err := ValidateRule(ok); err != nil {
+ t.Errorf("ValidateRule(%q) = %v, want nil", ok, err)
+ }
+ }
+ for _, bad := range []string{"", "192.168.1", "10.0.0.0/33", "abc", "192.168.1.*"} {
+ if err := ValidateRule(bad); err == nil {
+ t.Errorf("ValidateRule(%q) = nil, want error", bad)
+ }
+ }
+}
+
+func TestDynamicLeasesRoundTrip(t *testing.T) {
+ w1 := NewWhitelist([]string{"10.0.0.1"})
+ w1.AddDynamic("192.168.1.5", time.Now().Add(time.Hour))
+ w1.AddDynamic("192.168.1.6", time.Now().Add(-time.Second))
+
+ w2 := NewWhitelist([]string{"10.0.0.2"})
+ w2.RestoreDynamic(w1.DynamicLeases())
+ if ok, _ := w2.IsAllowed("192.168.1.5:1234"); !ok {
+ t.Error("restored lease not honoured")
+ }
+ if ok, _ := w2.IsAllowed("192.168.1.6"); ok {
+ t.Error("expired lease should not be restored")
+ }
+}
+
+func TestRemoveDynamic(t *testing.T) {
+ w := NewWhitelist(nil)
+ w.AddDynamic("10.0.0.1", time.Now().Add(time.Hour))
+ w.AddDynamic("10.0.0.2", time.Now().Add(time.Hour))
+ if n := w.RemoveDynamic("10.0.0.1"); n != 1 {
+ t.Fatalf("removed %d, want 1", n)
+ }
+ if ok, _ := w.IsAllowed("10.0.0.1"); ok {
+ t.Error("revoked IP still allowed")
+ }
+ if n := w.RemoveDynamic(""); n != 1 {
+ t.Fatalf("remove all removed %d, want 1", n)
+ }
+ if ok, _ := w.IsAllowed("10.0.0.2"); ok {
+ t.Error("IP still allowed after revoke-all")
+ }
+}
diff --git a/core/protocol/messages.go b/core/protocol/messages.go
new file mode 100644
index 0000000..fa32211
--- /dev/null
+++ b/core/protocol/messages.go
@@ -0,0 +1,305 @@
+package protocol
+
+import "time"
+
+// Message types
+const (
+ TypeAuth = "auth"
+ TypeAuthResp = "auth_resp"
+ TypeRegisterTunnel = "register_tunnel"
+ TypeRegisterResp = "register_resp"
+ TypeUnregister = "unregister_tunnel"
+ TypeUnregisterResp = "unregister_resp"
+ TypeUpdateTunnel = "update_tunnel"
+ TypeKillConn = "kill_connection"
+ TypeRevokeLease = "revoke_lease"
+ TypeCreateInvite = "create_invite"
+ TypeInviteResp = "invite_resp"
+ TypeRevokeInvite = "revoke_invite"
+ TypeDecideRequest = "decide_request"
+ TypeMetrics = "metrics_update"
+ TypePing = "ping"
+ TypePong = "pong"
+)
+
+// BaseMessage represents any control message
+type BaseMessage struct {
+ Type string `json:"type"`
+}
+
+// AuthMessage sent by agent to authenticate
+type AuthMessage struct {
+ Type string `json:"type"`
+ Token string `json:"token"`
+}
+
+// AuthRespMessage returned by server
+type AuthRespMessage struct {
+ Type string `json:"type"`
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+}
+
+// TunnelConfig is the definition of a tunnel
+type TunnelConfig struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Target string `json:"target"` // Target host:port (accessed by Mac agent, e.g. 10.0.1.16:3306)
+ ListenPort int `json:"listen_port"`
+ Whitelist []string `json:"whitelist"` // e.g. ["192.168.1.0/24", "192.168.1.100", "*"]
+ Enabled bool `json:"enabled"`
+ Owner string `json:"owner,omitempty"` // 责任人
+ DurationMinutes int `json:"duration_minutes,omitempty"` // 最长开启时长,到时无论是否在用都关闭 (0: 不限制)
+ // IdleCloseMinutes 闲置多久自动关闭(无在线连接、无有效授权、无待审批、无可用邀请);nil 表示默认 30 分钟,0 表示不自动关闭
+ IdleCloseMinutes *int `json:"idle_close_minutes,omitempty"`
+ // 以下由程序维护
+ CloseReason string `json:"close_reason,omitempty"` // 上次关闭原因
+ FirstConnectedAt *time.Time `json:"first_connected_at,omitempty"` // 本次开启后首次有连接的时间
+ ConnectedSec int64 `json:"connected_sec,omitempty"` // 本次开启后有连接在线的累计秒数
+ LastConnectedSec int64 `json:"last_connected_sec,omitempty"` // 上次开启期间有连接在线的累计秒数
+ StartedAt *time.Time `json:"started_at,omitempty"` // 本次开启时间
+ ExpiresAt *time.Time `json:"expires_at,omitempty"` // 到期自动结束时间
+ HistoricalBytesIn int64 `json:"historical_bytes_in,omitempty"` // 历史累计入流量
+ HistoricalBytesOut int64 `json:"historical_bytes_out,omitempty"` // 历史累计出流量
+ LastDurationSec int64 `json:"last_duration_sec,omitempty"` // 上次开启累计运行秒数
+ MaxBandwidthMB float64 `json:"max_bandwidth_mb,omitempty"` // 最大带宽限速 (MB/s, 0 为不限制)
+ AuthCode string `json:"auth_code,omitempty"` // 网页放通授权识别码
+ DynamicWhitelist []string `json:"dynamic_whitelist,omitempty"` // 动态放通中的客户端 IP 列表
+ Notes string `json:"notes,omitempty"`
+ LeaseDuration string `json:"lease_duration,omitempty"` // 已废弃:放通时长改为在每个邀请上设置,保留字段仅为兼容旧配置
+ RequireApproval bool `json:"require_approval,omitempty"` // 已废弃:是否审批改为在每个邀请上设置,保留字段仅为兼容旧配置
+}
+
+// RegisterTunnelMessage registers a tunnel on server
+type RegisterTunnelMessage struct {
+ Type string `json:"type"`
+ Tunnel TunnelConfig `json:"tunnel"`
+}
+
+// RegisterRespMessage response to register
+type RegisterRespMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+ Port int `json:"port"`
+}
+
+// UnregisterMessage removes a tunnel
+type UnregisterMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+}
+
+// UnregisterRespMessage response to unregister
+type UnregisterRespMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+ Success bool `json:"success"`
+}
+
+// UpdateTunnelMessage updates tunnel configuration (e.g. whitelist or name)
+type UpdateTunnelMessage struct {
+ Type string `json:"type"`
+ Tunnel TunnelConfig `json:"tunnel"`
+}
+
+// KillConnMessage requests server to terminate a specific client connection
+type KillConnMessage struct {
+ Type string `json:"type"`
+ ConnID string `json:"conn_id"`
+}
+
+// RevokeLeaseMessage removes a temporary (web-auth) whitelist lease; empty IP revokes all leases of the tunnel
+type RevokeLeaseMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+ IP string `json:"ip,omitempty"`
+}
+
+// Lease durations (Invite.LeaseDuration):通过邀请获得授权后可访问多久
+const (
+ LeaseDuration1h = "1h"
+ LeaseDuration2h = "2h"
+ LeaseDuration8h = "8h"
+ LeaseDurationToday = "today" // 当天 23:59:59 前有效
+ LeaseDurationTunnel = "tunnel" // 通道开启期间有效
+)
+
+// ValidLeaseDuration reports whether d is a supported lease duration ("" means default 2h)
+func ValidLeaseDuration(d string) bool {
+ switch d {
+ case "", LeaseDuration1h, LeaseDuration2h, LeaseDuration8h, LeaseDurationToday, LeaseDurationTunnel:
+ return true
+ }
+ return false
+}
+
+// DescribeLeaseDuration is the human-readable lease duration
+func DescribeLeaseDuration(d string) string {
+ switch d {
+ case LeaseDuration1h:
+ return "1 小时"
+ case LeaseDuration8h:
+ return "8 小时"
+ case LeaseDurationToday:
+ return "当天有效"
+ case LeaseDurationTunnel:
+ return "通道开启期间有效"
+ default:
+ return "2 小时"
+ }
+}
+
+// DynamicLease is a temporary whitelist entry granted through an invite link
+type DynamicLease struct {
+ IP string `json:"ip"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Name string `json:"name,omitempty"` // 申请人填写的名字
+ Invite string `json:"invite,omitempty"` // 来源邀请码
+ // Duration 授权时长;计时从该设备首次连接开始
+ Duration string `json:"duration,omitempty"`
+ // AwaitingConnect 为 true 表示已授权但尚未首次连接:计时未开始,ExpiresAt 是必须首次连接的截止时间
+ AwaitingConnect bool `json:"awaiting_connect,omitempty"`
+ StartedAt *time.Time `json:"started_at,omitempty"` // 首次连接(开始计时)的时间
+}
+
+// InviteUse records one device that used an invite
+type InviteUse struct {
+ Name string `json:"name"`
+ IP string `json:"ip"`
+ At time.Time `json:"at"`
+}
+
+// Invite is a shareable link that lets a limited number of devices request access
+type Invite struct {
+ Code string `json:"code"`
+ Note string `json:"note,omitempty"` // 管理员备注(发给谁)
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt time.Time `json:"expires_at"` // 链接可被打开的截止时间
+ MaxUses int `json:"max_uses"` // 0 表示不限设备数
+ // LeaseDuration 通过该邀请获得授权后可访问多久(每个邀请单独设置)
+ LeaseDuration string `json:"lease_duration,omitempty"`
+ // RequireApproval 通过该邀请提交的申请需管理员批准后才放通
+ RequireApproval bool `json:"require_approval,omitempty"`
+ Uses []InviteUse `json:"uses,omitempty"`
+}
+
+// AccessRequest is an access application on a tunnel that requires approval
+type AccessRequest struct {
+ ID string `json:"id"`
+ TunnelID string `json:"tunnel_id"`
+ Name string `json:"name"`
+ IP string `json:"ip"`
+ Invite string `json:"invite,omitempty"`
+ At time.Time `json:"at"`
+ Status string `json:"status"` // pending / approved / rejected
+}
+
+// CreateInviteMessage asks the server to create an invite link
+type CreateInviteMessage struct {
+ Type string `json:"type"`
+ RequestID string `json:"request_id"`
+ TunnelID string `json:"tunnel_id"`
+ Note string `json:"note,omitempty"`
+ ValidMinutes int `json:"valid_minutes"`
+ MaxUses int `json:"max_uses"`
+ // LeaseDuration 授权后可访问时长,见 LeaseDuration* 常量
+ LeaseDuration string `json:"lease_duration,omitempty"`
+ // RequireApproval 打开链接后需管理员批准才放通
+ RequireApproval bool `json:"require_approval,omitempty"`
+}
+
+// InviteRespMessage answers CreateInviteMessage
+type InviteRespMessage struct {
+ Type string `json:"type"`
+ RequestID string `json:"request_id"`
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+ Invite Invite `json:"invite"`
+}
+
+// RevokeInviteMessage invalidates an invite and the leases it granted
+type RevokeInviteMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+ Code string `json:"code"`
+}
+
+// DecideRequestMessage approves or rejects a pending access request
+type DecideRequestMessage struct {
+ Type string `json:"type"`
+ TunnelID string `json:"tunnel_id"`
+ RequestID string `json:"request_id"`
+ Approve bool `json:"approve"`
+}
+
+// ActiveConnInfo details about currently connected client
+type ActiveConnInfo struct {
+ ConnID string `json:"conn_id"`
+ TunnelID string `json:"tunnel_id"`
+ ClientIP string `json:"client_ip"`
+ ConnectedAt time.Time `json:"connected_at"`
+ BytesIn int64 `json:"bytes_in"` // Upload from client to target
+ BytesOut int64 `json:"bytes_out"` // Download from target to client
+ RateIn int64 `json:"rate_in"` // Current bytes/sec
+ RateOut int64 `json:"rate_out"` // Current bytes/sec
+}
+
+// ClientHistoryRecord historical record of a client connection
+type ClientHistoryRecord struct {
+ ID string `json:"id"`
+ TunnelID string `json:"tunnel_id"`
+ TunnelName string `json:"tunnel_name"`
+ Port int `json:"port"`
+ ClientIP string `json:"client_ip"`
+ ConnectedAt time.Time `json:"connected_at"`
+ DisconnectedAt time.Time `json:"disconnected_at"`
+ DurationSec int64 `json:"duration_sec"`
+ BytesIn int64 `json:"bytes_in"`
+ BytesOut int64 `json:"bytes_out"`
+}
+
+// TunnelMetrics summary statistics for a tunnel
+type TunnelMetrics struct {
+ TunnelID string `json:"tunnel_id"`
+ ListenPort int `json:"listen_port"`
+ ActiveConns int `json:"active_conns"`
+ TotalConns int64 `json:"total_conns"`
+ BytesIn int64 `json:"bytes_in"` // 本次会话入流量
+ BytesOut int64 `json:"bytes_out"` // 本次会话出流量
+ RateIn int64 `json:"rate_in"`
+ RateOut int64 `json:"rate_out"`
+ BlockedAttemps int64 `json:"blocked_attempts"`
+ Status string `json:"status"` // "online", "error", "disabled"
+ Error string `json:"error,omitempty"`
+ DynamicWhitelist []string `json:"dynamic_whitelist,omitempty"`
+ DynamicLeases []DynamicLease `json:"dynamic_leases,omitempty"`
+ Invites []Invite `json:"invites,omitempty"`
+ Pending []AccessRequest `json:"pending,omitempty"`
+}
+
+// SecurityEvent records blocked or flagged connection attempts
+type SecurityEvent struct {
+ ID string `json:"id"`
+ TunnelID string `json:"tunnel_id"`
+ Port int `json:"port"`
+ ClientIP string `json:"client_ip"`
+ Timestamp time.Time `json:"timestamp"`
+ Reason string `json:"reason"`
+}
+
+// MetricsMessage pushed by server to agent periodically (e.g. every 1s)
+type MetricsMessage struct {
+ Type string `json:"type"`
+ Tunnels []TunnelMetrics `json:"tunnels"`
+ ActiveConns []ActiveConnInfo `json:"active_conns"`
+ SecurityEvents []SecurityEvent `json:"security_events,omitempty"`
+}
+
+// StreamHandshake sent as first line when a data stream is opened
+type StreamHandshake struct {
+ TunnelID string `json:"tunnel_id"`
+ ConnID string `json:"conn_id"`
+ ClientIP string `json:"client_ip"`
+}
diff --git a/core/traffic/meter.go b/core/traffic/meter.go
new file mode 100644
index 0000000..0a66a08
--- /dev/null
+++ b/core/traffic/meter.go
@@ -0,0 +1,210 @@
+package traffic
+
+import (
+ "io"
+ "net"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// Meter tracks bandwidth and total bytes
+type Meter struct {
+ bytesIn atomic.Int64 // Read from client (upload)
+ bytesOut atomic.Int64 // Written to client (download)
+ lastBytesIn int64
+ lastBytesOut int64
+ rateIn atomic.Int64 // Bytes per second
+ rateOut atomic.Int64 // Bytes per second
+ lastTick time.Time
+}
+
+// NewMeter creates a new meter
+func NewMeter() *Meter {
+ return &Meter{
+ lastTick: time.Now(),
+ }
+}
+
+// AddIn adds incoming bytes
+func (m *Meter) AddIn(n int64) {
+ m.bytesIn.Add(n)
+}
+
+// AddOut adds outgoing bytes
+func (m *Meter) AddOut(n int64) {
+ m.bytesOut.Add(n)
+}
+
+// Tick calculates current transfer rate
+func (m *Meter) Tick() {
+ now := time.Now()
+ elapsed := now.Sub(m.lastTick).Seconds()
+ if elapsed <= 0 {
+ elapsed = 1
+ }
+
+ currIn := m.bytesIn.Load()
+ currOut := m.bytesOut.Load()
+
+ diffIn := currIn - m.lastBytesIn
+ diffOut := currOut - m.lastBytesOut
+
+ m.rateIn.Store(int64(float64(diffIn) / elapsed))
+ m.rateOut.Store(int64(float64(diffOut) / elapsed))
+
+ m.lastBytesIn = currIn
+ m.lastBytesOut = currOut
+ m.lastTick = now
+}
+
+// Stats returns snapshot values
+func (m *Meter) Stats() (bytesIn, bytesOut, rateIn, rateOut int64) {
+ return m.bytesIn.Load(), m.bytesOut.Load(), m.rateIn.Load(), m.rateOut.Load()
+}
+
+// Conn wraps a net.Conn with traffic metering
+type Conn struct {
+ net.Conn
+ meter *Meter
+}
+
+// WrapConn wraps a net.Conn
+func WrapConn(c net.Conn, meter *Meter) *Conn {
+ return &Conn{
+ Conn: c,
+ meter: meter,
+ }
+}
+
+func (c *Conn) Read(b []byte) (n int, err error) {
+ n, err = c.Conn.Read(b)
+ if n > 0 {
+ c.meter.AddIn(int64(n))
+ }
+ return
+}
+
+func (c *Conn) Write(b []byte) (n int, err error) {
+ n, err = c.Conn.Write(b)
+ if n > 0 {
+ c.meter.AddOut(int64(n))
+ }
+ return
+}
+
+// Pipe copies data bidirectionally between two ReadWriteClosers with buffer pooling
+func Pipe(c1, c2 io.ReadWriteCloser) {
+ PipeWithLimiter(c1, c2, nil)
+}
+
+// Limiter implements a token-bucket rate limiter for bandwidth QoS.
+// 令牌可以透支,调用方在锁外按欠额休眠,多个连接共享同一个桶时不会互相串行阻塞。
+type Limiter struct {
+ mbPerSec float64
+ rate float64 // bytes per second
+ capacity float64 // burst capacity
+ tokens float64
+ lastUpdate time.Time
+ mu sync.Mutex
+}
+
+// NewLimiter creates a limiter with MB/s target
+func NewLimiter(mbPerSec float64) *Limiter {
+ if mbPerSec <= 0 {
+ return nil
+ }
+ bytesPerSec := mbPerSec * 1024 * 1024
+ return &Limiter{
+ mbPerSec: mbPerSec,
+ rate: bytesPerSec,
+ capacity: bytesPerSec * 2,
+ tokens: bytesPerSec,
+ lastUpdate: time.Now(),
+ }
+}
+
+// MBPerSec returns the configured limit (0 for a nil limiter)
+func (l *Limiter) MBPerSec() float64 {
+ if l == nil {
+ return 0
+ }
+ return l.mbPerSec
+}
+
+// reserve takes n tokens and returns how long the caller must wait before proceeding
+func (l *Limiter) reserve(n int) time.Duration {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ now := time.Now()
+ if elapsed := now.Sub(l.lastUpdate).Seconds(); elapsed > 0 {
+ l.tokens += elapsed * l.rate
+ if l.tokens > l.capacity {
+ l.tokens = l.capacity
+ }
+ l.lastUpdate = now
+ }
+
+ l.tokens -= float64(n)
+ if l.tokens >= 0 {
+ return 0
+ }
+ return time.Duration(-l.tokens / l.rate * float64(time.Second))
+}
+
+// Allow limits byte throughput
+func (l *Limiter) Allow(n int) {
+ if l == nil || n <= 0 {
+ return
+ }
+ if wait := l.reserve(n); wait > 0 {
+ time.Sleep(wait)
+ }
+}
+
+// LimitedReader wraps an io.Reader with a Limiter
+type LimitedReader struct {
+ R io.Reader
+ Limiter *Limiter
+}
+
+func (lr *LimitedReader) Read(p []byte) (n int, err error) {
+ n, err = lr.R.Read(p)
+ if n > 0 && lr.Limiter != nil {
+ lr.Limiter.Allow(n)
+ }
+ return
+}
+
+// PipeWithLimiter copies data bidirectionally between two ReadWriteClosers with optional rate limiting
+func PipeWithLimiter(c1, c2 io.ReadWriteCloser, limiter *Limiter) {
+ done := make(chan struct{}, 2)
+
+ go func() {
+ buf := make([]byte, 32*1024)
+ var r io.Reader = c2
+ if limiter != nil {
+ r = &LimitedReader{R: c2, Limiter: limiter}
+ }
+ _, _ = io.CopyBuffer(c1, r, buf)
+ _ = c1.Close()
+ _ = c2.Close()
+ done <- struct{}{}
+ }()
+
+ go func() {
+ buf := make([]byte, 32*1024)
+ var r io.Reader = c1
+ if limiter != nil {
+ r = &LimitedReader{R: c1, Limiter: limiter}
+ }
+ _, _ = io.CopyBuffer(c2, r, buf)
+ _ = c1.Close()
+ _ = c2.Close()
+ done <- struct{}{}
+ }()
+
+ <-done
+ <-done
+}
diff --git a/core/traffic/meter_test.go b/core/traffic/meter_test.go
new file mode 100644
index 0000000..7b259db
--- /dev/null
+++ b/core/traffic/meter_test.go
@@ -0,0 +1,36 @@
+package traffic
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestLimiterDoesNotSerializeCallers(t *testing.T) {
+ // 1 MB/s,初始令牌 1 MB;两个调用方各取 1 MB,总耗时应约 1 秒而不是被锁串行化
+ l := NewLimiter(1)
+ start := time.Now()
+ var wg sync.WaitGroup
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ l.Allow(1024 * 1024)
+ }()
+ }
+ wg.Wait()
+ if d := time.Since(start); d < 800*time.Millisecond || d > 1500*time.Millisecond {
+ t.Fatalf("expected ~1s total wait, got %v", d)
+ }
+}
+
+func TestNilLimiter(t *testing.T) {
+ var l *Limiter
+ l.Allow(1 << 20)
+ if l.MBPerSec() != 0 {
+ t.Fatal("nil limiter should report 0")
+ }
+ if NewLimiter(0) != nil {
+ t.Fatal("NewLimiter(0) should be nil (unlimited)")
+ }
+}
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 0000000..b800443
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,17 @@
+# 用法:PORTRELAY_TOKEN=$(openssl rand -hex 24) docker compose up -d
+services:
+ portrelay:
+ image: ghcr.io/deepfal/portrelay-server:latest
+ # 或者从源码构建:
+ # build: ..
+ # host 网络保留访问者的真实 IP(白名单依赖它),也免去映射 1000 个端口
+ network_mode: host
+ environment:
+ PORTRELAY_TOKEN: ${PORTRELAY_TOKEN:?请设置 PORTRELAY_TOKEN}
+ # command: ["-listen", ":33000", "-port-min", "33000", "-port-max", "34000", "-state", "/var/lib/portrelay/leases.json", "-public-host", "gw.example.com"]
+ volumes:
+ - portrelay-state:/var/lib/portrelay
+ restart: unless-stopped
+
+volumes:
+ portrelay-state:
diff --git a/deploy/install-server.sh b/deploy/install-server.sh
new file mode 100755
index 0000000..79aa26d
--- /dev/null
+++ b/deploy/install-server.sh
@@ -0,0 +1,49 @@
+#!/bin/sh
+# 在 Linux 网关上安装 / 升级 PortRelay 服务端(systemd)。
+#
+# sudo ./install-server.sh ./portrelay-server
+#
+# 首次安装会生成随机 token 写入 /etc/portrelay-server.env;升级时保留已有配置。
+set -eu
+
+BIN="${1:-./portrelay-server}"
+HERE="$(cd "$(dirname "$0")" && pwd)"
+ENV_FILE=/etc/portrelay-server.env
+UNIT=/etc/systemd/system/portrelay-server.service
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo "请用 root 运行:sudo $0 $*" >&2
+ exit 1
+fi
+if [ ! -x "$BIN" ]; then
+ echo "找不到可执行文件 $BIN" >&2
+ exit 1
+fi
+
+if [ -f /usr/local/bin/portrelay-server ]; then
+ cp /usr/local/bin/portrelay-server "/usr/local/bin/portrelay-server.bak-$(date +%Y%m%d-%H%M%S)"
+fi
+install -m 755 "$BIN" /usr/local/bin/portrelay-server
+
+if [ ! -f "$ENV_FILE" ]; then
+ if command -v openssl >/dev/null 2>&1; then
+ TOKEN="$(openssl rand -hex 24)"
+ else
+ TOKEN="$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"
+ fi
+ umask 077
+ sed "s/^PORTRELAY_TOKEN=.*/PORTRELAY_TOKEN=$TOKEN/" "$HERE/portrelay-server.env.example" > "$ENV_FILE"
+ chmod 600 "$ENV_FILE"
+ echo "已生成 token 并写入 $ENV_FILE"
+fi
+
+install -m 644 "$HERE/portrelay-server.service" "$UNIT"
+systemctl daemon-reload
+systemctl enable portrelay-server >/dev/null
+systemctl restart portrelay-server
+sleep 1
+systemctl --no-pager --lines=5 status portrelay-server || true
+
+echo
+echo "完成。查看 token:sudo grep PORTRELAY_TOKEN $ENV_FILE"
+echo "防火墙需放通 TCP 33000-34000(控制端口 + 通道端口池)"
diff --git a/deploy/portrelay-server.env.example b/deploy/portrelay-server.env.example
new file mode 100644
index 0000000..2114ac3
--- /dev/null
+++ b/deploy/portrelay-server.env.example
@@ -0,0 +1,10 @@
+# 复制为 /etc/portrelay-server.env 并设为 0600:
+# sudo install -m 600 deploy/portrelay-server.env.example /etc/portrelay-server.env
+#
+# Agent 与网关之间的共享密钥(必填)。生成方式:openssl rand -hex 24
+PORTRELAY_TOKEN=change-me-to-a-long-random-string
+
+# 额外的启动参数(可选),例如:
+# -public-host gw.example.com 授权页上展示给访问者的地址(默认取访问者打开链接时用的地址)
+# -trust-proxy 网关前面有可信反向代理时才开启
+PORTRELAY_ARGS=
diff --git a/deploy/portrelay-server.service b/deploy/portrelay-server.service
new file mode 100644
index 0000000..8f02f3d
--- /dev/null
+++ b/deploy/portrelay-server.service
@@ -0,0 +1,30 @@
+[Unit]
+Description=PortRelay edge gateway
+Documentation=https://github.com/DeepFal/PortRelay
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+# PORTRELAY_TOKEN 等配置写在这里(权限 0600),参考 portrelay-server.env.example
+EnvironmentFile=/etc/portrelay-server.env
+ExecStart=/usr/local/bin/portrelay-server -listen :33000 -port-min 33000 -port-max 34000 -state /var/lib/portrelay/leases.json $PORTRELAY_ARGS
+Restart=always
+RestartSec=3
+LimitNOFILE=65536
+
+# 以临时系统用户运行,状态保存在 /var/lib/portrelay
+DynamicUser=yes
+StateDirectory=portrelay
+NoNewPrivileges=yes
+ProtectSystem=strict
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectControlGroups=yes
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+LockPersonality=yes
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/images/app-access.png b/docs/images/app-access.png
new file mode 100644
index 0000000000000000000000000000000000000000..9a5a6fa1ccc1f75529600ffcd7116b731c5e5087
GIT binary patch
literal 176177
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