PortRelay:把内网服务临时、可控地开放给同事

边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上;
通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。

- portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页)
- portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计)
- mac-native:SwiftUI 菜单栏 App
This commit is contained in:
DeepFal committed 2026-10-13 16:33:17 +08:00
commit 4317aa7efa
67 files changed
+13888

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
.git
.github
bin
build
dist
docs
mac-native
tools
PortRelay.app
*.md
+86
View File
@@ -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
+100
View File
@@ -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
+19
View File
@@ -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
+21
View File
@@ -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"]
+21
View File
@@ -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.
+57
View File
@@ -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
+310
View File
@@ -0,0 +1,310 @@
<p align="center">
<img src="mac-native/AppIcon.iconset/icon_256x256.png" width="96" alt="PortRelay">
</p>
<h1 align="center">PortRelay</h1>
<p align="center">
<b>把内网服务临时、可控地开放给同事:发一条邀请链接,到期自动收回。</b>
</p>
<p align="center">
<a href="https://github.com/DeepFal/PortRelay/actions/workflows/ci.yml"><img src="https://github.com/DeepFal/PortRelay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/DeepFal/PortRelay/releases"><img src="https://img.shields.io/github/v/release/DeepFal/PortRelay?sort=semver" alt="Release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a>
<img src="https://img.shields.io/github/go-mod/go-version/DeepFal/PortRelay" alt="Go">
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey" alt="Platform">
</p>
<p align="center">
简体中文 · <a href="README_EN.md">English</a>
</p>
<p align="center">
<img src="docs/images/architecture.svg" alt="PortRelay 架构" width="100%">
</p>
你的电脑能连上某个内网网段:数据库、跳板机、内部面板,但同事连不上。常见的做法是开 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` 就能找到“数据库”),以及按运行状态筛选。
## 界面一览
<p align="center">
<img src="docs/images/live-traffic.gif" alt="通道卡片与实时流量" width="100%">
<br><sub>通道卡片:访问地址、授权状态、实际连接时长,以及近 10 分钟流量(动图为加速演示)</sub>
</p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/images/app-main-dark.png">
<img src="docs/images/app-main-light.png" alt="PortRelay macOS App 主界面">
</picture>
**邀请与授权**:生成链接后得到一段可直接发送的文案和二维码。同事在手机或电脑上打开链接填写姓名,需要审批时进入等待页,批准后页面自动更新。
<p align="center">
<img src="docs/images/app-invite.png" alt="邀请分享卡片" width="100%">
</p>
<table>
<tr>
<td align="center"><img src="docs/images/auth-form.png" alt="填写姓名" width="100%"><br><sub>① 打开邀请链接</sub></td>
<td align="center"><img src="docs/images/auth-pending.png" alt="等待审批" width="100%"><br><sub>② 等待管理员批准(可选)</sub></td>
<td align="center"><img src="docs/images/auth-ok.png" alt="授权成功" width="100%"><br><sub>③ 授权成功</sub></td>
</tr>
</table>
**连接与授权管理**:待审批的申请、已授权设备(在线状态与剩余时间),以及仍然有效的邀请链接,都在同一处管理。
<p align="center">
<img src="docs/images/app-access.png" alt="授权管理" width="80%">
</p>
<details>
<summary><b>网页面板与更多截图</b></summary>
<br>
不使用 macOS App 时,Agent 自带网页面板,地址为 `http://127.0.0.1:33080`:
<img src="docs/images/web-panel.png" alt="网页面板">
<table>
<tr>
<td align="center"><img src="docs/images/app-edit.png" alt="编辑通道"><br><sub>编辑通道:访问方式、闲置关闭、最长时长</sub></td>
<td align="center"><img src="docs/images/app-clients.png" alt="在线设备"><br><sub>在线设备:按 IP 合并,可逐条断开</sub></td>
</tr>
</table>
</details>
## 工作原理
<p align="center">
<img src="docs/images/invite-flow.svg" alt="邀请流程" width="100%">
</p>
- **边缘网关**(`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)。
## 常见问题
<details>
<summary><b>必须用 Tailscale 吗?</b></summary>
不需要。PortRelay 只要求 Agent 所在机器能访问目标网段,并且能通过 TCP 连到网关的控制端口。Tailscale 只是让 Agent 连上网关的方式之一,局域网直连、VPN 或公网地址都可以。
</details>
<details>
<summary><b>和 frp、<code>ssh -R</code> 有什么区别?</b></summary>
它们都能做端口转发,而且更通用。PortRelay 专注于**临时、按人授权**的场景:默认拒绝、通过邀请链接按设备授权、可选审批、首次连接起计时、到期或闲置自动收回,并配有图形界面和接入台账。如果只需要一条长期开着的转发,frp 或 SSH 更简单。
</details>
<details>
<summary><b>一个网关能接多个 Agent 吗?</b></summary>
目前同一时间只服务一个 Agent,新的认证会话会替换旧会话。需要多个 Agent 时,可以部署多个网关实例(使用不同的端口池和 token)。
</details>
<details>
<summary><b>网关重启会怎样?</b></summary>
正在进行的连接会断开。授权租约、邀请链接和审批请求保存在状态文件里,重启后依然有效。Agent 会自动重连并重新注册通道。
</details>
<details>
<summary><b>访问者看到的 IP 不对 / 白名单不生效?</b></summary>
检查网关前面是否有 NAT、端口映射或反向代理。Docker 部署请使用 `--network host`。只有在可信反向代理之后才开启 `-trust-proxy`,否则 `X-Forwarded-For` 可以被伪造。
</details>
<details>
<summary><b>App 提示“无法验证开发者”?</b></summary>
发布的 App 只做了 ad-hoc 签名,没有经过 Apple 公证。请右键选择“打开”,或者执行 `xattr -dr com.apple.quarantine /Applications/PortRelay.app`。
</details>
## 开发
需要 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
+329
View File
@@ -0,0 +1,329 @@
<p align="center">
<img src="mac-native/AppIcon.iconset/icon_256x256.png" width="96" alt="PortRelay">
</p>
<h1 align="center">PortRelay</h1>
<p align="center">
<b>Share internal services with teammates temporarily and safely: send an invite link, and access expires on its own.</b>
</p>
<p align="center">
<a href="https://github.com/DeepFal/PortRelay/actions/workflows/ci.yml"><img src="https://github.com/DeepFal/PortRelay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/DeepFal/PortRelay/releases"><img src="https://img.shields.io/github/v/release/DeepFal/PortRelay?sort=semver" alt="Release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a>
<img src="https://img.shields.io/github/go-mod/go-version/DeepFal/PortRelay" alt="Go">
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey" alt="Platform">
</p>
<p align="center">
<a href="README.md">简体中文</a> · English
</p>
<p align="center">
<img src="docs/images/architecture.svg" alt="PortRelay architecture" width="100%">
</p>
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
<p align="center">
<img src="docs/images/live-traffic.gif" alt="Tunnel card with live traffic" width="100%">
<br><sub>A tunnel card: access address, grants, connected time and the last 10 minutes of traffic (sped up)</sub>
</p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/images/app-main-dark.png">
<img src="docs/images/app-main-light.png" alt="PortRelay macOS app">
</picture>
**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.
<p align="center">
<img src="docs/images/app-invite.png" alt="Invite share card" width="100%">
</p>
<table>
<tr>
<td align="center"><img src="docs/images/auth-form.png" alt="Request access" width="100%"><br><sub>① Open the invite link</sub></td>
<td align="center"><img src="docs/images/auth-pending.png" alt="Waiting for approval" width="100%"><br><sub>② Wait for approval (optional)</sub></td>
<td align="center"><img src="docs/images/auth-ok.png" alt="Access granted" width="100%"><br><sub>③ Access granted</sub></td>
</tr>
</table>
**Access management.** Pending requests, authorized devices (with online state and remaining time) and active invite links are all managed in one place.
<p align="center">
<img src="docs/images/app-access.png" alt="Access management" width="80%">
</p>
<details>
<summary><b>Web panel and more screenshots</b></summary>
<br>
Without the macOS app, the agent serves a web panel at `http://127.0.0.1:33080`:
<img src="docs/images/web-panel.png" alt="Web panel">
<table>
<tr>
<td align="center"><img src="docs/images/app-edit.png" alt="Edit tunnel"><br><sub>Edit tunnel: access mode, idle close, max open time</sub></td>
<td align="center"><img src="docs/images/app-clients.png" alt="Online devices"><br><sub>Online devices, grouped by IP</sub></td>
</tr>
</table>
</details>
## How it works
<p align="center">
<img src="docs/images/invite-flow.svg" alt="Invite flow" width="100%">
</p>
- 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
<details>
<summary><b>Do I need Tailscale?</b></summary>
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.
</details>
<details>
<summary><b>How is this different from frp or <code>ssh -R</code>?</b></summary>
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.
</details>
<details>
<summary><b>Can one gateway serve several agents?</b></summary>
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.
</details>
<details>
<summary><b>What happens when the gateway restarts?</b></summary>
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.
</details>
<details>
<summary><b>Visitors show up with the wrong IP, or the allowlist doesn't work</b></summary>
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.
</details>
<details>
<summary><b>macOS says the developer cannot be verified</b></summary>
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`.
</details>
## 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
+15
View File
@@ -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.
Executable
+102
View File
@@ -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"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>PortRelay</string>
<key>CFBundleIdentifier</key>
<string>com.deepfal.portrelay</string>
<key>CFBundleName</key>
<string>PortRelay</string>
<key>CFBundleDisplayName</key>
<string>PortRelay</string>
<key>CFBundleIconFile</key>
<string>AppIcon.icns</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>${BUNDLE_VERSION}</string>
<key>LSMinimumSystemVersion</key>
<string>${MIN_MACOS}</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>PortRelay 需要访问本地网络,以连接边缘网关并把流量转发到内网目标服务。</string>
</dict>
</plist>
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)"
+807
View File
@@ -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
}
+647
View File
@@ -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})
}
+712
View File
@@ -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)
}
}
+702
View File
@@ -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
}
+45
View File
@@ -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)
}
+127
View File
@@ -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)
}
}
}
+52
View File
@@ -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")
}
}
+105
View File
@@ -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()
}
File diff suppressed because it is too large. Load diff
+125
View File
@@ -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)
}
}
+193
View File
@@ -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
}
+114
View File
@@ -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")
}
}
+305
View File
@@ -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"`
}
+210
View File
@@ -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
}
+36
View File
@@ -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)")
}
}
+17
View File
@@ -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:
+49
View File
@@ -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(控制端口 + 通道端口池)"
+10
View File
@@ -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=
+30
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

+146
View File
@@ -0,0 +1,146 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 960 400" width="960" height="400" role="img" aria-label="PortRelay 架构:访问者连接边缘网关的端口,Agent 通过一条主动外连的隧道把流量转发到内网目标服务">
<style>
svg {
--bg: #f8fafc; --zone: #cbd5e1; --label: #64748b; --box: #ffffff; --stroke: #e2e8f0;
--text: #0f172a; --sub: #64748b; --accent: #2563eb; --pill: #eff6ff; --green: #16a34a;
--orange: #ea580c; --red: #dc2626; --pipe: #94a3b8; --wire: #cbd5e1;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
}
@media (prefers-color-scheme: dark) {
svg {
--bg: #0d1117; --zone: #30363d; --label: #8b949e; --box: #161b22; --stroke: #30363d;
--text: #e6edf3; --sub: #8b949e; --accent: #58a6ff; --pill: #1b2738; --green: #3fb950;
--orange: #f0883e; --red: #f85149; --pipe: #6e7681; --wire: #30363d;
}
}
.bg { fill: var(--bg); }
.zone { fill: none; stroke: var(--zone); stroke-width: 1.5; stroke-dasharray: 6 6; }
.zlabel { fill: var(--label); font-size: 12px; font-weight: 600; letter-spacing: .5px; }
.box { fill: var(--box); stroke: var(--stroke); stroke-width: 1.5; }
.title { fill: var(--text); font-size: 15px; font-weight: 700; }
.t { fill: var(--text); font-size: 13px; font-weight: 600; }
.sub { fill: var(--sub); font-size: 11.5px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.pill { fill: var(--pill); }
.pt { fill: var(--accent); font-size: 11.5px; font-weight: 600; }
.wire { fill: none; stroke: var(--wire); stroke-width: 1.5; }
.pipe { fill: none; stroke: var(--pipe); stroke-width: 1.5; }
.flow { fill: none; stroke: var(--accent); stroke-width: 2; stroke-dasharray: 4 10; opacity: .55; }
.ok { fill: var(--green); }
.warn { fill: var(--orange); }
.bad { fill: var(--red); }
.badl { stroke: var(--red); stroke-width: 2.5; stroke-linecap: round; }
.acc { fill: var(--accent); }
.glow { fill: var(--accent); opacity: .18; }
</style>
<rect class="bg" x="0" y="0" width="960" height="400" rx="16"/>
<!-- 网络区域 -->
<rect class="zone" x="20" y="40" width="440" height="340" rx="14"/>
<text class="zlabel" x="36" y="64">访问者所在网络 · 办公网 / 局域网 / 公网</text>
<rect class="zone" x="600" y="40" width="340" height="340" rx="14"/>
<text class="zlabel" x="616" y="64">目标内网</text>
<!-- 连线 -->
<path class="wire" d="M184,121 C216,121 216,201 248,201"/>
<path class="wire" d="M184,210 C216,210 216,167 248,167"/>
<path class="wire" d="M184,299 C214,299 214,269 240,269"/>
<path class="wire" d="M758,210 C774,210 774,112 790,112"/>
<path class="wire" d="M758,210 L790,210"/>
<path class="wire" d="M758,210 C774,210 774,308 790,308"/>
<!-- 数据包:请求(蓝)与响应(绿) -->
<path id="req" d="M184,121 C216,121 216,201 248,201 L428,210 L628,210 L758,210 C774,210 774,112 790,112" fill="none"/>
<path id="resp" d="M790,112 C774,112 774,210 758,210 L628,210 L428,210 L248,201 C216,201 216,121 184,121" fill="none"/>
<circle class="acc" r="4.5"><animateMotion dur="3.2s" repeatCount="indefinite" begin="0s"><mpath xlink:href="#req"/></animateMotion></circle>
<circle class="acc" r="4.5"><animateMotion dur="3.2s" repeatCount="indefinite" begin="-1.07s"><mpath xlink:href="#req"/></animateMotion></circle>
<circle class="acc" r="4.5"><animateMotion dur="3.2s" repeatCount="indefinite" begin="-2.13s"><mpath xlink:href="#req"/></animateMotion></circle>
<circle class="ok" r="4.5"><animateMotion dur="3.2s" repeatCount="indefinite" begin="-0.53s"><mpath xlink:href="#resp"/></animateMotion></circle>
<circle class="ok" r="4.5"><animateMotion dur="3.2s" repeatCount="indefinite" begin="-1.6s"><mpath xlink:href="#resp"/></animateMotion></circle>
<circle class="ok" r="4.5"><animateMotion dur="3.2s" repeatCount="indefinite" begin="-2.67s"><mpath xlink:href="#resp"/></animateMotion></circle>
<!-- 手机打开邀请链接(橙) -->
<path id="auth" d="M184,210 C216,210 216,167 262,167" fill="none"/>
<circle class="warn" r="4.5">
<animateMotion dur="4s" repeatCount="indefinite" keyPoints="0;1;1" keyTimes="0;.4;1" calcMode="linear"><mpath xlink:href="#auth"/></animateMotion>
<animate attributeName="opacity" values="1;1;0;0" keyTimes="0;.4;.45;1" dur="4s" repeatCount="indefinite"/>
</circle>
<!-- 陌生设备被拦截(红) -->
<path id="deny" d="M184,299 C214,299 214,269 240,269" fill="none"/>
<circle class="bad" r="4.5">
<animateMotion dur="3s" repeatCount="indefinite" keyPoints="0;1;1" keyTimes="0;.55;1" calcMode="linear"><mpath xlink:href="#deny"/></animateMotion>
<animate attributeName="opacity" values="1;1;0;0" keyTimes="0;.55;.58;1" dur="3s" repeatCount="indefinite"/>
</circle>
<g opacity="0">
<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;.55;.58;.9;1" dur="3s" repeatCount="indefinite"/>
<path class="badl" d="M232,261 l14,14 M246,261 l-14,14"/>
</g>
<!-- 访问者 -->
<rect class="box" x="44" y="92" width="140" height="58" rx="10"/>
<circle class="ok" cx="60" cy="112" r="4"/>
<text class="t" x="70" y="116">张三的电脑</text>
<text class="sub mono" x="58" y="137">192.168.1.21 已授权</text>
<rect class="box" x="44" y="181" width="140" height="58" rx="10"/>
<circle class="warn" cx="60" cy="201" r="4"/>
<text class="t" x="70" y="205">手机扫码申请</text>
<text class="sub mono" x="58" y="226">/i/k7m2xq9fpa</text>
<rect class="box" x="44" y="270" width="140" height="58" rx="10"/>
<circle class="bad" cx="60" cy="290" r="4"/>
<text class="t" x="70" y="294">陌生设备</text>
<text class="sub" x="58" y="315">未授权 · 直接拦截</text>
<!-- 边缘网关 -->
<rect class="box" x="248" y="92" width="180" height="236" rx="12"/>
<text class="title" x="264" y="120">边缘网关</text>
<text class="sub mono" x="264" y="138">portrelay-server</text>
<rect class="pill" x="262" y="154" width="152" height="26" rx="6"/>
<text class="pt mono" x="272" y="171">:33000 控制 · 授权页</text>
<rect class="pill" x="262" y="188" width="152" height="26" rx="6">
<animate attributeName="opacity" values="1;.55;1" dur="3.2s" repeatCount="indefinite"/>
</rect>
<text class="pt mono" x="272" y="205">:33660 → MySQL</text>
<rect class="pill" x="262" y="222" width="152" height="26" rx="6"/>
<text class="pt mono" x="272" y="239">:33661 → Grafana</text>
<rect class="pill" x="262" y="256" width="152" height="26" rx="6"/>
<text class="pt mono" x="272" y="273">:33662 → SSH</text>
<path class="acc" d="M268,296 l8,-3 l8,3 v6 c0,5 -4,8 -8,10 c-4,-2 -8,-5 -8,-10 z" opacity=".85"/>
<text class="sub" x="290" y="309">白名单 · 授权租约</text>
<!-- 隧道:Agent 主动外连,单条 TCP + yamux 多路复用 -->
<path class="pipe" d="M428,203 L628,203"/>
<path class="pipe" d="M428,217 L628,217"/>
<path class="flow" d="M628,210 L428,210">
<animate attributeName="stroke-dashoffset" from="0" to="-28" dur="1s" repeatCount="indefinite"/>
</path>
<text class="t" x="528" y="190" text-anchor="middle">Agent 主动外连</text>
<text class="sub" x="528" y="240" text-anchor="middle">任意连通方式</text>
<text class="sub" x="528" y="257" text-anchor="middle">局域网 · VPN · Tailscale</text>
<text class="sub" x="528" y="273" text-anchor="middle">ZeroTier · WireGuard · 公网</text>
<!-- Agent -->
<rect class="glow" x="620" y="152" width="146" height="116" rx="16">
<animate attributeName="opacity" values=".08;.22;.08" dur="2.4s" repeatCount="indefinite"/>
</rect>
<rect class="box" x="628" y="160" width="130" height="100" rx="12"/>
<text class="title" x="644" y="190">Agent</text>
<text class="sub" x="644" y="212">macOS App</text>
<text class="sub" x="644" y="230">Linux / Windows</text>
<text class="sub" x="644" y="246">命令行同样可用</text>
<!-- 内网目标 -->
<rect class="box" x="790" y="84" width="130" height="56" rx="10"/>
<text class="t" x="806" y="108">MySQL</text>
<text class="sub mono" x="806" y="127">10.0.1.16:3306</text>
<rect class="box" x="790" y="182" width="130" height="56" rx="10"/>
<text class="t" x="806" y="206">Grafana</text>
<text class="sub mono" x="806" y="225">10.0.2.30:3000</text>
<rect class="box" x="790" y="280" width="130" height="56" rx="10"/>
<text class="t" x="806" y="304">SSH</text>
<text class="sub mono" x="806" y="323">10.0.8.3:22</text>
</svg>

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+82
View File
@@ -0,0 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 210" width="960" height="210" role="img" aria-label="邀请流程:生成邀请链接、同事打开链接填写姓名、可选的管理员审批、首次连接开始计时、到期自动断开">
<style>
svg {
--bg: #f8fafc; --track: #e2e8f0; --ring: #cbd5e1; --base: #ffffff; --text: #0f172a; --sub: #64748b; --accent: #2563eb; --on: #ffffff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
}
@media (prefers-color-scheme: dark) {
svg { --bg: #0d1117; --track: #21262d; --ring: #30363d; --base: #161b22; --text: #e6edf3; --sub: #8b949e; --accent: #58a6ff; --on: #0d1117; }
}
.bg { fill: var(--bg); }
.track { stroke: var(--track); stroke-width: 4; stroke-linecap: round; }
.progress { stroke: var(--accent); stroke-width: 4; stroke-linecap: round; stroke-dasharray: 740; stroke-dashoffset: 740; animation: progress 10s linear infinite; }
.ring { fill: var(--base); stroke: var(--ring); stroke-width: 2; }
.ring.optional { stroke-dasharray: 4 4; }
.num { fill: var(--sub); font-size: 15px; font-weight: 700; }
.dot { fill: var(--accent); }
.numon { fill: var(--on); font-size: 15px; font-weight: 700; }
.title { fill: var(--text); font-size: 14px; font-weight: 700; }
.sub { fill: var(--sub); font-size: 12px; }
.tag { fill: var(--accent); font-size: 10.5px; font-weight: 600; }
@keyframes progress { 0% { stroke-dashoffset: 740; } 80%, 96% { stroke-dashoffset: 0; opacity: 1; } 100% { stroke-dashoffset: 0; opacity: 0; } }
@keyframes on1 { 0%, 96% { opacity: 1; } 100% { opacity: 0; } }
.on1 { animation: on1 10s linear infinite; }
@keyframes on2 { 0%, 19% { opacity: 0; } 21%, 96% { opacity: 1; } 100% { opacity: 0; } }
.on2 { animation: on2 10s linear infinite; }
@keyframes on3 { 0%, 39% { opacity: 0; } 41%, 96% { opacity: 1; } 100% { opacity: 0; } }
.on3 { animation: on3 10s linear infinite; }
@keyframes on4 { 0%, 59% { opacity: 0; } 61%, 96% { opacity: 1; } 100% { opacity: 0; } }
.on4 { animation: on4 10s linear infinite; }
@keyframes on5 { 0%, 79% { opacity: 0; } 81%, 96% { opacity: 1; } 100% { opacity: 0; } }
.on5 { animation: on5 10s linear infinite; }
</style>
<rect class="bg" x="0" y="0" width="960" height="210" rx="16"/>
<line class="track" x1="110" y1="62" x2="850" y2="62"/>
<line class="progress" x1="110" y1="62" x2="850" y2="62"/>
<circle class="ring" cx="110" cy="62" r="20"/>
<text class="num" x="110" y="67.5" text-anchor="middle">1</text>
<g class="on1">
<circle class="dot" cx="110" cy="62" r="20"/>
<text class="numon" x="110" y="67.5" text-anchor="middle">1</text>
</g>
<text class="title" x="110" y="116" text-anchor="middle">生成邀请链接</text>
<text class="sub" x="110" y="140" text-anchor="middle">有效期 · 设备数</text>
<text class="sub" x="110" y="160" text-anchor="middle">访问时长 · 是否审批</text>
<circle class="ring" cx="295" cy="62" r="20"/>
<text class="num" x="295" y="67.5" text-anchor="middle">2</text>
<g class="on2">
<circle class="dot" cx="295" cy="62" r="20"/>
<text class="numon" x="295" y="67.5" text-anchor="middle">2</text>
</g>
<text class="title" x="295" y="116" text-anchor="middle">同事打开链接</text>
<text class="sub" x="295" y="140" text-anchor="middle">填写姓名即可</text>
<text class="sub" x="295" y="160" text-anchor="middle">手机扫码也可以</text>
<circle class="ring optional" cx="480" cy="62" r="20"/>
<text class="num" x="480" y="67.5" text-anchor="middle">3</text>
<g class="on3">
<circle class="dot" cx="480" cy="62" r="20"/>
<text class="numon" x="480" y="67.5" text-anchor="middle">3</text>
</g>
<text class="title" x="480" y="116" text-anchor="middle">管理员审批</text>
<text class="sub" x="480" y="140" text-anchor="middle">App 弹出通知提醒</text>
<text class="sub" x="480" y="160" text-anchor="middle">一键批准或拒绝</text>
<text class="tag" x="480" y="28" text-anchor="middle">可选</text>
<circle class="ring" cx="665" cy="62" r="20"/>
<text class="num" x="665" y="67.5" text-anchor="middle">4</text>
<g class="on4">
<circle class="dot" cx="665" cy="62" r="20"/>
<text class="numon" x="665" y="67.5" text-anchor="middle">4</text>
</g>
<text class="title" x="665" y="116" text-anchor="middle">首次连接起计时</text>
<text class="sub" x="665" y="140" text-anchor="middle">2 小时 / 8 小时 / 当天</text>
<text class="sub" x="665" y="160" text-anchor="middle">或直到通道关闭</text>
<circle class="ring" cx="850" cy="62" r="20"/>
<text class="num" x="850" y="67.5" text-anchor="middle">5</text>
<g class="on5">
<circle class="dot" cx="850" cy="62" r="20"/>
<text class="numon" x="850" y="67.5" text-anchor="middle">5</text>
</g>
<text class="title" x="850" y="116" text-anchor="middle">到期自动断开</text>
<text class="sub" x="850" y="140" text-anchor="middle">已建立的连接一并断开</text>
<text class="sub" x="850" y="160" text-anchor="middle">闲置的通道自动关闭</text>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

+8
View File
@@ -0,0 +1,8 @@
module github.com/DeepFal/PortRelay
go 1.23.12
require (
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
)
+4
View File
@@ -0,0 +1,4 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

File diff suppressed because it is too large. Load diff
+106
View File
@@ -0,0 +1,106 @@
import AppKit
let size: CGFloat = 1024
let img = NSImage(size: NSSize(width: size, height: size))
img.lockFocus()
guard let ctx = NSGraphicsContext.current?.cgContext else {
exit(1)
}
// 1. Draw rounded squircle background (Apple style macOS icon radius)
let rect = NSRect(x: 80, y: 80, width: 864, height: 864)
let path = NSBezierPath(roundedRect: rect, xRadius: 190, yRadius: 190)
// Shadow
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: -20), blur: 50, color: NSColor.black.withAlphaComponent(0.6).cgColor)
NSColor.black.setFill()
path.fill()
ctx.restoreGState()
// Gradient Fill
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradientColors = [
NSColor(red: 0.05, green: 0.10, blue: 0.22, alpha: 1.0).cgColor,
NSColor(red: 0.08, green: 0.16, blue: 0.35, alpha: 1.0).cgColor,
NSColor(red: 0.02, green: 0.45, blue: 0.75, alpha: 1.0).cgColor
] as CFArray
let locations: [CGFloat] = [0.0, 0.5, 1.0]
if let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: locations) {
ctx.saveGState()
path.addClip()
ctx.drawLinearGradient(gradient, start: CGPoint(x: 80, y: 944), end: CGPoint(x: 944, y: 80), options: [])
// Border highlight
ctx.setStrokeColor(NSColor.white.withAlphaComponent(0.2).cgColor)
ctx.setLineWidth(4)
ctx.stroke(rect.insetBy(dx: 2, dy: 2))
ctx.restoreGState()
}
// 2. Outer glowing ring (Network relay portal)
ctx.saveGState()
ctx.setShadow(offset: .zero, blur: 40, color: NSColor(red: 0.22, green: 0.74, blue: 0.97, alpha: 0.8).cgColor)
let ringRect = NSRect(x: 232, y: 232, width: 560, height: 560)
let ringPath = NSBezierPath(ovalIn: ringRect)
ringPath.lineWidth = 28
NSColor(red: 0.22, green: 0.74, blue: 0.97, alpha: 0.9).setStroke()
ringPath.stroke()
ctx.restoreGState()
// 3. Inner lightning bolt & relay arrow
let boltPath = NSBezierPath()
// Center coordinates
let cx: CGFloat = 512
let cy: CGFloat = 512
boltPath.move(to: NSPoint(x: cx + 30, y: cy + 240))
boltPath.line(to: NSPoint(x: cx - 140, y: cy + 20))
boltPath.line(to: NSPoint(x: cx - 10, y: cy + 20))
boltPath.line(to: NSPoint(x: cx - 50, y: cy - 240))
boltPath.line(to: NSPoint(x: cx + 150, y: cy - 20))
boltPath.line(to: NSPoint(x: cx + 15, y: cy - 20))
boltPath.close()
ctx.saveGState()
ctx.setShadow(offset: .zero, blur: 50, color: NSColor.white.withAlphaComponent(0.9).cgColor)
NSColor.white.setFill()
boltPath.fill()
ctx.restoreGState()
img.unlockFocus()
// Save iconset
// 在仓库根目录运行(build-app.sh 会先 cd 到仓库根目录)
let iconsetDir = "mac-native/AppIcon.iconset"
try? FileManager.default.createDirectory(atPath: iconsetDir, withIntermediateDirectories: true)
let sizes: [(String, Int)] = [
("icon_16x16.png", 16),
("icon_16x16@2x.png", 32),
("icon_32x32.png", 32),
("icon_32x32@2x.png", 64),
("icon_128x128.png", 128),
("icon_128x128@2x.png", 256),
("icon_256x256.png", 256),
("icon_256x256@2x.png", 512),
("icon_512x512.png", 512),
("icon_512x512@2x.png", 1024)
]
for (name, sz) in sizes {
let destImg = NSImage(size: NSSize(width: sz, height: sz))
destImg.lockFocus()
img.draw(in: NSRect(x: 0, y: 0, width: sz, height: sz))
destImg.unlockFocus()
if let tiff = destImg.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:]) {
let filePath = "\(iconsetDir)/\(name)"
try? png.write(to: URL(fileURLWithPath: filePath))
}
}
print("Iconset PNGs generated successfully.")
+10
View File
@@ -0,0 +1,10 @@
import AppKit
// 用法: swift mac-native/set_icon.swift [PortRelay.app](默认相对当前目录)
let appPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "PortRelay.app"
let iconPath = appPath + "/Contents/Resources/AppIcon.icns"
if let img = NSImage(contentsOfFile: iconPath) {
let ok = NSWorkspace.shared.setIcon(img, forFile: appPath, options: [])
print("NSWorkspace setIcon result:", ok)
}
+611
View File
@@ -0,0 +1,611 @@
package server
import (
"bufio"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/hashicorp/yamux"
"github.com/DeepFal/PortRelay/core/protocol"
)
const (
DefaultPortMin = 33000
DefaultPortMax = 34000
// authTimeout 是新连接完成鉴权的最长等待时间,超时直接断开
authTimeout = 10 * time.Second
)
// agentSession 表示一个已建立的 Agent 连接,控制流写入与指标推送都绑定在该会话上
type agentSession struct {
sess *yamux.Session
writer *bufio.Writer
writeMu sync.Mutex
done chan struct{}
}
func (s *agentSession) send(v any) {
data, err := json.Marshal(v)
if err != nil {
return
}
data = append(data, '\n')
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, _ = s.writer.Write(data)
_ = s.writer.Flush()
}
// Manager manages all active tunnels on the server node
type Manager struct {
portMin int
portMax int
token string
// PublicHost 显示在授权页面上的中转访问地址;为空时使用访问者打开链接时的主机名
PublicHost string
// TrustProxyHeaders 为 true 时 /auth 信任 X-Forwarded-For / X-Real-IP(仅在前置可信反向代理时开启)
TrustProxyHeaders bool
mu sync.RWMutex
active *agentSession
tunnels map[string]*Tunnel
portMap map[int]string // port -> tunnelID
secEvents []protocol.SecurityEvent
secEventsMu sync.Mutex
state *stateStore // 临时放通、邀请链接、审批申请
}
// NewManager creates a manager
func NewManager(portMin, portMax int, token string) *Manager {
if portMin <= 0 {
portMin = DefaultPortMin
}
if portMax <= 0 {
portMax = DefaultPortMax
}
return &Manager{
portMin: portMin,
portMax: portMax,
token: token,
tunnels: make(map[string]*Tunnel),
portMap: make(map[int]string),
secEvents: make([]protocol.SecurityEvent, 0),
state: newStateStore(),
}
}
// HandleSession handles an incoming connection from the Mac agent.
// 只有在控制流上通过 token 鉴权后,该连接才会成为当前活跃会话。
func (m *Manager) HandleSession(rawConn net.Conn) error {
defer rawConn.Close()
cfg := yamux.DefaultConfig()
cfg.EnableKeepAlive = true
cfg.KeepAliveInterval = 10 * time.Second
cfg.LogOutput = io.Discard
session, err := yamux.Server(rawConn, cfg)
if err != nil {
return fmt.Errorf("yamux server init error: %w", err)
}
defer session.Close()
// 非 Agent 连接(端口扫描、探活等)在超时内打不开控制流,直接丢弃
type acceptResult struct {
stream net.Conn
err error
}
acceptCh := make(chan acceptResult, 1)
go func() {
st, err := session.AcceptStream()
acceptCh <- acceptResult{st, err}
}()
var controlStream net.Conn
select {
case res := <-acceptCh:
if res.err != nil {
return fmt.Errorf("failed to accept control stream: %w", res.err)
}
controlStream = res.stream
case <-time.After(authTimeout):
return errors.New("timed out waiting for control stream")
}
defer controlStream.Close()
as := &agentSession{
sess: session,
writer: bufio.NewWriter(controlStream),
done: make(chan struct{}),
}
defer close(as.done)
reader := bufio.NewReader(controlStream)
// 1. 第一条消息必须是鉴权
_ = controlStream.SetReadDeadline(time.Now().Add(authTimeout))
line, err := reader.ReadBytes('\n')
if err != nil {
return fmt.Errorf("read auth message: %w", err)
}
_ = controlStream.SetReadDeadline(time.Time{})
var auth protocol.AuthMessage
if err := json.Unmarshal(line, &auth); err != nil || auth.Type != protocol.TypeAuth {
as.send(protocol.AuthRespMessage{Type: protocol.TypeAuthResp, Success: false, Error: "first message must be auth"})
return errors.New("first control message is not auth")
}
if !m.checkToken(auth.Token) {
as.send(protocol.AuthRespMessage{Type: protocol.TypeAuthResp, Success: false, Error: "invalid authentication token"})
return errors.New("invalid authentication token")
}
// 2. 鉴权通过:替换旧会话,旧会话的通道全部释放,由新 Agent 重新注册
m.activate(as)
defer m.deactivate(as)
as.send(protocol.AuthRespMessage{Type: protocol.TypeAuthResp, Success: true})
log.Printf("[Server] Agent %s authenticated, control stream established", rawConn.RemoteAddr())
go m.metricsLoop(as)
// 3. 控制消息循环
for {
line, err := reader.ReadBytes('\n')
if err != nil {
if err != io.EOF {
log.Printf("[Server] control stream read error: %v", err)
}
return nil
}
if len(line) == 0 {
continue
}
var base protocol.BaseMessage
if err := json.Unmarshal(line, &base); err != nil {
log.Printf("[Server] malformed control message: %v", err)
continue
}
m.handleControlMessage(as, base.Type, line)
}
}
func (m *Manager) checkToken(token string) bool {
if m.token == "" {
return true
}
return subtle.ConstantTimeCompare([]byte(token), []byte(m.token)) == 1
}
func (m *Manager) activate(as *agentSession) {
m.mu.Lock()
old := m.active
m.active = as
m.closeAllTunnelsLocked()
m.mu.Unlock()
if old != nil {
log.Printf("[Server] New agent session replaces the previous one")
_ = old.sess.Close()
}
}
// deactivate 只在该会话仍是活跃会话时清理,避免旧会话迟到的断开把新会话的通道清掉
func (m *Manager) deactivate(as *agentSession) {
m.mu.Lock()
defer m.mu.Unlock()
if m.active != as {
return
}
m.active = nil
m.closeAllTunnelsLocked()
log.Printf("[Server] Agent disconnected, cleaned up all tunnels")
}
func (m *Manager) closeAllTunnelsLocked() {
for _, t := range m.tunnels {
t.Close()
}
m.tunnels = make(map[string]*Tunnel)
m.portMap = make(map[int]string)
}
func (m *Manager) handleControlMessage(as *agentSession, msgType string, raw []byte) {
defer func() {
if r := recover(); r != nil {
log.Printf("[Server] PANIC while handling control message %q: %v", msgType, r)
}
}()
switch msgType {
case protocol.TypeRegisterTunnel:
var msg protocol.RegisterTunnelMessage
if json.Unmarshal(raw, &msg) == nil {
as.send(m.registerTunnel(as, msg.Tunnel))
}
case protocol.TypeUnregister:
var msg protocol.UnregisterMessage
if json.Unmarshal(raw, &msg) == nil {
m.unregisterTunnel(as, msg.TunnelID)
as.send(protocol.UnregisterRespMessage{Type: protocol.TypeUnregisterResp, TunnelID: msg.TunnelID, Success: true})
}
case protocol.TypeUpdateTunnel:
var msg protocol.UpdateTunnelMessage
if json.Unmarshal(raw, &msg) == nil {
as.send(m.updateTunnel(as, msg.Tunnel))
}
case protocol.TypeRevokeLease:
var msg protocol.RevokeLeaseMessage
if json.Unmarshal(raw, &msg) == nil {
m.revokeLease(msg.TunnelID, msg.IP)
}
case protocol.TypeCreateInvite:
var msg protocol.CreateInviteMessage
if json.Unmarshal(raw, &msg) == nil {
as.send(m.createInvite(msg))
}
case protocol.TypeRevokeInvite:
var msg protocol.RevokeInviteMessage
if json.Unmarshal(raw, &msg) == nil {
m.revokeInvite(msg.TunnelID, msg.Code)
}
case protocol.TypeDecideRequest:
var msg protocol.DecideRequestMessage
if json.Unmarshal(raw, &msg) == nil {
m.decideRequest(msg.TunnelID, msg.RequestID, msg.Approve)
}
case protocol.TypeKillConn:
var msg protocol.KillConnMessage
if json.Unmarshal(raw, &msg) == nil {
m.killConn(msg.ConnID)
}
case protocol.TypePing:
as.send(protocol.BaseMessage{Type: protocol.TypePong})
}
}
func registerResp(cfg protocol.TunnelConfig, err error) protocol.RegisterRespMessage {
resp := protocol.RegisterRespMessage{
Type: protocol.TypeRegisterResp,
TunnelID: cfg.ID,
Success: err == nil,
Port: cfg.ListenPort,
}
if err != nil {
resp.Error = err.Error()
}
return resp
}
// checkPortLocked 校验端口范围以及是否被其它通道占用
func (m *Manager) checkPortLocked(cfg protocol.TunnelConfig) error {
if cfg.ListenPort < m.portMin || cfg.ListenPort > m.portMax {
return fmt.Errorf("port %d out of allowed range [%d, %d]", cfg.ListenPort, m.portMin, m.portMax)
}
if existingID, inUse := m.portMap[cfg.ListenPort]; inUse && existingID != cfg.ID {
return fmt.Errorf("port %d already in use by tunnel %s", cfg.ListenPort, existingID)
}
return nil
}
func (m *Manager) registerTunnel(as *agentSession, cfg protocol.TunnelConfig) protocol.RegisterRespMessage {
m.mu.Lock()
defer m.mu.Unlock()
if m.active != as {
return registerResp(cfg, errors.New("agent session is no longer active"))
}
if err := m.checkPortLocked(cfg); err != nil {
return registerResp(cfg, err)
}
// 同 ID 重新注册:先关闭旧监听(端口可能相同,必须先释放),统计与临时白名单沿用
old := m.tunnels[cfg.ID]
if old != nil {
old.Close()
delete(m.portMap, old.ListenPort())
delete(m.tunnels, cfg.ID)
}
t, err := newTunnel(cfg, as.sess, m.addSecEvent, m.onClientConnect, old)
if err != nil {
return registerResp(cfg, err)
}
if old == nil {
// 服务端重启或 Agent 重连后,恢复该通道尚未过期的临时放通
t.restoreDynamicLeases(m.state.leaseExpiries(cfg.ID))
}
m.tunnels[cfg.ID] = t
m.portMap[cfg.ListenPort] = cfg.ID
log.Printf("[Server] Registered tunnel '%s' listening on 0.0.0.0:%d", cfg.Name, cfg.ListenPort)
return registerResp(cfg, nil)
}
func (m *Manager) unregisterTunnel(as *agentSession, tunnelID string) {
m.mu.Lock()
defer m.mu.Unlock()
if m.active != as {
return
}
// 主动关闭/删除通道时,放通、邀请链接与待审批申请一并失效(断线重连不会走到这里)
m.state.forgetTunnel(tunnelID)
if t, exists := m.tunnels[tunnelID]; exists {
t.Close()
delete(m.portMap, t.ListenPort())
delete(m.tunnels, tunnelID)
log.Printf("[Server] Unregistered tunnel '%s' (port %d freed)", t.Name(), t.ListenPort())
}
}
// updateTunnel 端口不变时原地更新(不断开现有连接);端口变化时先绑定新端口再关闭旧端口,失败则保留旧通道
func (m *Manager) updateTunnel(as *agentSession, cfg protocol.TunnelConfig) protocol.RegisterRespMessage {
m.mu.Lock()
t, exists := m.tunnels[cfg.ID]
if !exists || m.active != as {
m.mu.Unlock()
return m.registerTunnel(as, cfg)
}
defer m.mu.Unlock()
if t.ListenPort() == cfg.ListenPort {
t.UpdateConfig(cfg)
log.Printf("[Server] Updated tunnel '%s' whitelist/metadata", cfg.Name)
return registerResp(cfg, nil)
}
if err := m.checkPortLocked(cfg); err != nil {
return registerResp(cfg, err)
}
nt, err := newTunnel(cfg, as.sess, m.addSecEvent, m.onClientConnect, t)
if err != nil {
return registerResp(cfg, err)
}
t.Close()
delete(m.portMap, t.ListenPort())
m.tunnels[cfg.ID] = nt
m.portMap[cfg.ListenPort] = cfg.ID
log.Printf("[Server] Moved tunnel '%s' from port %d to %d", cfg.Name, t.ListenPort(), cfg.ListenPort)
return registerResp(cfg, nil)
}
func (m *Manager) killConn(connID string) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, t := range m.tunnels {
if t.KillConn(connID) {
log.Printf("[Server] Killed connection %s", connID)
break
}
}
}
func (m *Manager) revokeLease(tunnelID, ip string) {
m.mu.RLock()
t := m.tunnels[tunnelID]
m.mu.RUnlock()
if t == nil {
return
}
revoked, kicked := t.RevokeDynamic(ip)
m.state.removeLease(tunnelID, ip)
target := ip
if target == "" {
target = "all"
}
log.Printf("[WebAuth] Revoked %d temporary lease(s) (%s) on tunnel %s, disconnected %d client(s)",
revoked, target, t.Name(), kicked)
}
func (m *Manager) tunnel(id string) *Tunnel {
m.mu.RLock()
defer m.mu.RUnlock()
return m.tunnels[id]
}
func (m *Manager) createInvite(msg protocol.CreateInviteMessage) protocol.InviteRespMessage {
resp := protocol.InviteRespMessage{Type: protocol.TypeInviteResp, RequestID: msg.RequestID}
t := m.tunnel(msg.TunnelID)
switch {
case t == nil:
resp.Error = "通道未开启,无法生成邀请链接"
case msg.ValidMinutes <= 0 || msg.ValidMinutes > 30*24*60:
resp.Error = "邀请有效期须在 1 分钟到 30 天之间"
case msg.MaxUses < 0:
resp.Error = "可用设备数不能为负数"
case !protocol.ValidLeaseDuration(msg.LeaseDuration):
resp.Error = "无效的授权时长"
default:
resp.Invite = m.state.createInvite(msg.TunnelID, truncateRunes(msg.Note, 64), time.Duration(msg.ValidMinutes)*time.Minute, msg.MaxUses, msg.LeaseDuration, msg.RequireApproval)
resp.Success = true
log.Printf("[WebAuth] Created invite for tunnel %s (valid %dm, max %d device(s))", t.Name(), msg.ValidMinutes, msg.MaxUses)
}
return resp
}
func (m *Manager) revokeInvite(tunnelID, code string) {
ips := m.state.revokeInvite(tunnelID, code)
kicked := 0
if t := m.tunnel(tunnelID); t != nil {
for _, ip := range ips {
_, k := t.RevokeDynamic(ip)
kicked += k
}
}
log.Printf("[WebAuth] Revoked invite on tunnel %s: %d lease(s) removed, %d client(s) disconnected", tunnelID, len(ips), kicked)
}
func (m *Manager) decideRequest(tunnelID, requestID string, approve bool) {
t := m.tunnel(tunnelID)
if t == nil {
return
}
r, exp, err := m.state.decide(tunnelID, requestID, approve, t.Snapshot().ExpiresAt)
if err != nil {
return
}
if approve {
t.AddDynamicWhitelist(r.IP, exp)
}
log.Printf("[WebAuth] Request from %s (%s) on tunnel %s: %s", r.Name, r.IP, t.Name(), r.Status)
}
// onClientConnect starts the countdown of a device's lease on its first connection
func (m *Manager) onClientConnect(tunnelID, ip string) {
t := m.tunnel(tunnelID)
if t == nil {
return
}
if exp, started := m.state.startLease(tunnelID, ip, t.Snapshot().ExpiresAt, time.Now()); started {
t.AddDynamicWhitelist(ip, exp)
log.Printf("[WebAuth] %s connected for the first time on tunnel %s, access valid until %s", ip, t.Name(), exp.Format("01-02 15:04"))
}
}
func truncateRunes(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) > n {
r = r[:n]
}
return string(r)
}
func (m *Manager) addSecEvent(ev protocol.SecurityEvent) {
m.secEventsMu.Lock()
defer m.secEventsMu.Unlock()
m.secEvents = append(m.secEvents, ev)
if len(m.secEvents) > 100 {
m.secEvents = m.secEvents[len(m.secEvents)-100:]
}
}
func (m *Manager) metricsLoop(as *agentSession) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-as.done:
return
case <-ticker.C:
}
m.mu.RLock()
if m.active != as {
m.mu.RUnlock()
return
}
tunnelMetricsList := make([]protocol.TunnelMetrics, 0, len(m.tunnels))
allActiveConns := make([]protocol.ActiveConnInfo, 0)
for _, t := range m.tunnels {
// 授权到期(或白名单变更)后,已建立的连接也要断开,“授权后可访问 X”才名副其实
for _, addr := range t.EnforceAccess() {
log.Printf("[Tunnel %d] access expired, disconnected %s", t.ListenPort(), addr)
}
t.Tick()
tm, conns := t.Metrics()
tm.DynamicLeases = m.state.leaseList(tm.TunnelID)
tm.Invites = m.state.inviteList(tm.TunnelID)
tm.Pending = m.state.pendingList(tm.TunnelID)
tunnelMetricsList = append(tunnelMetricsList, tm)
allActiveConns = append(allActiveConns, conns...)
}
m.mu.RUnlock()
m.secEventsMu.Lock()
secCopy := make([]protocol.SecurityEvent, len(m.secEvents))
copy(secCopy, m.secEvents)
m.secEventsMu.Unlock()
as.send(protocol.MetricsMessage{
Type: protocol.TypeMetrics,
Tunnels: tunnelMetricsList,
ActiveConns: allActiveConns,
SecurityEvents: secCopy,
})
}
}
type httpResponseWriter struct {
conn net.Conn
header http.Header
statusCode int
wroteHdr bool
}
func (w *httpResponseWriter) Header() http.Header {
return w.header
}
func (w *httpResponseWriter) WriteHeader(statusCode int) {
if !w.wroteHdr {
w.statusCode = statusCode
w.wroteHdr = true
fmt.Fprintf(w.conn, "HTTP/1.1 %d %s\r\n", statusCode, http.StatusText(statusCode))
_ = w.header.Write(w.conn)
fmt.Fprintf(w.conn, "\r\n")
}
}
func (w *httpResponseWriter) Write(b []byte) (int, error) {
if !w.wroteHdr {
w.WriteHeader(http.StatusOK)
}
return w.conn.Write(b)
}
// HandleHTTP handles incoming HTTP requests (such as /auth?code=... and /health)
func (m *Manager) HandleHTTP(conn net.Conn) {
defer conn.Close()
// 单个请求出错不能拖垮整个服务进程
defer func() {
if r := recover(); r != nil {
log.Printf("[Server] PANIC while handling HTTP request: %v", r)
}
}()
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
br := bufio.NewReader(conn)
req, err := http.ReadRequest(br)
if err != nil {
return
}
req.RemoteAddr = conn.RemoteAddr().String()
if m.TrustProxyHeaders {
if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" {
req.RemoteAddr = first
}
} else if xrip := strings.TrimSpace(req.Header.Get("X-Real-IP")); xrip != "" {
req.RemoteAddr = xrip
}
}
w := &httpResponseWriter{
conn: conn,
header: make(http.Header),
}
w.header.Set("Server", "PortRelay")
w.header.Set("Connection", "close")
m.handleHTTPRequest(w, req)
}
+887
View File
@@ -0,0 +1,887 @@
package server
import (
"bufio"
"encoding/json"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/hashicorp/yamux"
"github.com/DeepFal/PortRelay/core/protocol"
)
const testToken = "secret"
type peekedConn struct {
net.Conn
r io.Reader
}
func (c *peekedConn) Read(b []byte) (int, error) { return c.r.Read(b) }
// startServer 复刻 cmd/portrelay-server 的分流逻辑
func startServer(t *testing.T, m *Manager) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
br := bufio.NewReader(c)
p, err := br.Peek(3)
if err != nil {
c.Close()
return
}
_ = c.SetReadDeadline(time.Time{})
if s := string(p); s == "GET" || s == "POS" || s == "HEA" {
m.HandleHTTP(&peekedConn{c, br})
return
}
_ = m.HandleSession(&peekedConn{c, br})
}(c)
}
}()
return ln.Addr().String()
}
type testAgent struct {
sess *yamux.Session
w *bufio.Writer
msgs chan []byte
}
// dialAgent 连接服务端、发送鉴权,并对每条数据流回复 "OK"
func dialAgent(t *testing.T, addr, token string) *testAgent {
t.Helper()
c, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
cfg := yamux.DefaultConfig()
cfg.LogOutput = io.Discard
s, err := yamux.Client(c, cfg)
if err != nil {
t.Fatal(err)
}
cs, err := s.OpenStream()
if err != nil {
t.Fatal(err)
}
a := &testAgent{sess: s, w: bufio.NewWriter(cs), msgs: make(chan []byte, 64)}
t.Cleanup(func() { s.Close() })
go func() {
r := bufio.NewReader(cs)
for {
line, err := r.ReadBytes('\n')
if err != nil {
close(a.msgs)
return
}
var base protocol.BaseMessage
if json.Unmarshal(line, &base) == nil && base.Type != protocol.TypeMetrics {
a.msgs <- line
}
}
}()
go func() {
for {
st, err := s.AcceptStream()
if err != nil {
return
}
go func() {
// 回复 OK 后保持连接,直到对端关闭(便于验证服务端主动断开)
r := bufio.NewReader(st)
_, _ = r.ReadBytes('\n')
_, _ = st.Write([]byte("OK"))
_, _ = io.Copy(io.Discard, r)
st.Close()
}()
}
}()
a.send(t, protocol.AuthMessage{Type: protocol.TypeAuth, Token: token})
return a
}
func (a *testAgent) send(t *testing.T, v any) {
t.Helper()
b, _ := json.Marshal(v)
_, _ = a.w.Write(append(b, '\n'))
if err := a.w.Flush(); err != nil {
t.Fatalf("send: %v", err)
}
}
func (a *testAgent) next(t *testing.T) []byte {
t.Helper()
select {
case m, ok := <-a.msgs:
if !ok {
return nil
}
return m
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for control message")
return nil
}
}
func (a *testAgent) expectAuth(t *testing.T) protocol.AuthRespMessage {
t.Helper()
var resp protocol.AuthRespMessage
if line := a.next(t); line != nil {
_ = json.Unmarshal(line, &resp)
}
return resp
}
func (a *testAgent) register(t *testing.T, msgType string, cfg protocol.TunnelConfig) protocol.RegisterRespMessage {
t.Helper()
if msgType == protocol.TypeUpdateTunnel {
a.send(t, protocol.UpdateTunnelMessage{Type: msgType, Tunnel: cfg})
} else {
a.send(t, protocol.RegisterTunnelMessage{Type: msgType, Tunnel: cfg})
}
var resp protocol.RegisterRespMessage
_ = json.Unmarshal(a.next(t), &resp)
return resp
}
// createInvite asks the server for an invite through the control stream
func (a *testAgent) createInvite(t *testing.T, tunnelID string, maxUses int) protocol.Invite {
t.Helper()
return a.createInviteWith(t, tunnelID, maxUses, "")
}
func (a *testAgent) createInviteWith(t *testing.T, tunnelID string, maxUses int, lease string) protocol.Invite {
t.Helper()
return a.createInviteFull(t, tunnelID, maxUses, lease, false)
}
func (a *testAgent) createInviteFull(t *testing.T, tunnelID string, maxUses int, lease string, approval bool) protocol.Invite {
t.Helper()
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "r1", TunnelID: tunnelID, ValidMinutes: 60, MaxUses: maxUses, LeaseDuration: lease, RequireApproval: approval})
var resp protocol.InviteRespMessage
_ = json.Unmarshal(a.next(t), &resp)
if !resp.Success {
t.Fatalf("create invite failed: %s", resp.Error)
}
return resp.Invite
}
var noRedirect = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
func postForm(t *testing.T, addr, code, name string) (int, string, string) {
t.Helper()
resp, err := noRedirect.PostForm("http://"+addr+"/auth", url.Values{"code": {code}, "name": {name}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, resp.Header.Get("Location"), string(b)
}
// freePort picks a port outside the OS ephemeral range (so outgoing connections cannot
// grab it between the check and the tunnel's bind) and verifies it is bindable on 0.0.0.0.
func freePort(t *testing.T) int {
t.Helper()
start := 20000 + rand.Intn(9000)
for i := 0; i < 1000; i++ {
p := 20000 + (start-20000+i)%9000
if ln, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", p)); err == nil {
ln.Close()
return p
}
}
t.Fatal("no free port")
return 0
}
func newTestManager() *Manager {
return NewManager(1024, 65535, testToken)
}
func tunnelCfg(id string, port int) protocol.TunnelConfig {
return protocol.TunnelConfig{ID: id, Name: id, ListenPort: port, Whitelist: []string{"*"}, Enabled: true, AuthCode: "code-" + id}
}
func hit(port int) string {
c, err := net.DialTimeout("tcp", "127.0.0.1:"+strconv.Itoa(port), time.Second)
if err != nil {
return "dial error"
}
defer c.Close()
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
b := make([]byte, 2)
n, _ := io.ReadFull(c, b)
return string(b[:n])
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("condition not met in time")
}
func TestWrongTokenIsRejected(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, "WRONG")
if resp := a.expectAuth(t); resp.Success {
t.Fatal("auth with wrong token succeeded")
}
port := freePort(t)
// 服务端在鉴权失败后会关闭连接,这里的写入允许失败
b, _ := json.Marshal(protocol.RegisterTunnelMessage{Type: protocol.TypeRegisterTunnel, Tunnel: tunnelCfg("evil", port)})
_, _ = a.w.Write(append(b, '\n'))
_ = a.w.Flush()
time.Sleep(200 * time.Millisecond)
if got := hit(port); got == "OK" {
t.Fatal("unauthenticated agent managed to open a tunnel")
}
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.tunnels) != 0 {
t.Fatalf("expected no tunnels, got %d", len(m.tunnels))
}
}
func TestPortProbeDoesNotBreakActiveSession(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
if !a.expectAuth(t).Success {
t.Fatal("auth failed")
}
// 模拟 nc -z、端口扫描、非 HTTP 垃圾数据
c, _ := net.Dial("tcp", addr)
c.Close()
c, _ = net.Dial("tcp", addr)
_, _ = c.Write([]byte("SSH-2.0-scanner\r\n"))
c.Close()
time.Sleep(200 * time.Millisecond)
port := freePort(t)
if resp := a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("b", port)); !resp.Success {
t.Fatalf("register failed: %s", resp.Error)
}
if got := hit(port); got != "OK" {
t.Fatalf("tunnel registered after port probe is broken: got %q", got)
}
}
func TestStaleSessionTeardownKeepsNewSessionTunnels(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
old := dialAgent(t, addr, testToken)
if !old.expectAuth(t).Success {
t.Fatal("auth failed")
}
port := freePort(t)
if resp := old.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port)); !resp.Success {
t.Fatal(resp.Error)
}
// 唤醒后 Agent 立刻重连并重新注册,旧会话随后才断开
fresh := dialAgent(t, addr, testToken)
if !fresh.expectAuth(t).Success {
t.Fatal("auth failed")
}
if resp := fresh.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port)); !resp.Success {
t.Fatalf("re-register on new session failed: %s", resp.Error)
}
old.sess.Close()
time.Sleep(300 * time.Millisecond)
if got := hit(port); got != "OK" {
t.Fatalf("tunnel lost after stale session teardown: got %q", got)
}
}
func TestUpdateKeepsConnectionsAndLeases(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
port := freePort(t)
cfg := tunnelCfg("a", port)
a.register(t, protocol.TypeRegisterTunnel, cfg)
m.mu.RLock()
tun := m.tunnels["a"]
m.mu.RUnlock()
tun.AddDynamicWhitelist("10.1.2.3", time.Now().Add(time.Hour))
cfg.Name = "renamed"
cfg.Whitelist = []string{"127.0.0.1"}
if resp := a.register(t, protocol.TypeUpdateTunnel, cfg); !resp.Success {
t.Fatal(resp.Error)
}
m.mu.RLock()
same := m.tunnels["a"] == tun
m.mu.RUnlock()
if !same {
t.Fatal("metadata update recreated the tunnel (would drop active connections)")
}
if got := tun.GetDynamicWhitelist(); len(got) != 1 || got[0] != "10.1.2.3" {
t.Fatalf("dynamic lease lost after update: %v", got)
}
if hit(port) != "OK" {
t.Fatal("tunnel unusable after update")
}
}
func TestUpdatePortConflictKeepsOldTunnel(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
p1, p2 := freePort(t), freePort(t)
a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", p1))
a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("b", p2))
moved := tunnelCfg("a", p2)
if resp := a.register(t, protocol.TypeUpdateTunnel, moved); resp.Success {
t.Fatal("moving onto an occupied port should fail")
}
if hit(p1) != "OK" {
t.Fatal("failed port change destroyed the original tunnel")
}
p3 := freePort(t)
if resp := a.register(t, protocol.TypeUpdateTunnel, tunnelCfg("a", p3)); !resp.Success {
t.Fatal(resp.Error)
}
if hit(p3) != "OK" {
t.Fatal("tunnel not reachable on new port")
}
if hit(p1) == "OK" {
t.Fatal("old port still served after move")
}
}
func TestUpdateOfUnknownTunnelRegistersIt(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
port := freePort(t)
if resp := a.register(t, protocol.TypeUpdateTunnel, tunnelCfg("x", port)); !resp.Success {
t.Fatal(resp.Error)
}
if hit(port) != "OK" {
t.Fatal("update of unknown tunnel did not register it")
}
}
func TestDisconnectCleansUpTunnels(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
port := freePort(t)
a.register(t, protocol.TypeRegisterTunnel, tunnelCfg("a", port))
a.sess.Close()
waitFor(t, func() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.active == nil && len(m.tunnels) == 0
})
}
func httpGet(t *testing.T, addr, path string, hdr map[string]string) (int, string) {
t.Helper()
req, _ := http.NewRequest("GET", "http://"+addr+path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
func TestInviteFlowIgnoresForwardedHeadersAndEscapesHTML(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := tunnelCfg("a", freePort(t))
cfg.Name = `<script>alert(1)</script>`
cfg.Whitelist = nil // 仅授权链接
a.register(t, protocol.TypeRegisterTunnel, cfg)
inv := a.createInvite(t, "a", 1)
if hit(cfg.ListenPort) == "OK" {
t.Fatal("link-only tunnel must reject unauthorised clients")
}
status, body := httpGet(t, addr, "/i/"+inv.Code, map[string]string{"X-Forwarded-For": "8.8.8.8"})
if status != 200 || !strings.Contains(body, `name="name"`) {
t.Fatalf("invite form not shown: %d", status)
}
if strings.Contains(body, "<script>") {
t.Fatal("tunnel name rendered without escaping")
}
status, _, body = postForm(t, addr, inv.Code, `<b>张三</b>`)
if status != 200 || !strings.Contains(body, "授权成功") {
t.Fatalf("submit failed: %d %s", status, body)
}
if strings.Contains(body, "<b>张三</b>") {
t.Fatal("name rendered without escaping")
}
leases := m.state.leaseList("a")
if len(leases) != 1 || leases[0].IP != "127.0.0.1" || leases[0].Name != "<b>张三</b>" || leases[0].Invite != inv.Code {
t.Fatalf("unexpected leases (X-Forwarded-For must be ignored): %+v", leases)
}
if hit(cfg.ListenPort) != "OK" {
t.Fatal("authorised client cannot connect")
}
// 同一设备再次打开同一链接不占用新名额
if status, _, _ := postForm(t, addr, inv.Code, "张三"); status != 200 {
t.Fatalf("same device reuse rejected: %d", status)
}
if uses := m.state.inviteList("a")[0].Uses; len(uses) != 1 {
t.Fatalf("reuse by same device consumed another slot: %+v", uses)
}
if status, _ := httpGet(t, addr, "/i/wrongcode", nil); status != 404 {
t.Fatalf("wrong code: status %d", status)
}
if status, _, _ := postForm(t, addr, inv.Code, " "); status != 400 {
t.Fatalf("empty name accepted: %d", status)
}
}
func TestLegacyFixedAuthCodeNoLongerWorks(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := tunnelCfg("a", freePort(t))
a.register(t, protocol.TypeRegisterTunnel, cfg)
if status, _ := httpGet(t, addr, "/auth?code="+cfg.AuthCode, nil); status != 404 {
t.Fatalf("legacy fixed code should be rejected, got %d", status)
}
}
func TestApprovalFlow(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := tunnelCfg("a", freePort(t))
cfg.Whitelist = nil
a.register(t, protocol.TypeRegisterTunnel, cfg)
inv := a.createInviteFull(t, "a", 1, "", true)
status, loc, _ := postForm(t, addr, inv.Code, "李四")
if status != http.StatusSeeOther || !strings.HasPrefix(loc, "/auth/status?id=") {
t.Fatalf("expected redirect to status page, got %d %q", status, loc)
}
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "等待管理员批准") {
t.Fatal("status page should show pending")
}
if hit(cfg.ListenPort) == "OK" {
t.Fatal("pending request must not grant access")
}
pending := m.state.pendingList("a")
if len(pending) != 1 || pending[0].Name != "李四" {
t.Fatalf("pending list: %+v", pending)
}
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: "a", RequestID: pending[0].ID, Approve: true})
waitFor(t, func() bool { return len(m.state.leaseList("a")) == 1 })
if _, body := httpGet(t, addr, loc, nil); !strings.Contains(body, "申请已批准") {
t.Fatal("status page should show approval")
}
if hit(cfg.ListenPort) != "OK" {
t.Fatal("approved client cannot connect")
}
// 拒绝流程
inv2 := a.createInviteFull(t, "a", 1, "", true)
m.state.removeLease("a", "")
_, loc2, _ := postForm(t, addr, inv2.Code, "王五")
id := strings.TrimPrefix(loc2, "/auth/status?id=")
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: "a", RequestID: id, Approve: false})
waitFor(t, func() bool {
r, ok := m.state.request(id)
return ok && r.Status == "rejected"
})
if _, body := httpGet(t, addr, loc2, nil); !strings.Contains(body, "申请未通过") {
t.Fatal("status page should show rejection")
}
}
func TestRevokeInviteRemovesItsLeases(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := tunnelCfg("a", freePort(t))
cfg.Whitelist = nil
a.register(t, protocol.TypeRegisterTunnel, cfg)
inv := a.createInvite(t, "a", 0)
postForm(t, addr, inv.Code, "张三")
if hit(cfg.ListenPort) != "OK" {
t.Fatal("grant failed")
}
a.send(t, protocol.RevokeInviteMessage{Type: protocol.TypeRevokeInvite, TunnelID: "a", Code: inv.Code})
waitFor(t, func() bool { return len(m.state.leaseList("a")) == 0 && len(m.state.inviteList("a")) == 0 })
if hit(cfg.ListenPort) == "OK" {
t.Fatal("leases of revoked invite still active")
}
if status, _ := httpGet(t, addr, "/i/"+inv.Code, nil); status != 404 {
t.Fatal("revoked invite still usable")
}
}
func TestApprovalIsPerInvite(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := tunnelCfg("a", freePort(t))
cfg.Whitelist = nil
a.register(t, protocol.TypeRegisterTunnel, cfg)
// 同一通道:需要审批的邀请进入待审批,不需要审批的直接放通
needs := a.createInviteFull(t, "a", 1, "", true)
direct := a.createInviteFull(t, "a", 1, "", false)
if status, loc, _ := postForm(t, addr, needs.Code, "甲"); status != http.StatusSeeOther || !strings.HasPrefix(loc, "/auth/status") {
t.Fatalf("approval invite should go to pending, got %d %q", status, loc)
}
if hit(cfg.ListenPort) == "OK" {
t.Fatal("pending request granted access")
}
m.state.removeLease("a", "")
if status, _, body := postForm(t, addr, direct.Code, "乙"); status != 200 || !strings.Contains(body, "授权成功") {
t.Fatalf("direct invite should grant immediately, got %d", status)
}
if hit(cfg.ListenPort) != "OK" {
t.Fatal("direct invite did not grant access")
}
}
func TestGrantAfterRevokeAllDoesNotPanic(t *testing.T) {
s := newStateStore()
inv := s.createInvite("a", "", time.Hour, 0, "", false)
if _, err := s.useInviteAndGrant("a", inv.Code, "甲", "10.0.0.1", nil); err != nil {
t.Fatal(err)
}
s.removeLease("a", "") // 全部撤销
if _, err := s.useInviteAndGrant("a", inv.Code, "乙", "10.0.0.2", nil); err != nil {
t.Fatal(err)
}
if l := s.leaseList("a"); len(l) != 1 || l[0].IP != "10.0.0.2" {
t.Fatalf("grant after revoke-all failed: %+v", l)
}
}
func TestExpiredLeaseDisconnectsActiveConnection(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := tunnelCfg("a", freePort(t))
cfg.Whitelist = nil
a.register(t, protocol.TypeRegisterTunnel, cfg)
m.mu.RLock()
tun := m.tunnels["a"]
m.mu.RUnlock()
tun.AddDynamicWhitelist("127.0.0.1", time.Now().Add(1500*time.Millisecond))
c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.ListenPort))
if err != nil {
t.Fatal(err)
}
defer c.Close()
buf := make([]byte, 2)
_ = c.SetReadDeadline(time.Now().Add(time.Second))
if _, err := io.ReadFull(c, buf); err != nil {
t.Fatalf("leased client should connect: %v", err)
}
// 授权到期后,连接应在下一次检查(约 1 秒)内被断开
_ = c.SetReadDeadline(time.Now().Add(4 * time.Second))
start := time.Now()
_, err = c.Read(buf)
if err == nil {
t.Fatal("connection still alive after lease expired")
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
t.Fatalf("connection was not closed within %v of lease expiry", time.Since(start))
}
}
func TestLeaseCountdownStartsAtFirstConnection(t *testing.T) {
s := newStateStore()
now := time.Now()
inv := s.createInvite("a", "", time.Hour, 1, protocol.LeaseDuration2h, false)
l, err := s.useInviteAndGrant("a", inv.Code, "甲", "10.0.0.1", nil)
if err != nil {
t.Fatal(err)
}
if !l.Deferred || l.StartedAt != nil || l.ExpiresAt.Sub(now) < 23*time.Hour {
t.Fatalf("new lease should wait for first connection within 24h: %+v", l)
}
// 1 小时后才首次连接:从那时起算 2 小时
later := now.Add(time.Hour)
exp, started := s.startLease("a", "10.0.0.1", nil, later)
if !started || !exp.Equal(later.Add(2*time.Hour)) {
t.Fatalf("countdown should start at first connection: started=%v exp=%v", started, exp)
}
// 再次连接不会重置计时
if _, again := s.startLease("a", "10.0.0.1", nil, later.Add(30*time.Minute)); again {
t.Fatal("countdown must only start once")
}
// 通道开启期间有效:立即开始,无需等待
inv2 := s.createInvite("a", "", time.Hour, 1, protocol.LeaseDurationTunnel, false)
l2, _ := s.useInviteAndGrant("a", inv2.Code, "乙", "10.0.0.2", nil)
if l2.Deferred {
t.Fatal("tunnel-lifetime lease should start immediately")
}
// 首次连接截止时间不超过通道自身到期时间
tunnelEnd := now.Add(3 * time.Hour)
inv3 := s.createInvite("a", "", time.Hour, 1, "", false)
l3, _ := s.useInviteAndGrant("a", inv3.Code, "丙", "10.0.0.3", &tunnelEnd)
if !l3.ExpiresAt.Equal(tunnelEnd) {
t.Fatalf("start window should be capped by tunnel TTL: %v", l3.ExpiresAt)
}
}
func TestInviteCapacityAndExpiry(t *testing.T) {
s := newStateStore()
inv := s.createInvite("a", "", time.Hour, 1, "", false)
if _, err := s.useInviteAndGrant("a", inv.Code, "甲", "10.0.0.1", nil); err != nil {
t.Fatal(err)
}
if _, err := s.useInviteAndGrant("a", inv.Code, "甲", "10.0.0.1", nil); err != nil {
t.Fatalf("same device should be able to reuse: %v", err)
}
if _, err := s.useInviteAndGrant("a", inv.Code, "乙", "10.0.0.2", nil); err != errInviteUsedUp {
t.Fatalf("second device should be rejected, got %v", err)
}
// 待审批的申请也占用名额
inv2 := s.createInvite("a", "", time.Hour, 1, "", false)
if _, err := s.addRequest("a", inv2.Code, "丙", "10.0.0.3"); err != nil {
t.Fatal(err)
}
if _, err := s.addRequest("a", inv2.Code, "丁", "10.0.0.4"); err != errInviteUsedUp {
t.Fatalf("pending request should hold the slot, got %v", err)
}
expired := s.createInvite("a", "", -time.Minute, 0, "", false)
if _, _, ok := s.findInvite(expired.Code); ok {
t.Fatal("expired invite must not be found")
}
}
func TestLeaseExpiryModes(t *testing.T) {
now := time.Date(2026, 10, 13, 10, 0, 0, 0, time.Local)
cases := map[string]time.Time{
"": now.Add(2 * time.Hour),
protocol.LeaseDuration1h: now.Add(time.Hour),
protocol.LeaseDuration8h: now.Add(8 * time.Hour),
protocol.LeaseDurationToday: time.Date(2026, 10, 13, 23, 59, 59, 0, time.Local),
protocol.LeaseDurationTunnel: now.Add(30 * 24 * time.Hour),
}
for mode, want := range cases {
if got := leaseExpiry(mode, nil, now); !got.Equal(want) {
t.Errorf("mode %q: got %v want %v", mode, got, want)
}
}
// 不超过通道自身的到期时间
tunnelEnd := now.Add(time.Hour)
if got := leaseExpiry(protocol.LeaseDuration8h, &tunnelEnd, now); !got.Equal(tunnelEnd) {
t.Errorf("lease outlives tunnel TTL: %v", got)
}
}
func TestLeaseDurationComesFromInvite(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
// 直接放通:同一通道上两个邀请各自的时长
cfg := tunnelCfg("a", freePort(t))
cfg.Whitelist = nil
a.register(t, protocol.TypeRegisterTunnel, cfg)
short := a.createInviteWith(t, "a", 1, protocol.LeaseDuration1h)
long := a.createInviteWith(t, "a", 1, protocol.LeaseDurationToday)
if short.LeaseDuration != "1h" || long.LeaseDuration != "today" {
t.Fatalf("invite lease durations not stored: %q %q", short.LeaseDuration, long.LeaseDuration)
}
if _, body := httpGet(t, addr, "/i/"+short.Code, nil); !strings.Contains(body, "1 小时") {
t.Fatal("invite page should show the invite's own lease duration")
}
postForm(t, addr, short.Code, "张三")
leases := m.state.leaseList("a")
if len(leases) != 1 || !leases[0].AwaitingConnect || time.Until(leases[0].ExpiresAt) < 23*time.Hour {
t.Fatalf("lease should wait for the first connection (24h window): %+v", leases)
}
// 首次连接后开始计时,按邀请的 1 小时计算
if hit(cfg.ListenPort) != "OK" {
t.Fatal("authorised client cannot connect")
}
waitFor(t, func() bool { l := m.state.leaseList("a"); return len(l) == 1 && !l[0].AwaitingConnect })
leases = m.state.leaseList("a")
if d := time.Until(leases[0].ExpiresAt); d > 61*time.Minute || d < 59*time.Minute || leases[0].StartedAt == nil {
t.Fatalf("lease should last 1h from first connection: %+v", leases)
}
// 审批:批准后的时长同样取自邀请
cfgB := tunnelCfg("b", freePort(t))
cfgB.Whitelist = nil
a.register(t, protocol.TypeRegisterTunnel, cfgB)
inv := a.createInviteFull(t, "b", 1, protocol.LeaseDuration8h, true)
postForm(t, addr, inv.Code, "李四")
pending := m.state.pendingList("b")
if len(pending) != 1 {
t.Fatal("expected a pending request")
}
a.send(t, protocol.DecideRequestMessage{Type: protocol.TypeDecideRequest, TunnelID: "b", RequestID: pending[0].ID, Approve: true})
waitFor(t, func() bool { return len(m.state.leaseList("b")) == 1 })
if !m.state.leaseList("b")[0].AwaitingConnect {
t.Fatal("approved lease should wait for the first connection")
}
if hit(cfgB.ListenPort) != "OK" {
t.Fatal("approved client cannot connect")
}
waitFor(t, func() bool { return !m.state.leaseList("b")[0].AwaitingConnect })
if d := time.Until(m.state.leaseList("b")[0].ExpiresAt); d < 7*time.Hour+50*time.Minute || d > 8*time.Hour+time.Minute {
t.Fatalf("approved lease should last 8h from first connection, got %v", d)
}
// 无效时长被拒绝
a.send(t, protocol.CreateInviteMessage{Type: protocol.TypeCreateInvite, RequestID: "bad", TunnelID: "a", ValidMinutes: 60, MaxUses: 1, LeaseDuration: "99y"})
var resp protocol.InviteRespMessage
_ = json.Unmarshal(a.next(t), &resp)
if resp.Success {
t.Fatal("invalid lease duration should be rejected")
}
}
func TestStateSurvivesRestartAndLegacyFormat(t *testing.T) {
dir := t.TempDir()
port := freePort(t)
cfg := tunnelCfg("a", port)
cfg.Whitelist = nil
state := dir + "/state.json"
m1 := newTestManager()
if err := m1.EnableLeasePersistence(state); err != nil {
t.Fatal(err)
}
addr1 := startServer(t, m1)
a1 := dialAgent(t, addr1, testToken)
a1.expectAuth(t)
a1.register(t, protocol.TypeRegisterTunnel, cfg)
inv := a1.createInvite(t, "a", 2)
postForm(t, addr1, inv.Code, "张三")
a1.sess.Close()
waitFor(t, func() bool {
m1.mu.RLock()
defer m1.mu.RUnlock()
return len(m1.tunnels) == 0
})
// 模拟服务端重启:新 Manager 读取同一状态文件,Agent 重新注册后放通与邀请仍然有效
m2 := newTestManager()
if err := m2.EnableLeasePersistence(state); err != nil {
t.Fatal(err)
}
addr2 := startServer(t, m2)
a2 := dialAgent(t, addr2, testToken)
a2.expectAuth(t)
if resp := a2.register(t, protocol.TypeRegisterTunnel, cfg); !resp.Success {
t.Fatal(resp.Error)
}
if got := hit(port); got != "OK" {
t.Fatalf("lease lost after restart: got %q", got)
}
if invs := m2.state.inviteList("a"); len(invs) != 1 || len(invs[0].Uses) != 1 || invs[0].Uses[0].Name != "张三" {
t.Fatalf("invite lost after restart: %+v", invs)
}
// 主动关闭通道后放通与邀请都失效
a2.send(t, protocol.UnregisterMessage{Type: protocol.TypeUnregister, TunnelID: "a"})
a2.next(t)
a2.register(t, protocol.TypeRegisterTunnel, cfg)
if got := hit(port); got == "OK" {
t.Fatal("lease should be dropped when the tunnel is explicitly closed")
}
if len(m2.state.inviteList("a")) != 0 {
t.Fatal("invites should be dropped when the tunnel is explicitly closed")
}
// 兼容上一版的 leases.json 格式
legacy := dir + "/legacy.json"
exp := time.Now().Add(time.Hour).Format(time.RFC3339Nano)
if err := os.WriteFile(legacy, []byte(`{"t1":{"10.0.0.9":"`+exp+`"}}`), 0600); err != nil {
t.Fatal(err)
}
m3 := newTestManager()
if err := m3.EnableLeasePersistence(legacy); err != nil {
t.Fatal(err)
}
if l := m3.state.leaseList("t1"); len(l) != 1 || l[0].IP != "10.0.0.9" {
t.Fatalf("legacy lease file not loaded: %+v", l)
}
}
func TestAuthPageShowsHostUsedByVisitor(t *testing.T) {
m := newTestManager()
addr := startServer(t, m)
a := dialAgent(t, addr, testToken)
a.expectAuth(t)
cfg := tunnelCfg("a", freePort(t))
a.register(t, protocol.TypeRegisterTunnel, cfg)
inv := a.createInvite(t, "a", 1)
_, body := httpGet(t, addr, "/i/"+inv.Code, nil)
if !strings.Contains(body, fmt.Sprintf("127.0.0.1:%d", cfg.ListenPort)) {
t.Fatal("auth page should show the host the visitor used")
}
if strings.Contains(body, "192.168.") {
t.Fatal("auth page must not show a hard-coded host")
}
}
+604
View File
@@ -0,0 +1,604 @@
package server
import (
"crypto/rand"
"encoding/json"
"errors"
"io/fs"
"log"
"math/big"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/DeepFal/PortRelay/core/protocol"
)
const (
// 待审批申请的有效期;已处理的申请保留同样时长,供申请人页面查询结果
requestTTL = 30 * time.Minute
inviteCodeLen = 10
// leaseStartWindow 授权后需在多久内首次连接;授权时长从首次连接开始计算
leaseStartWindow = 24 * time.Hour
)
var (
errInviteNotFound = errors.New("邀请链接无效或已过期")
errInviteUsedUp = errors.New("该邀请链接的可用设备数已用完,请向管理员索取新的链接")
errRequestGone = errors.New("申请不存在或已过期")
)
type leaseInfo struct {
ExpiresAt time.Time `json:"expires_at"` // 计时开始前:首次连接截止时间;开始后:授权到期时间
Name string `json:"name,omitempty"`
Invite string `json:"invite,omitempty"`
Duration string `json:"duration,omitempty"`
// Deferred 为 true 表示尚未首次连接,计时未开始
Deferred bool `json:"deferred,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
}
// newLease creates a lease whose countdown starts at the device's first connection.
// Leases valid for the whole tunnel lifetime start immediately.
func newLease(name, invite, duration string, tunnelExpires *time.Time, now time.Time) leaseInfo {
l := leaseInfo{Name: name, Invite: invite, Duration: duration}
if duration == protocol.LeaseDurationTunnel {
l.StartedAt = &now
l.ExpiresAt = leaseExpiry(duration, tunnelExpires, now)
return l
}
l.Deferred = true
l.ExpiresAt = now.Add(leaseStartWindow)
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(l.ExpiresAt) {
l.ExpiresAt = *tunnelExpires
}
return l
}
// startLease starts the countdown of a deferred lease on the device's first connection.
// Returns the new expiry and true when the lease was started now.
func (s *stateStore) startLease(tunnelID, ip string, tunnelExpires *time.Time, now time.Time) (time.Time, bool) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
if st == nil {
return time.Time{}, false
}
l, ok := st.Leases[ip]
if !ok || !l.Deferred || !now.Before(l.ExpiresAt) {
return time.Time{}, false
}
l.Deferred = false
l.StartedAt = &now
l.ExpiresAt = leaseExpiry(l.Duration, tunnelExpires, now)
st.Leases[ip] = l
s.saveLocked()
return l.ExpiresAt, true
}
func (s *stateStore) lease(tunnelID, ip string) (leaseInfo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if st := s.data[tunnelID]; st != nil {
l, ok := st.Leases[ip]
return l, ok && time.Now().Before(l.ExpiresAt)
}
return leaseInfo{}, false
}
type tunnelState struct {
Leases map[string]leaseInfo `json:"leases,omitempty"` // ip -> lease
Invites map[string]*protocol.Invite `json:"invites,omitempty"` // code -> invite
Requests map[string]*protocol.AccessRequest `json:"requests,omitempty"` // id -> request
}
// stateStore keeps per-tunnel access state (leases, invites, approval requests).
// It lives in memory and is optionally persisted to a file so it survives restarts.
type stateStore struct {
path string
mu sync.Mutex
data map[string]*tunnelState // tunnelID -> state
}
func newStateStore() *stateStore {
return &stateStore{data: make(map[string]*tunnelState)}
}
// EnableLeasePersistence loads access state from path and keeps it updated
func (m *Manager) EnableLeasePersistence(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
raw, err := os.ReadFile(path)
data := make(map[string]*tunnelState)
switch {
case err == nil:
if err := json.Unmarshal(raw, &data); err != nil || !looksLikeState(data) {
// 兼容旧格式:tunnelID -> ip -> 到期时间
var legacy map[string]map[string]time.Time
if json.Unmarshal(raw, &legacy) == nil {
data = make(map[string]*tunnelState)
for id, ips := range legacy {
st := &tunnelState{Leases: make(map[string]leaseInfo)}
for ip, exp := range ips {
st.Leases[ip] = leaseInfo{ExpiresAt: exp}
}
data[id] = st
}
} else {
log.Printf("[WebAuth] ignoring unreadable state file %s: %v", path, err)
data = make(map[string]*tunnelState)
}
}
case !errors.Is(err, fs.ErrNotExist):
return err
}
s := m.state
s.mu.Lock()
s.path = path
s.data = data
s.pruneLocked()
n := 0
for _, st := range s.data {
n += len(st.Leases)
}
s.mu.Unlock()
if n > 0 {
log.Printf("[WebAuth] Loaded %d unexpired temporary lease(s) from %s", n, path)
}
return nil
}
// looksLikeState distinguishes the new format from the legacy one (both are JSON objects of objects)
func looksLikeState(d map[string]*tunnelState) bool {
for _, st := range d {
if st == nil || (st.Leases == nil && st.Invites == nil && st.Requests == nil) {
return false
}
}
return true
}
func (s *stateStore) tunnelLocked(id string) *tunnelState {
st := s.data[id]
if st == nil {
st = &tunnelState{}
s.data[id] = st
}
if st.Leases == nil {
st.Leases = make(map[string]leaseInfo)
}
if st.Invites == nil {
st.Invites = make(map[string]*protocol.Invite)
}
if st.Requests == nil {
st.Requests = make(map[string]*protocol.AccessRequest)
}
return st
}
func randomCode(n int) string {
const chars = "23456789abcdefghjkmnpqrstuvwxyz"
b := make([]byte, n)
for i := range b {
v, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
panic(err)
}
b[i] = chars[v.Int64()]
}
return string(b)
}
// ---- leases ----
func (s *stateStore) grantLease(tunnelID, ip string, exp time.Time, name, invite string) {
s.mu.Lock()
defer s.mu.Unlock()
s.tunnelLocked(tunnelID).Leases[ip] = leaseInfo{ExpiresAt: exp, Name: name, Invite: invite}
s.saveLocked()
}
// removeLease removes one lease, or all leases of the tunnel when ip is empty
func (s *stateStore) removeLease(tunnelID, ip string) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
if st == nil {
return
}
if ip == "" {
st.Leases = make(map[string]leaseInfo)
} else {
delete(st.Leases, ip)
}
s.saveLocked()
}
func (s *stateStore) leaseExpiries(tunnelID string) map[string]time.Time {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
out := make(map[string]time.Time)
if st := s.data[tunnelID]; st != nil {
for ip, l := range st.Leases {
if now.Before(l.ExpiresAt) {
out[ip] = l.ExpiresAt
}
}
}
return out
}
func (s *stateStore) leaseList(tunnelID string) []protocol.DynamicLease {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
var out []protocol.DynamicLease
if st := s.data[tunnelID]; st != nil {
for ip, l := range st.Leases {
if now.Before(l.ExpiresAt) {
out = append(out, protocol.DynamicLease{
IP: ip, ExpiresAt: l.ExpiresAt, Name: l.Name, Invite: l.Invite,
Duration: l.Duration, AwaitingConnect: l.Deferred, StartedAt: l.StartedAt,
})
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) })
return out
}
// forgetTunnel drops all access state of a tunnel (explicitly closed or deleted)
func (s *stateStore) forgetTunnel(tunnelID string) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.data[tunnelID]; ok {
delete(s.data, tunnelID)
s.saveLocked()
}
}
// ---- invites ----
func (s *stateStore) createInvite(tunnelID, note string, valid time.Duration, maxUses int, leaseDuration string, requireApproval bool) protocol.Invite {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if leaseDuration == "" {
leaseDuration = protocol.LeaseDuration2h
}
inv := &protocol.Invite{
Code: randomCode(inviteCodeLen),
Note: note,
CreatedAt: now,
ExpiresAt: now.Add(valid),
MaxUses: maxUses,
LeaseDuration: leaseDuration,
RequireApproval: requireApproval,
}
s.tunnelLocked(tunnelID).Invites[inv.Code] = inv
s.saveLocked()
return *inv
}
// findInvite returns the tunnel owning an unexpired invite
func (s *stateStore) findInvite(code string) (string, protocol.Invite, bool) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for id, st := range s.data {
if inv, ok := st.Invites[code]; ok && now.Before(inv.ExpiresAt) {
return id, *inv, true
}
}
return "", protocol.Invite{}, false
}
// capacityOKLocked reports whether ip may use the invite: devices that already used it
// (or have a pending request through it) may come back; new devices need a free slot.
func capacityOKLocked(st *tunnelState, inv *protocol.Invite, ip string) bool {
if inv.MaxUses <= 0 {
return true
}
devices := make(map[string]bool)
for _, u := range inv.Uses {
devices[u.IP] = true
}
for _, r := range st.Requests {
if r.Invite == inv.Code && r.Status == "pending" {
devices[r.IP] = true
}
}
return devices[ip] || len(devices) < inv.MaxUses
}
// checkInvite validates that ip may use the invite right now
func (s *stateStore) checkInvite(tunnelID, code, ip string) error {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
if st == nil {
return errInviteNotFound
}
inv, ok := st.Invites[code]
if !ok || !time.Now().Before(inv.ExpiresAt) {
return errInviteNotFound
}
if !capacityOKLocked(st, inv, ip) {
return errInviteUsedUp
}
return nil
}
func (s *stateStore) recordUseLocked(st *tunnelState, code, name, ip string) {
inv, ok := st.Invites[code]
if !ok {
return
}
for i, u := range inv.Uses {
if u.IP == ip {
inv.Uses[i] = protocol.InviteUse{Name: name, IP: ip, At: time.Now()}
return
}
}
inv.Uses = append(inv.Uses, protocol.InviteUse{Name: name, IP: ip, At: time.Now()})
}
// useInviteAndGrant records the use and stores the lease atomically; returns the lease
func (s *stateStore) useInviteAndGrant(tunnelID, code, name, ip string, tunnelExpires *time.Time) (leaseInfo, error) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
if st == nil {
return leaseInfo{}, errInviteNotFound
}
inv, ok := st.Invites[code]
if !ok || !time.Now().Before(inv.ExpiresAt) {
return leaseInfo{}, errInviteNotFound
}
if !capacityOKLocked(st, inv, ip) {
return leaseInfo{}, errInviteUsedUp
}
st = s.tunnelLocked(tunnelID)
s.recordUseLocked(st, code, name, ip)
l := newLease(name, code, inv.LeaseDuration, tunnelExpires, time.Now())
st.Leases[ip] = l
s.saveLocked()
return l, nil
}
// revokeInvite removes the invite and the leases it granted; returns the affected IPs
func (s *stateStore) revokeInvite(tunnelID, code string) []string {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
if st == nil {
return nil
}
delete(st.Invites, code)
var ips []string
for ip, l := range st.Leases {
if l.Invite == code {
ips = append(ips, ip)
delete(st.Leases, ip)
}
}
for id, r := range st.Requests {
if r.Invite == code && r.Status == "pending" {
delete(st.Requests, id)
}
}
s.saveLocked()
return ips
}
func (s *stateStore) inviteList(tunnelID string) []protocol.Invite {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
var out []protocol.Invite
if st := s.data[tunnelID]; st != nil {
for _, inv := range st.Invites {
if now.Before(inv.ExpiresAt) {
c := *inv
c.Uses = append([]protocol.InviteUse(nil), inv.Uses...)
out = append(out, c)
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out
}
// ---- approval requests ----
// addRequest files a pending request (re-using an existing pending one from the same IP)
func (s *stateStore) addRequest(tunnelID, code, name, ip string) (protocol.AccessRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.data[tunnelID]
if st == nil {
return protocol.AccessRequest{}, errInviteNotFound
}
inv, ok := st.Invites[code]
if !ok || !time.Now().Before(inv.ExpiresAt) {
return protocol.AccessRequest{}, errInviteNotFound
}
st = s.tunnelLocked(tunnelID)
for _, r := range st.Requests {
if r.IP == ip && r.Status == "pending" {
r.Name = name
s.saveLocked()
return *r, nil
}
}
if !capacityOKLocked(st, inv, ip) {
return protocol.AccessRequest{}, errInviteUsedUp
}
r := &protocol.AccessRequest{
ID: randomCode(16),
TunnelID: tunnelID,
Name: name,
IP: ip,
Invite: code,
At: time.Now(),
Status: "pending",
}
st.Requests[r.ID] = r
s.saveLocked()
return *r, nil
}
func (s *stateStore) request(id string) (protocol.AccessRequest, bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneRequestsLocked()
for _, st := range s.data {
if r, ok := st.Requests[id]; ok {
return *r, true
}
}
return protocol.AccessRequest{}, false
}
// decide marks a pending request; on approval it records the invite use and stores the lease,
// lasting as long as the originating invite specifies (capped by the tunnel's own TTL).
func (s *stateStore) decide(tunnelID, id string, approve bool, tunnelExpires *time.Time) (protocol.AccessRequest, time.Time, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneRequestsLocked()
st := s.data[tunnelID]
if st == nil {
return protocol.AccessRequest{}, time.Time{}, errRequestGone
}
r, ok := st.Requests[id]
if !ok || r.Status != "pending" {
return protocol.AccessRequest{}, time.Time{}, errRequestGone
}
st = s.tunnelLocked(tunnelID)
duration := ""
if inv, ok := st.Invites[r.Invite]; ok {
duration = inv.LeaseDuration
}
l := newLease(r.Name, r.Invite, duration, tunnelExpires, time.Now())
exp := l.ExpiresAt
r.At = time.Now()
if approve {
r.Status = "approved"
s.recordUseLocked(st, r.Invite, r.Name, r.IP)
st.Leases[r.IP] = l
} else {
r.Status = "rejected"
}
s.saveLocked()
return *r, exp, nil
}
func (s *stateStore) pendingList(tunnelID string) []protocol.AccessRequest {
s.mu.Lock()
defer s.mu.Unlock()
s.pruneRequestsLocked()
var out []protocol.AccessRequest
if st := s.data[tunnelID]; st != nil {
for _, r := range st.Requests {
if r.Status == "pending" {
out = append(out, *r)
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
return out
}
// ---- housekeeping ----
func (s *stateStore) pruneRequestsLocked() {
cutoff := time.Now().Add(-requestTTL)
for _, st := range s.data {
for id, r := range st.Requests {
if r.At.Before(cutoff) {
delete(st.Requests, id)
}
}
}
}
func (s *stateStore) pruneLocked() {
now := time.Now()
s.pruneRequestsLocked()
for id, st := range s.data {
if st == nil {
delete(s.data, id)
continue
}
for ip, l := range st.Leases {
if !now.Before(l.ExpiresAt) {
delete(st.Leases, ip)
}
}
for code, inv := range st.Invites {
if !now.Before(inv.ExpiresAt) {
delete(st.Invites, code)
}
}
if len(st.Leases) == 0 && len(st.Invites) == 0 && len(st.Requests) == 0 {
delete(s.data, id)
}
}
}
func (s *stateStore) saveLocked() {
s.pruneLocked()
if s.path == "" {
return
}
data, err := json.Marshal(s.data)
if err != nil {
return
}
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".state-*.json")
if err != nil {
log.Printf("[WebAuth] save state: %v", err)
return
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
log.Printf("[WebAuth] save state: %v", err)
return
}
if err := tmp.Close(); err != nil {
return
}
if err := os.Rename(tmp.Name(), s.path); err != nil {
log.Printf("[WebAuth] save state: %v", err)
}
}
// leaseExpiry computes when a lease granted now ends, per the invite's lease duration,
// never outliving the tunnel's own TTL.
func leaseExpiry(duration string, tunnelExpires *time.Time, now time.Time) time.Time {
var exp time.Time
switch duration {
case protocol.LeaseDuration1h:
exp = now.Add(time.Hour)
case protocol.LeaseDuration8h:
exp = now.Add(8 * time.Hour)
case protocol.LeaseDurationToday:
y, mo, d := now.Date()
exp = time.Date(y, mo, d, 23, 59, 59, 0, now.Location())
case protocol.LeaseDurationTunnel:
// 通道开启期间有效:通道被主动关闭时放通会被清除;设一个上限避免永久放通
exp = now.Add(30 * 24 * time.Hour)
default:
exp = now.Add(2 * time.Hour)
}
if tunnelExpires != nil && tunnelExpires.After(now) && tunnelExpires.Before(exp) {
exp = *tunnelExpires
}
return exp
}
+451
View File
@@ -0,0 +1,451 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/hashicorp/yamux"
"github.com/DeepFal/PortRelay/core/acl"
"github.com/DeepFal/PortRelay/core/protocol"
"github.com/DeepFal/PortRelay/core/traffic"
)
// ActiveClient records a live client TCP connection
type ActiveClient struct {
ID string
ClientIP string
ConnectedAt time.Time
Conn net.Conn
Meter *traffic.Meter
}
// Tunnel manages a single edge listening port
type Tunnel struct {
Config protocol.TunnelConfig
listener net.Listener
whitelist *acl.Whitelist
limiter *traffic.Limiter
mu sync.RWMutex
activeConns map[string]*ActiveClient
totalConns atomic.Int64
blockedAttemps atomic.Int64
meter *traffic.Meter
closed chan struct{}
closeOnce sync.Once
session *yamux.Session
onSecEvent func(ev protocol.SecurityEvent)
// onConnect 在客户端通过访问控制、连接建立时调用(用于开始授权计时)
onConnect func(tunnelID, ip string)
}
// NewTunnel creates and binds a new tunnel listener
func NewTunnel(cfg protocol.TunnelConfig, session *yamux.Session, onSecEvent func(protocol.SecurityEvent)) (*Tunnel, error) {
return newTunnel(cfg, session, onSecEvent, nil, nil)
}
// newTunnel binds a listener; when prev is set, its traffic counters and dynamic leases carry over
// (used when the same tunnel is re-registered or moved to another port).
func newTunnel(cfg protocol.TunnelConfig, session *yamux.Session, onSecEvent func(protocol.SecurityEvent), onConnect func(tunnelID, ip string), prev *Tunnel) (*Tunnel, error) {
addr := fmt.Sprintf("0.0.0.0:%d", cfg.ListenPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("failed to bind port %d: %w", cfg.ListenPort, err)
}
t := &Tunnel{
Config: cfg,
listener: ln,
whitelist: acl.NewWhitelist(cfg.Whitelist),
limiter: traffic.NewLimiter(cfg.MaxBandwidthMB),
activeConns: make(map[string]*ActiveClient),
meter: traffic.NewMeter(),
closed: make(chan struct{}),
session: session,
onSecEvent: onSecEvent,
onConnect: onConnect,
}
if prev != nil {
t.meter = prev.meter
t.totalConns.Store(prev.totalConns.Load())
t.blockedAttemps.Store(prev.blockedAttemps.Load())
t.whitelist.RestoreDynamic(prev.dynamicLeases())
}
go t.acceptLoop()
return t, nil
}
// UpdateConfig updates whitelist, rate limiting and metadata dynamically (existing connections are kept)
func (t *Tunnel) UpdateConfig(cfg protocol.TunnelConfig) {
t.mu.Lock()
defer t.mu.Unlock()
dynamic := t.whitelist.DynamicLeases()
t.Config = cfg
t.whitelist = acl.NewWhitelist(cfg.Whitelist)
t.whitelist.RestoreDynamic(dynamic)
if t.limiter == nil || t.limiter.MBPerSec() != cfg.MaxBandwidthMB {
t.limiter = traffic.NewLimiter(cfg.MaxBandwidthMB)
}
}
// ListenPort returns the bound port
func (t *Tunnel) ListenPort() int {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Config.ListenPort
}
// Snapshot returns a copy of the current tunnel config
func (t *Tunnel) Snapshot() protocol.TunnelConfig {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Config
}
// ID returns the tunnel ID
func (t *Tunnel) ID() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Config.ID
}
func (t *Tunnel) restoreDynamicLeases(leases map[string]time.Time) {
if len(leases) == 0 {
return
}
t.mu.RLock()
defer t.mu.RUnlock()
t.whitelist.RestoreDynamic(leases)
}
// Name returns the tunnel display name
func (t *Tunnel) Name() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Config.Name
}
// AuthCode returns the web auth code
func (t *Tunnel) AuthCode() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Config.AuthCode
}
// AddDynamicWhitelist grants temporary IP access
func (t *Tunnel) AddDynamicWhitelist(ip string, exp time.Time) {
t.mu.RLock()
defer t.mu.RUnlock()
t.whitelist.AddDynamic(ip, exp)
}
// GetDynamicWhitelist returns active granted IPs
func (t *Tunnel) GetDynamicWhitelist() []string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.whitelist.GetDynamic()
}
// RevokeDynamic removes temporary leases (all when ip is empty) and disconnects
// active clients that are no longer permitted by the remaining rules.
func (t *Tunnel) RevokeDynamic(ip string) (revoked, kicked int) {
t.mu.Lock()
defer t.mu.Unlock()
revoked = t.whitelist.RemoveDynamic(ip)
if revoked == 0 {
return 0, 0
}
for id, ac := range t.activeConns {
if ok, _ := t.whitelist.IsAllowed(ac.ClientIP); !ok {
_ = ac.Conn.Close()
delete(t.activeConns, id)
kicked++
}
}
return revoked, kicked
}
// EnforceAccess disconnects active clients that are no longer permitted — e.g. their
// temporary lease expired or the whitelist changed. Returns the disconnected client addresses.
func (t *Tunnel) EnforceAccess() []string {
t.mu.Lock()
defer t.mu.Unlock()
var kicked []string
for id, ac := range t.activeConns {
if ok, _ := t.whitelist.IsAllowed(ac.ClientIP); !ok {
_ = ac.Conn.Close()
delete(t.activeConns, id)
kicked = append(kicked, ac.ClientIP)
}
}
return kicked
}
func (t *Tunnel) dynamicLeases() map[string]time.Time {
t.mu.RLock()
defer t.mu.RUnlock()
return t.whitelist.DynamicLeases()
}
func (t *Tunnel) acceptLoop() {
var backoff time.Duration
for {
clientConn, err := t.listener.Accept()
if err != nil {
select {
case <-t.closed:
return
default:
}
if errors.Is(err, net.ErrClosed) {
return
}
// 临时错误(如文件句柄耗尽)退避后继续,不能让监听永久退出
if backoff == 0 {
backoff = 5 * time.Millisecond
} else if backoff *= 2; backoff > time.Second {
backoff = time.Second
}
log.Printf("[Tunnel %d] accept error: %v; retrying in %v", t.ListenPort(), err, backoff)
select {
case <-t.closed:
return
case <-time.After(backoff):
}
continue
}
backoff = 0
go t.handleClient(clientConn)
}
}
func (t *Tunnel) handleClient(clientConn net.Conn) {
remoteAddr := clientConn.RemoteAddr().String()
// 1. Check IP Whitelist immediately at the edge
t.mu.RLock()
allowed, reason := t.whitelist.IsAllowed(remoteAddr)
tunnelID := t.Config.ID
listenPort := t.Config.ListenPort
t.mu.RUnlock()
if !allowed {
t.blockedAttemps.Add(1)
_ = clientConn.Close()
if t.onSecEvent != nil {
t.onSecEvent(protocol.SecurityEvent{
ID: uuid.New().String(),
TunnelID: tunnelID,
Port: listenPort,
ClientIP: remoteAddr,
Timestamp: time.Now(),
Reason: reason,
})
}
log.Printf("[Tunnel %d] BLOCKED connection from %s (%s)", listenPort, remoteAddr, reason)
return
}
t.totalConns.Add(1)
connID := uuid.New().String()
if t.onConnect != nil {
host := remoteAddr
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = h
}
t.onConnect(tunnelID, host)
}
// 2. Open Yamux stream to Mac agent
stream, err := t.session.OpenStream()
if err != nil {
log.Printf("[Tunnel %d] failed to open stream to agent: %v", listenPort, err)
_ = clientConn.Close()
return
}
// 3. Send handshake line
handshake := protocol.StreamHandshake{
TunnelID: tunnelID,
ConnID: connID,
ClientIP: remoteAddr,
}
data, _ := json.Marshal(handshake)
data = append(data, '\n')
if _, err := stream.Write(data); err != nil {
_ = stream.Close()
_ = clientConn.Close()
return
}
// 4. Track active connection
connMeter := traffic.NewMeter()
wrappedClientConn := traffic.WrapConn(clientConn, connMeter)
ac := &ActiveClient{
ID: connID,
ClientIP: remoteAddr,
ConnectedAt: time.Now(),
Conn: clientConn,
Meter: connMeter,
}
t.mu.Lock()
t.activeConns[connID] = ac
t.mu.Unlock()
defer func() {
t.mu.Lock()
delete(t.activeConns, connID)
t.mu.Unlock()
_ = wrappedClientConn.Close()
_ = stream.Close()
}()
// 5. Bidirectional copy with optional QoS rate limiting
done := make(chan struct{}, 2)
go func() {
buf := make([]byte, 32*1024)
for {
nr, er := wrappedClientConn.Read(buf)
if nr > 0 {
t.meter.AddIn(int64(nr))
t.mu.RLock()
lim := t.limiter
t.mu.RUnlock()
if lim != nil {
lim.Allow(nr)
}
nw, ew := stream.Write(buf[0:nr])
if ew != nil || nr != nw {
break
}
}
if er != nil {
break
}
}
_ = stream.Close()
_ = wrappedClientConn.Close()
done <- struct{}{}
}()
go func() {
buf := make([]byte, 32*1024)
for {
nr, er := stream.Read(buf)
if nr > 0 {
t.meter.AddOut(int64(nr))
t.mu.RLock()
lim := t.limiter
t.mu.RUnlock()
if lim != nil {
lim.Allow(nr)
}
nw, ew := wrappedClientConn.Write(buf[0:nr])
if ew != nil || nr != nw {
break
}
}
if er != nil {
break
}
}
_ = stream.Close()
_ = wrappedClientConn.Close()
done <- struct{}{}
}()
<-done
}
// KillConn closes a specific connection by ID
func (t *Tunnel) KillConn(connID string) bool {
t.mu.Lock()
defer t.mu.Unlock()
if ac, ok := t.activeConns[connID]; ok {
_ = ac.Conn.Close()
delete(t.activeConns, connID)
return true
}
return false
}
// Close shuts down the listener and disconnects all clients
func (t *Tunnel) Close() {
t.closeOnce.Do(func() {
close(t.closed)
_ = t.listener.Close()
t.mu.Lock()
for _, ac := range t.activeConns {
_ = ac.Conn.Close()
}
t.activeConns = make(map[string]*ActiveClient)
t.mu.Unlock()
})
}
// Tick updates metrics
func (t *Tunnel) Tick() {
t.meter.Tick()
t.mu.RLock()
defer t.mu.RUnlock()
for _, ac := range t.activeConns {
ac.Meter.Tick()
}
}
// Metrics returns the current metrics snapshot
func (t *Tunnel) Metrics() (protocol.TunnelMetrics, []protocol.ActiveConnInfo) {
t.mu.RLock()
defer t.mu.RUnlock()
bIn, bOut, rIn, rOut := t.meter.Stats()
tm := protocol.TunnelMetrics{
TunnelID: t.Config.ID,
ListenPort: t.Config.ListenPort,
ActiveConns: len(t.activeConns),
TotalConns: t.totalConns.Load(),
BytesIn: bIn,
BytesOut: bOut,
RateIn: rIn,
RateOut: rOut,
BlockedAttemps: t.blockedAttemps.Load(),
Status: "online",
DynamicWhitelist: t.whitelist.GetDynamic(),
}
for ip, exp := range t.whitelist.DynamicLeases() {
tm.DynamicLeases = append(tm.DynamicLeases, protocol.DynamicLease{IP: ip, ExpiresAt: exp})
}
sort.Slice(tm.DynamicLeases, func(i, j int) bool {
return tm.DynamicLeases[i].ExpiresAt.Before(tm.DynamicLeases[j].ExpiresAt)
})
conns := make([]protocol.ActiveConnInfo, 0, len(t.activeConns))
for _, ac := range t.activeConns {
cbIn, cbOut, crIn, crOut := ac.Meter.Stats()
conns = append(conns, protocol.ActiveConnInfo{
ConnID: ac.ID,
TunnelID: t.Config.ID,
ClientIP: ac.ClientIP,
ConnectedAt: ac.ConnectedAt,
BytesIn: cbIn,
BytesOut: cbOut,
RateIn: crIn,
RateOut: crOut,
})
}
return tm, conns
}
+309
View File
@@ -0,0 +1,309 @@
package server
import (
"fmt"
"html"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"unicode"
"github.com/DeepFal/PortRelay/core/protocol"
)
const maxNameRunes = 32
func (m *Manager) handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == "/health":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","server":"PortRelay"}`))
case strings.HasPrefix(path, "/i/") && r.Method != http.MethodPost:
m.serveInviteForm(w, r, strings.TrimPrefix(path, "/i/"))
case path == "/auth" && r.Method == http.MethodPost:
m.serveInviteSubmit(w, r)
case path == "/auth":
m.serveInviteForm(w, r, r.URL.Query().Get("code"))
case path == "/auth/status":
m.serveRequestStatus(w, r)
default:
http.NotFound(w, r)
}
}
func clientIPOf(r *http.Request) string {
ip := r.RemoteAddr
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
ip = h
}
if parsed := net.ParseIP(ip); parsed != nil {
return parsed.String()
}
return ""
}
// accessHost returns the host LAN users should connect to: the configured public host,
// otherwise the host the visitor used to open the link (it reached this server).
func (m *Manager) accessHost(r *http.Request) string {
if m.PublicHost != "" {
return m.PublicHost
}
if h, _, err := net.SplitHostPort(r.Host); err == nil {
return h
}
return r.Host
}
// inviteTarget resolves an invite code to its (currently registered) tunnel
func (m *Manager) inviteTarget(code string) (*Tunnel, protocol.Invite, string) {
code = strings.TrimSpace(code)
if code == "" {
return nil, protocol.Invite{}, "缺少邀请码"
}
tunnelID, inv, ok := m.state.findInvite(code)
if !ok {
return nil, protocol.Invite{}, "链接无效或已过期,请向管理员索取新的邀请链接"
}
t := m.tunnel(tunnelID)
if t == nil {
return nil, protocol.Invite{}, "对应通道当前未开启,请联系管理员"
}
return t, inv, ""
}
func (m *Manager) serveInviteForm(w http.ResponseWriter, r *http.Request, code string) {
ip := clientIPOf(r)
t, inv, problem := m.inviteTarget(code)
if problem == "" && ip == "" {
problem = "无法识别客户端 IP"
}
if problem == "" {
if err := m.state.checkInvite(t.ID(), inv.Code, ip); err != nil {
problem = err.Error()
}
}
if problem != "" {
m.writePage(w, r, http.StatusNotFound, pageError, "无法使用此链接", problemHTML(problem))
return
}
cfg := t.Snapshot()
action := "确认并获取访问权限"
approvalTip := ""
if inv.RequireApproval {
action = "提交访问申请"
approvalTip = `<div class="tip">此邀请需要管理员审批,提交后请保持此页面打开,批准后会自动显示结果。</div>`
}
body := fmt.Sprintf(`
%s
<form method="post" action="/auth" class="form">
<input type="hidden" name="code" value="%s">
<label for="name">您的姓名</label>
<input id="name" name="name" required maxlength="%d" placeholder="例如:张三" autofocus>
<button type="submit">%s</button>
</form>
%s`,
m.detailRows(r, cfg, ip, durationText(inv.LeaseDuration)),
html.EscapeString(inv.Code), maxNameRunes, action, approvalTip)
m.writePage(w, r, http.StatusOK, pageForm, "申请访问 "+cfg.Name, body)
}
func sanitizeName(s string) string {
s = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
return truncateRunes(s, maxNameRunes)
}
func (m *Manager) serveInviteSubmit(w http.ResponseWriter, r *http.Request) {
r.Body = io.NopCloser(io.LimitReader(r.Body, 8<<10))
if err := r.ParseForm(); err != nil {
m.writePage(w, r, http.StatusBadRequest, pageError, "请求无效", problemHTML("表单内容无法解析"))
return
}
ip := clientIPOf(r)
name := sanitizeName(r.PostForm.Get("name"))
t, inv, problem := m.inviteTarget(r.PostForm.Get("code"))
switch {
case problem != "":
case ip == "":
problem = "无法识别客户端 IP"
case name == "":
problem = "请填写您的姓名"
}
if problem != "" {
m.writePage(w, r, http.StatusBadRequest, pageError, "无法完成授权", problemHTML(problem))
return
}
cfg := t.Snapshot()
if inv.RequireApproval {
req, err := m.state.addRequest(cfg.ID, inv.Code, name, ip)
if err != nil {
m.writePage(w, r, http.StatusConflict, pageError, "无法提交申请", problemHTML(err.Error()))
return
}
log.Printf("[WebAuth] Access request from %s (%s) on tunnel %s awaiting approval", name, ip, cfg.Name)
http.Redirect(w, r, "/auth/status?id="+url.QueryEscape(req.ID), http.StatusSeeOther)
return
}
lease, err := m.state.useInviteAndGrant(cfg.ID, inv.Code, name, ip, cfg.ExpiresAt)
if err != nil {
m.writePage(w, r, http.StatusConflict, pageError, "无法完成授权", problemHTML(err.Error()))
return
}
t.AddDynamicWhitelist(ip, lease.ExpiresAt)
log.Printf("[WebAuth] Granted %s (%s) access to tunnel %s (port %d): %s",
name, ip, cfg.Name, cfg.ListenPort, leaseValidity(lease))
m.writePage(w, r, http.StatusOK, pageSuccess, "授权成功", m.successBody(r, cfg, ip, lease))
}
func (m *Manager) serveRequestStatus(w http.ResponseWriter, r *http.Request) {
req, ok := m.state.request(r.URL.Query().Get("id"))
if !ok {
m.writePage(w, r, http.StatusNotFound, pageError, "申请已失效", problemHTML("申请不存在或已过期,请重新打开邀请链接提交"))
return
}
t := m.tunnel(req.TunnelID)
switch {
case req.Status == "rejected":
m.writePage(w, r, http.StatusOK, pageError, "申请未通过", problemHTML("管理员拒绝了您的访问申请"))
case t == nil:
m.writePage(w, r, http.StatusOK, pageError, "通道未开启", problemHTML("对应通道当前未开启,请联系管理员"))
case req.Status == "approved":
cfg := t.Snapshot()
lease, _ := m.state.lease(cfg.ID, req.IP)
m.writePage(w, r, http.StatusOK, pageSuccess, "申请已批准", m.successBody(r, cfg, req.IP, lease))
default:
cfg := t.Snapshot()
duration := ""
if _, inv, ok := m.state.findInvite(req.Invite); ok {
duration = inv.LeaseDuration
}
body := m.detailRows(r, cfg, req.IP, durationText(duration)) +
fmt.Sprintf(`<div class="tip">%s,您的申请已提交,正在等待管理员批准。此页面会自动刷新。</div>`, html.EscapeString(req.Name))
w.Header().Set("Refresh", "5")
m.writePage(w, r, http.StatusOK, pagePending, "等待管理员批准", body)
}
}
// durationText describes how long access lasts once granted
func durationText(d string) string {
if d == protocol.LeaseDurationTunnel {
return protocol.DescribeLeaseDuration(d)
}
return protocol.DescribeLeaseDuration(d) + "(从首次连接开始计算)"
}
// leaseValidity describes a granted lease: not yet started, or running until its expiry
func leaseValidity(l leaseInfo) string {
if l.Deferred {
return fmt.Sprintf("首次连接后 %s,请在 %s 前连接", protocol.DescribeLeaseDuration(l.Duration), l.ExpiresAt.Format("01-02 15:04"))
}
return "至 " + l.ExpiresAt.Format("01-02 15:04")
}
func (m *Manager) successBody(r *http.Request, cfg protocol.TunnelConfig, ip string, l leaseInfo) string {
return m.detailRows(r, cfg, ip, leaseValidity(l)) +
`<div class="tip">您的设备已获得访问权限,现在可以直接用客户端连接上面的访问地址,计时从首次连接开始。到期后如需继续使用,请向管理员索取新的邀请链接。</div>`
}
// detailRows renders the tunnel info table
func (m *Manager) detailRows(r *http.Request, cfg protocol.TunnelConfig, ip string, validity string) string {
validity = html.EscapeString(validity)
return fmt.Sprintf(`
<div class="row"><span class="label">目标通道</span><span class="value">%s</span></div>
<div class="row"><span class="label">访问地址</span><span class="value mono">%s:%d</span></div>
<div class="row"><span class="label">您的设备 IP</span><span class="value mono">%s</span></div>
<div class="row"><span class="label">访问有效期</span><span class="value highlight">%s</span></div>`,
html.EscapeString(cfg.Name), html.EscapeString(m.accessHost(r)), cfg.ListenPort, html.EscapeString(ip), validity)
}
func problemHTML(msg string) string {
return fmt.Sprintf(`<div class="tip error">%s</div>`, html.EscapeString(msg))
}
type pageKind int
const (
pageSuccess pageKind = iota
pageError
pagePending
pageForm
)
func (m *Manager) writePage(w http.ResponseWriter, r *http.Request, status int, kind pageKind, title, body string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write([]byte(renderPage(kind, title, body)))
}
func renderPage(kind pageKind, title, body string) string {
color := "#34C759"
icon := `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#34C759" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`
switch kind {
case pageError:
color = "#FF3B30"
icon = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#FF3B30" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>`
case pagePending:
color = "#FF9500"
icon = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#FF9500" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>`
case pageForm:
color = "#007AFF"
icon = `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#007AFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`
}
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s - PortRelay</title>
<style>
:root { --bg:#F5F5F7; --card:#FFFFFF; --text:#1D1D1F; --sub:#86868B; --border:#D2D2D7; --code-bg:#E8E8ED; --accent:%s; }
@media (prefers-color-scheme: dark) {
:root { --bg:#000000; --card:#1C1C1E; --text:#F5F5F7; --sub:#8E8E93; --border:#38383A; --code-bg:#2C2C2E; }
}
body { margin:0; padding:24px 16px; background:var(--bg); color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
display:flex; justify-content:center; align-items:center; min-height:90vh; }
.card { background:var(--card); border:1px solid var(--border); border-radius:16px; max-width:440px; width:100%%;
padding:32px 24px; box-shadow:0 4px 24px rgba(0,0,0,0.06); box-sizing:border-box; }
.header { text-align:center; margin-bottom:24px; }
.title { font-size:20px; font-weight:700; margin-top:12px; margin-bottom:4px; }
.sub { font-size:13px; color:var(--sub); }
.row { display:flex; justify-content:space-between; align-items:center; gap:12px; padding:10px 0; border-bottom:1px solid var(--border); font-size:13px; }
.label { color:var(--sub); white-space:nowrap; }
.value { font-weight:600; text-align:right; word-break:break-all; }
.mono { font-family:ui-monospace,Menlo,Monaco,Consolas,monospace; background:var(--code-bg); padding:2px 6px; border-radius:4px; }
.highlight { color:var(--accent); }
.tip { margin-top:20px; padding:12px 14px; background:var(--code-bg); border-radius:8px; font-size:12px; line-height:1.5; color:var(--sub); }
.tip.error { color:#FF3B30; background:rgba(255,59,48,0.1); font-weight:600; text-align:center; }
.form { margin-top:20px; display:flex; flex-direction:column; gap:8px; }
.form label { font-size:13px; color:var(--sub); }
.form input { font-size:16px; padding:10px 12px; border-radius:8px; border:1px solid var(--border); background:var(--bg); color:var(--text); }
.form button { margin-top:8px; font-size:15px; font-weight:600; padding:11px; border:0; border-radius:8px; background:var(--accent); color:#fff; cursor:pointer; }
.footer { text-align:center; margin-top:24px; font-size:11px; color:var(--sub); }
</style>
</head>
<body>
<div class="card">
<div class="header">
%s
<div class="title">%s</div>
<div class="sub">PortRelay 端口访问授权</div>
</div>
%s
<div class="footer">PortRelay 安全防护 · 临时访问通行证</div>
</div>
</body>
</html>`, html.EscapeString(title), color, icon, html.EscapeString(title), body)
}
+241
View File
@@ -0,0 +1,241 @@
import SwiftUI
import AppKit
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "."
let only = CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : ""
_ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited)
let iso: ISO8601DateFormatter = { let f = ISO8601DateFormatter(); f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]; return f }()
func t(_ secondsFromNow: Double) -> String { iso.string(from: Date().addingTimeInterval(secondsFromNow)) }
func decode<T: Decodable>(_ type: T.Type, _ json: String) -> T {
do { return try JSONDecoder().decode(T.self, from: Data(json.utf8)) }
catch { fatalError("mock decode \(T.self): \(error)") }
}
@MainActor
func makeVM() -> PortRelayViewModel {
let vm = PortRelayViewModel(preview: true)
vm.isConnected = true
vm.serverAddr = "192.168.1.10:33000"
vm.publicHost = "192.168.1.10"
vm.inviteBase = "http://192.168.1.10:33000/i/"
vm.tunnels = decode([TunnelItem].self, """
[
{"id":"t1","name":"MySQL 数据库 (10.0.1.16)","target":"10.0.1.16:3306","listen_port":33660,"whitelist":[],"enabled":true,
"owner":"数据组","duration_minutes":120,"started_at":"\(t(-3725))","expires_at":"\(t(3475))","historical_bytes_in":1288490188,"historical_bytes_out":53687091200,
"idle_close_minutes":30,"effective_idle_close_minutes":30,"first_connected_at":"\(t(-3300))","connected_sec":1520,
"activity":{"in_use":true,"connected_sec":1525},
"max_bandwidth_mb":10,
"metrics":{"tunnel_id":"t1","status":"online","active_conns":4,"total_conns":1287,"bytes_in":245039104,"bytes_out":8703180,"rate_in":1740,"rate_out":46285,"blocked_attempts":12,
"dynamic_leases":[{"ip":"192.168.1.21","expires_at":"\(t(7000))","name":"张三","invite":"a1","duration":"2h","started_at":"\(t(-200))"},{"ip":"192.168.1.35","expires_at":"\(t(80000))","name":"一个名字特别长的外包同事王小明","invite":"a2","duration":"2h","awaiting_connect":true}],
"pending":[{"id":"r1","tunnel_id":"t1","name":"李四","ip":"192.168.1.88","invite":"a3","at":"\(t(-60))","status":"pending"}],
"invites":[{"code":"a1","note":"张三","created_at":"\(t(-3600))","expires_at":"\(t(80000))","max_uses":1,"lease_duration":"today","uses":[{"name":"张三","ip":"192.168.1.21","at":"\(t(-3500))"}]},
{"code":"a3","created_at":"\(t(-60))","expires_at":"\(t(600000))","max_uses":0,"lease_duration":"1h","require_approval":true}]}},
{"id":"t2","name":"SSH(10.0.8.3)测试环境 docker 后端跳板机入口","target":"10.0.8.3:22","listen_port":33661,"whitelist":["192.168.1.0/24","10.0.0.1"],"enabled":true,
"owner":"运维组","duration_minutes":0,"started_at":"\(t(-50000))","effective_idle_close_minutes":30,"connected_sec":0,
"activity":{"in_use":false,"idle_since":"\(t(-720))","auto_close_at":"\(t(1080))","connected_sec":0},
"metrics":{"tunnel_id":"t2","status":"error","error":"port 33661 already in use by tunnel 3f2c9a7e-1b2d-4c5e-9f00-1234567890ab"}},
{"id":"t3","name":"Redis 缓存","target":"10.0.1.20:6379","listen_port":33662,"whitelist":["*"],"enabled":false,"last_duration_sec":1905,"duration_minutes":0,"effective_idle_close_minutes":30,"last_connected_sec":742,"close_reason":"闲置 30 分钟自动关闭",
"historical_bytes_in":1024,"historical_bytes_out":0}
]
""")
vm.activeConns = decode([ActiveClientInfo].self, """
[
{"conn_id":"c1","tunnel_id":"t1","client_ip":"192.168.1.21:52889","connected_at":"\(t(-600))","bytes_in":416358,"bytes_out":4089446,"rate_in":632,"rate_out":16486,"rate_in":632},
{"conn_id":"c2","tunnel_id":"t1","client_ip":"192.168.1.21:50395","connected_at":"\(t(-577))","bytes_in":406528,"bytes_out":3984588,"rate_in":1126,"rate_out":29798},
{"conn_id":"c3","tunnel_id":"t1","client_ip":"192.168.1.21:53023","connected_at":"\(t(-526))","bytes_in":244213760,"bytes_out":633856,"rate_in":0,"rate_out":0},
{"conn_id":"c4","tunnel_id":"t1","client_ip":"[fe80::1c2d:3e4f:aa:bb]:61001","connected_at":"\(t(-30))","bytes_in":120,"bytes_out":88,"rate_in":0,"rate_out":0}
]
""")
vm.previewHistory = decode([ClientHistoryRecord].self, """
[
{"id":"h1","tunnel_id":"t1","tunnel_name":"MySQL","port":33660,"client_ip":"192.168.1.21:51000","connected_at":"\(t(-5000))","disconnected_at":"\(t(-4000))","duration_sec":1000,"bytes_in":1288490188,"bytes_out":5368709},
{"id":"h2","tunnel_id":"t1","tunnel_name":"MySQL","port":33660,"client_ip":"192.168.1.21:51001","connected_at":"\(t(-3000))","disconnected_at":"\(t(-2990))","duration_sec":10,"bytes_in":2048,"bytes_out":4096},
{"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1}
]
""")
// 模拟近 10 分钟流量:几段查询突发 + 平稳传输
func wave(_ seed: Double, base: Double, burst: Double) -> [Int64] {
(0..<120).map { i in
let x = Double(i)
var v = base * (0.6 + 0.4 * sin(x / 7 + seed))
for c in [18.0, 47.0, 83.0, 108.0] { v += burst * exp(-pow((x - c - seed) / 3.5, 2)) }
return Int64(max(0, v))
}
}
vm.tunnels[0].rate_history = RateSeriesItem(end: Int64(Date().timeIntervalSince1970), step: 5,
up: wave(1, base: 1800, burst: 9000), down: wave(0, base: 22000, burst: 160000))
vm.tunnels[1].rate_history = RateSeriesItem(end: Int64(Date().timeIntervalSince1970), step: 5,
up: Array(repeating: 0, count: 120), down: Array(repeating: 0, count: 120))
vm.totalRateHistory = vm.tunnels[0].rate_history
vm.totalActiveConns = 4; vm.totalHistoricalConns = 1287; vm.totalRateIn = 1740; vm.totalRateOut = 46285
vm.totalTransferred = 60000000000; vm.totalBlockedAttempts = 12
vm.runDiagnostics()
return vm
}
// MARK: - README 演示数据(render.sh readme / render.sh gif)
/// 平滑、确定性的模拟流量:k 为 5 秒桶的绝对序号,便于逐帧平移
func demoRate(_ k: Int, seed: Double, base: Double, burst: Double) -> Int64 {
let x = Double(k)
var v = base * (0.55 + 0.25 * sin(x / 9 + seed) + 0.2 * sin(x / 3.7 + seed * 2))
for c in stride(from: -60, through: 600, by: 21) {
let h = 0.35 + 0.65 * abs(sin(Double(c) * 0.731 + seed))
v += burst * h * exp(-pow((x - Double(c) - 7 * sin(Double(c) + seed)) / 3.2, 2))
}
return Int64(max(0, v))
}
func demoSeries(tick: Int, seed: Double, base: Double, burst: Double, upScale: Double) -> RateSeriesItem {
let down = (0..<120).map { demoRate($0 + tick, seed: seed, base: base, burst: burst) }
let up = (0..<120).map { demoRate($0 + tick, seed: seed + 1.7, base: base * upScale, burst: burst * upScale) }
return RateSeriesItem(end: Int64(Date().timeIntervalSince1970) + Int64(tick * 5), step: 5, up: up, down: down)
}
/// 演示数据的时间基准:逐帧渲染时各时间点保持不变,只有计数随 tick 增长
let demoBase = Date()
@MainActor
func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
let vm = PortRelayViewModel(preview: true)
let dt = Double(tick * 5)
func t(_ s: Double) -> String { iso.string(from: demoBase.addingTimeInterval(s)) }
vm.isConnected = true
vm.serverAddr = "192.168.1.10:33000"
vm.publicHost = "192.168.1.10"
vm.inviteBase = "http://192.168.1.10:33000/i/"
vm.tunnels = decode([TunnelItem].self, """
[
{"id":"t1","name":"MySQL 数据库","target":"10.0.1.16:3306","listen_port":33660,"whitelist":[],"enabled":true,
"owner":"数据组","duration_minutes":240,"started_at":"\(t(-3725))","expires_at":"\(t(10675))","historical_bytes_in":1288490188,"historical_bytes_out":53687091200,
"idle_close_minutes":30,"effective_idle_close_minutes":30,"first_connected_at":"\(t(-3300))","connected_sec":\(1520 + Int(dt)),
"activity":{"in_use":true,"connected_sec":\(1525 + Int(dt))},
"metrics":{"tunnel_id":"t1","status":"online","active_conns":3,"total_conns":1287,"bytes_in":\(245039104 + tick * 900000),"bytes_out":\(8703180 + tick * 40000),"rate_in":0,"rate_out":0,"blocked_attempts":12,
"dynamic_leases":[{"ip":"192.168.1.21","expires_at":"\(t(7000))","name":"张三","invite":"a1","duration":"2h","started_at":"\(t(-200))"},{"ip":"192.168.1.35","expires_at":"\(t(80000))","name":"王小明","invite":"a2","duration":"8h","awaiting_connect":true}],
"pending":[{"id":"r1","tunnel_id":"t1","name":"李四","ip":"192.168.1.88","invite":"a3","at":"\(t(-60))","status":"pending"}],
"invites":[{"code":"a1","note":"张三","created_at":"\(t(-3600))","expires_at":"\(t(80000))","max_uses":1,"lease_duration":"2h","uses":[{"name":"张三","ip":"192.168.1.21","at":"\(t(-3500))"}]},
{"code":"a2","note":"王小明","created_at":"\(t(-1800))","expires_at":"\(t(84600))","max_uses":1,"lease_duration":"8h","uses":[{"name":"王小明","ip":"192.168.1.35","at":"\(t(-1700))"}]},
{"code":"a3","note":"测试组","created_at":"\(t(-60))","expires_at":"\(t(600000))","max_uses":0,"lease_duration":"today","require_approval":true}]}},
{"id":"t2","name":"Grafana 监控面板","target":"10.0.2.30:3000","listen_port":33661,"whitelist":["192.168.1.0/24"],"enabled":true,
"owner":"运维组","duration_minutes":0,"started_at":"\(t(-50000))","effective_idle_close_minutes":0,"idle_close_minutes":0,"first_connected_at":"\(t(-49000))","connected_sec":\(36120 + Int(dt)),
"activity":{"in_use":true,"connected_sec":\(36120 + Int(dt))},
"metrics":{"tunnel_id":"t2","status":"online","active_conns":2,"total_conns":342,"bytes_in":\(52039104 + tick * 300000),"bytes_out":2703180,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
{"id":"t3","name":"SSH 跳板机(测试环境)","target":"10.0.8.3:22","listen_port":33662,"whitelist":[],"enabled":true,
"owner":"运维组","duration_minutes":0,"started_at":"\(t(-2100))","effective_idle_close_minutes":30,"idle_close_minutes":30,"first_connected_at":"\(t(-2000))","connected_sec":640,
"activity":{"in_use":false,"idle_since":"\(t(-720))","auto_close_at":"\(t(1080))","connected_sec":640},
"metrics":{"tunnel_id":"t3","status":"online","active_conns":0,"total_conns":4,"bytes_in":1983000,"bytes_out":204000,"rate_in":0,"rate_out":0,"blocked_attempts":0}},
{"id":"t4","name":"Redis 缓存","target":"10.0.1.20:6379","listen_port":33663,"whitelist":[],"enabled":false,"last_duration_sec":1905,"duration_minutes":0,"effective_idle_close_minutes":30,"last_connected_sec":742,"close_reason":"闲置 30 分钟自动关闭",
"historical_bytes_in":3145728,"historical_bytes_out":524288}
]
""")
vm.tunnels[0].rate_history = demoSeries(tick: tick, seed: 0.4, base: 26000, burst: 150000, upScale: 0.08)
vm.tunnels[1].rate_history = demoSeries(tick: tick, seed: 2.1, base: 9000, burst: 30000, upScale: 0.15)
vm.tunnels[2].rate_history = RateSeriesItem(end: Int64(Date().timeIntervalSince1970), step: 5,
up: (0..<120).map { $0 < 30 ? demoRate($0, seed: 5, base: 300, burst: 2000) : 0 },
down: (0..<120).map { $0 < 30 ? demoRate($0, seed: 6, base: 1500, burst: 9000) : 0 })
// 卡片与顶部统计的实时速率取曲线最后一个点
var totalIn: Int64 = 0, totalOut: Int64 = 0
for i in 0..<2 {
let h = vm.tunnels[i].rate_history!
vm.tunnels[i].metrics?.rate_in = h.up.last ?? 0
vm.tunnels[i].metrics?.rate_out = h.down.last ?? 0
totalIn += h.up.last ?? 0; totalOut += h.down.last ?? 0
}
let a = vm.tunnels[0].rate_history!, b = vm.tunnels[1].rate_history!
vm.totalRateHistory = RateSeriesItem(end: a.end, step: 5, up: zip(a.up, b.up).map { $0 + $1 }, down: zip(a.down, b.down).map { $0 + $1 })
vm.activeConns = decode([ActiveClientInfo].self, """
[
{"conn_id":"c1","tunnel_id":"t1","client_ip":"192.168.1.21:52889","connected_at":"\(t(-600))","bytes_in":416358,"bytes_out":4089446,"rate_in":632,"rate_out":16486},
{"conn_id":"c2","tunnel_id":"t1","client_ip":"192.168.1.21:50395","connected_at":"\(t(-577))","bytes_in":406528,"bytes_out":3984588,"rate_in":1126,"rate_out":29798},
{"conn_id":"c3","tunnel_id":"t1","client_ip":"192.168.1.21:53023","connected_at":"\(t(-526))","bytes_in":244213760,"bytes_out":633856,"rate_in":0,"rate_out":0},
{"conn_id":"c4","tunnel_id":"t2","client_ip":"192.168.1.52:61001","connected_at":"\(t(-3000))","bytes_in":120000,"bytes_out":8800000,"rate_in":120,"rate_out":9100},
{"conn_id":"c5","tunnel_id":"t2","client_ip":"192.168.1.60:61201","connected_at":"\(t(-300))","bytes_in":42000,"bytes_out":1800000,"rate_in":80,"rate_out":3100}
]
""")
vm.previewHistory = decode([ClientHistoryRecord].self, """
[
{"id":"h1","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51000","connected_at":"\(t(-5000))","disconnected_at":"\(t(-4000))","duration_sec":1000,"bytes_in":1288490188,"bytes_out":5368709},
{"id":"h2","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.21:51001","connected_at":"\(t(-3000))","disconnected_at":"\(t(-2990))","duration_sec":10,"bytes_in":2048,"bytes_out":4096},
{"id":"h3","tunnel_id":"t1","tunnel_name":"MySQL 数据库","port":33660,"client_ip":"192.168.1.35:40000","connected_at":"\(t(-90000))","disconnected_at":"\(t(-86000))","duration_sec":4000,"bytes_in":99999999,"bytes_out":1}
]
""")
vm.totalActiveConns = 5; vm.totalHistoricalConns = 1633; vm.totalRateIn = totalIn; vm.totalRateOut = totalOut
vm.totalTransferred = 60000000000; vm.totalBlockedAttempts = 12
vm.runDiagnostics()
return vm
}
/// 让离屏窗口按"前台激活"状态绘制控件(开关、分段控件显示强调色)
final class KeyWindow: NSWindow {
override var isKeyWindow: Bool { true }
override var isMainWindow: Bool { true }
}
@MainActor
func render<V: View>(_ name: String, _ size: CGSize, dark: Bool = false, _ view: V) {
if !only.isEmpty && !name.hasPrefix(only) { return }
let root = view.frame(width: size.width, height: size.height, alignment: .topLeading)
.background(Color(NSColor.windowBackgroundColor))
let hosting = NSHostingView(rootView: root)
hosting.frame = NSRect(origin: .zero, size: size)
let window = KeyWindow(contentRect: hosting.frame, styleMask: [.titled], backing: .buffered, defer: false)
window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua)
hosting.appearance = window.appearance
window.contentView = hosting
for _ in 0..<6 {
hosting.layoutSubtreeIfNeeded()
RunLoop.main.run(until: Date().addingTimeInterval(0.15))
}
guard let rep = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds) else { return }
hosting.cacheDisplay(in: hosting.bounds, to: rep)
try! rep.representation(using: .png, properties: [:])!.write(to: URL(fileURLWithPath: "\(outDir)/\(name).png"))
print("rendered", name)
}
MainActor.assumeIsolated {
let vm = makeVM()
let t1 = vm.tunnels[0]
let yes = Binding.constant(true)
render("01-main-light", CGSize(width: 980, height: 720), ContentView(vm: vm))
render("01c-search-hit", CGSize(width: 980, height: 560), ContentView(vm: vm, initialSearch: "sjk"))
render("01d-search-none", CGSize(width: 980, height: 560), ContentView(vm: vm, initialSearch: "oracle", initialFilter: .running))
render("02-main-dark", CGSize(width: 980, height: 720), dark: true, ContentView(vm: vm))
render("03-clients-online", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 0, isPresented: yes))
let vmSingle = makeVM()
vmSingle.activeConns = vmSingle.activeConns.filter { $0.client_ip.hasPrefix("192.168.1.21:") }
render("03b-clients-online-single", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vmSingle, tunnel: vmSingle.tunnels[0], initialTab: 0, isPresented: yes))
render("03c-clients-online-min", CGSize(width: 720, height: 540), ClientDetailsSheet(vm: vmSingle, tunnel: vmSingle.tunnels[0], initialTab: 0, isPresented: yes))
render("01b-main-min", CGSize(width: 920, height: 640), ContentView(vm: vm))
render("04-clients-history", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 1, isPresented: yes))
render("05-clients-auth", CGSize(width: 760, height: 600), ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 2, isPresented: yes))
render("06-edit-new", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: nil, isPresented: yes))
render("07-edit-existing", CGSize(width: 490, height: 760), TunnelEditSheet(vm: vm, tunnel: vm.tunnels[1], isPresented: yes))
render("08-invite-form", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: false, isPresented: yes))
render("09-invite-result", CGSize(width: 560, height: 400), InviteSheet(vm: vm, tunnel: t1, autoGenerate: true, isPresented: yes))
render("10-settings", CGSize(width: 400, height: 320), SettingsSheet(vm: vm, isPresented: yes))
render("11-diagnostics", CGSize(width: 580, height: 460), DiagnosticsSheet(vm: vm, isPresented: yes))
render("12-clients-auth-dark", CGSize(width: 760, height: 600), dark: true, ClientDetailsSheet(vm: vm, tunnel: t1, initialTab: 2, isPresented: yes))
// README 截图
let demo = makeDemoVM()
render("readme-main-light", CGSize(width: 980, height: 880), ContentView(vm: demo))
render("readme-main-dark", CGSize(width: 980, height: 880), dark: true, ContentView(vm: demo))
render("readme-invite", CGSize(width: 560, height: 250), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes))
render("readme-clients", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 0, isPresented: yes))
render("readme-access", CGSize(width: 760, height: 490), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 2, isPresented: yes))
render("readme-edit", CGSize(width: 490, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
// README 动图逐帧:卡片随时间推移(每帧 = 5 秒)
if only.hasPrefix("gif") {
let frames = Int(ProcessInfo.processInfo.environment["FRAMES"] ?? "") ?? 40
for i in 0..<frames {
let fvm = makeDemoVM(tick: i)
let card = TunnelCardView(tunnel: fvm.tunnels[0], vm: fvm, onEdit: {}, onViewClients: {}, onViewLeases: {}, onInvite: {})
.padding(.horizontal, 20).padding(.vertical, 12)
render(String(format: "gif-%03d", i), CGSize(width: 980, height: 192), card)
}
}
}
+260
View File
@@ -0,0 +1,260 @@
// mkgif 把逐帧 PNG 合成为循环播放的 GIF,用于 README 动图。
//
// go run ./tools/ui-snapshots/mkgif -o docs/images/live-traffic.gif tools/ui-snapshots/out/gif-*.png
//
// 所有帧共用一套中位切分调色板;第二帧起只编码与上一帧不同的区域,
// 区域内未变化的像素写成透明色,界面类动图体积因此很小。
package main
import (
"flag"
"image"
"image/color"
"image/gif"
"image/png"
"log"
"os"
"sort"
)
func main() {
out := flag.String("o", "out.gif", "output file")
delay := flag.Int("delay", 40, "frame delay in 1/100 s")
hold := flag.Int("hold", 120, "extra delay on the last frame in 1/100 s")
shrink := flag.Int("shrink", 1, "downscale factor (box filter)")
flag.Parse()
if flag.NArg() == 0 {
log.Fatal("no input frames")
}
var frames []*image.RGBA
for _, path := range flag.Args() {
frames = append(frames, downscale(load(path), *shrink))
}
bounds := frames[0].Bounds()
pal := medianCut(histogram(frames), 255)
transparent := uint8(len(pal))
pal = append(pal, color.RGBA{})
idx := newMapper(pal[:transparent])
anim := &gif.GIF{LoopCount: 0, Config: image.Config{ColorModel: pal, Width: bounds.Dx(), Height: bounds.Dy()}}
var prev *image.Paletted
for i, f := range frames {
cur := image.NewPaletted(bounds, pal)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
cur.SetColorIndex(x, y, idx.index(f.RGBAAt(x, y)))
}
}
frame := cur
if prev != nil {
frame = diff(prev, cur, transparent)
}
d := *delay
if i == len(frames)-1 {
d += *hold
}
anim.Image = append(anim.Image, frame)
anim.Delay = append(anim.Delay, d)
anim.Disposal = append(anim.Disposal, gif.DisposalNone)
prev = cur
}
w, err := os.Create(*out)
if err != nil {
log.Fatal(err)
}
if err := gif.EncodeAll(w, anim); err != nil {
log.Fatal(err)
}
if err := w.Close(); err != nil {
log.Fatal(err)
}
st, _ := os.Stat(*out)
log.Printf("%s: %d frames, %dx%d, %d KB", *out, len(frames), bounds.Dx(), bounds.Dy(), st.Size()/1024)
}
func load(path string) *image.RGBA {
f, err := os.Open(path)
if err != nil {
log.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
log.Fatalf("%s: %v", path, err)
}
b := img.Bounds()
rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
for y := 0; y < b.Dy(); y++ {
for x := 0; x < b.Dx(); x++ {
rgba.Set(x, y, img.At(b.Min.X+x, b.Min.Y+y))
}
}
return rgba
}
func downscale(src *image.RGBA, k int) *image.RGBA {
if k <= 1 {
return src
}
b := src.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, b.Dx()/k, b.Dy()/k))
for y := 0; y < dst.Bounds().Dy(); y++ {
for x := 0; x < dst.Bounds().Dx(); x++ {
var r, g, bl, a int
for dy := 0; dy < k; dy++ {
for dx := 0; dx < k; dx++ {
c := src.RGBAAt(x*k+dx, y*k+dy)
r, g, bl, a = r+int(c.R), g+int(c.G), bl+int(c.B), a+int(c.A)
}
}
n := k * k
dst.SetRGBA(x, y, color.RGBA{uint8(r / n), uint8(g / n), uint8(bl / n), uint8(a / n)})
}
}
return dst
}
type bucket struct {
c [3]uint8
count int
}
func histogram(frames []*image.RGBA) []bucket {
counts := map[[3]uint8]int{}
for _, f := range frames {
for i := 0; i+3 < len(f.Pix); i += 4 {
counts[[3]uint8{f.Pix[i], f.Pix[i+1], f.Pix[i+2]}]++
}
}
out := make([]bucket, 0, len(counts))
for c, n := range counts {
out = append(out, bucket{c, n})
}
return out
}
// medianCut 反复把"像素数 × 色彩跨度"最大的盒子沿最长轴按像素中位数切开。
func medianCut(colors []bucket, n int) color.Palette {
boxes := [][]bucket{colors}
for len(boxes) < n {
best, bestScore, bestAxis := -1, 0, 0
for i, b := range boxes {
if len(b) < 2 {
continue
}
axis, span := longestAxis(b)
total := 0
for _, c := range b {
total += c.count
}
if score := span * total; score > bestScore {
best, bestScore, bestAxis = i, score, axis
}
}
if best < 0 {
break
}
b := boxes[best]
sort.Slice(b, func(i, j int) bool { return b[i].c[bestAxis] < b[j].c[bestAxis] })
total := 0
for _, c := range b {
total += c.count
}
cut, acc := 1, 0
for i, c := range b {
acc += c.count
if acc*2 >= total {
cut = i + 1
break
}
}
if cut >= len(b) {
cut = len(b) - 1
}
boxes[best] = b[:cut]
boxes = append(boxes, b[cut:])
}
pal := make(color.Palette, 0, len(boxes))
for _, b := range boxes {
var r, g, bl, total int
for _, c := range b {
r += int(c.c[0]) * c.count
g += int(c.c[1]) * c.count
bl += int(c.c[2]) * c.count
total += c.count
}
pal = append(pal, color.RGBA{uint8(r / total), uint8(g / total), uint8(bl / total), 255})
}
return pal
}
func longestAxis(b []bucket) (axis, span int) {
for a := 0; a < 3; a++ {
lo, hi := 255, 0
for _, c := range b {
v := int(c.c[a])
lo, hi = min(lo, v), max(hi, v)
}
if hi-lo > span {
axis, span = a, hi-lo
}
}
return axis, span
}
type mapper struct {
pal color.Palette
cache map[color.RGBA]uint8
}
func newMapper(pal color.Palette) *mapper {
return &mapper{pal: pal, cache: map[color.RGBA]uint8{}}
}
func (m *mapper) index(c color.RGBA) uint8 {
c.A = 255
if i, ok := m.cache[c]; ok {
return i
}
best, bestD := 0, int(^uint(0)>>1)
for i, p := range m.pal {
q := p.(color.RGBA)
dr, dg, db := int(c.R)-int(q.R), int(c.G)-int(q.G), int(c.B)-int(q.B)
if d := 3*dr*dr + 4*dg*dg + 2*db*db; d < bestD {
best, bestD = i, d
}
}
m.cache[c] = uint8(best)
return uint8(best)
}
// diff 返回只覆盖变化区域的帧,区域内未变化的像素为透明色。
func diff(prev, cur *image.Paletted, transparent uint8) *image.Paletted {
b := cur.Bounds()
minX, minY, maxX, maxY := b.Max.X, b.Max.Y, b.Min.X-1, b.Min.Y-1
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
if prev.ColorIndexAt(x, y) != cur.ColorIndexAt(x, y) {
minX, minY, maxX, maxY = min(minX, x), min(minY, y), max(maxX, x), max(maxY, y)
}
}
}
if maxX < minX {
minX, minY, maxX, maxY = 0, 0, 0, 0
}
r := image.Rect(minX, minY, maxX+1, maxY+1)
out := image.NewPaletted(r, cur.Palette)
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
if v := cur.ColorIndexAt(x, y); v != prev.ColorIndexAt(x, y) {
out.SetColorIndex(x, y, v)
} else {
out.SetColorIndex(x, y, transparent)
}
}
}
return out
}
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# 离屏渲染 App 各界面(模拟数据、浅色/深色),用于检查布局。
# 用法: tools/ui-snapshots/render.sh [只渲染的名称前缀] 输出: tools/ui-snapshots/out/*.png
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$DIR/../.." && pwd)"
BUILD="$DIR/.build"
mkdir -p "$BUILD" "$DIR/out"
# 去掉 @main,与渲染入口一起编译
sed 's/^@main$//' "$ROOT/mac-native/PortRelayApp.swift" > "$BUILD/app.swift"
xcrun swiftc -O -target "$(uname -m)-apple-macos13.0" "$BUILD/app.swift" "$DIR/main.swift" -o "$BUILD/render"
rm -f "$DIR/out/${1:-}"*.png
"$BUILD/render" "$DIR/out" "$1"