PortRelay:把内网服务临时、可控地开放给同事
边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上; 通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。 - portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页) - portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计) - mac-native:SwiftUI 菜单栏 App
No files matched your search
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.github
|
||||
bin
|
||||
build
|
||||
dist
|
||||
docs
|
||||
mac-native
|
||||
tools
|
||||
PortRelay.app
|
||||
*.md
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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(控制端口 + 通道端口池)"
|
||||
@@ -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=
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
After Width: | Height: | Size: 426 KiB |
@@ -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 |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 78 KiB |
@@ -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 |
|
After Width: | Height: | Size: 560 KiB |
|
After Width: | Height: | Size: 711 KiB |
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 1006 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 1006 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
@@ -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.")
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||