Compare commits

15 Commits
Author SHA1 Message Date
mrlee b9d43538b5 refactor: simplify public proxy traffic table
CI / verify (push) Canceled after 0s
2026-10-24 20:31:27 +08:00
mrlee 56f04ae12b feat: refresh dashboard layout and navigation
CI / verify (push) Canceled after 0s
2026-10-24 20:06:42 +08:00
mrleeandDevin 9d75fb1d3e fix: 收敛正确性缺口并补齐质量门禁
CI / verify (push) Canceled after 0s
备份、隧道生命周期、日志、会话与设置项存在一批「静默失效」问题,本次一并收敛。

正确性:
- 备份归档补上 session_secret 与 frp 配置。缺密钥时换机恢复后库里所有凭据都解不开,
  界面表现为「配置都在、一执行就报解密失败」;同时支持口令加密(scrypt + AES-256-GCM)。
- 修复「立即备份」必失败:tar 头用的是遍历时的文件尺寸,而 logs、frpc 配置在打包期间
  还在增长,读出的字节多于声明值导致 archive/tar 报错、整份备份作废。改为打开后重新
  取尺寸并用 LimitReader 兜住,被截短时补零保持条目结构完整。
- 数据库改用 VACUUM INTO 生成一致性快照。直接打包正在写入的 SQLite 文件可能得到
  结构完整但内容自相矛盾的副本,恢复后才暴露。
- Cloudflare 启动改用 StartAuto,Shutdown 显式 StopAll,子进程绑定应用 ctx。此前
  重启会把用户手动停掉的隧道重新拉起,退出也可能留下孤儿进程。
- Nginx 访问/错误日志加轮转(200MB 阈值、gzip 归档、USR1 重开),读取改为从文件尾
  分块倒读,并跨当日归档统计。此前日志只增不减,读接口每次全量扫描。
- 过期会话改由日任务清理;删除反代规则时同步清掉流量累计,避免同名规则重建继承旧读数。
- 启动加就绪门:Nginx bootstrap 完成后再放行 FRP 对账、Agent 隧道与 Cloudflare 自启。

设置项:
- 新增设置项注册表(internal/settings/registry.go),白名单与默认值从它派生,
  并记录每个键的消费者,防止再出现「界面能改、后端没人读」的黑洞设置。
- 接上三个此前不生效的设置项:timezone(启动与保存时应用到 time.Local)、
  theme(后端为权威源,挂载时覆盖本地偏好、顶栏切换回写)、log_retention_days
  (控制日志归档保留天数)。

工程质量:
- gofmt 全量格式化 23 个历史文件,CI 增加格式化门禁。
- 前端引入 ESLint(flat config)与 lint 脚本,CI 增加 lint 步骤;顺带修掉
  logDisplay 的 let→const 与 LogsView 模板内直接改 prop 两处问题。
- 删除无引用的 pnpm-lock.yaml,prototype/ 加入忽略列表。
- 打包版本对齐:Docker manifest 同步到 2.0.0,CI 校验 VERSION 与 package.json 一致。

验证:gofmt 0 文件、go vet、go test ./...、npm run lint/typecheck/test/build 全绿。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-24 16:30:00 +08:00
mrlee 6dd2136dd2 禁用暂不可用的穿透类型
CI / verify (push) Canceled after 0s
2026-10-24 09:29:52 +08:00
mrlee e4edbdcef0 tcp新增端口校验逻辑
CI / verify (push) Canceled after 0s
2026-10-19 15:53:59 +08:00
mrlee 5b9773b74a 修复列表错位
CI / verify (push) Canceled after 0s
2026-10-19 15:49:22 +08:00
mrlee 2c1cf1260b 修正frpc重启按钮的工作流程
CI / verify (push) Canceled after 0s
2026-10-19 13:50:05 +08:00
mrlee 211d8e66e0 添加自定义404页面
CI / verify (push) Canceled after 0s
2026-10-17 20:22:27 +08:00
mrlee 6b8ff0e5a3 WebSocket 把 proxy_http_version 1.1 + Upgrade/Connection 移进 location / 内部
CI / verify (push) Canceled after 0s
2026-10-17 11:58:43 +08:00
mrlee c1bcdd0b86 公网反代默认转发 WebSocket 升级头
CI / verify (push) Canceled after 0s
2026-10-17 10:21:24 +08:00
mrlee 302248a907 chore(release): 发布 v2.0.0
CI / verify (push) Canceled after 0s
- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验
- 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令
- DDNS 服务商名称与自定义 IP,新增迁移 037/038/039
- CHANGELOG 与 VERSION 更新至 2.0.0
2026-10-16 20:18:04 +08:00
mrlee a0cc553d2d fix(native): updater Token 不再拖垮非 root 启动(飞牛原生版无法启用)
CI / verify (push) Canceled after 0s
原生版以非 root 的 havline 用户运行,而 app.New 会无条件调用 updater.EnsureToken,
去写默认的 /run/havline-updater/updater.token:MkdirAll 因权限不足失败 →
app.New 返回错误 → main 里 log.Fatalf → 进程秒退,飞牛应用中心报「本地应用启动失败」。
该逻辑由 v1.9.2 引入,v1.9.1 及之前不受影响。

- 新增 updater.EnsureTokenIfEnabled:updater 未启用(socket 为空)时直接返回,
  完全不触碰 Token 文件
- app.New 改用该函数,只有真正启用一键升级 sidecar 时才准备共享 Token
- 补充回归测试:socket 为空不创建文件、socket 非空照旧创建
2026-10-11 15:59:03 +08:00
mrlee 2856040883 chore(release): 发布 v1.9.3
CI / verify (push) Canceled after 0s
Docker Release / build-and-push (amd64, linux/amd64) (push) Canceled after 0s
Docker Release / build-and-push (arm64, linux/arm64) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Docker Release / create-manifest (push) Canceled after 0s
- VERSION 与 web/package.json、package-lock 提升到 1.9.3
- README 版本徽章与飞牛安装包名同步到 1.9.3
- CHANGELOG 的 [Unreleased] 段落转为 [v1.9.3] - 2026-10-11
2026-10-11 15:18:32 +08:00
mrlee 0f68101503 feat(frp): frps vhost 回源端口支持 80/443,公网反代在该配置下不再适用
- 服务端表单不再硬性拒绝 vhostHTTPPort=80 / vhostHTTPSPort=443,改为保存时弹窗二次确认;
  确认后请求携带 allow_privileged_vhost_ports 提交并由后端校验放行,未携带标记的请求(含直接调 API)仍被拒绝
- frps.toml 生成改为写入风险注释、不再报错;各规则的公网反代回源地址随 80/443 自动跟随
- 「公网反代」页检测到 80/443 后显示说明卡片并隐藏统计/Nginx/规则台账/漂移区块,
  同时跳过 agent 的路由、漂移与健康请求
- 后端 DeployAgentRoute 与 RedeployAllRoutes(含漂移一键修复)拒绝下发,自动下发路径 syncAgentRoute 跳过
- 新增 internal/frp/vhost_port_test.go:校验、二次确认、frps.toml 生成、反代回源跟随与守卫对照
2026-10-11 15:18:32 +08:00
mrlee 50b8cc1351 Update README.md 2026-10-11 13:58:57 +08:00
123 changed files with 13702 additions and 2445 deletions
+27 -3
View File
@@ -16,6 +16,18 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
# 版本号是构建的唯一事实来源(VERSION 文件):前端 package.json 与它不一致时,
# 界面上显示的版本和二进制里的版本会对不上,所以在构建前先卡住。
- name: Check version consistency
run: |
version="$(tr -d ' \t\r\n' < VERSION)"
pkg="$(node -p "require('./web/package.json').version")"
echo "VERSION=$version package.json=$pkg"
if [ "$version" != "$pkg" ]; then
echo "::error::版本不一致:VERSION=$version,web/package.json=$pkg。请把两者改成同一个版本号。"
exit 1
fi
# 顺序不能颠倒:cmd/havline/main.go 用 //go:embed all:web/dist 嵌入前端,
# 干净检出时该目录不存在,go vet / go build 会直接失败。
- name: Setup Node
@@ -29,6 +41,10 @@ jobs:
working-directory: web
run: npm ci
- name: Lint web
working-directory: web
run: npm run lint
- name: Type-check and build web
working-directory: web
run: npm run build
@@ -48,6 +64,17 @@ jobs:
go-version-file: go.mod
cache: true
# 格式化门禁:历史文件已在 M2 一次性 gofmt -w 过,从这里开始保持零容忍,
# 避免再退回「改动时顺手格式化一大片、review 看不出真实差异」的状态。
- name: gofmt
run: |
unformatted="$(gofmt -l internal cmd)"
if [ -n "$unformatted" ]; then
echo "::error::以下文件未格式化,请运行 gofmt -w internal cmd"
echo "$unformatted"
exit 1
fi
- name: go vet
run: go vet ./...
@@ -56,6 +83,3 @@ jobs:
- name: go build
run: go build ./...
# 说明:gofmt 卡口暂未开启——仓库里还有 25 个历史未格式化文件(见 docs/project-audit-2026-09-24.md §八),
# 全仓 `gofmt -w` 之后再补一步 `gofmt -l internal cmd` 作为门禁。
+5
View File
@@ -34,3 +34,8 @@ scripts/*
*.7z
*.tar.gz
*.log
# 前端 UI 原型(HTML 草稿与截图,约 2.8MB):只作设计参考,不是构建输入
/prototype/
# 只保留 npm 一份 lockfile:仓库里曾同时存在 pnpm-lock.yaml,
# 两份锁文件各自记录不同版本,装出哪个依赖取决于用哪个包管理器,容易排查不清
web/pnpm-lock.yaml
+43 -1
View File
@@ -2,7 +2,49 @@
All notable changes to this project will be documented in this file.
> 说明:1.1.0 起的相近补丁版本已合并整理并重新编排版本号;0.1.0-1.0.2 保持原样。
## [v2.0.0] - 2026-10-16
### 新增
- **内置 SSH 终端(`/terminal`)**:在浏览器里直接连到「公网服务端」已配置的 VPS,复用已保存的 SSH 密码 / 私钥与每台机器独立的 known_hosts 校验,凭据不下发到浏览器;总开关默认关闭,会话空闲 10 分钟自动断开、单实例最多 5 个并发会话、单会话最长 12 小时,页面按需懒加载 xterm
- **终端二次认证**:进入终端前需要用管理员密码再确认一次,通过后签发 10 分钟解锁票据并绑定当前登录会话,期间新建会话不再重复输入;改密、登出、关闭开关都会立即失效,同一 IP 连续失败 5 次锁定 5 分钟;终端相关接口一律不接受 API Token,避免绕过二次认证
- **终端会话审计**:每次会话结束落库「谁、何时、连了哪台、多久、为什么断开」以及收发字节数,新增独立审计页 `/terminal/audits`;记录保留 90 天,由每日任务清理
- **会话录制开关**:设置 → 安全新增「录制会话内容」(默认关闭,并提示录像包含屏幕上的全部内容,包括在远端提示符下输入的口令)。开启后按 asciinema v2 把输出写入 `logs/terminal/<会话ID>.cast`,单文件上限 32 MB、保留 90 天,可在「录像审计」里直接回放
- **终端常用命令**:右侧面板保存常用命令(落库,换浏览器仍在),一键发送到当前会话
- **终端页右侧面板**:服务器列表 / 录像审计 / 常用命令三个标签页;服务器列表可直接连接服务端,也可跳转「公网服务端」维护 SSH 配置
- **DDNS 服务商名称与自定义 IP**:同一服务商可建多条配置并用名称区分(如「家庭」「公司」);新增「自定义 IP」入口,保存后 DDNS 更新直接使用指定 IP,不再探测本机出口 IP
### 变更
- **终端体验**:多标签会话、断线一键重连、复制粘贴(Ctrl/⌘+Shift+C/V)、字号记忆、清屏,以及输出里的 URL 可直接点击;终端高度可拖动(默认跟随视口,双击恢复自适应),鼠标向下滚动能把最后一行推到视口顶部
- **移动端只读**:窄屏默认只读(可滚动、可复制),需要输入时显式点「允许输入」,减少手机上的误触
- **终端开关按布尔语义解析**:`1` / `true` / `on` 都视为开启,避免开关显示已打开、接口却按未启用拒绝
- **公网反代默认转发 WebSocket 升级头**:新建穿透规则的公网反代部署选项默认打开「WebSocket 支持」,老规则缺少该键时也按开启处理(显式关掉的保持关闭)。此前该开关默认关闭,NAS 桌面、各类控制台、Portainer、Home Assistant 等依赖 WebSocket 的页面在公网访问时会一直挂起
### 测试
- 新增终端会话回放缓冲、并发上限、空闲回收、resize 转发用例
- 新增二次认证解锁票据、错误密码限流与锁定、API Token 拦截用例
- 新增终端审计写入、远端断开回收、保留期清理用例
- 新增会话录制(cast 头与帧格式、非法会话 ID 拦截、过期清理、输出接入录制器)与常用命令校验用例
- `go test ./...`、`npm run typecheck`、`npm run build` 通过
## [v1.9.3] - 2026-10-11
### 新增
- **frps vhost 回源端口支持 80/443(需二次确认)**:服务端表单不再硬性拒绝 `vhostHTTPPort = 80` / `vhostHTTPSPort = 443`,改为保存时弹窗二次确认,提示「80/443 通常已被 VPS 上的 Nginx 占用,需先停止占用该端口的服务,否则 frps 会因端口冲突启动失败」;确认后请求携带 `allow_privileged_vhost_ports` 提交并由后端校验放行,未携带标记的请求(包括直接调 API)仍被拒绝。生成的 `frps.toml` 会写入一行风险注释,各规则的公网反代回源地址(HTTP → `127.0.0.1:80`、HTTPS → `127.0.0.1:443`)随回源端口自动跟随
### 变更
- **80/443 回源端口下公网反代不适用,也不再自动部署**:frps 直接占用 80/443 意味着 VPS 上不再有 Nginx 的位置,「公网反代」页检测到该配置后显示说明卡片,隐藏统计、Nginx、规则台账与部署漂移区块,并跳过 agent 的路由 / 漂移 / 健康请求;后端 `DeployAgentRoute` 与 `RedeployAllRoutes`(含漂移一键修复)一律拒绝下发,自动下发路径 `syncAgentRoute` 直接跳过。历史遗留的 Nginx 路由需把回源端口改回 8080/8443 后才能在本页移除
### 测试
- 新增 `internal/frp/vhost_port_test.go`:80/443 未二次确认被拒、已确认可保存、生成的 `frps.toml` 含风险注释、公网反代回源地址跟随 80/443
- 新增回源端口特权判定边界用例(默认 8080/8443、显式 0、自定义高位端口)与「默认端口不受 80/443 规则影响」对照测试
- `go test ./...`、`npm run typecheck` 通过
## [v1.9.2] - 2026-10-07
+2 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<a href="https://github.com/mrlee233/havline/releases"><img src="https://img.shields.io/badge/version-1.9.2-22c55e?style=flat-square" alt="发布版本" /></a>
<a href="https://github.com/mrlee233/havline/releases"><img src="https://img.shields.io/badge/version-2.0.0-22c55e?style=flat-square" alt="发布版本" /></a>
<img src="https://img.shields.io/badge/目标平台-飞牛_fnOS-22c55e?style=flat-square" alt="飞牛 fnOS" />
<img src="https://img.shields.io/badge/其他环境-未测试-94a3b8?style=flat-square" alt="其他环境未测试" />
<img src="https://img.shields.io/badge/Go-1.23-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go" />
@@ -56,7 +56,6 @@ Havline 基于开源项目 [O96u/Fonu](https://github.com/O96u/Fonu) **1.0.2**
| **Cloudflare Tunnel** | cloudflared 按需下载、Account/API Token 预检、隧道与路由管理、DNS 同步、健康巡检和漂移修复 |
| **一键升级** | Compose + updater sidecar,支持构建/重建/健康检查,并在部分失败场景下尝试回滚 |
| **飞牛交付** | 提供 Docker 离线镜像包与 Native 应用两种形态,支持统一网关 `/app/havline` 接入 |
| **文档体系** | 增加图文操作手册、部署/升级/迁移文档、飞牛打包方案和 Cloudflare/FRP 专题方案 |
## 技术栈
@@ -230,7 +229,7 @@ Havline 提供两种飞牛应用形态,均支持统一网关入口 `/app/havli
### Docker 应用(离线镜像包)
- 独立仓库:<https://github.com/mrlee233/havline>
- 安装包:`havline-1.9.2.fpk`
- 安装包:`havline-2.0.0.fpk`
- 包内包含 amd64 / arm64 离线镜像,安装时执行 `docker load`
- Compose 使用本地镜像并设置 `pull_policy: never`,不依赖外部 Registry
- 管理界面通过统一网关 `/app/havline` 访问
+1 -1
View File
@@ -1 +1 @@
1.9.2
2.0.0
+2
View File
@@ -12,6 +12,8 @@ services:
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
image: havline:${HAVLINE_VERSION:-local}
# 本地源码部署直接构建,跳过无意义的 Docker Hub 拉取和超时警告。
pull_policy: build
container_name: havline
restart: unless-stopped
network_mode: host
+3
View File
@@ -17,6 +17,8 @@ services:
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
image: havline:${HAVLINE_VERSION:-local}
# 本地源码部署直接构建,跳过无意义的 Docker Hub 拉取和超时警告。
pull_policy: build
container_name: havline
restart: unless-stopped
depends_on:
@@ -54,6 +56,7 @@ services:
UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli}
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
image: havline-updater:${HAVLINE_VERSION:-local}
pull_policy: build
container_name: havline-updater
restart: unless-stopped
volumes:
+1
View File
@@ -4,6 +4,7 @@ go 1.23.0
require (
github.com/go-acme/lego/v4 v4.23.1
github.com/gorilla/websocket v1.5.3
github.com/nrdcg/dnspod-go v0.4.0
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128
+2
View File
@@ -225,6 +225,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
+4 -4
View File
@@ -31,10 +31,10 @@ type JobDonePayload struct {
}
type Job struct {
id string
mu sync.Mutex
events []JobEvent
done bool
id string
mu sync.Mutex
events []JobEvent
done bool
waiters map[chan JobEvent]struct{}
}
+80
View File
@@ -0,0 +1,80 @@
package agent
import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
)
// frps 的 custom404Page 只认 VPS 上的文件路径,所以「自定义 404 页面内容」必须先把内容
// 落到 VPS 上。这里给 Havline 一个受控的写文件入口:只写绝对路径、只写 .html,避免变成
// 任意文件写入。
type frpsPageRequest struct {
Path string `json:"path"`
Content string `json:"content"`
}
func (s *Server) putFRPS404Page(w http.ResponseWriter, r *http.Request) {
var req frpsPageRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
path, err := validatePagePath(req.Path)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if strings.TrimSpace(req.Content) == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "页面内容为空"})
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
s.mu.Lock()
defer s.mu.Unlock()
if err := atomicWrite(path, []byte(req.Content), 0o644); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": path, "bytes": len(req.Content)})
}
func (s *Server) deleteFRPS404Page(w http.ResponseWriter, r *http.Request) {
path, err := validatePagePath(r.URL.Query().Get("path"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
s.mu.Lock()
defer s.mu.Unlock()
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// validatePagePath 只接受绝对路径 + .html/.htm 结尾,防止这个接口被当成任意文件写入。
func validatePagePath(raw string) (string, error) {
path := strings.TrimSpace(raw)
// agent 只跑在 Linux 上,这里按 POSIX 绝对路径判断(用 filepath.IsAbs 会在别的平台判错)
if path == "" || !strings.HasPrefix(path, "/") {
return "", errors.New("页面路径必须是绝对路径")
}
if strings.Contains(path, "..") {
return "", errors.New("页面路径不允许包含 ..")
}
switch strings.ToLower(filepath.Ext(path)) {
case ".html", ".htm":
return filepath.Clean(path), nil
default:
return "", errors.New("页面文件必须以 .html 或 .htm 结尾")
}
}
+19
View File
@@ -0,0 +1,19 @@
package agent
import "testing"
// 404 页面写入接口只接受绝对路径 + .html/.htm,避免被当成任意文件写入。
func TestValidatePagePath(t *testing.T) {
ok := []string{"/etc/frp/havline-404.html", "/var/www/404.htm"}
for _, path := range ok {
if _, err := validatePagePath(path); err != nil {
t.Errorf("%s 应当通过:%v", path, err)
}
}
bad := []string{"", "etc/frp/404.html", "/etc/frp/404.txt", "/etc/../etc/passwd.html"}
for _, path := range bad {
if _, err := validatePagePath(path); err == nil {
t.Errorf("%s 应当被拒绝", path)
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package agent
import (
"net"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// 用临时参数跑探测,避免单测真的等满默认的 1.5 秒间隔
func stubProbeRetry(t *testing.T, attempts int) {
t.Helper()
oldAttempts, oldInterval := routeProbeAttempts, routeProbeInterval
routeProbeAttempts, routeProbeInterval = attempts, time.Millisecond
t.Cleanup(func() { routeProbeAttempts, routeProbeInterval = oldAttempts, oldInterval })
}
func probePort(t *testing.T, server *httptest.Server) int {
t.Helper()
addr, ok := server.Listener.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("无法取到测试服务端口:%v", server.Listener.Addr())
}
return addr.Port
}
// TestProbeVhostReadyWaitsForRegistration:前两次 404(隧道尚未注册)后应继续重试并拿到 200,
// 这正是「部署后立刻探测误报失败、刷新又正常」的场景。
func TestProbeVhostReadyWaitsForRegistration(t *testing.T) {
stubProbeRetry(t, 4)
var hits int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if atomic.AddInt32(&hits, 1) <= 2 {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
code, _ := (&Server{}).probeVhostReady(probePort(t, server), "a.example.com")
if code != http.StatusOK {
t.Fatalf("期望重试后拿到 200,实际 %d", code)
}
if hits != 3 {
t.Fatalf("期望探测 3 次后成功,实际 %d 次", hits)
}
}
// TestProbeVhostReadyStopsOnSuccess:非 404 立即返回,不做多余重试。
func TestProbeVhostReadyStopsOnSuccess(t *testing.T) {
stubProbeRetry(t, 4)
var hits int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusBadGateway)
}))
defer server.Close()
code, _ := (&Server{}).probeVhostReady(probePort(t, server), "a.example.com")
if code != http.StatusBadGateway {
t.Fatalf("期望原样返回 502,实际 %d", code)
}
if hits != 1 {
t.Fatalf("502 不应重试,实际探测 %d 次", hits)
}
}
// TestProbeVhostReadyGivesUpAfterAttempts:一直 404 时用尽重试次数并返回最后一次结果。
func TestProbeVhostReadyGivesUpAfterAttempts(t *testing.T) {
stubProbeRetry(t, 3)
var hits int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
code, _ := (&Server{}).probeVhostReady(probePort(t, server), "a.example.com")
if code != http.StatusNotFound {
t.Fatalf("期望最终仍为 404,实际 %d", code)
}
if hits != 3 {
t.Fatalf("期望探测 3 次后放弃,实际 %d 次", hits)
}
}
+10
View File
@@ -81,6 +81,16 @@ func TestNginxRouteSharedTemplateSemantics(t *testing.T) {
}
}
denyIdx := strings.Index(out, "deny 8.8.8.8;")
// WebSocket 头必须落在 location 内部:location 自定义 proxy_set_header 后,
// server 级的那组不会被继承,写在 server 块里等于没生效(升级请求会一直挂起)
locationIdx := strings.Index(out, "location / {")
upgradeIdx := strings.Index(out, "proxy_set_header Upgrade $http_upgrade;")
if locationIdx < 0 || upgradeIdx < 0 || upgradeIdx < locationIdx {
t.Fatalf("Upgrade 头必须写在 location 内:\n%s", out)
}
if !strings.Contains(out, "proxy_read_timeout 3600s;") {
t.Fatalf("WebSocket 未配读超时时应给长连接默认超时:\n%s", out)
}
allowIdx := strings.Index(out, "allow 192.168.1.0/24;")
denyAllIdx := strings.Index(out, "deny all;")
if denyIdx < 0 || allowIdx < 0 || denyAllIdx < 0 || !(denyIdx < allowIdx && allowIdx < denyAllIdx) {
+63 -23
View File
@@ -31,7 +31,7 @@ import (
"github.com/havline/havline/internal/sysinfo"
)
const AgentVersion = "0.15.2"
const AgentVersion = "0.15.5"
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct {
@@ -218,6 +218,8 @@ func agentRoutes(s *Server) []agentRoute {
{http.MethodPut, "/api/v1/frps/config", s.putFRPSConfig},
{http.MethodPost, "/api/v1/frps/install", s.installFRPS},
{http.MethodPost, "/api/v1/frps/action", s.frpsAction},
{http.MethodPut, "/api/v1/frps/404-page", s.putFRPS404Page},
{http.MethodDelete, "/api/v1/frps/404-page", s.deleteFRPS404Page},
{http.MethodPost, "/api/v1/certs", s.putCertificate},
{http.MethodGet, "/api/v1/metrics", s.metrics},
{http.MethodGet, "/api/v1/logs/nginx", s.nginxLogs},
@@ -1029,6 +1031,31 @@ func (s *Server) putRoute(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "VPS 未安装 Nginx(或不在常见路径):请先安装(apt install nginx / yum install nginx),或在「公网反代」页点「一键安装 Nginx」后再部署"})
return
}
// 写配置串行化;回源自检不持锁,避免重试等待期间阻塞其他路由操作
generatedPassword, writeErr := s.writeRouteLocked(req)
if writeErr != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": writeErr.Error()})
return
}
result := map[string]any{"ok": true, "domain": req.Domain, "websocket": req.WebSocket}
if generatedPassword != "" {
result["basic_auth_user"] = strings.TrimSpace(req.BasicAuthUser)
result["basic_auth_password"] = generatedPassword
}
// 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态:
// 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
if vhost := req.VhostPort; vhost > 0 {
code, body := s.probeVhostReady(vhost, req.Domain)
result["probe_status"] = code
result["probe_ok"] = code >= 200 && code < 400
result["probe_notfound"] = code == 404
result["probe_body_snip"] = body
}
writeJSON(w, http.StatusOK, result)
}
// writeRouteLocked 串行化写入路由配置,返回首次自动生成的 Basic Auth 明文(仅本次响应返回一次)。
func (s *Server) writeRouteLocked(req routeRequest) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Basic Auth 密码留空时首次自动生成:生成的明文只在本次响应里回一次,
@@ -1044,24 +1071,31 @@ func (s *Server) putRoute(w http.ResponseWriter, r *http.Request) {
// 部署失败就丢弃刚生成的凭据,否则重试时文件已存在、密码再也拿不到
_ = os.Remove(basicAuthFilePath(req.Domain))
}
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
return "", err
}
result := map[string]any{"ok": true, "domain": req.Domain, "websocket": req.WebSocket}
if generatedPassword != "" {
result["basic_auth_user"] = strings.TrimSpace(req.BasicAuthUser)
result["basic_auth_password"] = generatedPassword
return generatedPassword, nil
}
// 回源自检的重试参数:刚应用完 frpc 配置时隧道可能还没注册到 frps,单测里会调小间隔。
var (
routeProbeAttempts = 6
routeProbeInterval = 1500 * time.Millisecond
)
// probeVhostReady 与 probeVhost 相同,但在 404 时短时间重试:frpc 应用配置后需要几秒
// 才把域名注册到 frps,立即探测会误报「没有该域名的隧道」。
func (s *Server) probeVhostReady(port int, host string) (int, string) {
code, body := 0, ""
for attempt := 0; attempt < routeProbeAttempts; attempt++ {
code, body = s.probeVhost(port, host)
if code != http.StatusNotFound {
return code, body
}
if attempt < routeProbeAttempts-1 {
time.Sleep(routeProbeInterval)
}
}
// 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态:
// 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
if vhost := req.VhostPort; vhost > 0 {
code, body := s.probeVhost(vhost, req.Domain)
result["probe_status"] = code
result["probe_ok"] = code >= 200 && code < 400
result["probe_notfound"] = code == 404
result["probe_body_snip"] = body
}
writeJSON(w, http.StatusOK, result)
return code, body
}
// probeVhost 从 VPS 本机模拟 Nginx 回源:返回 HTTP 状态码与响应片段(限长)。
@@ -1309,7 +1343,7 @@ func (s *Server) writeRoute(req routeRequest) error {
if err != nil {
return err
}
// putRoute 已持有 s.mu;这里不能再次加锁,否则会发生自锁死。
// writeRouteLocked 已持有 s.mu;这里不能再次加锁,否则会发生自锁死。
s.cfg.NginxConfDir = dir
}
certDir := req.CertDir
@@ -1624,10 +1658,6 @@ func nginxRoute(req routeRequest, certDir string) string {
if req.ConnLimitMax > 0 {
b.WriteString(" limit_conn_status 503;\n")
}
if req.WebSocket {
b.WriteString(" proxy_http_version 1.1;\n")
b.WriteString(nginxtmpl.UpgradeHeaders(" ", "\"upgrade\""))
}
// IP 访问控制:白名单(allow + deny all)与黑名单(deny)可同时生效;
// 顺序由共享模板统一:deny → 仅大陆规则 → allow → deny all。
chinaDeny := ""
@@ -1658,7 +1688,17 @@ func nginxRoute(req routeRequest, certDir string) string {
Burst: req.RateLimitBurst,
Conn: req.ConnLimitMax,
}, " ")
fmt.Fprintf(&b, " location / {\n%s%s%s proxy_pass http://%s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_http_version 1.1;\n }\n}\n", aclText, authLine, limitText, req.Upstream)
// WebSocket 必须写在 location 里:location 一旦自定义了 proxy_set_header,
// server 级的那组不会被继承——写在 server 块等于没写,表现就是升级请求一直挂起。
wsText := ""
if req.WebSocket {
wsText = nginxtmpl.UpgradeHeaders(" ", "\"upgrade\"")
// 规则没显式配 proxy_read_timeout 时给长连接留足空闲时间,避免 60 秒被 Nginx 掐断
if strings.TrimSpace(req.ProxyReadTimeout) == "" {
wsText += " proxy_read_timeout 3600s;\n"
}
}
fmt.Fprintf(&b, " location / {\n%s%s%s proxy_pass http://%s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_http_version 1.1;\n%s }\n}\n", aclText, authLine, limitText, req.Upstream, wsText)
return b.String()
}
+11 -5
View File
@@ -14,13 +14,15 @@ import (
)
type AuthHandler struct {
auth *auth.Service
notify *notify.Service
guard *loginGuard
auth *auth.Service
notify *notify.Service
guard *loginGuard
unlocks *terminalUnlockStore
}
func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler {
return &AuthHandler{auth: authSvc, notify: notifySvc, guard: newLoginGuard()}
// NewAuthHandler 装配登录处理器;unlocks 是终端二次认证票据,改密后要让当前会话立即失效。
func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service, unlocks *terminalUnlockStore) *AuthHandler {
return &AuthHandler{auth: authSvc, notify: notifySvc, guard: newLoginGuard(), unlocks: unlocks}
}
const (
@@ -155,6 +157,10 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
// 改密后当前会话的终端解锁票据立即作废:密码都换了,不该还留着已解锁的 shell
if h.unlocks != nil {
h.unlocks.revoke(sessionID)
}
writeJSON(w, http.StatusOK, map[string]string{"message": "密码已更新"})
}
+15 -6
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/havline/havline/internal/backup"
"github.com/havline/havline/internal/settings"
@@ -19,8 +20,14 @@ func NewBackupHandler(svc *backup.Service, settingsStore *settings.Store) *Backu
return &BackupHandler{svc: svc, settings: settingsStore}
}
// passphraseFromHeader 读取可选的归档口令。
// 放在请求头而不是查询串:查询串会进访问日志,口令不该落盘。
func passphraseFromHeader(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-Backup-Passphrase"))
}
func (h *BackupHandler) Export(w http.ResponseWriter, r *http.Request) {
path, err := h.svc.Export(r.Context())
path, err := h.svc.ExportWith(r.Context(), backup.Options{Passphrase: passphraseFromHeader(r)})
if err != nil {
writeError(r, w, http.StatusInternalServerError, "创建备份失败")
return
@@ -66,7 +73,7 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
}
out.Close()
if err := h.svc.Restore(r.Context(), tmpPath); err != nil {
if err := h.svc.RestoreWith(r.Context(), tmpPath, backup.Options{Passphrase: passphraseFromHeader(r)}); err != nil {
_ = os.Remove(tmpPath)
writeError(r, w, http.StatusBadRequest, err.Error())
return
@@ -91,14 +98,15 @@ func (h *BackupHandler) ListArchives(w http.ResponseWriter, r *http.Request) {
// CreateArchive 立即备份一份并保留在服务器上,顺带按保留份数剪枝
func (h *BackupHandler) CreateArchive(w http.ResponseWriter, r *http.Request) {
path, err := h.svc.Export(r.Context())
opts := backup.Options{Passphrase: passphraseFromHeader(r)}
path, err := h.svc.ExportWith(r.Context(), opts)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "创建备份失败", err)
return
}
name := filepath.Base(path)
// 写失败的备份不能留在列表里冒充可用备份
if _, err := h.svc.Verify(r.Context(), path); err != nil {
if _, err := h.svc.VerifyWith(r.Context(), path, opts); err != nil {
_ = h.svc.Remove(name)
writeError(r, w, http.StatusInternalServerError, "备份校验失败:"+err.Error(), err)
return
@@ -141,12 +149,13 @@ func (h *BackupHandler) RestoreArchive(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
opts := backup.Options{Passphrase: passphraseFromHeader(r)}
// 恢复前先校验:半个包不能拿去覆盖现有数据
if _, err := h.svc.Verify(r.Context(), path); err != nil {
if _, err := h.svc.VerifyWith(r.Context(), path, opts); err != nil {
writeError(r, w, http.StatusBadRequest, "该备份不可用:"+err.Error(), err)
return
}
if err := h.svc.Restore(r.Context(), path); err != nil {
if err := h.svc.RestoreWith(r.Context(), path, opts); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error(), err)
return
}
+2
View File
@@ -18,6 +18,7 @@ func NewDDNSHandler(svc *ddns.Service) *DDNSHandler {
type ddnsRequest struct {
Provider string `json:"provider"`
Name string `json:"name"`
RootDomain string `json:"root_domain"`
RecordName string `json:"record_name"`
RecordNames []string `json:"record_names"`
@@ -35,6 +36,7 @@ type ddnsRequest struct {
func (h *DDNSHandler) toInput(req ddnsRequest) ddns.SaveInput {
return ddns.SaveInput{
Provider: req.Provider,
Name: req.Name,
RootDomain: req.RootDomain,
RecordName: req.RecordName,
RecordNames: req.RecordNames,
+9
View File
@@ -17,6 +17,7 @@ import (
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
"github.com/havline/havline/internal/terminal"
"github.com/havline/havline/internal/traffic"
"github.com/havline/havline/internal/updater"
)
@@ -40,4 +41,12 @@ type Deps struct {
Updater *updater.Client
StaticFS fs.FS
StartedAt string
// Terminal 是内置 SSH 终端的会话管理器;为 nil 时相关接口不注册
Terminal *terminal.Manager
// TerminalAudits 是终端会话审计存储;为 nil 时不注册审计接口
TerminalAudits *terminal.AuditStore
// TerminalCommands 是终端常用命令存储;为 nil 时常用命令接口返回空
TerminalCommands *terminal.CommandStore
// TerminalRecordings 是会话录制存储;为 nil 时录制接口返回空
TerminalRecordings *terminal.RecordingStore
}
+33 -5
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"errors"
"io"
@@ -50,7 +51,9 @@ type frpServerRequest struct {
TLSTrustedCA string `json:"tls_trusted_ca"`
ClearTLSCertificate bool `json:"clear_tls_certificate"`
ClearTLSKey bool `json:"clear_tls_key"`
ClearTLSTrustedCA bool `json:"clear_tls_trusted_ca"`
// 二次确认标记:vhost 回源端口填 80/443 时前端必须带上
AllowPrivilegedVhostPorts bool `json:"allow_privileged_vhost_ports"`
ClearTLSTrustedCA bool `json:"clear_tls_trusted_ca"`
}
type frpProxyRequest struct {
@@ -959,20 +962,33 @@ func (h *FRPMultiHandler) DeleteProxy(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// frpDetachedContext 给 frpc 的启停操作一个不随请求取消的上下文。
//
// 用户常常是从隧道那头点进来的(浏览器 → VPS → frpc → 本机),而「停止/重启」会先杀掉
// frpc —— 那一刻用户的连接就断了,r.Context() 随之取消。继续拿它做后续的数据库读写,
// 就会出现「frpc 停掉了、却再也拉不起来」的经典事故。
func frpDetachedContext(r *http.Request) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(r.Context()), 2*time.Minute)
}
func (h *FRPMultiHandler) Start(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Start(r.Context()); err != nil {
ctx, cancel := frpDetachedContext(r)
defer cancel()
if err := h.svc.Start(ctx); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
writeJSON(w, http.StatusOK, h.svc.Status(ctx))
}
func (h *FRPMultiHandler) Stop(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Stop(r.Context()); err != nil {
ctx, cancel := frpDetachedContext(r)
defer cancel()
if err := h.svc.Stop(ctx); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
writeJSON(w, http.StatusOK, h.svc.Status(ctx))
}
func (h *FRPMultiHandler) Reload(w http.ResponseWriter, r *http.Request) {
@@ -983,6 +999,17 @@ func (h *FRPMultiHandler) Reload(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
}
// Restart 重启全部 frpc:比「停止 → 启动」更稳,重启后会重建 Agent 隧道。
func (h *FRPMultiHandler) Restart(w http.ResponseWriter, r *http.Request) {
ctx, cancel := frpDetachedContext(r)
defer cancel()
if err := h.svc.Restart(ctx); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, h.svc.Status(ctx))
}
func (h *FRPMultiHandler) Logs(w http.ResponseWriter, r *http.Request) {
lines, err := h.svc.ReadLogs(queryInt(r, "limit", 200), int64(queryInt(r, "server_id", 0)))
if err != nil {
@@ -1061,6 +1088,7 @@ func (request frpServerRequest) serverInput() frp.ServerInput {
DashboardPassword: request.DashboardPassword, ClearDashboardPassword: request.ClearDashboardPassword,
TLSCertificate: request.TLSCertificate, TLSKey: request.TLSKey, TLSTrustedCA: request.TLSTrustedCA,
ClearTLSCertificate: request.ClearTLSCertificate, ClearTLSKey: request.ClearTLSKey, ClearTLSTrustedCA: request.ClearTLSTrustedCA,
AllowPrivilegedVhostPorts: request.AllowPrivilegedVhostPorts,
}
}
+5 -1
View File
@@ -69,11 +69,15 @@ func bearerToken(r *http.Request) string {
return strings.TrimSpace(header[len(prefix):])
}
// tokenDeniedPath 是只允许浏览器会话的路径(账号与令牌管理)。
// tokenDeniedPath 是只允许浏览器会话的路径(账号与令牌管理,以及需要二次认证的终端)。
func tokenDeniedPath(path string) bool {
if path == "/api/auth/password" || path == "/api/auth/logout" {
return true
}
// 终端是 root shell 入口:API Token 不能绕过二次认证,一律要求浏览器会话
if strings.HasPrefix(path, "/api/terminal") {
return true
}
return strings.HasPrefix(path, "/api/settings/tokens") ||
strings.HasPrefix(path, "/api/system/update")
}
+8
View File
@@ -9,6 +9,8 @@ import (
type ErrorBody struct {
Error string `json:"error"`
// Code 是给前端分支用的稳定标识(例如 terminal_locked),文案可能改,code 不改
Code string `json:"code,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
@@ -40,6 +42,12 @@ func writeError(r *http.Request, w http.ResponseWriter, status int, message stri
writeJSON(w, status, ErrorBody{Error: message})
}
// writeErrorCode 与 writeError 相同,但多带一个稳定错误码,供前端区分分支。
func writeErrorCode(r *http.Request, w http.ResponseWriter, status int, code, message string, cause ...error) {
logAPIError(r, status, message, cause)
writeJSON(w, status, ErrorBody{Error: message, Code: code})
}
func logAPIError(r *http.Request, status int, message string, cause []error) {
if status < 400 {
return
+52 -3
View File
@@ -1,8 +1,11 @@
package api
import (
"bufio"
"fmt"
"io/fs"
"log/slog"
"net"
"net/http"
"strings"
"time"
@@ -24,8 +27,10 @@ func NewRouter(deps Deps) http.Handler {
func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
logsHandler := NewLogsHandler(deps.Config)
// 终端二次认证票据:登录处理器与终端处理器共用一份,改密即可让当前会话失效
terminalUnlocks := newTerminalUnlockStore()
r := &Router{
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
authHandler: NewAuthHandler(deps.Auth, deps.Notify, terminalUnlocks),
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
nginxHandler: NewNginxHandler(deps.Proxy),
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.Cloudflare, deps.StartedAt, deps.Config.NginxPIDFile),
@@ -41,8 +46,19 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth),
cloudflareHandler: NewCloudflareHandler(deps.Cloudflare),
updateHandler: NewUpdateHandler(deps.Updater),
authSvc: deps.Auth,
staticFS: deps.StaticFS,
terminalHandler: NewTerminalHandler(TerminalHandlerOptions{
FRP: deps.FRPMulti,
Settings: deps.Settings,
Auth: deps.Auth,
Manager: deps.Terminal,
Audits: deps.TerminalAudits,
Commands: deps.TerminalCommands,
Recordings: deps.TerminalRecordings,
Unlocks: terminalUnlocks,
Logger: deps.Logger,
}),
authSvc: deps.Auth,
staticFS: deps.StaticFS,
}
mux := http.NewServeMux()
@@ -187,6 +203,7 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
protect("GET /api/frpmulti/proxies/{id}/logs", frpHandler.ProxyLogs)
protect("POST /api/frpmulti/start", frpHandler.Start)
protect("POST /api/frpmulti/stop", frpHandler.Stop)
protect("POST /api/frpmulti/restart", frpHandler.Restart)
protect("POST /api/frpmulti/reload", frpHandler.Reload)
protect("GET /api/frpmulti/logs", frpHandler.Logs)
protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
@@ -226,6 +243,28 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
protect("POST /api/cloudflare/routes/test", cf.TestRoute)
}
if deps.Terminal != nil {
term := r.terminalHandler
protect("GET /api/terminal/targets", term.ListTargets)
protect("GET /api/terminal/unlock", term.UnlockStatus)
protect("POST /api/terminal/unlock", term.Unlock)
protect("DELETE /api/terminal/unlock", term.Lock)
protect("GET /api/terminal/commands", term.ListCommands)
protect("POST /api/terminal/commands", term.CreateCommand)
protect("DELETE /api/terminal/commands/{id}", term.DeleteCommand)
protect("GET /api/terminal/recordings", term.ListRecordings)
protect("GET /api/terminal/recordings/{id}", term.GetRecording)
protect("GET /api/terminal/sessions", term.ListSessions)
protect("POST /api/terminal/sessions", term.CreateSession)
protect("GET /api/terminal/sessions/{id}/ws", term.Stream)
protect("DELETE /api/terminal/sessions/{id}", term.CloseSession)
}
// 审计不依赖终端开关:功能关掉后,历史记录仍然应该查得到
if deps.TerminalAudits != nil {
protect("GET /api/terminal/audits", r.terminalHandler.ListAudits)
}
if deps.StaticFS != nil {
mux.Handle("/", r.spaHandler())
}
@@ -262,6 +301,7 @@ type Router struct {
frpmultiHandler *FRPMultiHandler
cloudflareHandler *CloudflareHandler
updateHandler *UpdateHandler
terminalHandler *TerminalHandler
authSvc *auth.Service
staticFS fs.FS
}
@@ -302,6 +342,15 @@ func (w *statusRecorder) Flush() {
}
}
// Hijack 转发给底层 ResponseWriter:WebSocket 升级(内置 SSH 终端)需要拿到原始连接。
func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("底层 ResponseWriter 不支持 Hijack")
}
return hijacker.Hijack()
}
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
+31 -59
View File
@@ -44,65 +44,9 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
allowed := map[string]bool{
settings.KeyTheme: true,
settings.KeyTimezone: true,
settings.KeyLogRetentionDays: true,
settings.KeyDDNSCheckInterval: true,
settings.KeyCertRenewThreshold: true,
settings.KeyRootDomain: true,
settings.KeyACMEEmail: true,
settings.KeyACMECA: true,
settings.KeyZeroSSLAPIKey: true,
settings.KeyACMEDNSResolvers: true,
settings.KeyLiteSSLEABKid: true,
settings.KeyLiteSSLEABHMAC: true,
settings.KeyNotifyWebhookURL: true,
settings.KeyNotifyType: true,
settings.KeyNotifyEmailJSON: true,
settings.KeyNotifySMTPPassword: true,
settings.KeyNotifyWebhookJSON: true,
settings.KeyNotifyWebhookSecret: true,
settings.KeyNotifyTelegramJSON: true,
settings.KeyNotifyTelegramToken: true,
settings.KeyNotifyOnDDNSIPChange: true,
settings.KeyNotifyOnDDNSFailure: true,
settings.KeyNotifyOnCertExpiry: true,
settings.KeyNotifyOnCertRenewSuccess: true,
settings.KeyNotifyOnIPFrequentAccess: true,
settings.KeyNotifyOnCertRenewFailure: true,
settings.KeyNotifyOnLoginFailure: true,
settings.KeyNotifyOnNginxReloadFailure: true,
settings.KeyNotifyIPFrequentThreshold: true,
settings.KeyNotifyIPFrequentWindowSec: true,
settings.KeyNotifyLoginFailureThreshold: true,
settings.KeyNotifyLoginFailureWindowSec: true,
settings.KeyNotifyOnResourceThreshold: true,
settings.KeyNotifyOnTunnelDown: true,
settings.KeyNotifyCPUThreshold: true,
settings.KeyNotifyMemoryThreshold: true,
settings.KeyNotifyDiskThreshold: true,
settings.KeyNotifyTunnelFailThreshold: true,
settings.KeyNotifyOnFrpsDown: true,
settings.KeyNotifyOnAgentUnreachable: true,
settings.KeyNotifyOnRouteDrift: true,
settings.KeyNotifyFrpsFailThreshold: true,
settings.KeyNotifyAgentFailThreshold: true,
settings.KeyNotifyDriftCooldownHours: true,
settings.KeyBackupAutoEnabled: true,
settings.KeyRouteHealthEnabled: true,
settings.KeyStatusPageEnabled: true,
settings.KeyMetricsHistoryEnabled: true,
settings.KeyStatusPageCode: true,
settings.KeyBackupKeepCount: true,
settings.KeyNotifyOnBackupFailure: true,
settings.KeyTrustedProxy: true,
settings.KeyGlobalIPBlacklist: true,
settings.KeyGlobalIPWhitelist: true,
settings.KeyChinaCIDRUpdateHours: true,
settings.KeyChinaCIDRSourceV4: true,
settings.KeyChinaCIDRSourceV6: true,
}
// 白名单从设置注册表派生(settings.Specs 里 Writable=true 的键)。
// 之前是手写 map,新增设置项时容易只改前端与常量、忘了加白名单。
allowed := settings.WritableKeys()
filtered := map[string]string{}
for key, value := range req {
if !allowed[key] {
@@ -114,6 +58,13 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
}
filtered[key] = value
}
// 时区在写入前先校验:写进库之后才发现不合法,就得再回滚一次。
if tz, ok := filtered[settings.KeyTimezone]; ok {
if _, err := settings.LoadTimezone(tz); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error(), err)
return
}
}
// 状态页访问码走「掩码往返」:客户端提交明文,这里落 SHA-256;
// 空串 = 清除访问码,掩码值 = 保持不变。哈希字段不接受客户端直接写入。
if code, ok := filtered[settings.KeyStatusPageCode]; ok {
@@ -144,6 +95,14 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusInternalServerError, "保存设置失败", err)
return
}
// 时区是进程级状态:保存后立即生效,不必等重启(否则用户改了看不到变化,
// 又会变成「界面能改、实际不生效」的观感)。
if tz, ok := filtered[settings.KeyTimezone]; ok && strings.TrimSpace(tz) != "" {
if _, err := settings.ApplyTimezone(r.Context(), h.store); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error(), err)
return
}
}
if h.proxy != nil && (filtered[settings.KeyTrustedProxy] != "" || filtered[settings.KeyGlobalIPBlacklist] != "" || filtered[settings.KeyGlobalIPWhitelist] != "") {
if err := h.proxy.ReloadAll(r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "设置已保存,但 Nginx 重载失败:"+err.Error(), err)
@@ -265,6 +224,19 @@ func (h *SettingsHandler) validateSetting(key, value string) error {
return err
}
}
case settings.KeyTheme:
switch strings.TrimSpace(value) {
case "system", "light", "dark":
default:
return fmt.Errorf("无效的主题:只支持 system / light / dark")
}
case settings.KeyLogRetentionDays:
if value != "" {
n, err := strconv.Atoi(value)
if err != nil || n < 1 || n > 365 {
return fmt.Errorf("日志保留天数需在 1–365 之间")
}
}
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/terminal"
)
// TestListAuditsHandler:审计接口返回落库记录;未接存储时返回空数组而不是 null。
func TestListAuditsHandler(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatalf("打开数据库失败:%v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("迁移失败:%v", err)
}
store := terminal.NewAuditStore(conn)
now := time.Now().UTC()
entry := terminal.AuditEntry{
SessionID: "s1", ServerID: 3, Target: "东京 VPS · root@10.0.0.1",
User: "admin", ClientIP: "192.168.1.9",
StartedAt: now.Format(time.RFC3339), EndedAt: now.Add(time.Second).Format(time.RFC3339),
DurationMS: 1_000, Reason: "用户关闭",
}
if err := store.WriteAudit(context.Background(), entry); err != nil {
t.Fatalf("写入审计失败:%v", err)
}
handler := NewTerminalHandler(TerminalHandlerOptions{Audits: store})
rec := httptest.NewRecorder()
handler.ListAudits(rec, httptest.NewRequest(http.MethodGet, "/api/terminal/audits", nil))
if rec.Code != http.StatusOK {
t.Fatalf("状态码不符:%d %s", rec.Code, rec.Body.String())
}
var body struct {
Entries []terminal.AuditEntry `json:"entries"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("解析响应失败:%v", err)
}
if len(body.Entries) != 1 {
t.Fatalf("期望 1 条审计,实际 %d 条", len(body.Entries))
}
if body.Entries[0].User != "admin" || body.Entries[0].Reason != "用户关闭" {
t.Fatalf("审计字段不符:%+v", body.Entries[0])
}
// 未接存储时也要给前端一个数组,避免前端拿到 null 崩掉
empty := NewTerminalHandler(TerminalHandlerOptions{})
emptyRec := httptest.NewRecorder()
empty.ListAudits(emptyRec, httptest.NewRequest(http.MethodGet, "/api/terminal/audits", nil))
if got := emptyRec.Body.String(); got != "{\"entries\":[]}\n" {
t.Fatalf("空审计响应不符:%s", got)
}
}
+535
View File
@@ -0,0 +1,535 @@
package api
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/frp"
"github.com/havline/havline/internal/settings"
"github.com/havline/havline/internal/terminal"
)
// 终端通道的读超时:前端每 30 秒发一次 ping,这里留 3 倍余量。
const terminalReadTimeout = 90 * time.Second
// TerminalHandlerOptions 是终端处理器的装配参数;Manager 为 nil 时相关接口返回不可用。
type TerminalHandlerOptions struct {
FRP *frp.Service
Settings *settings.Store
Auth *auth.Service
Manager *terminal.Manager
Audits *terminal.AuditStore
// Commands 是「常用命令」存储;为 nil 时相关接口返回空列表
Commands *terminal.CommandStore
// Recordings 是会话录制存储;为 nil 时录制接口返回空
Recordings *terminal.RecordingStore
// Unlocks 与 AuthHandler 共用同一份解锁票据:改密后要让当前会话立即失效
Unlocks *terminalUnlockStore
Logger *slog.Logger
}
// TerminalHandler 提供内置 SSH 终端:目标列表、会话创建、WebSocket 双向通道与关闭。
type TerminalHandler struct {
frp *frp.Service
settings *settings.Store
auth *auth.Service
manager *terminal.Manager
audits *terminal.AuditStore
commands *terminal.CommandStore
recordings *terminal.RecordingStore
unlocks *terminalUnlockStore
guard *loginGuard
logger *slog.Logger
upgrader websocket.Upgrader
}
// NewTerminalHandler 装配终端处理器。
func NewTerminalHandler(opts TerminalHandlerOptions) *TerminalHandler {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
unlocks := opts.Unlocks
if unlocks == nil {
unlocks = newTerminalUnlockStore()
}
return &TerminalHandler{
frp: opts.FRP, settings: opts.Settings, auth: opts.Auth, manager: opts.Manager,
audits: opts.Audits, commands: opts.Commands, recordings: opts.Recordings,
unlocks: unlocks, guard: newLoginGuard(), logger: logger,
upgrader: websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: allowSameOrigin,
},
}
}
// allowSameOrigin 只允许同源页面建立 WebSocket,避免第三方页面借用已登录会话。
func allowSameOrigin(r *http.Request) bool {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
return true
}
parsed, err := url.Parse(origin)
if err != nil {
return false
}
return strings.EqualFold(parsed.Host, r.Host)
}
type terminalTarget struct {
Value string `json:"value"`
Label string `json:"label"`
Detail string `json:"detail"`
Ready bool `json:"ready"`
Disabled string `json:"disabled_reason,omitempty"`
}
type terminalCreateRequest struct {
ServerID int64 `json:"server_id"`
Cols int `json:"cols"`
Rows int `json:"rows"`
}
// terminalUnlockRequest 是终端二次认证的请求体。
type terminalUnlockRequest struct {
Password string `json:"password"`
}
// terminalCommandRequest 是新增快捷命令的请求体。
type terminalCommandRequest struct {
Name string `json:"name"`
Command string `json:"command"`
}
type terminalClientMessage struct {
Type string `json:"type"`
Data string `json:"data,omitempty"`
Cols int `json:"cols,omitempty"`
Rows int `json:"rows,omitempty"`
}
type terminalServerMessage struct {
Type string `json:"type"`
Data string `json:"data,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
}
// ListTargets 返回可选目标:只列出已配置 SSH 的服务端,未配置的给出原因。
func (h *TerminalHandler) ListTargets(w http.ResponseWriter, r *http.Request) {
if !h.requireEnabled(r, w) {
return
}
servers, err := h.frp.ListServers(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取服务端列表失败", err)
return
}
targets := make([]terminalTarget, 0, len(servers))
for _, server := range servers {
item := terminalTarget{
Value: strconv.FormatInt(server.ID, 10),
Label: server.Name,
Detail: fmt.Sprintf("%s@%s:%d", server.SSHUser, server.SSHHost, server.SSHPort),
Ready: server.SSHConfigured,
}
if !server.SSHConfigured {
item.Disabled = "未配置 SSH,请到「公网服务端」填写"
}
targets = append(targets, item)
}
writeJSON(w, http.StatusOK, map[string]any{"targets": targets})
}
// ListRecordings 返回磁盘上的会话录制,供「录像审计」列表使用。
func (h *TerminalHandler) ListRecordings(w http.ResponseWriter, r *http.Request) {
if !h.requireEnabled(r, w) {
return
}
if h.recordings == nil {
writeJSON(w, http.StatusOK, map[string]any{"recordings": []terminal.Recording{}})
return
}
items, err := h.recordings.List()
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取录制列表失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"recordings": items})
}
// GetRecording 读取一段录制(asciinema v2 文本),供前端回放。
func (h *TerminalHandler) GetRecording(w http.ResponseWriter, r *http.Request) {
if !h.requireEnabled(r, w) {
return
}
if h.recordings == nil {
writeError(r, w, http.StatusNotFound, "录制不存在或已被清理")
return
}
id := r.PathValue("id")
path, ok := h.recordings.Path(id)
if !ok {
writeError(r, w, http.StatusBadRequest, "录制 ID 不合法")
return
}
file, err := os.Open(path)
if err != nil {
writeError(r, w, http.StatusNotFound, "录制不存在或已被清理")
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取录制失败", err)
return
}
// 录制内容按原样返回:前端按 asciinema v2 解析后逐帧回放
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
http.ServeContent(w, r, id+".cast", info.ModTime(), file)
}
// ListCommands 返回已保存的快捷命令(右侧「常用命令」面板)。
func (h *TerminalHandler) ListCommands(w http.ResponseWriter, r *http.Request) {
if !h.requireEnabled(r, w) {
return
}
if h.commands == nil {
writeJSON(w, http.StatusOK, map[string]any{"commands": []terminal.TerminalCommand{}})
return
}
commands, err := h.commands.ListCommands(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取常用命令失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"commands": commands})
}
// CreateCommand 保存一条快捷命令。
func (h *TerminalHandler) CreateCommand(w http.ResponseWriter, r *http.Request) {
if !h.requireEnabled(r, w) {
return
}
if h.commands == nil {
writeError(r, w, http.StatusServiceUnavailable, "常用命令当前不可用")
return
}
var req terminalCommandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
command, err := h.commands.CreateCommand(r.Context(), req.Name, req.Command)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error(), err)
return
}
writeJSON(w, http.StatusCreated, command)
}
// DeleteCommand 删除一条快捷命令。
func (h *TerminalHandler) DeleteCommand(w http.ResponseWriter, r *http.Request) {
if !h.requireEnabled(r, w) {
return
}
if h.commands == nil {
writeError(r, w, http.StatusServiceUnavailable, "常用命令当前不可用")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeError(r, w, http.StatusBadRequest, "命令 ID 无效")
return
}
if err := h.commands.DeleteCommand(r.Context(), id); err != nil {
if errors.Is(err, terminal.ErrCommandNotFound) {
writeError(r, w, http.StatusNotFound, err.Error())
return
}
writeError(r, w, http.StatusInternalServerError, "删除常用命令失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// UnlockStatus 返回当前登录会话的终端解锁状态,供前端显示剩余时间。
func (h *TerminalHandler) UnlockStatus(w http.ResponseWriter, r *http.Request) {
if !h.requireEnabled(r, w) {
return
}
expires, ok := h.unlocks.expiresAt(sessionIDFromContext(r.Context()))
if !ok {
writeJSON(w, http.StatusOK, map[string]any{"unlocked": false})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"unlocked": true,
"expires_at": expires.UTC().Format(time.RFC3339),
})
}
// Unlock 校验管理员密码,换取终端短时解锁。
// 这是「登录态点一下就能拿到 root shell」与「还需要知道一个密码」之间的那道门。
func (h *TerminalHandler) Unlock(w http.ResponseWriter, r *http.Request) {
if !h.requireEnabled(r, w) {
return
}
sessionID := sessionIDFromContext(r.Context())
if sessionID == "" {
// 终端不接受 API Token,二次认证也必须走浏览器会话
writeErrorCode(r, w, http.StatusForbidden, "terminal_session_required", "终端二次认证需要使用浏览器登录会话")
return
}
ip := clientIP(r)
if h.guard.blocked(ip) {
writeErrorCode(r, w, http.StatusTooManyRequests, "too_many_attempts", "密码错误次数过多,请 5 分钟后再试")
return
}
var req terminalUnlockRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
adminID, err := h.auth.AdminIDForSession(r.Context(), sessionID)
if err != nil {
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
return
}
// 密码原样比对:不做 TrimSpace,避免把首尾空格当作无关字符
if err := h.auth.VerifyPassword(r.Context(), adminID, req.Password); err != nil {
h.guard.fail(ip)
h.logger.Warn("终端二次认证失败", "module", "TERMINAL", "client_ip", ip, "admin_id", adminID)
writeErrorCode(r, w, http.StatusUnauthorized, "bad_password", "管理员密码不正确")
return
}
h.guard.reset(ip)
expires := h.unlocks.grant(sessionID)
h.logger.Info("终端二次认证通过",
"module", "TERMINAL", "client_ip", ip, "admin_id", adminID,
"expires_at", expires.UTC().Format(time.RFC3339))
writeJSON(w, http.StatusOK, map[string]any{"expires_at": expires.UTC().Format(time.RFC3339)})
}
// Lock 主动锁定当前会话的终端解锁,之后新建会话需要重新输入密码。
func (h *TerminalHandler) Lock(w http.ResponseWriter, r *http.Request) {
h.unlocks.revoke(sessionIDFromContext(r.Context()))
h.logger.Info("终端已锁定", "module", "TERMINAL", "client_ip", clientIP(r))
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// CreateSession 建立一条新的 SSH 终端会话。
func (h *TerminalHandler) CreateSession(w http.ResponseWriter, r *http.Request) {
if !h.requireEnabled(r, w) {
return
}
// 二次认证:没有有效解锁票据(或已过期)就不许建会话
if _, unlocked := h.unlocks.expiresAt(sessionIDFromContext(r.Context())); !unlocked {
writeErrorCode(r, w, http.StatusForbidden, "terminal_locked", "终端需要二次认证:请先输入管理员密码解锁")
return
}
var req terminalCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
if req.ServerID <= 0 {
writeError(r, w, http.StatusBadRequest, "请选择要连接的服务端")
return
}
// SSH 认证 + PTY 申请需要时间,单独给 30 秒上限
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
user, clientIP := h.terminalActor(r)
// 录制开关默认关闭:开启后本次会话输出会写入 logs/terminal
record, err := h.settings.GetBool(r.Context(), settings.KeyTerminalRecording)
if err != nil {
record = false
}
session, err := h.manager.Create(ctx, terminal.CreateOptions{
ServerID: req.ServerID,
Cols: req.Cols,
Rows: req.Rows,
User: user,
ClientIP: clientIP,
Record: record,
})
if err != nil {
writeTerminalError(r, w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"session_id": session.ID, "label": session.Label})
}
// ListAudits 返回最近的终端会话审计:谁、何时、连了哪台、多久、为什么断开。
func (h *TerminalHandler) ListAudits(w http.ResponseWriter, r *http.Request) {
if h.audits == nil {
writeJSON(w, http.StatusOK, map[string]any{"entries": []terminal.AuditEntry{}})
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
entries, err := h.audits.ListAudits(r.Context(), limit)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取终端审计记录失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
}
// terminalActor 解析审计需要的调用方身份:登录用户名与来源 IP。
// 用 API Token 调用时没有会话 Cookie,这里如实记成「API Token」而不是伪造用户名。
func (h *TerminalHandler) terminalActor(r *http.Request) (string, string) {
ip := clientIP(r)
if bearerToken(r) != "" {
return "API Token", ip
}
sessionID := sessionIDFromContext(r.Context())
if sessionID == "" || h.auth == nil {
return "未知", ip
}
adminID, err := h.auth.AdminIDForSession(r.Context(), sessionID)
if err != nil {
return "未知", ip
}
username, err := h.auth.AdminUsername(r.Context(), adminID)
if err != nil || strings.TrimSpace(username) == "" {
return "未知", ip
}
return username, ip
}
// ListSessions 返回当前会话快照(前端刷新后清理与排查用)。
func (h *TerminalHandler) ListSessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"sessions": h.manager.List()})
}
// CloseSession 主动关闭会话。用户点「关闭」或页面卸载时调用。
func (h *TerminalHandler) CloseSession(w http.ResponseWriter, r *http.Request) {
h.manager.Close(r.PathValue("id"), "用户关闭")
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// Stream 是终端双向通道:浏览器发送按键/尺寸,服务端推送输出与结束原因。
func (h *TerminalHandler) Stream(w http.ResponseWriter, r *http.Request) {
if !h.requireEnabled(r, w) {
return
}
id := r.PathValue("id")
session, ok := h.manager.Get(id)
if !ok {
writeError(r, w, http.StatusNotFound, "终端会话不存在或已关闭")
return
}
conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
go h.readClient(conn, session, id)
h.writeClient(conn, session)
}
// writeClient 把会话输出推给浏览器;订阅通道关闭表示会话结束。
func (h *TerminalHandler) writeClient(conn *websocket.Conn, session *terminal.Session) {
chunks, cancel := session.Subscribe()
defer cancel()
for chunk := range chunks {
message := terminalServerMessage{Type: "output", Data: base64.StdEncoding.EncodeToString(chunk)}
if err := writeSocketMessage(conn, message); err != nil {
return
}
}
_ = writeSocketMessage(conn, terminalServerMessage{Type: "exit", Reason: session.Snapshot().Reason})
}
// readClient 处理按键与窗口尺寸;连接断开时立刻回收会话。
func (h *TerminalHandler) readClient(conn *websocket.Conn, session *terminal.Session, id string) {
conn.SetReadLimit(1 << 20)
for {
_ = conn.SetReadDeadline(time.Now().Add(terminalReadTimeout))
_, payload, err := conn.ReadMessage()
if err != nil {
h.manager.Close(id, "前端断开")
return
}
var message terminalClientMessage
if err := json.Unmarshal(payload, &message); err != nil {
continue
}
h.dispatchClientMessage(session, message)
}
}
func (h *TerminalHandler) dispatchClientMessage(session *terminal.Session, message terminalClientMessage) {
switch message.Type {
case "input":
data, err := base64.StdEncoding.DecodeString(message.Data)
if err != nil {
return
}
_ = session.Input(data)
case "resize":
_ = session.Resize(message.Cols, message.Rows)
}
}
func writeSocketMessage(conn *websocket.Conn, message terminalServerMessage) error {
payload, err := json.Marshal(message)
if err != nil {
return err
}
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
return conn.WriteMessage(websocket.TextMessage, payload)
}
// terminalEnabled 读设置开关:缺省视为关闭(shell 入口必须显式打开)。
func (h *TerminalHandler) terminalEnabled(r *http.Request) (bool, error) {
// 用 GetBool 而不是只认 "1":设置页写 "1"/"0",但 true/on 这类写法也应被认作开启,
// 否则开关看着是开的、接口却按未启用拒绝,排查起来很费劲
return h.settings.GetBool(r.Context(), settings.KeyTerminalEnabled)
}
// requireEnabled 检查设置开关与运行能力;未通过时直接写出错误并返回 false。
func (h *TerminalHandler) requireEnabled(r *http.Request, w http.ResponseWriter) bool {
enabled, err := h.terminalEnabled(r)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
return false
}
if !enabled {
writeError(r, w, http.StatusForbidden, "SSH 终端未启用:请到「系统设置」打开后再使用")
return false
}
if h.manager == nil || !h.manager.Enabled() {
writeError(r, w, http.StatusServiceUnavailable, "SSH 终端当前不可用")
return false
}
return true
}
func writeTerminalError(r *http.Request, w http.ResponseWriter, err error) {
switch {
case errors.Is(err, terminal.ErrDisabled):
writeError(r, w, http.StatusForbidden, "SSH 终端未启用")
case errors.Is(err, terminal.ErrTooManySessions):
writeError(r, w, http.StatusTooManyRequests, err.Error())
case errors.Is(err, terminal.ErrNotFound):
writeError(r, w, http.StatusNotFound, err.Error())
default:
writeError(r, w, http.StatusBadGateway, err.Error())
}
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"sync"
"time"
)
// terminalUnlockTTL 是一次二次认证的有效期:输一次管理员密码,十分钟内新建会话不再重复输入。
const terminalUnlockTTL = 10 * time.Minute
// terminalUnlockStore 记录「哪个登录会话已通过终端二次认证」。
//
// 只放内存、不落盘:进程重启即全部失效,符合「短时解锁」的定位;
// 键是会话 Cookie 的会话 ID 而不是管理员 ID,换个浏览器/会话就要重新解锁。
type terminalUnlockStore struct {
mu sync.Mutex
expires map[string]time.Time
}
func newTerminalUnlockStore() *terminalUnlockStore {
return &terminalUnlockStore{expires: make(map[string]time.Time)}
}
// grant 记下这次解锁并返回到期时间。
func (s *terminalUnlockStore) grant(sessionID string) time.Time {
now := time.Now()
expires := now.Add(terminalUnlockTTL)
s.mu.Lock()
defer s.mu.Unlock()
s.pruneLocked(now)
s.expires[sessionID] = expires
return expires
}
// expiresAt 返回该会话的解锁到期时间;未解锁或已过期返回 false。
func (s *terminalUnlockStore) expiresAt(sessionID string) (time.Time, bool) {
if sessionID == "" {
return time.Time{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
expires, ok := s.expires[sessionID]
if !ok {
return time.Time{}, false
}
if time.Now().After(expires) {
delete(s.expires, sessionID)
return time.Time{}, false
}
return expires, true
}
// revoke 主动锁定:用户点锁定,或改密后让当前会话立即失效。
func (s *terminalUnlockStore) revoke(sessionID string) {
if sessionID == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
delete(s.expires, sessionID)
}
// pruneLocked 清理过期条目,避免长期运行后 map 里堆积失效的旧会话(调用方需持锁)。
func (s *terminalUnlockStore) pruneLocked(now time.Time) {
for sessionID, expires := range s.expires {
if now.After(expires) {
delete(s.expires, sessionID)
}
}
}
+273
View File
@@ -0,0 +1,273 @@
package api
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/settings"
"github.com/havline/havline/internal/terminal"
)
const (
testAdminPassword = "admin-password-123"
)
// TestTerminalUnlockStore:票据按时失效、可主动撤销,过期条目会被顺带清理。
func TestTerminalUnlockStore(t *testing.T) {
store := newTerminalUnlockStore()
const testSessionID = "session-abc"
if _, ok := store.expiresAt(testSessionID); ok {
t.Fatal("未解锁的会话不应有效")
}
expires := store.grant(testSessionID)
if !expires.After(time.Now()) {
t.Fatalf("解锁到期时间应当在未来:%v", expires)
}
if _, ok := store.expiresAt(testSessionID); !ok {
t.Fatal("解锁后应当有效")
}
store.revoke(testSessionID)
if _, ok := store.expiresAt(testSessionID); ok {
t.Fatal("锁定后应当失效")
}
// 手动塞一条过期记录,grant 时应当被清掉
store.expires["stale"] = time.Now().Add(-time.Minute)
store.grant("other")
if _, ok := store.expires["stale"]; ok {
t.Fatal("过期条目未被清理")
}
}
// TestTerminalUnlockFlow:未解锁不能建会话 → 密码错误被拒并计数 → 解锁后可建会话 → 可主动锁定。
func TestTerminalUnlockFlow(t *testing.T) {
handler, openerCalled, sessionID := newUnlockTestHandler(t)
// 1) 未解锁:建会话被拒,并给出稳定错误码
rec := callTerminal(t, handler.CreateSession, http.MethodPost, `{"server_id":1,"cols":80,"rows":24}`, sessionID)
if rec.Code != http.StatusForbidden {
t.Fatalf("未解锁建会话应当 403,实际 %d %s", rec.Code, rec.Body.String())
}
if code := errorCode(t, rec); code != "terminal_locked" {
t.Fatalf("错误码应当是 terminal_locked,实际 %q", code)
}
if *openerCalled {
t.Fatal("未解锁就触发了后端连接")
}
// 2) 密码错误:401,且不发放票据
rec = callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"wrong-password"}`, sessionID)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("错误密码应当 401,实际 %d %s", rec.Code, rec.Body.String())
}
if code := errorCode(t, rec); code != "bad_password" {
t.Fatalf("错误码应当是 bad_password,实际 %q", code)
}
if _, ok := handler.unlocks.expiresAt(sessionID); ok {
t.Fatal("密码错误却发放了解锁票据")
}
// 3) 密码正确:解锁成功
rec = callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"`+testAdminPassword+`"}`, sessionID)
if rec.Code != http.StatusOK {
t.Fatalf("正确密码应当 200,实际 %d %s", rec.Code, rec.Body.String())
}
var unlocked struct {
ExpiresAt string `json:"expires_at"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &unlocked); err != nil || unlocked.ExpiresAt == "" {
t.Fatalf("解锁响应缺少 expires_at:%s", rec.Body.String())
}
// 状态接口能读到剩余时间
rec = callTerminal(t, handler.UnlockStatus, http.MethodGet, "", sessionID)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"unlocked":true`) {
t.Fatalf("解锁状态不符:%d %s", rec.Code, rec.Body.String())
}
// 4) 已解锁:放行到后端连接(测试用 Opener 固定失败,所以这里是 502 而不是 403)
rec = callTerminal(t, handler.CreateSession, http.MethodPost, `{"server_id":1,"cols":80,"rows":24}`, sessionID)
if rec.Code == http.StatusForbidden {
t.Fatalf("解锁后仍被拦:%s", rec.Body.String())
}
if !*openerCalled {
t.Fatal("解锁后应当尝试连接后端")
}
// 5) 主动锁定
rec = callTerminal(t, handler.Lock, http.MethodDelete, "", sessionID)
if rec.Code != http.StatusOK {
t.Fatalf("锁定失败:%d %s", rec.Code, rec.Body.String())
}
if _, ok := handler.unlocks.expiresAt(sessionID); ok {
t.Fatal("锁定后票据仍有效")
}
}
// TestTerminalUnlockRateLimit:同一 IP 连续失败到阈值后被锁定。
func TestTerminalUnlockRateLimit(t *testing.T) {
handler, _, sessionID := newUnlockTestHandler(t)
for i := 0; i < loginFailureLimit; i++ {
rec := callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"wrong-password"}`, sessionID)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("第 %d 次失败应当 401,实际 %d", i+1, rec.Code)
}
}
rec := callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"wrong-password"}`, sessionID)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("超过阈值应当 429,实际 %d %s", rec.Code, rec.Body.String())
}
if code := errorCode(t, rec); code != "too_many_attempts" {
t.Fatalf("错误码应当是 too_many_attempts,实际 %q", code)
}
// 锁定期间即使密码正确也不放行
rec = callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"`+testAdminPassword+`"}`, sessionID)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("锁定期间应当继续 429,实际 %d", rec.Code)
}
}
// TestTerminalCommandsAPI:常用命令的新增、列表与删除。
func TestTerminalCommandsAPI(t *testing.T) {
handler, _, sessionID := newUnlockTestHandler(t)
rec := callTerminal(t, handler.CreateCommand, http.MethodPost, `{"name":"磁盘占用","command":"df -h"}`, sessionID)
if rec.Code != http.StatusCreated {
t.Fatalf("新增命令应当 201,实际 %d %s", rec.Code, rec.Body.String())
}
var created terminal.TerminalCommand
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil || created.ID == 0 {
t.Fatalf("新增响应缺少 ID:%s", rec.Body.String())
}
rec = callTerminal(t, handler.ListCommands, http.MethodGet, "", sessionID)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "df -h") {
t.Fatalf("列表未包含新增命令:%d %s", rec.Code, rec.Body.String())
}
// 空名称应当被拒
rec = callTerminal(t, handler.CreateCommand, http.MethodPost, `{"name":" ","command":"ls"}`, sessionID)
if rec.Code != http.StatusBadRequest {
t.Fatalf("空名称应当 400,实际 %d", rec.Code)
}
// 删除要走带路径参数的分支,这里手工塞 PathValue
if code := deleteCommand(t, handler, sessionID, created.ID); code != http.StatusOK {
t.Fatalf("删除应当 200,实际 %d", code)
}
if code := deleteCommand(t, handler, sessionID, created.ID); code != http.StatusNotFound {
t.Fatalf("重复删除应当 404,实际 %d", code)
}
}
// deleteCommand 按路由形态调用删除接口(PathValue 由 ServeMux 注入,测试里手工设置)。
func deleteCommand(t *testing.T, handler *TerminalHandler, sessionID string, id int64) int {
t.Helper()
raw := strconv.FormatInt(id, 10)
req := httptest.NewRequest(http.MethodDelete, "/api/terminal/commands/"+raw, nil)
req.AddCookie(&http.Cookie{Name: "havline_session", Value: sessionID})
req.SetPathValue("id", raw)
rec := httptest.NewRecorder()
SessionMiddleware(nil)(http.HandlerFunc(handler.DeleteCommand)).ServeHTTP(rec, req)
return rec.Code
}
// TestTokenDeniedPathCoversTerminal:终端不接受 API Token,否则二次认证可以被绕过。
func TestTokenDeniedPathCoversTerminal(t *testing.T) {
for _, path := range []string{
"/api/terminal/targets",
"/api/terminal/unlock",
"/api/terminal/sessions",
} {
if !tokenDeniedPath(path) {
t.Fatalf("%s 应当只允许浏览器会话", path)
}
}
if tokenDeniedPath("/api/proxies") {
t.Fatal("普通接口不应被限制")
}
}
// newUnlockTestHandler 造一套「终端已开启 + 已初始化管理员」的处理器,并返回一个真实会话 ID。
func newUnlockTestHandler(t *testing.T) (*TerminalHandler, *bool, string) {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("打开数据库失败:%v", err)
}
t.Cleanup(func() { conn.Close() })
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("迁移失败:%v", err)
}
ctx := context.Background()
authSvc := auth.New(conn)
if err := authSvc.Setup(ctx, "admin", testAdminPassword); err != nil {
t.Fatalf("初始化管理员失败:%v", err)
}
settingsStore := settings.NewStore(conn)
if err := settingsStore.SetBool(ctx, settings.KeyTerminalEnabled, true); err != nil {
t.Fatalf("打开终端开关失败:%v", err)
}
// 解锁票据绑定真实会话:没有这条会话记录,AdminIDForSession 会先拒绝
sessionID, err := authSvc.Login(ctx, "admin", testAdminPassword)
if err != nil {
t.Fatalf("登录失败:%v", err)
}
openerCalled := false
manager := terminal.NewManager(
slog.New(slog.NewTextHandler(io.Discard, nil)),
func(context.Context, int64, int, int) (terminal.Terminal, string, error) {
openerCalled = true
return nil, "", errors.New("测试环境不建立真实连接")
},
terminal.Options{},
)
handler := NewTerminalHandler(TerminalHandlerOptions{
Auth: authSvc,
Settings: settingsStore,
Manager: manager,
Unlocks: newTerminalUnlockStore(),
Commands: terminal.NewCommandStore(conn),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
return handler, &openerCalled, sessionID
}
// callTerminal 带上会话 Cookie 调用处理器;SessionMiddleware 只读 Cookie,这里复用它注入会话。
func callTerminal(t *testing.T, next http.HandlerFunc, method, body, sessionID string) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
req := httptest.NewRequest(method, "/api/terminal", reader)
req.AddCookie(&http.Cookie{Name: "havline_session", Value: sessionID})
rec := httptest.NewRecorder()
SessionMiddleware(nil)(next).ServeHTTP(rec, req)
return rec
}
// errorCode 读出响应体里的稳定错误码。
func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var body ErrorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("解析错误响应失败:%v", err)
}
return body.Code
}
+191 -42
View File
@@ -35,6 +35,7 @@ import (
"github.com/havline/havline/internal/secret"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
"github.com/havline/havline/internal/terminal"
"github.com/havline/havline/internal/traffic"
"github.com/havline/havline/internal/updater"
)
@@ -70,7 +71,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
}
logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource)
updaterToken, updaterTokenSource, err := updater.EnsureToken(cfg.UpdaterToken, cfg.UpdaterTokenFile)
// updater Token 只给「一键升级」sidecar 用;原生版与飞牛 Docker 版都显式把 socket 置空,
// 此时不能去写 /run/havline-updater:原生版以非 root 运行,权限不足会让应用直接起不来。
updaterToken, updaterTokenSource, err := updater.EnsureTokenIfEnabled(cfg.UpdaterSocket, cfg.UpdaterToken, cfg.UpdaterTokenFile)
if err != nil {
return nil, err
}
@@ -100,6 +103,13 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
}
proxyStore := proxy.NewStore(conn)
settingsStore := settings.NewStore(conn)
// 时区必须在任何业务开始前生效:通知时间戳、日志时间、调度判断都依赖 time.Local。
// 设置项缺失或写坏时退回 TZ 环境变量,不让应用起不来。
if loc, err := settings.ApplyTimezone(context.Background(), settingsStore); err != nil {
logger.Warn("应用时区设置失败,沿用系统 TZ", "module", "SYSTEM", "error", err.Error())
} else {
logger.Info("时区已应用", "module", "SYSTEM", "timezone", loc.String())
}
ddnsStore := ddns.NewStore(conn)
certStore := certificate.NewStore(conn)
nginxMgr := nginx.NewManager(cfg, logger.With("module", "NGINX"))
@@ -115,6 +125,8 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc)
acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc)
backupSvc := backup.New(cfg.DataDir)
// 注入数据库连接:导出时用 VACUUM INTO 拿一致性快照,而不是直接打包正在写的文件
backupSvc.SetDB(conn)
// 指标历史:与巡检同生命周期,采样跟随巡检轮次
metricsStore := metrics.NewStore(conn)
discoverySvc := discovery.New()
@@ -124,6 +136,8 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
conn.Close()
return nil, err
}
// 记录/恢复 frpc 运行状态:应用被停用再启用后要能自动回到运行状态
frpMultiSvc.SetSettingsStore(settingsStore)
cloudflareSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), secretBox, logger, proxySvc)
if err := cloudflareSvc.EnsureDirs(); err != nil {
conn.Close()
@@ -132,27 +146,49 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
proxySvc.SetExitSync(cloudflareSvc)
startedAt := time.Now().UTC().Format(time.RFC3339)
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
// 删规则时连带清掉它的流量累计,否则同名规则重建会继承上一轮的读数
proxySvc.SetTrafficCleaner(trafficCollector)
updaterClient := updater.NewClient(cfg.UpdaterSocket, updaterToken)
// 内置 SSH 终端:会话管理借用 frp 的 SSH 凭据与 known_hosts;开关默认关闭,见 settings.KeyTerminalEnabled
// 会话审计落 SQLite:谁、何时、连了哪台、多久、为什么断开
terminalAuditStore := terminal.NewAuditStore(conn)
// 常用命令:右侧面板的快捷命令,落库后换浏览器也在
terminalCommandStore := terminal.NewCommandStore(conn)
terminalManager := terminal.NewManager(logger, frpMultiSvc.OpenSSHTerminal, terminal.Options{})
terminalManager.SetAuditSink(terminalAuditStore)
// 会话录制:默认关闭,开启后按 asciinema v2 把输出写进 logs/terminal
terminalRecordingStore := terminal.NewRecordingStore(filepath.Join(cfg.LogsDir(), "terminal"))
if err := terminalRecordingStore.EnsureDir(); err != nil {
logger.Warn("终端录制目录创建失败", "module", "TERMINAL", "error", err.Error())
}
terminalManager.SetRecorderFactory(func(sessionID string, opts terminal.CreateOptions) (terminal.OutputRecorder, error) {
title := fmt.Sprintf("服务端 #%d", opts.ServerID)
return terminal.NewCastRecorder(terminalRecordingStore, sessionID, title, opts.Cols, opts.Rows, time.Now())
})
handler := api.NewRouter(api.Deps{
Config: cfg,
Logger: logger,
Auth: authSvc,
Proxy: proxySvc,
DDNS: ddnsSvc,
ACME: acmeSvc,
Settings: settingsStore,
Notify: notifySvc,
ChinaCIDR: chinaCIDRSvc,
Backup: backupSvc,
Metrics: metricsStore,
Discovery: discoverySvc,
FRPMulti: frpMultiSvc,
Cloudflare: cloudflareSvc,
Traffic: trafficCollector,
Updater: updaterClient,
StaticFS: staticFS,
StartedAt: startedAt,
Config: cfg,
Logger: logger,
Auth: authSvc,
Proxy: proxySvc,
DDNS: ddnsSvc,
ACME: acmeSvc,
Settings: settingsStore,
Notify: notifySvc,
ChinaCIDR: chinaCIDRSvc,
Backup: backupSvc,
Metrics: metricsStore,
Discovery: discoverySvc,
FRPMulti: frpMultiSvc,
Cloudflare: cloudflareSvc,
Traffic: trafficCollector,
Updater: updaterClient,
StaticFS: staticFS,
StartedAt: startedAt,
Terminal: terminalManager,
TerminalAudits: terminalAuditStore,
TerminalCommands: terminalCommandStore,
TerminalRecordings: terminalRecordingStore,
})
if prefix := strings.TrimSpace(cfg.GatewayPrefix); prefix != "" {
handler = stripPrefixIfPresent(prefix, handler)
@@ -167,10 +203,11 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
}
ctx, cancel := context.WithCancel(context.Background())
// 终端会话回收:空闲超时、超长会话与已结束会话都靠它清理
terminalManager.StartSweeper(ctx, time.Minute)
frpMultiSvc.SetBaseContext(ctx)
go frpMultiSvc.StartAgentTunnels(ctx)
go frpMultiSvc.StartAgentCertificateRenewal(ctx)
go cloudflareSvc.StartAll(ctx)
// cloudflared 子进程跟随应用 ctx:应用退出时不会留下孤儿进程
cloudflareSvc.SetBaseContext(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 {
intervalMinutes = 5
@@ -209,6 +246,51 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
Interval: backup.AutoInterval,
Run: backupRunner.Run,
},
scheduler.Job{
Name: "terminal_audit",
Interval: 24 * time.Hour,
// 审计只留 90 天:清理比会话回收慢得多,单独挂一个日任务即可
Run: func(ctx context.Context) {
deleted, err := terminalAuditStore.PruneAudits(ctx)
if err != nil {
logger.Warn("终端审计清理失败", "module", "TERMINAL", "error", err.Error())
}
if deleted > 0 {
logger.Info("终端审计清理完成", "module", "TERMINAL", "deleted", deleted)
}
removed, err := terminalRecordingStore.Prune(time.Now().Add(-terminal.AuditRetention))
if err != nil {
logger.Warn("终端录制清理失败", "module", "TERMINAL", "error", err.Error())
}
if removed > 0 {
logger.Info("终端录制清理完成", "module", "TERMINAL", "deleted", removed)
}
},
},
scheduler.Job{
Name: "session_prune",
Interval: 24 * time.Hour,
// 过期会话否则只能等自己被访问时删掉自己,长期不登录的设备会一直留在表里
Run: func(ctx context.Context) {
removed, err := authSvc.PruneExpiredSessions(ctx)
if err != nil {
logger.Warn("过期会话清理失败", "module", "AUTH", "error", err.Error())
return
}
if removed > 0 {
logger.Info("过期会话清理完成", "module", "AUTH", "deleted", removed)
}
},
},
scheduler.Job{
Name: "log_rotate",
Interval: time.Hour,
// Nginx 自己不会轮转:不切的话 access.log 会一直长,同时读日志的
// 几个接口(今日统计、小时曲线)会越来越慢。
Run: func(ctx context.Context) {
rotateNginxLogs(ctx, cfg, nginxMgr, settingsStore, logger)
},
},
)
sched.Start(ctx)
trafficCollector.Start(ctx)
@@ -219,27 +301,78 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
server: server,
nginx: nginxMgr,
frpMulti: frpMultiSvc,
cloudflare: cloudflareSvc,
scheduler: sched,
shutdownFn: cancel,
db: conn,
}
// 就绪门:Nginx 首轮配置应用完之前不放后台任务。
// 否则 Agent 路由下发、Cloudflare ingress 同步会在启动瞬间各触发一次
// Nginx reload,和 bootstrap 抢同一份配置与 pid。
ready := make(chan struct{})
if err := app.bootstrapNginx(context.Background(), proxySvc); err != nil {
cancel()
conn.Close()
return nil, err
}
if err := frpMultiSvc.ReconcileRuntime(context.Background()); err != nil {
logger.Warn("initial frp runtime reconcile skipped", "module", "FRP", "error", err.Error())
}
if err := frpMultiSvc.AutoStart(context.Background()); err != nil {
logger.Warn("initial frp autostart skipped", "module", "FRP", "error", err.Error())
}
close(ready)
go waitReady(ctx, ready, func() {
if err := frpMultiSvc.ReconcileRuntime(ctx); err != nil {
logger.Warn("initial frp runtime reconcile skipped", "module", "FRP", "error", err.Error())
}
if err := frpMultiSvc.AutoStart(ctx); err != nil {
logger.Warn("initial frp autostart skipped", "module", "FRP", "error", err.Error())
}
cloudflareSvc.StartAuto(ctx)
})
go waitReady(ctx, ready, func() { frpMultiSvc.StartAgentTunnels(ctx) })
go waitReady(ctx, ready, func() { frpMultiSvc.StartAgentCertificateRenewal(ctx) })
logger.Info("application started", "module", "SYSTEM", "listen", cfg.ListenAddr)
listen := strings.TrimSpace(cfg.ListenAddr)
if listen == "" {
listen = strings.TrimSpace(cfg.GatewaySocket)
}
logger.Info("application started", "module", "SYSTEM", "listen", listen)
return app, nil
}
// waitReady 等到 ready 关闭(或 ctx 取消)后再执行 fn。
func waitReady(ctx context.Context, ready <-chan struct{}, fn func()) {
select {
case <-ready:
case <-ctx.Done():
return
}
if ctx.Err() != nil {
return
}
fn()
}
// rotateNginxLogs 轮转 access.log 与 error.log。
// 保留天数接「设置 → 常规 → 日志保留天数」,份数用 logstore 默认值兜底。
func rotateNginxLogs(ctx context.Context, cfg config.Config, nginxMgr *nginx.Manager, store *settings.Store, logger *slog.Logger) {
retentionDays, err := store.GetInt(ctx, settings.KeyLogRetentionDays)
if err != nil || retentionDays <= 0 {
retentionDays = 30
}
logsDir := cfg.LogsDir()
for _, name := range []string{"access.log", "error.log"} {
rotator := logstore.NewRotator(filepath.Join(logsDir, name), retentionDays, func() error {
return nginxMgr.ReopenLogs(ctx)
})
rotated, err := rotator.RotateIfNeeded(time.Now())
if err != nil {
logger.Warn("日志轮转失败", "module", "LOGSTORE", "file", name, "error", err.Error())
continue
}
if rotated {
logger.Info("日志已轮转", "module", "LOGSTORE", "file", name)
}
}
}
func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService) error {
if err := proxySvc.ReloadAll(ctx); err != nil {
a.logger.Warn("initial nginx apply skipped", "module", "NGINX", "error", err.Error())
@@ -248,31 +381,40 @@ func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService
}
func (a *App) Run() error {
if strings.TrimSpace(a.cfg.GatewaySocket) == "" {
socket := strings.TrimSpace(a.cfg.GatewaySocket)
if socket == "" {
return a.server.ListenAndServe()
}
// 先准备两端监听,任何一端失败都不留下半启动的服务。
tcp, err := net.Listen("tcp", a.server.Addr)
if err != nil {
return err
listeners := make([]net.Listener, 0, 2)
// 原生版显式把 TCP 地址置空,只保留 Unix Socket,避免和 Docker 版抢 6893。
if addr := strings.TrimSpace(a.server.Addr); addr != "" {
tcp, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer tcp.Close()
listeners = append(listeners, tcp)
}
defer tcp.Close()
socket := strings.TrimSpace(a.cfg.GatewaySocket)
if err := os.Remove(socket); err != nil && !os.IsNotExist(err) {
return err
}
listener, err := net.Listen("unix", socket)
unixListener, err := net.Listen("unix", socket)
if err != nil {
return err
}
defer listener.Close()
defer unixListener.Close()
defer os.Remove(socket)
if err := os.Chmod(socket, 0o660); err != nil {
return err
}
errCh := make(chan error, 2)
go func() { errCh <- a.server.Serve(tcp) }()
go func() { errCh <- a.server.Serve(listener) }()
listeners = append(listeners, unixListener)
errCh := make(chan error, len(listeners))
for _, listener := range listeners {
go func(l net.Listener) { errCh <- a.server.Serve(l) }(listener)
}
return <-errCh
}
@@ -291,10 +433,17 @@ func (a *App) Shutdown(ctx context.Context) error {
a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error())
}
if a.frpMulti != nil {
if err := a.frpMulti.Stop(ctx); err != nil {
// 退出时保留「上次在运行」标记,供下次启用应用时自动恢复 frpc
if err := a.frpMulti.StopForShutdown(ctx); err != nil {
a.logger.Error("frpc stop failed", "module", "FRP", "error", err.Error())
}
}
// cloudflared 不保留「上次在运行」:AutoStart 是用户的显式选择,
// 这里只负责把当前子进程收干净,避免退出后残留进程占着 Cloudflare 连接。
// 放在 nginx 之后:ingress 还指向本机 Nginx,先收隧道再做别的没有意义。
if a.cloudflare != nil {
a.cloudflare.StopAll(ctx)
}
if a.db != nil {
return a.db.Close()
}
+56
View File
@@ -1,9 +1,19 @@
package app
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/havline/havline/internal/config"
)
func TestGatewayPrefix(t *testing.T) {
@@ -23,3 +33,49 @@ func TestGatewayPrefix(t *testing.T) {
})
}
}
func TestRunGatewaySocketWithoutTCP(t *testing.T) {
socket := filepath.Join(t.TempDir(), "app.sock")
server := &http.Server{
Addr: "",
Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("havline-gateway-ok"))
}),
}
app := &App{
cfg: config.Config{GatewaySocket: socket},
server: server,
}
errCh := make(chan error, 1)
go func() { errCh <- app.Run() }()
response := requestUnixSocket(t, socket)
if !strings.Contains(response, "havline-gateway-ok") {
t.Fatalf("unix socket response %q", response)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = server.Shutdown(ctx)
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) {
t.Fatalf("Run() error: %v", err)
}
}
func requestUnixSocket(t *testing.T, socket string) string {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, err := net.Dial("unix", socket)
if err == nil {
defer conn.Close()
_, _ = fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
data, _ := io.ReadAll(conn)
return string(data)
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("unix socket not ready: %s", socket)
return ""
}
+45
View File
@@ -135,6 +135,39 @@ func (s *Service) AdminIDForSession(ctx context.Context, sessionID string) (int,
return adminID, nil
}
// AdminUsername 反查管理员用户名:审计记录要写清「谁」操作的。
func (s *Service) AdminUsername(ctx context.Context, adminID int) (string, error) {
var username string
err := s.db.QueryRowContext(ctx, `SELECT username FROM admins WHERE id = ?`, adminID).Scan(&username)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrUnauthorized
}
return "", err
}
return username, nil
}
// VerifyPassword 校验管理员密码,供终端二次认证使用。
// 复用登录密码的 bcrypt hash,不新增任何存储;密码本身不落日志、不落盘。
func (s *Service) VerifyPassword(ctx context.Context, adminID int, password string) error {
if adminID <= 0 || password == "" {
return ErrUnauthorized
}
var hash string
err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM admins WHERE id = ?`, adminID).Scan(&hash)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrUnauthorized
}
return err
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return ErrUnauthorized
}
return nil
}
func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword, keepSessionID string) error {
if err := validate.Password(newPassword); err != nil {
return err
@@ -164,6 +197,18 @@ func (s *Service) Logout(ctx context.Context, sessionID string) error {
return err
}
// PruneExpiredSessions 删除已经过期的会话,返回删除条数。
// 过期会话只有在自己被再次访问时才会删掉自己,于是「登录过又再也不回来的设备」
// 会永久留着一条记录;这里由日任务统一清掉,顺便让会话表大小与实际活跃设备一致。
func (s *Service) PruneExpiredSessions(ctx context.Context) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *Service) ValidateSession(ctx context.Context, sessionID string) error {
if sessionID == "" {
return ErrUnauthorized
+40
View File
@@ -2,6 +2,7 @@ package auth
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
@@ -10,6 +11,45 @@ import (
"github.com/havline/havline/internal/db"
)
// TestVerifyPassword:终端二次认证依赖的密码校验——对的放行,错的与不存在的账号都拒绝。
func TestVerifyPassword(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
ctx := context.Background()
svc := New(conn)
if err := svc.Setup(ctx, "admin", "admin-password-123"); err != nil {
t.Fatalf("setup: %v", err)
}
sessionID, err := svc.Login(ctx, "admin", "admin-password-123")
if err != nil {
t.Fatalf("login: %v", err)
}
adminID, err := svc.AdminIDForSession(ctx, sessionID)
if err != nil {
t.Fatalf("admin id: %v", err)
}
if err := svc.VerifyPassword(ctx, adminID, "admin-password-123"); err != nil {
t.Fatalf("正确密码应当通过:%v", err)
}
if err := svc.VerifyPassword(ctx, adminID, "admin-password-124"); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("错误密码应当拒绝,实际 %v", err)
}
if err := svc.VerifyPassword(ctx, adminID, ""); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("空密码应当拒绝,实际 %v", err)
}
if err := svc.VerifyPassword(ctx, 9999, "admin-password-123"); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("不存在的账号应当拒绝,实际 %v", err)
}
}
func TestEnsureDefaultAdminCreatesOnce(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
+87
View File
@@ -0,0 +1,87 @@
package auth
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/db"
)
// TestPruneExpiredSessions 过期会话应被日任务清掉,未过期的必须留着。
func TestPruneExpiredSessions(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
ctx := context.Background()
if err := db.Migrate(ctx, conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := New(conn)
if err := svc.Setup(ctx, "admin", "admin-password-123"); err != nil {
t.Fatalf("setup: %v", err)
}
adminID, err := svc.AdminIDForSession(ctx, mustLogin(t, svc))
if err != nil {
t.Fatalf("admin id: %v", err)
}
now := time.Now().UTC()
expired := []string{"expired-1", "expired-2"}
for _, id := range expired {
if _, err := conn.ExecContext(ctx,
`INSERT INTO sessions(id, admin_id, expires_at) VALUES (?, ?, ?)`,
id, adminID, now.Add(-time.Hour).Format(time.RFC3339)); err != nil {
t.Fatalf("insert expired session: %v", err)
}
}
future := "still-valid"
if _, err := conn.ExecContext(ctx,
`INSERT INTO sessions(id, admin_id, expires_at) VALUES (?, ?, ?)`,
future, adminID, now.Add(time.Hour).Format(time.RFC3339)); err != nil {
t.Fatalf("insert valid session: %v", err)
}
before := countSessions(t, conn)
removed, err := svc.PruneExpiredSessions(ctx)
if err != nil {
t.Fatalf("prune: %v", err)
}
if removed != int64(len(expired)) {
t.Fatalf("应删除 %d 条过期会话,实际 %d", len(expired), removed)
}
if after := countSessions(t, conn); after != before-int64(len(expired)) {
t.Fatalf("会话数不符:before=%d after=%d", before, after)
}
if err := svc.ValidateSession(ctx, future); err != nil {
t.Fatalf("未过期会话不应被删:%v", err)
}
// 再跑一次应当没有可删的(幂等)
if removed, err = svc.PruneExpiredSessions(ctx); err != nil || removed != 0 {
t.Fatalf("重复清理应为空操作:removed=%d err=%v", removed, err)
}
}
func mustLogin(t *testing.T, svc *Service) string {
t.Helper()
sessionID, err := svc.Login(context.Background(), "admin", "admin-password-123")
if err != nil {
t.Fatalf("login: %v", err)
}
return sessionID
}
func countSessions(t *testing.T, conn *sql.DB) int64 {
t.Helper()
var count int64
if err := conn.QueryRowContext(context.Background(), `SELECT COUNT(1) FROM sessions`).Scan(&count); err != nil {
t.Fatalf("count sessions: %v", err)
}
return count
}
+81 -2
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/havline/havline/internal/settings"
@@ -106,13 +107,37 @@ func (s *Service) Remove(name string) error {
// Verify 回读归档并返回条目数:确认这份备份不是写了一半的残件,
// 同时要求包内必须有 havline.db(否则多半不是 Havline 的备份)。
func (s *Service) Verify(ctx context.Context, path string) (int, error) {
return s.VerifyWith(ctx, path, Options{})
}
// VerifyWith 校验归档;加密归档需要提供口令才能验证内容。
// 自动备份若启用口令,必须把口令传进来,否则一份好备份会被判成坏包而删掉。
func (s *Service) VerifyWith(ctx context.Context, path string, opts Options) (int, error) {
encrypted, err := IsEncrypted(path)
if err != nil {
return 0, err
}
file, err := os.Open(path)
if err != nil {
return 0, err
}
defer file.Close()
gr, err := gzip.NewReader(file)
var reader io.Reader = file
if encrypted {
passphrase := strings.TrimSpace(opts.Passphrase)
if passphrase == "" {
return 0, ErrPassphraseRequired
}
plain, err := openSealedFile(file, passphrase)
if err != nil {
return 0, err
}
reader = plain
}
gr, err := gzip.NewReader(reader)
if err != nil {
return 0, fmt.Errorf("备份文件无法解压:%w", err)
}
@@ -121,6 +146,7 @@ func (s *Service) Verify(ctx context.Context, path string) (int, error) {
tr := tar.NewReader(gr)
count := 0
hasDatabase := false
hasSecret := false
for {
header, err := tr.Next()
if err == io.EOF {
@@ -133,16 +159,69 @@ func (s *Service) Verify(ctx context.Context, path string) (int, error) {
return count, err
}
count++
if header.Name == "havline.db" {
switch header.Name {
case "havline.db":
hasDatabase = true
case "session_secret":
hasSecret = true
}
}
if !hasDatabase {
return count, fmt.Errorf("备份里没有 havline.db,可能不是 Havline 的备份")
}
// 缺少 session_secret 的归档不能判成坏包(旧版本导出的备份本来就长这样,
// 而且数据库与证书都还在,多半仍值得恢复),只是恢复后凭据解不开。
// 这个差别交给 MissingSessionSecret 单独告知调用方,别混进校验错误里。
_ = hasSecret
return count, nil
}
// MissingSessionSecret 判断归档是否缺少凭据加密密钥。
// 只服务于「恢复后要不要提示用户」,不参与备份有效性判定。
func (s *Service) MissingSessionSecret(ctx context.Context, path string, opts Options) (bool, error) {
encrypted, err := IsEncrypted(path)
if err != nil {
return false, err
}
file, err := os.Open(path)
if err != nil {
return false, err
}
defer file.Close()
var reader io.Reader = file
if encrypted {
passphrase := strings.TrimSpace(opts.Passphrase)
if passphrase == "" {
return false, ErrPassphraseRequired
}
plain, err := openSealedFile(file, passphrase)
if err != nil {
return false, err
}
reader = plain
}
gr, err := gzip.NewReader(reader)
if err != nil {
return false, fmt.Errorf("备份文件无法解压:%w", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
header, err := tr.Next()
if err == io.EOF {
return true, nil
}
if err != nil {
return false, err
}
if header.Name == "session_secret" {
return false, nil
}
}
}
// KeepCount 读取保留份数(未配置或越界时回落到默认值)。
// 自动备份任务与「立即备份」共用它,避免两处各写一套默认值。
func KeepCount(ctx context.Context, store *settings.Store) int {
+2
View File
@@ -50,6 +50,8 @@ func (r *AutoRunner) Run(ctx context.Context) {
return
}
// 自动备份不加密:口令没法安全地存在同一台设备上(存了就等于没加密),
// 需要加密归档的用户走「立即备份 / 立即下载」手动流程。
path, err := r.svc.Export(ctx)
if err != nil {
r.fail(ctx, fmt.Sprintf("导出备份失败:%s", err.Error()))
+148
View File
@@ -0,0 +1,148 @@
package backup
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"golang.org/x/crypto/scrypt"
)
// 加密归档的容器格式:magic + salt + nonce + AES-256-GCM 密文。
//
// 为什么不用「拿 session_secret 直接加密」:那份密钥本来就在数据目录里,
// 跟着归档一起发出去等于没加密。用户填的口令才是唯一不随文件流动的东西。
var encMagic = []byte("HAVLINE-ENC1\n")
// ErrPassphraseRequired 表示这份归档被口令加密过,必须提供口令才能读取。
var ErrPassphraseRequired = errors.New("该备份已加密,需要提供口令")
// 加密参数:默认值在 NAS 这类低功耗设备上约需几十到一百毫秒,
// 对「一天一次备份」完全可接受,同时把离线爆破成本抬到足够高。
const (
scryptN = 1 << 15
scryptR = 8
scryptP = 1
scryptKeyLen = 32
scryptSaltLen = 16
)
// IsEncrypted 判断一份归档是否被口令加密(只读开头几个字节)。
func IsEncrypted(path string) (bool, error) {
file, err := os.Open(path)
if err != nil {
return false, err
}
defer file.Close()
head := make([]byte, len(encMagic))
if _, err := io.ReadFull(file, head); err != nil {
// 短到连 magic 都放不下:只能是空文件或残件,交给后续解压去报错
return false, nil
}
return string(head) == string(encMagic), nil
}
// sealFile 把 src 全量读出,加密后写进 dst。
// 备份受「恢复上传 64MB」这一上限约束,整份读进内存是可控的,
// 换来的是不需要自己拼分块 GCM 的复杂度。
func sealFile(dst io.Writer, src io.Reader, passphrase string) error {
if passphrase == "" {
return fmt.Errorf("口令不能为空")
}
salt := make([]byte, scryptSaltLen)
if _, err := rand.Read(salt); err != nil {
return err
}
key, err := deriveKey(passphrase, salt)
if err != nil {
return err
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return err
}
plain, err := io.ReadAll(src)
if err != nil {
return err
}
// magic 作为附加认证数据:容器格式被改动(比如换成别的格式)时解密直接失败
if _, err := dst.Write(encMagic); err != nil {
return err
}
if _, err := dst.Write(salt); err != nil {
return err
}
if _, err := dst.Write(nonce); err != nil {
return err
}
_, err = dst.Write(gcm.Seal(nil, nonce, plain, encMagic))
return err
}
// openFile 解开一份加密归档,返回可读的明文流。
func openSealedFile(src io.Reader, passphrase string) (io.Reader, error) {
head := make([]byte, len(encMagic))
if _, err := io.ReadFull(src, head); err != nil {
return nil, fmt.Errorf("无效的备份文件")
}
if string(head) != string(encMagic) {
return nil, fmt.Errorf("无效的备份文件")
}
salt := make([]byte, scryptSaltLen)
if _, err := io.ReadFull(src, salt); err != nil {
return nil, fmt.Errorf("备份文件已损坏")
}
key, err := deriveKey(passphrase, salt)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(src, nonce); err != nil {
return nil, fmt.Errorf("备份文件已损坏")
}
ciphertext, err := io.ReadAll(src)
if err != nil {
return nil, err
}
plain, err := gcm.Open(nil, nonce, ciphertext, encMagic)
if err != nil {
// 校验失败只说明「密钥不对或内容被改过」,对用户是同一件事
return nil, fmt.Errorf("口令错误,或备份文件已被修改")
}
return newByteReader(plain), nil
}
// newByteReader 把内存里的明文包成 io.Reader;抽出来是为了让调用点读起来短一点。
func newByteReader(data []byte) io.Reader {
return bytes.NewReader(data)
}
func deriveKey(passphrase string, salt []byte) ([]byte, error) {
key, err := scrypt.Key([]byte(passphrase), salt, scryptN, scryptR, scryptP, scryptKeyLen)
if err != nil {
return nil, fmt.Errorf("派生加密密钥失败: %w", err)
}
return key, nil
}
+269
View File
@@ -0,0 +1,269 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"io"
"os"
"path/filepath"
"testing"
)
// listEntries 读出归档里的条目名,用于断言包含关系。
func listEntries(t *testing.T, path string) []string {
t.Helper()
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
gr, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
defer gr.Close()
tr := tar.NewReader(gr)
names := []string{}
for {
header, err := tr.Next()
if err == io.EOF {
return names
}
if err != nil {
t.Fatal(err)
}
names = append(names, header.Name)
}
}
// TestExportIncludesSessionSecretAndFrpConfig:备份必须带上凭据加密密钥与 frpc 配置。
// 少了 session_secret,恢复后库里的 DDNS / FRP / Cloudflare / 通知凭据全部解不开。
func TestExportIncludesSessionSecretAndFrpConfig(t *testing.T) {
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "havline.db"), []byte("db"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "session_secret"), []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
frpDir := filepath.Join(dataDir, "frp")
if err := os.MkdirAll(frpDir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(frpDir, "frpc-server-1.toml"), []byte("token = \"x\"\n"), 0o600); err != nil {
t.Fatal(err)
}
path, err := New(dataDir).Export(context.Background())
if err != nil {
t.Fatal(err)
}
entries := listEntries(t, path)
has := map[string]bool{}
for _, name := range entries {
has[name] = true
}
for _, want := range []string{"havline.db", "session_secret", "frp/frpc-server-1.toml"} {
if !has[want] {
t.Fatalf("备份应包含 %q,实际条目:%v", want, entries)
}
}
}
// TestExportSkipsFrpBinariesAndLogs:frp/bin、frp/downloads 与 *.log 体积大且可重新获取,不进归档。
func TestExportSkipsFrpBinariesAndLogs(t *testing.T) {
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "havline.db"), []byte("db"), 0o600); err != nil {
t.Fatal(err)
}
frpDir := filepath.Join(dataDir, "frp")
for _, dir := range []string{filepath.Join(frpDir, "bin", "v0.70.1"), filepath.Join(frpDir, "downloads")} {
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatal(err)
}
}
files := map[string]string{
filepath.Join(frpDir, "bin", "v0.70.1", "frpc"): "binary",
filepath.Join(frpDir, "downloads", "frpc.zip"): "zip",
filepath.Join(frpDir, "frpc-server-1.log"): "log",
filepath.Join(frpDir, "frpc-server-1.toml"): "token = \"x\"\n",
filepath.Join(frpDir, "active.json"): "{}",
}
for path, content := range files {
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
path, err := New(dataDir).Export(context.Background())
if err != nil {
t.Fatal(err)
}
entries := listEntries(t, path)
has := map[string]bool{}
for _, name := range entries {
has[name] = true
}
for _, skip := range []string{"frp/bin/v0.70.1/frpc", "frp/downloads/frpc.zip", "frp/frpc-server-1.log"} {
if has[skip] {
t.Fatalf("归档不应包含 %q,实际条目:%v", skip, entries)
}
}
for _, want := range []string{"frp/frpc-server-1.toml", "frp/active.json"} {
if !has[want] {
t.Fatalf("归档应保留 %q,实际条目:%v", want, entries)
}
}
}
// TestExportWithPassphraseIsEncrypted:给了口令就必须是加密容器,且不带口令读不出来。
func TestExportWithPassphraseIsEncrypted(t *testing.T) {
dataDir := seedDataDir(t)
path, err := New(dataDir).ExportWith(context.Background(), Options{Passphrase: "correct horse"})
if err != nil {
t.Fatal(err)
}
encrypted, err := IsEncrypted(path)
if err != nil {
t.Fatal(err)
}
if !encrypted {
t.Fatal("给了口令导出的归档应被标记为加密")
}
// 无口令恢复:必须明确报「需要口令」,而不是含糊的解压失败
target := t.TempDir()
if err := New(target).Restore(context.Background(), path); !errors.Is(err, ErrPassphraseRequired) {
t.Fatalf("无口令恢复应报 ErrPassphraseRequired,实际 %v", err)
}
// 错口令:GCM 校验失败
if err := New(target).RestoreWith(context.Background(), path, Options{Passphrase: "wrong"}); err == nil {
t.Fatal("错误口令不应恢复成功")
}
// 正确口令:能恢复出原来的文件内容
if err := New(target).RestoreWith(context.Background(), path, Options{Passphrase: "correct horse"}); err != nil {
t.Fatalf("正确口令应恢复成功:%v", err)
}
data, err := os.ReadFile(filepath.Join(target, "havline.db"))
if err != nil {
t.Fatal(err)
}
if string(data) != "db" {
t.Fatalf("恢复内容不符:%q", data)
}
}
// TestExportWithoutPassphraseStaysCompatible:不给口令时仍产出未加密归档,
// 老版本与现有客户端可以直接解压。
func TestExportWithoutPassphraseStaysCompatible(t *testing.T) {
dataDir := seedDataDir(t)
path, err := New(dataDir).Export(context.Background())
if err != nil {
t.Fatal(err)
}
encrypted, err := IsEncrypted(path)
if err != nil {
t.Fatal(err)
}
if encrypted {
t.Fatal("未给口令时的归档不应加密")
}
if _, err := New(t.TempDir()).Verify(context.Background(), path); err != nil {
t.Fatalf("未加密归档应能通过校验:%v", err)
}
}
// TestVerifyWithEncryptedArchiveNeedsPassphrase:加密归档校验同样需要口令,
// 否则自动备份会把一份好备份判成坏包删掉。
func TestVerifyWithEncryptedArchiveNeedsPassphrase(t *testing.T) {
dataDir := seedDataDir(t)
path, err := New(dataDir).ExportWith(context.Background(), Options{Passphrase: "pw"})
if err != nil {
t.Fatal(err)
}
if _, err := New(dataDir).Verify(context.Background(), path); !errors.Is(err, ErrPassphraseRequired) {
t.Fatalf("无口令校验应报 ErrPassphraseRequired,实际 %v", err)
}
count, err := New(dataDir).VerifyWith(context.Background(), path, Options{Passphrase: "pw"})
if err != nil {
t.Fatalf("带口令校验应通过:%v", err)
}
if count == 0 {
t.Fatal("校验应返回条目数")
}
}
// TestMissingSessionSecretDetectsLegacyArchive:旧归档没有 session_secret,
// 能恢复但凭据解不开,要能被识别出来提示用户。
func TestMissingSessionSecretDetectsLegacyArchive(t *testing.T) {
dataDir := t.TempDir()
// 手工造一个「只有 havline.db」的旧式归档
legacy := writeArchive(t, map[string]string{"havline.db": "db"})
svc := New(dataDir)
missing, err := svc.MissingSessionSecret(context.Background(), legacy, Options{})
if err != nil {
t.Fatalf("识别旧归档不应报错:%v", err)
}
if !missing {
t.Fatal("旧归档应被判定为缺少 session_secret")
}
fresh := seedExport(t, seedDataDir(t))
missing, err = svc.MissingSessionSecret(context.Background(), fresh, Options{})
if err != nil {
t.Fatal(err)
}
if missing {
t.Fatal("新归档包含 session_secret,不应判定为缺失")
}
}
// TestRestoreKeepsSecretPermissions:session_secret 还原后必须是 0600,
// 否则同机其他用户就能读到解密凭据的钥匙。
func TestRestoreKeepsSecretPermissions(t *testing.T) {
if os.PathSeparator == '\\' {
t.Skip("Windows 不保留 Unix 权限位")
}
dataDir := seedDataDir(t)
path, err := New(dataDir).Export(context.Background())
if err != nil {
t.Fatal(err)
}
target := t.TempDir()
if err := New(target).Restore(context.Background(), path); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(target, "session_secret"))
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != restoreSecretMode {
t.Fatalf("session_secret 还原后权限应为 %o,实际 %o", restoreSecretMode, got)
}
}
func seedDataDir(t *testing.T) string {
t.Helper()
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "havline.db"), []byte("db"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "session_secret"), []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
return dataDir
}
func seedExport(t *testing.T, dataDir string) string {
t.Helper()
path, err := New(dataDir).Export(context.Background())
if err != nil {
t.Fatal(err)
}
return path
}
+151
View File
@@ -0,0 +1,151 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"database/sql"
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/db"
)
// openTestDB 建一个跑过迁移的临时库,用于验证快照路径。
func openTestDB(t *testing.T) (*sql.DB, error) {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "source.db"))
if err != nil {
return nil, err
}
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
// TestExportSurvivesGrowingFiles 复现「立即备份」报 archive/tar: write too long 的现场。
//
// 成因:addFile 用 WalkDir 那一刻拿到的 FileInfo.Size() 写 tar 头,随后才去读文件内容。
// 而 logs、frpc 配置这些文件在打包期间还在长,读到的字节多于头部声明,tar 直接报错、
// 整份备份作废。这里的做法是让一个「边打包边追加」的写入者持续往文件里写。
func TestExportSurvivesGrowingFiles(t *testing.T) {
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "havline.db"), []byte("db"), 0o600); err != nil {
t.Fatal(err)
}
// 造一个会持续增长的文件,放在会被归档的目录下
frpDir := filepath.Join(dataDir, "frp")
if err := os.MkdirAll(frpDir, 0o750); err != nil {
t.Fatal(err)
}
growing := filepath.Join(frpDir, "frpc-server-1.toml")
if err := os.WriteFile(growing, []byte("token = \"x\"\n"), 0o600); err != nil {
t.Fatal(err)
}
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
chunk := []byte("padding = \"" + "0123456789abcdef" + "\"\n")
for {
select {
case <-stop:
return
default:
}
f, err := os.OpenFile(growing, os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return
}
_, _ = f.Write(chunk)
_ = f.Close()
time.Sleep(time.Millisecond)
}
}()
path, err := New(dataDir).Export(context.Background())
close(stop)
<-done
if err != nil {
t.Fatalf("文件在打包期间增长时导出不应失败:%v", err)
}
if _, err := New(dataDir).Verify(context.Background(), path); err != nil {
t.Fatalf("导出的归档应可校验:%v", err)
}
}
// TestExportUsesDatabaseSnapshot:注入数据库后,归档里的 havline.db 必须来自快照,
// 而不是直接读主文件——主文件在写入时并不自洽。
func TestExportUsesDatabaseSnapshot(t *testing.T) {
dataDir := t.TempDir()
// 故意在主文件里放一段「不是有效 SQLite」的内容:
// 若实现退回直接打包文件,归档里就会是这串字节。
if err := os.WriteFile(filepath.Join(dataDir, "havline.db"), []byte("not-a-valid-db"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "session_secret"), []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
conn, err := openTestDB(t)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
svc := New(dataDir)
svc.SetDB(conn)
path, err := svc.Export(context.Background())
if err != nil {
t.Fatal(err)
}
content := readArchiveEntry(t, path, "havline.db")
if string(content) == "not-a-valid-db" {
t.Fatal("归档里的数据库应来自 VACUUM INTO 快照,而不是直接读取主文件")
}
// SQLite 文件头是 "SQLite format 3\x00"
if len(content) < 16 || string(content[:15]) != "SQLite format 3" {
t.Fatalf("快照应是有效 SQLite 文件,实际头部:%q", content[:min(16, len(content))])
}
}
func readArchiveEntry(t *testing.T, path, want string) []byte {
t.Helper()
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
gr, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
header, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
t.Fatal(err)
}
if header.Name != want {
continue
}
data, err := io.ReadAll(tr)
if err != nil {
t.Fatal(err)
}
return data
}
}
+56
View File
@@ -0,0 +1,56 @@
package backup
import (
"context"
"os"
"testing"
"github.com/havline/havline/internal/db"
)
// TestExportRealData 用真实数据目录跑一遍完整导出,验证「立即备份」在实机上不再报
// archive/tar: write too long,并且归档里确实带上了 session_secret 与 frp 配置。
// 仅在设置了 HV_REAL_DATA 时执行,避免把本地数据依赖带进 CI。
func TestExportRealData(t *testing.T) {
dir := os.Getenv("HV_REAL_DATA")
if dir == "" {
t.Skip("未设置 HV_REAL_DATA")
}
conn, err := db.Open(dir + "/havline.db")
if err != nil {
t.Fatal(err)
}
defer conn.Close()
svc := New(dir)
svc.SetDB(conn)
path, err := svc.Export(context.Background())
if err != nil {
t.Fatalf("导出失败:%v", err)
}
defer os.Remove(path)
entries, err := svc.Verify(context.Background(), path)
if err != nil {
t.Fatalf("校验失败:%v", err)
}
missing, err := svc.MissingSessionSecret(context.Background(), path, Options{})
if err != nil {
t.Fatal(err)
}
if missing {
t.Fatal("真实数据的归档应包含 session_secret")
}
t.Logf("导出成功:%s,条目 %d", path, entries)
// 有口令时也要能正常导出 + 校验
encPath, err := svc.ExportWith(context.Background(), Options{Passphrase: "test-pass"})
if err != nil {
t.Fatalf("加密导出失败:%v", err)
}
defer os.Remove(encPath)
if _, err := svc.VerifyWith(context.Background(), encPath, Options{Passphrase: "test-pass"}); err != nil {
t.Fatalf("加密归档校验失败:%v", err)
}
t.Logf("加密导出成功:%s", encPath)
}
+253 -16
View File
@@ -4,6 +4,7 @@ import (
"archive/tar"
"compress/gzip"
"context"
"database/sql"
"fmt"
"io"
"os"
@@ -14,36 +15,114 @@ import (
type Service struct {
dataDir string
// db 用于把数据库导出成一致性快照。为 nil 时退回「直接打包数据库文件」,
// 后者只在测试或不接数据库的场景下使用。
db *sql.DB
}
// Options 是导出 / 恢复的可选项;Passphrase 非空时归档会被口令加密。
type Options struct {
Passphrase string
}
// archiveEntries 是要打进归档的数据。
//
// 必须包含 session_secret:它同时是 secret.Box 的 AES-GCM 密钥,
// 少了它则数据库里的 DDNS / FRP / Cloudflare / 通知凭据在换机恢复后全部解不开,
// 界面表现为「配置都在,但一执行就报解密失败」。
// 必须包含 frp:frpc 的 toml 里有明文 token,缺了它穿透服务需要重新配一遍。
var archiveEntries = []string{"havline.db", "session_secret", "certs", "nginx", "frp", "cloudflared/tunnels"}
// 还原时的固定权限:备份内容只有数据库、证书与 nginx 配置,不接受归档自带的权限位
const (
restoreFileMode = 0o640
restoreDirMode = 0o750
// 密钥文件的权限单独收紧:它是解密库里全部凭据的那把钥匙
restoreSecretMode = 0o600
)
func New(dataDir string) *Service {
return &Service{dataDir: dataDir}
}
// SetDB 注入数据库连接,导出一致性快照时使用。
// 不放进 New 的签名里:现有调用点(含测试)很多只关心文件集合。
func (s *Service) SetDB(db *sql.DB) {
s.db = db
}
// backupTimeFmt 归档文件名里的时间戳;List / Path 都按同一套格式校验
const backupTimeFmt = "20060102-150405"
// Export 导出一份归档并返回路径;文件留在 <DataDir>/backups 下,由调用方决定下载后删除还是长期保留。
// Export 导出一份未加密归档并返回路径;文件留在 <DataDir>/backups 下,
// 由调用方决定下载后删除还是长期保留。
func (s *Service) Export(ctx context.Context) (string, error) {
return s.ExportWith(ctx, Options{})
}
// ExportWith 导出一份归档;Passphrase 非空时用口令加密。
func (s *Service) ExportWith(ctx context.Context, opts Options) (string, error) {
if err := os.MkdirAll(s.backupsDir(), 0o755); err != nil {
return "", err
}
// CreateTemp 保证同一秒内多次导出不会覆盖已有备份(文件名带随机后缀)
file, err := os.CreateTemp(s.backupsDir(), fmt.Sprintf("havline-backup-%s-*.tar.gz", time.Now().Format(backupTimeFmt)))
// 先写到暂存文件:加密要先拿到完整的 tar.gz,且暂存名不匹配备份命名规则,
// 中途失败不会在列表里留下一个看起来能用的残件。
staging, err := os.CreateTemp(s.backupsDir(), "havline-backup-*.tar.gz.tmp")
if err != nil {
return "", err
}
outPath := file.Name()
if err := s.writeArchive(ctx, file, outPath); err != nil {
stagingPath := staging.Name()
defer os.Remove(stagingPath)
if err := s.writeArchive(ctx, staging, stagingPath); err != nil {
return "", err
}
return outPath, nil
// CreateTemp 保证同一秒内多次导出不会覆盖已有备份(文件名带随机后缀)
final, err := os.CreateTemp(s.backupsDir(), fmt.Sprintf("havline-backup-%s-*.tar.gz", time.Now().Format(backupTimeFmt)))
if err != nil {
return "", err
}
finalPath := final.Name()
if err := final.Close(); err != nil {
_ = os.Remove(finalPath)
return "", err
}
_ = os.Chmod(finalPath, 0o600)
passphrase := strings.TrimSpace(opts.Passphrase)
if passphrase == "" {
// 未加密:直接把暂存文件改名成正式备份名
if err := os.Remove(finalPath); err != nil && !os.IsNotExist(err) {
return "", err
}
if err := os.Rename(stagingPath, finalPath); err != nil {
return "", err
}
return finalPath, nil
}
src, err := os.Open(stagingPath)
if err != nil {
_ = os.Remove(finalPath)
return "", err
}
defer src.Close()
dst, err := os.OpenFile(finalPath, os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
_ = os.Remove(finalPath)
return "", err
}
if err := sealFile(dst, src, passphrase); err != nil {
_ = dst.Close()
_ = os.Remove(finalPath)
return "", err
}
if err := dst.Close(); err != nil {
_ = os.Remove(finalPath)
return "", err
}
return finalPath, nil
}
// writeArchive 写归档,逐层检查关闭错误。
@@ -59,7 +138,15 @@ func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath strin
gw := gzip.NewWriter(file)
tw := tar.NewWriter(gw)
for _, entry := range []string{"havline.db", "certs", "nginx", "cloudflared/tunnels"} {
for _, entry := range archiveEntries {
// havline.db 走一致性快照,其余(证书 / nginx / frp / 密钥)直接打包
if entry == databaseName {
if err := s.addDatabase(ctx, tw); err != nil {
discard()
return err
}
continue
}
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
discard()
return err
@@ -80,14 +167,106 @@ func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath strin
return nil
}
// databaseName 是归档里的数据库条目名,也是恢复时的落点文件名。
const databaseName = "havline.db"
// addDatabase 把数据库打成一份一致性快照。
//
// 不能直接读 havline.db 文件:SQLite 在写入过程中,主文件与 -wal / -journal
// 的内容随时在变,直接 `cp` 出来的副本可能是「结构完整但内容自相矛盾」的库
// (恢复后表现为表锁死、索引与数据不一致)。VACUUM INTO 由 SQLite 自己保证
// 快照一致性,且不需要停机。
func (s *Service) addDatabase(ctx context.Context, tw *tar.Writer) error {
if s.db == nil {
// 没接数据库(测试或旧调用点):退回直接打包文件
return addToArchive(ctx, tw, s.dataDir, databaseName)
}
snapshot, err := os.CreateTemp("", "havline-snapshot-*.db")
if err != nil {
return err
}
snapshotPath := snapshot.Name()
// VACUUM INTO 要求目标文件不存在;CreateTemp 已经把文件创建出来了
if err := snapshot.Close(); err != nil {
_ = os.Remove(snapshotPath)
return err
}
if err := os.Remove(snapshotPath); err != nil {
return err
}
defer os.Remove(snapshotPath)
// 参数不能直接拼在 SQL 里(VACUUM INTO 支持字符串字面量,但不支持占位符),
// 所以这里做转义:路径里的单引号在 Windows 临时目录下可能出现。
quoted := strings.ReplaceAll(snapshotPath, "'", "''")
if _, err := s.db.ExecContext(ctx, `VACUUM INTO '`+quoted+`'`); err != nil {
return fmt.Errorf("导出数据库快照失败:%w", err)
}
info, err := os.Stat(snapshotPath)
if err != nil {
return err
}
file, err := os.Open(snapshotPath)
if err != nil {
return err
}
defer file.Close()
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = databaseName
header.Size = info.Size()
if err := tw.WriteHeader(header); err != nil {
return err
}
written, err := io.Copy(tw, io.LimitReader(file, info.Size()))
if err != nil {
return err
}
if written < info.Size() {
if _, err := io.CopyN(tw, zeroReader{}, info.Size()-written); err != nil {
return err
}
}
return nil
}
// Restore 还原一份未加密归档。
func (s *Service) Restore(ctx context.Context, archivePath string) error {
return s.RestoreWith(ctx, archivePath, Options{})
}
// RestoreWith 还原归档;归档被口令加密时必须提供 Passphrase。
func (s *Service) RestoreWith(ctx context.Context, archivePath string, opts Options) error {
encrypted, err := IsEncrypted(archivePath)
if err != nil {
return err
}
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer file.Close()
gr, err := gzip.NewReader(file)
var reader io.Reader = file
if encrypted {
passphrase := strings.TrimSpace(opts.Passphrase)
if passphrase == "" {
return ErrPassphraseRequired
}
plain, err := openSealedFile(file, passphrase)
if err != nil {
return err
}
reader = plain
}
gr, err := gzip.NewReader(reader)
if err != nil {
return fmt.Errorf("无效的备份文件")
}
@@ -121,7 +300,7 @@ func (s *Service) Restore(ctx context.Context, archivePath string) error {
return err
}
// 归档里的权限位不照搬(构造的归档可带 0777 / 执行位):备份内容只有数据库、证书与 nginx 配置
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, restoreFileMode)
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, restoreModeFor(header.Name))
if err != nil {
return err
}
@@ -135,6 +314,14 @@ func (s *Service) Restore(ctx context.Context, archivePath string) error {
return nil
}
// restoreModeFor 决定还原后的权限:session_secret 是解密凭据的钥匙,单独收紧到 0600。
func restoreModeFor(name string) os.FileMode {
if filepath.Base(filepath.ToSlash(name)) == "session_secret" {
return restoreSecretMode
}
return restoreFileMode
}
func addToArchive(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
path := filepath.Join(baseDir, rel)
info, err := os.Stat(path)
@@ -150,6 +337,24 @@ func addToArchive(ctx context.Context, tw *tar.Writer, baseDir, rel string) erro
return addFile(tw, baseDir, rel, info)
}
// skipArchivePath 排除不该进归档的内容:
// - backups/:备份里套备份会指数级膨胀;
// - frp/bin、frp/downloads:frpc 二进制与下载缓存,体积大且可重新下载;
// - frp/*.log:运行日志没有恢复价值。
func skipArchivePath(rel string) bool {
slash := filepath.ToSlash(rel)
if strings.Contains("/"+slash+"/", "/backups/") {
return true
}
if slash == "frp/bin" || strings.HasPrefix(slash, "frp/bin/") {
return true
}
if slash == "frp/downloads" || strings.HasPrefix(slash, "frp/downloads/") {
return true
}
return strings.HasPrefix(slash, "frp/") && strings.HasSuffix(slash, ".log")
}
func addDir(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
root := filepath.Join(baseDir, rel)
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
@@ -159,14 +364,14 @@ func addDir(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
if d.IsDir() {
return nil
}
if strings.Contains(path, string(os.PathSeparator)+"backups"+string(os.PathSeparator)) {
return nil
}
info, err := d.Info()
relPath, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
relPath, err := filepath.Rel(baseDir, path)
if skipArchivePath(relPath) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
@@ -181,14 +386,46 @@ func addFile(tw *tar.Writer, baseDir, rel string, info os.FileInfo) error {
return err
}
defer file.Close()
// 必须以「打开后」的尺寸为准:WalkDir 给的 info 是遍历那一刻的快照,
// 而这台机器上 logs、frpc 配置这些文件随时在长。用旧尺寸写进 tar 头、
// 再读出更多内容,就会报 archive/tar: write too long。
size := info.Size()
if current, err := file.Stat(); err == nil {
size = current.Size()
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(rel)
header.Size = size
if err := tw.WriteHeader(header); err != nil {
return err
}
_, err = io.Copy(tw, file)
return err
// 精确写入 size 字节:tar 要求实际字节数与头部声明严格相等。
// - 文件在读取期间变长 → 多余部分截断(LimitReader);
// - 文件在读取期间被截短 → 补零凑够(zeroReader),保证这一条记录结构完整,
// 不至于让整份归档作废。
written, err := io.Copy(tw, io.LimitReader(file, size))
if err != nil {
return err
}
if written < size {
if _, err := io.CopyN(tw, zeroReader{}, size-written); err != nil {
return err
}
}
return nil
}
// zeroReader 提供无限个零字节,用于补齐读取期间被截短的文件条目。
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 0
}
return len(p), nil
}
+133
View File
@@ -0,0 +1,133 @@
package cloudflared
import (
"context"
"io"
"log/slog"
"path/filepath"
"testing"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/secret"
)
// newTestService 装配一个只依赖 SQLite 的 Service,cloudflared 二进制不存在时 Start 会失败,
// 正好用来验证「哪些隧道会被尝试拉起」而不真的启动进程。
func newTestService(t *testing.T) (*Service, *Store) {
t.Helper()
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
box, err := secret.NewBox("cloudflared-lifecycle-test")
if err != nil {
t.Fatal(err)
}
store := NewStore(conn)
svc := NewService(config.Config{DataDir: dir}, store, box, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
if err := svc.EnsureDirs(); err != nil {
t.Fatal(err)
}
return svc, store
}
// createTunnel 直接落库造一条隧道,跳过需要 Cloudflare API 的 Create。
func createTunnel(t *testing.T, store *Store, name string, autoStart bool) Tunnel {
t.Helper()
ctx := context.Background()
dir := t.TempDir()
tunnel, err := store.Create(ctx, CreateInput{
Name: name,
Mode: ModeAccountLocal,
Network: DefaultNetworkSettings(),
AutoStart: autoStart,
}, "11111111-1111-1111-1111-111111111111", "acc-tag", "", "",
filepath.Join(dir, "config.yml"), filepath.Join(dir, "creds.json"), filepath.Join(dir, "cloudflared.log"))
if err != nil {
t.Fatal(err)
}
return tunnel
}
// TestStartAutoSkipsNonAutoStart:应用启动只应拉起 auto_start 的隧道。
// 之前启动路径错用了 StartAll,用户手动停掉的隧道会在重启后自己回来。
func TestStartAutoSkipsNonAutoStart(t *testing.T) {
tunnels := []Tunnel{
{ID: 1, Name: "自动", AutoStart: true},
{ID: 2, Name: "手动", AutoStart: false},
{ID: 3, Name: "也自动", AutoStart: true},
}
targets := autoStartTargets(tunnels)
if len(targets) != 2 {
t.Fatalf("应只挑出 2 条 auto_start 隧道,实际 %d:%#v", len(targets), targets)
}
for _, tunnel := range targets {
if !tunnel.AutoStart {
t.Fatalf("挑出了未勾选 auto_start 的隧道:%#v", tunnel)
}
}
}
// TestStartAutoRespectsGlobalSwitch:全局总开关关闭时一条都不启动。
func TestStartAutoRespectsGlobalSwitch(t *testing.T) {
svc, store := newTestService(t)
ctx := context.Background()
tunnel := createTunnel(t, store, "自动", true)
if _, err := svc.SetEnabled(ctx, false); err != nil {
t.Fatal(err)
}
svc.StartAuto(ctx)
got, err := store.Get(ctx, tunnel.ID)
if err != nil {
t.Fatal(err)
}
if got.Status == "failed" {
t.Fatalf("全局停用时不应尝试启动隧道,实际 %q", got.Status)
}
}
// TestStopAllMarksStopped:StopAll 应把所有隧道的运行态改写为 stopped,
// 供应用退出与「全局停用」两条路径复用。
func TestStopAllMarksStopped(t *testing.T) {
svc, store := newTestService(t)
ctx := context.Background()
first := createTunnel(t, store, "一", true)
second := createTunnel(t, store, "二", true)
for _, id := range []int64{first.ID, second.ID} {
if err := store.SetRuntime(ctx, id, "connected", "", 0); err != nil {
t.Fatal(err)
}
}
svc.StopAll(ctx)
for _, id := range []int64{first.ID, second.ID} {
got, err := store.Get(ctx, id)
if err != nil {
t.Fatal(err)
}
if got.Status != "stopped" {
t.Fatalf("隧道 %d 应为 stopped,实际 %q", id, got.Status)
}
}
}
// TestManagerStopIsIdempotent:没有子进程时 Stop 不应报错(退出路径会无差别调用)。
func TestManagerStopIsIdempotent(t *testing.T) {
dir := t.TempDir()
m := NewManager(config.Config{DataDir: dir}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err := m.Stop(42); err != nil {
t.Fatalf("停止不存在的隧道不应报错:%v", err)
}
if status := m.Status(42); status.Status != "stopped" || status.Running {
t.Fatalf("未运行的隧道状态应为 stopped:%#v", status)
}
}
+21 -1
View File
@@ -2,6 +2,7 @@ package cloudflared
import (
"bufio"
"context"
"fmt"
"io"
"log/slog"
@@ -35,12 +36,31 @@ type Manager struct {
logger *slog.Logger
mu sync.Mutex
processes map[int64]*processState
// baseCtx 是应用级 ctx:应用退出时用它连带结束未显式 Stop 的子进程。
// 不能用请求 ctx——HTTP 请求一结束子进程就会被杀掉。
baseCtx context.Context
}
func NewManager(cfg config.Config, logger *slog.Logger) *Manager {
return &Manager{cfg: cfg, logger: logger, processes: map[int64]*processState{}}
}
// SetBaseContext 注入应用级 ctx,供子进程生命周期绑定。
func (m *Manager) SetBaseContext(ctx context.Context) {
m.mu.Lock()
defer m.mu.Unlock()
m.baseCtx = ctx
}
func (m *Manager) lifecycleContext() context.Context {
m.mu.Lock()
defer m.mu.Unlock()
if m.baseCtx == nil {
return context.Background()
}
return m.baseCtx
}
func buildRunArgs(tunnel Tunnel, configPath, token string) []string {
args := []string{"tunnel"}
if tunnel.Mode == ModeTokenRemote {
@@ -116,7 +136,7 @@ func (m *Manager) Start(tunnel Tunnel, binaryPath, configPath, token string) err
m.remove(tunnel.ID)
return err
}
cmd := exec.Command(binaryPath, buildRunArgs(tunnel, configPath, token)...)
cmd := exec.CommandContext(m.lifecycleContext(), binaryPath, buildRunArgs(tunnel, configPath, token)...)
cmd.Env = buildEnv(tunnel.Network)
cmd.SysProcAttr = hideWindowAttr()
stdout, err := cmd.StdoutPipe()
+18 -4
View File
@@ -47,6 +47,11 @@ func (s *Service) EnsureDirs() error {
return os.MkdirAll(filepath.Join(s.cfg.DataDir, "cloudflared", "bin"), 0o755)
}
// SetBaseContext 注入应用级 ctx:应用退出时 cloudflared 子进程随之结束。
func (s *Service) SetBaseContext(ctx context.Context) {
s.manager.SetBaseContext(ctx)
}
func (s *Service) List(ctx context.Context) ([]Tunnel, error) {
items, err := s.store.List(ctx)
if err != nil {
@@ -752,6 +757,18 @@ func (s *Service) IngressCandidates(ctx context.Context) ([]string, error) {
return hosts, nil
}
// autoStartTargets 从隧道列表里挑出启动时应被拉起的隧道。
// 单独抽出来是为了能用纯函数覆盖「只启动 auto_start」这条语义。
func autoStartTargets(tunnels []Tunnel) []Tunnel {
out := make([]Tunnel, 0, len(tunnels))
for _, tunnel := range tunnels {
if tunnel.AutoStart {
out = append(out, tunnel)
}
}
return out
}
// StartAuto 在应用启动时拉起标记了 auto_start 的隧道。
func (s *Service) StartAuto(ctx context.Context) {
settings, err := s.store.GetSettingsRaw(ctx)
@@ -767,10 +784,7 @@ func (s *Service) StartAuto(ctx context.Context) {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
if !tunnel.AutoStart {
continue
}
for _, tunnel := range autoStartTargets(tunnels) {
if _, err := s.Start(ctx, tunnel.ID); err != nil {
s.logger.Warn("自动启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
}
+11 -2
View File
@@ -34,7 +34,7 @@ func Load() Config {
dataDir = abs
}
return Config{
ListenAddr: envOr("HAVLINE_LISTEN", ":6893"),
ListenAddr: envOrAllowEmpty("HAVLINE_LISTEN", ":6893"),
GatewaySocket: envOr("HAVLINE_GATEWAY_SOCKET", ""),
GatewayPrefix: envOr("HAVLINE_GATEWAY_PREFIX", ""),
DataDir: dataDir,
@@ -48,7 +48,7 @@ func Load() Config {
FrpPIDFile: envOr("HAVLINE_FRP_PID", dataDir+"/frp/frpc.pid"),
UpdaterSocket: updaterSocket(),
UpdaterToken: envOr("HAVLINE_UPDATER_TOKEN", ""),
UpdaterTokenFile: envOr("HAVLINE_UPDATER_TOKEN_FILE", defaultUpdaterTokenFile()),
UpdaterTokenFile: envOrAllowEmpty("HAVLINE_UPDATER_TOKEN_FILE", defaultUpdaterTokenFile()),
}
}
@@ -222,6 +222,15 @@ func envOr(key, fallback string) string {
return fallback
}
// envOrAllowEmpty 区分“未配置”和“显式配置为空”。
// 原生版用它关闭 TCP 监听(避免与 Docker 版争用 6893)并禁用 updater Token 文件。
func envOrAllowEmpty(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return fallback
}
func defaultMimeTypes() string {
candidates := []string{
"/etc/nginx/mime.types",
+18
View File
@@ -44,6 +44,24 @@ func TestLoadUpdaterTokenFile(t *testing.T) {
}
}
func TestLoadListenAddrAllowsExplicitEmpty(t *testing.T) {
t.Setenv("HAVLINE_LISTEN", "")
cfg := Load()
if cfg.ListenAddr != "" {
t.Fatalf("listen addr %q, want empty for unix-socket-only mode", cfg.ListenAddr)
}
}
func TestLoadUpdaterTokenFileAllowsExplicitEmpty(t *testing.T) {
t.Setenv("HAVLINE_UPDATER_TOKEN_FILE", "")
cfg := Load()
if cfg.UpdaterTokenFile != "" {
t.Fatalf("updater token file %q, want empty", cfg.UpdaterTokenFile)
}
}
func TestSSHKnownHostsPathForServer(t *testing.T) {
cfg := Config{DataDir: "/data"}
want := filepath.Join("/data", "ssh_known_hosts", "server-3")
+27 -22
View File
@@ -10,17 +10,18 @@ import (
)
type Config struct {
ID int64 `json:"id"`
Provider string `json:"provider"`
RootDomain string `json:"root_domain"`
RecordName string `json:"record_name"`
RecordNames []string `json:"record_names"`
IPv4Enabled bool `json:"ipv4_enabled"`
IPv6Enabled bool `json:"ipv6_enabled"`
Enabled bool `json:"enabled"`
HasToken bool `json:"has_token"`
CustomIPv4 string `json:"custom_ipv4,omitempty"`
CustomIPv6 string `json:"custom_ipv6,omitempty"`
ID int64 `json:"id"`
Provider string `json:"provider"`
Name string `json:"name"`
RootDomain string `json:"root_domain"`
RecordName string `json:"record_name"`
RecordNames []string `json:"record_names"`
IPv4Enabled bool `json:"ipv4_enabled"`
IPv6Enabled bool `json:"ipv6_enabled"`
Enabled bool `json:"enabled"`
HasToken bool `json:"has_token"`
CustomIPv4 string `json:"custom_ipv4,omitempty"`
CustomIPv6 string `json:"custom_ipv6,omitempty"`
LastIPv4 string `json:"last_ipv4"`
LastIPv6 string `json:"last_ipv6"`
LastStatus string `json:"last_status"`
@@ -31,6 +32,7 @@ type Config struct {
type SaveInput struct {
Provider string
Name string
RootDomain string
RecordName string
RecordNames []string
@@ -64,7 +66,8 @@ func (s *Store) List(ctx context.Context) ([]Config, error) {
SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, ''),
COALESCE(name, '')
FROM ddns_configs ORDER BY id ASC
`)
if err != nil {
@@ -88,7 +91,8 @@ func (s *Store) GetByID(ctx context.Context, id int64) (Config, error) {
SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, ''),
COALESCE(name, '')
FROM ddns_configs WHERE id = ?
`, id)
cfg, err := scanConfig(row)
@@ -104,7 +108,8 @@ func (s *Store) GetByRootDomain(ctx context.Context, rootDomain string) (Config,
SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, ''),
COALESCE(name, '')
FROM ddns_configs WHERE root_domain = ? ORDER BY id LIMIT 1
`, rootDomain)
cfg, err := scanConfig(row)
@@ -132,9 +137,9 @@ func (s *Store) Create(ctx context.Context, in SaveInput, tokenEnc string) (Conf
return Config{}, err
}
res, err := s.db.ExecContext(ctx, `
INSERT INTO ddns_configs(provider, root_domain, record_name, record_names, ipv4_enabled, ipv6_enabled, enabled, api_token_enc, custom_ipv4, custom_ipv6, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6)
INSERT INTO ddns_configs(provider, name, root_domain, record_name, record_names, ipv4_enabled, ipv6_enabled, enabled, api_token_enc, custom_ipv4, custom_ipv6, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, provider, strings.TrimSpace(in.Name), rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return Config{}, fmt.Errorf("该域名与子域名组合已存在")
@@ -153,10 +158,10 @@ func (s *Store) Update(ctx context.Context, id int64, in SaveInput, tokenEnc str
if updateToken {
_, err := s.db.ExecContext(ctx, `
UPDATE ddns_configs
SET provider = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
SET provider = ?, name = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
api_token_enc = ?, custom_ipv4 = ?, custom_ipv6 = ?, updated_at = datetime('now')
WHERE id = ?
`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6, id)
`, provider, strings.TrimSpace(in.Name), rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6, id)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return Config{}, fmt.Errorf("该域名与子域名组合已存在")
@@ -166,10 +171,10 @@ func (s *Store) Update(ctx context.Context, id int64, in SaveInput, tokenEnc str
} else {
_, err := s.db.ExecContext(ctx, `
UPDATE ddns_configs
SET provider = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
SET provider = ?, name = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
custom_ipv4 = ?, custom_ipv6 = ?, updated_at = datetime('now')
WHERE id = ?
`, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), in.CustomIPv4, in.CustomIPv6, id)
`, provider, strings.TrimSpace(in.Name), rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), in.CustomIPv4, in.CustomIPv6, id)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return Config{}, fmt.Errorf("该域名与子域名组合已存在")
@@ -248,7 +253,7 @@ func scanConfig(row interface{ Scan(dest ...any) error }) (Config, error) {
var ipv4Enabled, ipv6Enabled, enabled, hasToken int
var recordNamesRaw, domainRecordsRaw string
var lastUpdated sql.NullString
if err := row.Scan(&cfg.ID, &cfg.Provider, &cfg.RootDomain, &cfg.RecordName, &recordNamesRaw, &ipv4Enabled, &ipv6Enabled, &enabled, &hasToken, &cfg.LastIPv4, &cfg.LastIPv6, &cfg.LastStatus, &cfg.LastError, &domainRecordsRaw, &lastUpdated, &cfg.CustomIPv4, &cfg.CustomIPv6); err != nil {
if err := row.Scan(&cfg.ID, &cfg.Provider, &cfg.RootDomain, &cfg.RecordName, &recordNamesRaw, &ipv4Enabled, &ipv6Enabled, &enabled, &hasToken, &cfg.LastIPv4, &cfg.LastIPv6, &cfg.LastStatus, &cfg.LastError, &domainRecordsRaw, &lastUpdated, &cfg.CustomIPv4, &cfg.CustomIPv6, &cfg.Name); err != nil {
return Config{}, err
}
cfg.RecordNames = DecodeRecordNames(recordNamesRaw, cfg.RecordName)
+3 -3
View File
@@ -13,9 +13,9 @@ func TestTencentSubdomain(t *testing.T) {
func TestCredentialsTencentCloud(t *testing.T) {
cred := CredentialsFromSave(SaveInput{
Provider: "tencentcloud",
APIToken: "AKIDxxx",
APISecret: "secret",
Provider: "tencentcloud",
APIToken: "AKIDxxx",
APISecret: "secret",
})
if cred.Provider != "tencentcloud" || !cred.HasValues() {
t.Fatal("expected tencentcloud credentials")
+14
View File
@@ -439,6 +439,20 @@ func (c *AgentClient) PushFRPSConfig(ctx context.Context, content string) error
return c.request(ctx, http.MethodPut, "/api/v1/frps/config", map[string]string{"content": content}, nil)
}
// PushFRPS404Page 把自定义 404 页面内容写到 VPS 上的指定路径。
// frps 的 custom404Page 只认文件路径,所以内容必须先落到 VPS 上。
func (c *AgentClient) PushFRPS404Page(ctx context.Context, path, content string) error {
var result map[string]any
return c.request(ctx, http.MethodPut, "/api/v1/frps/404-page",
map[string]string{"path": path, "content": content}, &result)
}
// DeleteFRPS404Page 移除 VPS 上的自定义 404 页面文件(文件不存在也算成功)。
func (c *AgentClient) DeleteFRPS404Page(ctx context.Context, path string) error {
var result map[string]any
return c.request(ctx, http.MethodDelete, "/api/v1/frps/404-page?path="+url.QueryEscape(path), nil, &result)
}
func (c *AgentClient) PushCertificate(ctx context.Context, domain, fullchain, privateKey string) error {
return c.request(ctx, http.MethodPost, "/api/v1/certs", map[string]string{
"domain": domain, "fullchain_pem": fullchain, "key_pem": privateKey,
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -15,9 +15,9 @@ const (
diagStateFailed = "failed"
diagStateUnknown = "unknown"
diagTCPTimeout = 3 * time.Second
diagDNSTimeout = 2 * time.Second
diagnosticsCacheTTL = 15 * time.Second
diagTCPTimeout = 3 * time.Second
diagDNSTimeout = 2 * time.Second
diagnosticsCacheTTL = 15 * time.Second
diagnosticsMaxInFlight = 4
)
+67
View File
@@ -0,0 +1,67 @@
package frp
import (
"strings"
"testing"
)
// 自定义 404 页面:填了「页面内容」就用 Havline 托管的那份(用户不用自己去 VPS 放文件),
// 只有手填路径时才用路径,两者都没填就不输出 custom404Page。
func TestBuildFRPSServerConfigCustom404Page(t *testing.T) {
svc, ctx := newVhostPortTestService(t)
base := func() ServerOptions {
return ServerOptions{AuthMethod: "token", LogLevel: "info", Protocol: "tcp"}
}
manualPath := base()
manualPath.Custom404Page = "/var/www/404.html"
managed := base()
managed.Custom404HTML = "<html><body>404</body></html>"
both := base()
both.Custom404Page = "/var/www/404.html"
both.Custom404HTML = "<html><body>404</body></html>"
cases := []struct {
name string
key string
options ServerOptions
want string
notWant string
}{
{name: "都没填", key: "none", options: base(), notWant: "custom404Page"},
{name: "只填路径", key: "path", options: manualPath, want: `custom404Page = "/var/www/404.html"`},
{
name: "填了页面内容", key: "html", options: managed,
want: `custom404Page = "/etc/frp/havline-404.html"`,
notWant: "/var/www/404.html",
},
{
name: "两者都填时以内容为准", key: "both", options: both,
want: `custom404Page = "/etc/frp/havline-404.html"`,
notWant: `custom404Page = "/var/www/404.html"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in := baseVhostServerInput()
// 每个子用例用独立名字,避免同一份测试库里重名
in.Name = "vps-" + tc.key
in.Options = tc.options
server, err := svc.CreateServer(ctx, in)
if err != nil {
t.Fatalf("创建服务端失败:%v", err)
}
content, err := svc.buildFRPSServerConfig(ctx, server)
if err != nil {
t.Fatalf("生成 frps.toml 失败:%v", err)
}
if tc.want != "" && !strings.Contains(content, tc.want) {
t.Fatalf("期望包含 %q:\n%s", tc.want, content)
}
if tc.notWant != "" && strings.Contains(content, tc.notWant) {
t.Fatalf("不应包含 %q:\n%s", tc.notWant, content)
}
})
}
}
+13 -3
View File
@@ -11,6 +11,11 @@ import (
// buildFRPSServerConfig 把多服务端模块的 frpc 侧服务端记录渲染为 VPS 侧的 frps.toml,
// bindPort、认证、管理接口与 frpc 配置同源生成,消除两端错配(官方 BuildFRPSConfig 的多服务端版)。
// 密钥只在生成前一刻解密进模板,永不写入日志或其他 API 响应。
// FixedAgent404PagePath 是「自定义 404 页面内容」在 VPS 上的固定落盘位置。
// 界面里填的内容由 Havline 写到这个路径,frps.toml 的 custom404Page 也指向它,
// 这样用户不需要自己去 VPS 上放文件。
const FixedAgent404PagePath = "/etc/frp/havline-404.html"
func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (string, error) {
// token 认证才需要取同一份 token;none / oidc 不涉及
token := ""
@@ -66,7 +71,8 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
httpsPort = 8443
}
if httpPort == 80 || httpsPort == 443 {
return "", fmt.Errorf("frps vhost 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用);请在服务端表单改为 8080/8443 后重新生成配置")
// 已由前端二次确认;这里只把后果写进生成的文件,便于日后在 VPS 上直接看懂
b.WriteString("# ⚠️ 回源端口使用 80/443:请确认 VPS 上没有 Nginx 等其他进程占用,否则 frps 会因端口冲突启动失败\n")
}
b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort))
b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort))
@@ -76,8 +82,12 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
b.WriteString(fmt.Sprintf("subDomainHost = %q\n", host))
}
// 自定义 404 页面(HTTP vhost 命中不到规则时的响应)
if page := strings.TrimSpace(server.Options.Custom404Page); page != "" {
b.WriteString(fmt.Sprintf("custom404Page = %q\n", page))
// 界面里填了「页面内容」就用 Havline 下发的那份,其次才用用户自己填的 VPS 路径。
switch {
case strings.TrimSpace(server.Options.Custom404HTML) != "":
b.WriteString(fmt.Sprintf("custom404Page = %q\n", FixedAgent404PagePath))
case strings.TrimSpace(server.Options.Custom404Page) != "":
b.WriteString(fmt.Sprintf("custom404Page = %q\n", strings.TrimSpace(server.Options.Custom404Page)))
}
// tcpmux 规则的硬依赖:存在 tcpmux 规则时必须配置监听端口
if port := server.Options.TCPMuxHTTPConnectPort; port > 0 {
+9 -2
View File
@@ -158,8 +158,11 @@ type ServerOptions struct {
VhostHTTPPort int `json:"vhost_http_port,omitempty"`
VhostHTTPSPort int `json:"vhost_https_port,omitempty"`
// frps 进阶配置:子域名/404/强制 TLS/端口白名单/每客户端端口上限/tcpmux/文件日志
SubDomainHost string `json:"sub_domain_host,omitempty"`
Custom404Page string `json:"custom_404_page,omitempty"`
SubDomainHost string `json:"sub_domain_host,omitempty"`
Custom404Page string `json:"custom_404_page,omitempty"`
// Custom404HTML 是「自定义 404 页面内容」:填了就不用自己去 VPS 放文件,
// Havline 会把它写到 fixedAgent404PagePath 并让 frps.toml 指向那里。
Custom404HTML string `json:"custom_404_html,omitempty"`
TLSForce bool `json:"tls_force"`
AllowPorts string `json:"allow_ports,omitempty"`
MaxPortsPerClient int `json:"max_ports_per_client,omitempty"`
@@ -241,6 +244,10 @@ type ServerInput struct {
ClearTLSCertificate bool
ClearTLSKey bool
ClearTLSTrustedCA bool
// AllowPrivilegedVhostPorts 是本次请求的二次确认标记:vhost 回源端口填 80/443 时必须为 true。
// 只作用于本次校验,不落库。
AllowPrivilegedVhostPorts bool
}
type ProxyInput struct {
+97
View File
@@ -0,0 +1,97 @@
package frp
import (
"context"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/secret"
"github.com/havline/havline/internal/settings"
)
func newRestartTestService(t *testing.T) (*Service, *settings.Store, context.Context) {
t.Helper()
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "havline.db"))
if err != nil {
t.Fatalf("打开数据库失败:%v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("迁移失败:%v", err)
}
box, err := secret.NewBox("restart-test-key")
if err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, logger, certificate.NewStore(conn))
if err := svc.EnsureDirs(); err != nil {
t.Fatalf("创建目录失败:%v", err)
}
settingsStore := settings.NewStore(conn)
svc.SetSettingsStore(settingsStore)
return svc, settingsStore, context.Background()
}
// 用户往往是从隧道那头点「重启」:Stop 一杀 frpc,连接就断、请求上下文随即取消。
// 重启流程必须无视这个取消走完,否则就是「停掉了却再也起不来」。
func TestRestartIgnoresCancelledRequestContext(t *testing.T) {
svc, _, _ := newRestartTestService(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := svc.Restart(ctx)
if err != nil && strings.Contains(err.Error(), "context canceled") {
t.Fatalf("重启不应被请求取消打断:%v", err)
}
}
// frpc 的启停状态要能被记录与读回:应用停用再启用后据此自动恢复。
func TestFrpResumeStateRoundTrip(t *testing.T) {
svc, _, ctx := newRestartTestService(t)
if svc.lastSessionWasRunning(ctx) {
t.Fatal("默认应为「上次未运行」")
}
svc.rememberResumeState(ctx, true)
if !svc.lastSessionWasRunning(ctx) {
t.Fatal("记录运行后应能读回,供下次启动自动恢复")
}
svc.rememberResumeState(ctx, false)
if svc.lastSessionWasRunning(ctx) {
t.Fatal("手动停止后不应再自动恢复")
}
}
// 应用退出(StopForShutdown)不能把「上次在运行」标记抹掉,
// 否则「停用应用 → 重新启用」永远触发不了自动恢复。
func TestStopForShutdownKeepsResumeFlag(t *testing.T) {
svc, _, ctx := newRestartTestService(t)
// 模拟退出前 frpc 正在运行(真实环境里由 Status 判断,测试里直接给结论)
svc.runningProbe = func(context.Context) bool { return true }
svc.rememberResumeState(ctx, true)
if err := svc.StopForShutdown(ctx); err != nil {
t.Fatalf("退出时停止失败:%v", err)
}
if !svc.lastSessionWasRunning(ctx) {
t.Fatal("退出后仍应保留「上次在运行」标记,供下次启用时自动恢复")
}
// 退出前本来就是停止的:不能凭空写成「在运行」
svc.runningProbe = func(context.Context) bool { return false }
if err := svc.StopForShutdown(ctx); err != nil {
t.Fatalf("退出时停止失败:%v", err)
}
if svc.lastSessionWasRunning(ctx) {
t.Fatal("退出前没在运行就不该标记为自动恢复")
}
}
+8
View File
@@ -159,6 +159,14 @@ type RedeployAllResult struct {
// RedeployAllRoutes 把该服务端上所有启用中的公网反代规则重新下发一遍。
// 逐条执行、单条失败不中断(沿用 Reload 的隔离报错风格),返回逐条结果供前端汇总展示。
func (s *Service) RedeployAllRoutes(ctx context.Context, serverID int64) (RedeployAllResult, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return RedeployAllResult{}, err
}
// 80/443 回源端口的服务端不做任何公网反代下发(含漂移一键修复)
if privilegedVhostPorts(server.Options) {
return RedeployAllResult{}, errPrivilegedVhostNoPublicProxy()
}
proxies, err := s.store.ListEnabledProxies(ctx, serverID)
if err != nil {
return RedeployAllResult{}, err
+212 -7
View File
@@ -18,6 +18,7 @@ import (
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/fsutil"
"github.com/havline/havline/internal/secret"
"github.com/havline/havline/internal/settings"
)
// maskedToken 是敏感凭据的统一回显掩码:API 永不返回明文;保存时收到掩码一律视为保留旧值
@@ -62,6 +63,13 @@ type Service struct {
// applyMu 保证 FRP 配置文件写入与批量启动/重载的原子边界(23.5):
// SQLite 事务只保护关系数据,不保护 frpc-server-*.toml 与 frpc 进程。
applyMu sync.Mutex
// settings 用来记录 frpc 的启停状态(KeyFrpResumeOnStart):应用被停用再启用后
// 据此自动恢复运行。未注入时自动恢复不生效。
settings *settings.Store
// runningProbe 仅供测试替换:默认按 Status 判断 frpc 是否在运行
runningProbe func(ctx context.Context) bool
}
func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *slog.Logger, certStore *certificate.Store) *Service {
@@ -684,7 +692,11 @@ func (s *Service) CreateProxy(ctx context.Context, in ProxyInput) (Proxy, error)
if err := validateProxyInput(in); err != nil {
return Proxy{}, err
}
if _, err := s.store.GetServer(ctx, in.ServerID); err != nil {
server, err := s.store.GetServer(ctx, in.ServerID)
if err != nil {
return Proxy{}, err
}
if err := validateRemotePortAllowed(server, in); err != nil {
return Proxy{}, err
}
proxy, err := s.store.CreateProxy(ctx, in)
@@ -703,7 +715,11 @@ func (s *Service) UpdateProxy(ctx context.Context, id int64, in ProxyInput) (Pro
if err := validateProxyInput(in); err != nil {
return Proxy{}, err
}
if _, err := s.store.GetServer(ctx, in.ServerID); err != nil {
server, err := s.store.GetServer(ctx, in.ServerID)
if err != nil {
return Proxy{}, err
}
if err := validateRemotePortAllowed(server, in); err != nil {
return Proxy{}, err
}
proxy, err := s.store.UpdateProxy(ctx, id, in)
@@ -1463,6 +1479,8 @@ func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, versio
warning := ""
if server, err := s.store.GetServer(ctx, serverID); err != nil {
warning = "frps 已安装,但读取服务端配置失败:" + err.Error()
} else if err := s.syncAgent404Page(ctx, client, server); err != nil {
warning = "frps 已安装,但自定义 404 页面下发失败:" + err.Error()
} else if content, err := s.buildFRPSServerConfig(ctx, server); err != nil {
warning = "frps 已安装,但生成 frps.toml 失败:" + err.Error()
} else if err := client.PushFRPSConfig(ctx, content); err != nil {
@@ -1494,6 +1512,10 @@ func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) err
if err != nil {
return err
}
// 先把「自定义 404 页面内容」落到 VPS 上:frps.toml 里写的是文件路径,文件必须先存在
if err := s.syncAgent404Page(ctx, client, server); err != nil {
return err
}
// 下发的是 frps 服务端配置(vhost 端口/auth/webServer),不是 frpc 客户端配置
content, err := s.buildFRPSServerConfig(ctx, server)
if err != nil {
@@ -1502,6 +1524,25 @@ func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) err
return client.PushFRPSConfig(ctx, content)
}
// syncAgent404Page 把界面里填写的 404 页面内容写到 VPS 的固定路径;内容清空时删掉旧文件。
func (s *Service) syncAgent404Page(ctx context.Context, client *AgentClient, server Server) error {
html := strings.TrimSpace(server.Options.Custom404HTML)
if html == "" {
// 没填内容就清掉 Havline 托管的那份,避免残留旧页面
return client.DeleteFRPS404Page(ctx, FixedAgent404PagePath)
}
if err := client.PushFRPS404Page(ctx, FixedAgent404PagePath, html); err != nil {
return fmt.Errorf("自定义 404 页面下发失败:%w", err)
}
return nil
}
// errPrivilegedVhostNoPublicProxy 说明:frps 回源端口落在 80/443 时,VPS 上由 frps 直接对外,
// 没有 Nginx 的位置,公网反代(路由下发)整体不适用。
func errPrivilegedVhostNoPublicProxy() error {
return fmt.Errorf("该服务端的 frps 回源端口为 80/443:VPS 上由 frps 直接对外,公网反代(Nginx)不适用也无需部署;如需公网反代,请到「公网服务端」页把回源端口改回 8080/8443")
}
// agentUpstreamFor 返回该规则反代时的回源地址(frps vhost 端口),端口来自服务端表单自定义,
// 未配置时用默认 8080/8443,与 frps.toml 生成器同源。
func (s *Service) agentUpstreamFor(ctx context.Context, proxy Proxy) (string, error) {
@@ -1840,6 +1881,27 @@ func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error {
if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
return fmt.Errorf("只有启用的 HTTP/HTTPS 规则可以部署公网反代")
}
server, serr := s.store.GetServer(ctx, proxy.ServerID)
if serr != nil {
return serr
}
// 80/443 回源端口的服务端由 frps 直接对外,VPS 上没有 Nginx 的位置,直接拒绝部署
if privilegedVhostPorts(server.Options) {
return errPrivilegedVhostNoPublicProxy()
}
// 本机 frpc 配置里没有该域名,说明规则改动后还没应用:先自动应用这台服务端的配置并等隧道注册,
// 否则 VPS 上部署完只会得到「隧道未注册」,用户还得手动回「内网穿透」页点应用。
if !s.frpcConfigHasDomains(server.ID, proxy.CustomDomains) {
if err := s.applyServerConfig(ctx, server.ID); err != nil {
return fmt.Errorf("该域名还没有下发给本机 frpc,自动应用配置又失败:%w;请到「内网穿透」页手动点「校验并应用配置」后再部署", err)
}
s.logger.Info("部署公网反代前自动应用 frpc 配置", "server_id", server.ID, "domains", strings.Join(proxy.CustomDomains, "、"))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
client, ok := s.agentClient(ctx, proxy.ServerID)
if !ok {
return fmt.Errorf("公网 agent 未配置或 Token 未保存")
@@ -1855,10 +1917,6 @@ func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error {
return uerr
}
// vhost 端口供 agent 回源自检用
server, serr := s.store.GetServer(ctx, proxy.ServerID)
if serr != nil {
return serr
}
vhostPort := server.Options.VhostHTTPPort
if vhostPort <= 0 {
vhostPort = 8080
@@ -1909,7 +1967,7 @@ func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error {
case code == 0:
return fmt.Errorf("反代已部署,但回源自检失败(连接不上 frps vhost :%d):检查 frps 是否运行、vhostHTTPPort 是否与表单一致", vhostPort)
case code == 404:
return fmt.Errorf("反代已部署,frps 正常但没有找到域名 %s 的隧道注册:请确认内网穿透里存在绑定该域名的 http 规则、类型为 http 且已启用,并已应用配置", domain)
return fmt.Errorf("反代已部署,frps 正常但没有找到域名 %s 的隧道注册(%s)", domain, s.frpcTunnelHint(server.ID, domain))
case code >= 500:
return fmt.Errorf("反代已部署,隧道已连通但内网服务异常(HTTP %.0f):请检查内网服务 %s 是否存活", code, upstream)
}
@@ -1918,6 +1976,64 @@ func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error {
return nil
}
// frpcTunnelHint 解释「frps 里查不到这条隧道」最可能的原因,并给出下一步动作:
// 先看最近一次应用过的 frpc 配置里有没有该域名,再看进程是否在跑,最后才怀疑 frpc↔frps 的连接。
func (s *Service) frpcTunnelHint(serverID int64, domain string) string {
if !s.frpcConfigHasDomains(serverID, []string{domain}) {
return "本机 frpc 配置里还没有这个域名,请到「内网穿透」页点「校验并应用配置」"
}
if s.managerForServer(serverID).Status().Status != "running" {
return "配置里有该域名,但本机 frpc 没有运行,请到「内网穿透」页启动"
}
return "配置里有该域名且 frpc 正在运行,说明 frpc 没有成功注册到 frps(多为登录失败或代理名冲突),请到「内网穿透 → 服务端详情 → 日志」看 frpc 报错"
}
// frpcConfigHasDomains 检查最近一次应用过的 frpc 配置里是否包含这些域名;
// 文件由 Reload/启动流程写入,内容即「已经下发给 frpc 的规则」。
func (s *Service) frpcConfigHasDomains(serverID int64, domains []string) bool {
data, err := os.ReadFile(s.serverConfigPath(serverID))
if err != nil {
return false
}
content := string(data)
for _, domain := range domains {
if !strings.Contains(content, domain) {
return false
}
}
return true
}
// applyServerConfig 重新生成配置文件并只重启这台服务端的 frpc,
// 用于「规则改了但没应用配置」的自动补救(不碰其他服务端的进程)。
func (s *Service) applyServerConfig(ctx context.Context, serverID int64) error {
s.applyMu.Lock()
defer s.applyMu.Unlock()
if err := s.persistConfigsLocked(ctx); err != nil {
return err
}
manager := s.managerForServer(serverID)
hasProxies, err := s.hasEnabledProxies(ctx, serverID)
if err != nil {
return err
}
if !hasProxies {
_ = manager.Stop(ctx)
s.recordProcessState(ctx, serverID, manager, "")
return nil
}
if err := manager.Verify(ctx, s.serverConfigPath(serverID)); err != nil {
s.recordProcessState(ctx, serverID, manager, err.Error())
return err
}
if err := manager.Restart(ctx); err != nil {
s.recordProcessState(ctx, serverID, manager, err.Error())
return err
}
s.recordProcessState(ctx, serverID, manager, "")
return nil
}
// pushAgentCertificate 从 Havline 本地证书存储读取域名对应证书并推送到公网 agent;
// 支持精确域名与通配符(*.example.com)匹配。
func (s *Service) pushAgentCertificate(ctx context.Context, client *AgentClient, domain string) error {
@@ -2116,6 +2232,10 @@ func (s *Service) syncAgentRoute(ctx context.Context, proxy Proxy) {
if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
return
}
// 80/443 回源端口的服务端由 frps 直接对外:任何自动下发都要跳过
if server, err := s.store.GetServer(ctx, proxy.ServerID); err == nil && privilegedVhostPorts(server.Options) {
return
}
client, ok := s.agentClient(ctx, proxy.ServerID)
if !ok {
return
@@ -2188,6 +2308,7 @@ func (s *Service) Start(ctx context.Context) error {
if len(failures) > 0 {
return fmt.Errorf("%s", strings.Join(failures, ";"))
}
s.rememberResumeState(ctx, true)
return nil
}
@@ -2203,12 +2324,66 @@ func (s *Service) AutoStart(ctx context.Context) error {
break
}
}
// 上次应用退出时 frpc 还在运行:这次也自动拉起来。
// 否则「停用应用 → 重新启用」之后 frpc 是停的,用户点图标没反应还得绕路进后台手动开。
if !shouldStart && len(servers) > 0 && s.lastSessionWasRunning(ctx) {
shouldStart = true
}
if !shouldStart {
return nil
}
return s.Start(ctx)
}
// SetSettingsStore 注入设置存储:用来记录/读取 frpc 的运行状态。
func (s *Service) SetSettingsStore(store *settings.Store) {
s.settings = store
}
// rememberResumeState 记录 frpc 当前是否在运行,供下次应用启动时决定要不要自动拉起。
func (s *Service) rememberResumeState(ctx context.Context, running bool) {
if s.settings == nil {
return
}
value := "0"
if running {
value = "1"
}
if err := s.settings.Set(ctx, settings.KeyFrpResumeOnStart, value); err != nil {
s.logger.Warn("记录 frpc 运行状态失败", "module", "FRP", "error", err.Error())
}
}
// lastSessionWasRunning 上次 frpc 处于运行状态时为真。
func (s *Service) lastSessionWasRunning(ctx context.Context) bool {
if s.settings == nil {
return false
}
value, err := s.settings.Get(ctx, settings.KeyFrpResumeOnStart)
return err == nil && value == "1"
}
// StopForShutdown 是应用退出时的停止:行为同 Stop,但保留「上次在运行」标记。
//
// 否则「停用应用 → 重新启用」会被 Stop 写成「上次是停的」,自动恢复就永远不触发。
func (s *Service) StopForShutdown(ctx context.Context) error {
wasRunning := s.frpRunning(ctx)
if err := s.Stop(ctx); err != nil {
return err
}
// Stop 会把标记写成「不再自动恢复」,这里按退出前的真实状态改回来
s.rememberResumeState(ctx, wasRunning)
return nil
}
// frpRunning 判断当前是否有 frpc 在运行。
func (s *Service) frpRunning(ctx context.Context) bool {
if s.runningProbe != nil {
return s.runningProbe(ctx)
}
return s.Status(ctx).Status == "running"
}
func (s *Service) Stop(ctx context.Context) error {
s.stopAgentTunnels()
_ = s.ReconcileRuntime(ctx)
@@ -2228,6 +2403,36 @@ func (s *Service) Stop(ctx context.Context) error {
if len(messages) > 0 {
return fmt.Errorf("%s", strings.Join(messages, ";"))
}
s.rememberResumeState(ctx, false)
return nil
}
// Restart 重启全部 frpc:先停后起。
//
// 比手动「停止 → 启动」多一层保障:Stop 会把 Agent 隧道一起停掉,而单独点「启动」
// 不会重建它们;重启在这里顺带把隧道重新拉起来,避免停一次之后就再也连不上。
func (s *Service) Restart(ctx context.Context) error {
// 双保险:即使调用方传进来的是会被取消的请求上下文(用户正是从隧道那头点进来的),
// 重启流程也必须走完——否则就变成「停掉了却起不来」。
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel()
stopErr := s.Stop(ctx)
startErr := s.Start(ctx)
if startErr != nil {
if stopErr != nil {
return fmt.Errorf("停止阶段:%v;启动阶段:%w", stopErr, startErr)
}
return startErr
}
// 隧道重建可能要等远端,放到后台跑,不占着这次请求
tunnelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
go func() {
defer cancel()
s.StartAgentTunnels(tunnelCtx)
}()
if stopErr != nil {
s.logger.Warn("frpc 重启时停止阶段报错,但已重新启动", "module", "FRP", "error", stopErr.Error())
}
return nil
}
+28
View File
@@ -0,0 +1,28 @@
package frp
import (
"context"
"fmt"
"github.com/havline/havline/internal/terminal"
)
// OpenSSHTerminal 用该服务端已保存的 SSH 凭据建立一个交互式 PTY 会话。
// 凭据解密与主机密钥校验(known_hosts TOFU)都复用「安装 Agent」那套 sshInstaller,
// 不再实现一份认证逻辑,也不允许终端页面输入密码。
func (s *Service) OpenSSHTerminal(ctx context.Context, serverID int64, cols, rows int) (terminal.Terminal, string, error) {
server, installer, err := s.sshInstaller(ctx, serverID)
if err != nil {
return nil, "", err
}
client, err := installer.client(ctx)
if err != nil {
return nil, "", err
}
term, err := terminal.NewSSHTerminal(client, cols, rows)
if err != nil {
_ = client.Close()
return nil, "", err
}
return term, fmt.Sprintf("%s · %s@%s", server.Name, server.SSHUser, server.SSHHost), nil
}
+64 -2
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/url"
"regexp"
"strconv"
"strings"
)
@@ -30,8 +31,10 @@ func validateServerInput(in ServerInput) error {
if in.Options.VhostHTTPSPort != 0 && !validPort(in.Options.VhostHTTPSPort) {
return fmt.Errorf("frps HTTPS 回源端口无效")
}
if in.Options.VhostHTTPPort == 80 || in.Options.VhostHTTPSPort == 443 {
return fmt.Errorf("frps vhost 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用);请使用 8080/8443")
// 80/443 默认由 VPS 上的 Nginx 占用,frps 再监听会因端口冲突启动失败;
// 仍允许使用(例如 VPS 上不跑 Nginx、由 frps 直接对外),但必须由前端二次确认后带标记提交。
if (in.Options.VhostHTTPPort == 80 || in.Options.VhostHTTPSPort == 443) && !in.AllowPrivilegedVhostPorts {
return fmt.Errorf("frps vhost 回源端口 80/443 通常由 VPS 上的 Nginx 占用;确认该端口已释放后,请确认风险提示再保存")
}
if !validDashboardAddr(in.Options.DashboardAddr) {
return fmt.Errorf("frps 管理接口地址无效")
@@ -366,6 +369,21 @@ func validPort(port int) bool {
return port > 0 && port <= 65535
}
// privilegedVhostPorts 判断 frps 回源端口是否落在 80/443(未配置时按默认 8080/8443 计)。
// 这种拓扑下 VPS 上由 frps 直接对外,没有 Nginx 监听 80/443 的位置,
// 公网反代(Nginx 路由下发)既不适用、也不应该部署。
func privilegedVhostPorts(options ServerOptions) bool {
httpPort := options.VhostHTTPPort
if httpPort <= 0 {
httpPort = 8080
}
httpsPort := options.VhostHTTPSPort
if httpsPort <= 0 {
httpsPort = 8443
}
return httpPort == 80 || httpsPort == 443
}
func validHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" || len(host) > 253 || strings.ContainsAny(host, " \t\r\n/\\\"'") {
@@ -377,6 +395,50 @@ func validHost(host string) bool {
return validDomain(host)
}
// validateRemotePortAllowed 检查 tcp/udp 规则的远程端口是否落在服务端的 allowPorts 白名单里。
//
// frps 只认 allowPorts:端口不在白名单时规则会被服务端拒绝,frpc 会一直重试并刷
// `[规则名] start error: port not allowed`。与其等用户去翻 frpc 日志,不如保存时就说清楚。
func validateRemotePortAllowed(server Server, in ProxyInput) error {
allow := strings.TrimSpace(server.Options.AllowPorts)
if allow == "" || in.RemotePort == nil {
return nil
}
if in.Type != "tcp" && in.Type != "udp" {
return nil
}
if portAllowed(allow, *in.RemotePort) {
return nil
}
return fmt.Errorf(
"远程端口 %d 不在服务端「%s」的 allowPorts 白名单(%s)内:frps 会拒绝这条规则,frpc 会反复报 \"port not allowed\"。请到「公网服务端 → 服务端参数」把 %d 加进白名单(或清空 allowPorts 表示不限),保存后重新下发 frps 配置",
*in.RemotePort, server.Name, allow, *in.RemotePort)
}
// portAllowed 按 allowPorts 的写法(单端口 / 起-止 区间,逗号分隔)判断端口是否放行。
// 区间写反(6010-6000)、越界(0 或 >65535)与 renderAllowPorts 一样视为无效,不放行;
// 否则会出现「校验说放行、实际生成的 frps.toml 里这条白名单被丢弃」的偏差。
func portAllowed(allowPorts string, port int) bool {
for _, part := range strings.Split(allowPorts, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if start, end, ok := strings.Cut(part, "-"); ok {
startN, err1 := strconv.Atoi(strings.TrimSpace(start))
endN, err2 := strconv.Atoi(strings.TrimSpace(end))
if err1 == nil && err2 == nil && startN > 0 && endN >= startN && endN <= 65535 && port >= startN && port <= endN {
return true
}
continue
}
if n, err := strconv.Atoi(part); err == nil && n > 0 && n <= 65535 && n == port {
return true
}
}
return false
}
func validDomain(domain string) bool {
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
if domain == "" || len(domain) > 253 || strings.Contains(domain, "..") {
+51 -3
View File
@@ -1,6 +1,9 @@
package frp
import "testing"
import (
"strings"
"testing"
)
func TestValidateProxyInput(t *testing.T) {
port := 60022
@@ -57,17 +60,22 @@ func TestValidateServerInputVhostPorts(t *testing.T) {
name string
http int
https int
confirm bool
wantErr bool
}{
{name: "默认端口", http: 8080, https: 8443},
{name: "HTTP 80 与 Nginx 冲突", http: 80, https: 8443, wantErr: true},
{name: "HTTPS 443 与 Nginx 冲突", http: 8080, https: 443, wantErr: true},
{name: "HTTP 80 未二次确认", http: 80, https: 8443, wantErr: true},
{name: "HTTPS 443 未二次确认", http: 8080, https: 443, wantErr: true},
{name: "HTTP 80 已二次确认", http: 80, https: 8443, confirm: true},
{name: "HTTPS 443 已二次确认", http: 8080, https: 443, confirm: true},
{name: "80 与 443 同时使用且已二次确认", http: 80, https: 443, confirm: true},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
input := normalizeServerInput(base)
input.Options.VhostHTTPPort = testCase.http
input.Options.VhostHTTPSPort = testCase.https
input.AllowPrivilegedVhostPorts = testCase.confirm
err := validateServerInput(input)
if (err != nil) != testCase.wantErr {
t.Fatalf("validateServerInput() error = %v, wantErr %v", err, testCase.wantErr)
@@ -75,3 +83,43 @@ func TestValidateServerInputVhostPorts(t *testing.T) {
})
}
}
func TestValidateRemotePortAllowed(t *testing.T) {
port := func(value int) *int { return &value }
base := Server{Name: "雨云vps"}
cases := []struct {
name string
allow string
input ProxyInput
wantErr bool
}{
{name: "单端口命中", allow: "6000,6095", input: ProxyInput{Type: "tcp", RemotePort: port(6095)}},
{name: "区间命中", allow: "6000-6100", input: ProxyInput{Type: "tcp", RemotePort: port(6095)}},
{name: "区间下界命中", allow: "6095-6200", input: ProxyInput{Type: "udp", RemotePort: port(6095)}},
{name: "带空格写法", allow: " 6000 - 6100 , 7000", input: ProxyInput{Type: "tcp", RemotePort: port(6095)}},
{name: "不在白名单拒绝", allow: "6000-6010", input: ProxyInput{Type: "tcp", RemotePort: port(6095)}, wantErr: true},
{name: "区间写反视为无效", allow: "6100-6000", input: ProxyInput{Type: "tcp", RemotePort: port(6095)}, wantErr: true},
{name: "越界区间视为无效", allow: "6000-70000", input: ProxyInput{Type: "tcp", RemotePort: port(6095)}, wantErr: true},
{name: "白名单为空不限制", allow: "", input: ProxyInput{Type: "tcp", RemotePort: port(6095)}},
{name: "HTTP 规则跳过校验", allow: "6000-6010", input: ProxyInput{Type: "http", RemotePort: port(6095)}},
{name: "无远程端口跳过校验", allow: "6000-6010", input: ProxyInput{Type: "tcp"}},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
server := base
server.Options.AllowPorts = testCase.allow
err := validateRemotePortAllowed(server, testCase.input)
if (err != nil) != testCase.wantErr {
t.Fatalf("validateRemotePortAllowed() error = %v, wantErr %v", err, testCase.wantErr)
}
if err == nil {
return
}
message := err.Error()
if !strings.Contains(message, "6095") || !strings.Contains(message, testCase.allow) {
t.Fatalf("错误信息应包含端口与白名单原文,实际为 %q", message)
}
})
}
}
+232
View File
@@ -0,0 +1,232 @@
package frp
import (
"context"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/secret"
)
// newVhostPortTestService 建一个带真实 SQLite 的 Service:80/443 的放行是「校验 + 生成 frps.toml」
// 两段链路共同决定的,纯函数测试覆盖不到。
func newVhostPortTestService(t *testing.T) (*Service, context.Context) {
t.Helper()
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
box, err := secret.NewBox("vhost-port-test-key")
if err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, logger, certificate.NewStore(conn))
if err := svc.EnsureDirs(); err != nil {
t.Fatalf("ensure dirs: %v", err)
}
return svc, context.Background()
}
func baseVhostServerInput() ServerInput {
return ServerInput{
Name: "test-vps",
ServerAddr: "10.0.0.1",
ServerPort: 7000,
Enabled: true,
Token: "test-token",
Options: ServerOptions{AuthMethod: "token", LogLevel: "info", Protocol: "tcp"},
}
}
// 未二次确认时,80/443 必须被拒绝。
func TestCreateServerPrivilegedVhostPortsRequiresConfirmation(t *testing.T) {
svc, ctx := newVhostPortTestService(t)
for _, testCase := range []struct {
name string
http int
https int
}{
{name: "HTTP 80", http: 80, https: 8443},
{name: "HTTPS 443", http: 8080, https: 443},
} {
t.Run(testCase.name, func(t *testing.T) {
in := baseVhostServerInput()
in.Options.VhostHTTPPort = testCase.http
in.Options.VhostHTTPSPort = testCase.https
if _, err := svc.CreateServer(ctx, in); err == nil {
t.Fatal("未二次确认时保存 80/443 应被拒绝")
} else if !strings.Contains(err.Error(), "80/443") {
t.Fatalf("错误信息应点明 80/443:%v", err)
}
})
}
}
// 二次确认后允许保存,生成的 frps.toml 要带风险注释且不能报错。
func TestCreateServerPrivilegedVhostPortsConfirmed(t *testing.T) {
svc, ctx := newVhostPortTestService(t)
in := baseVhostServerInput()
in.Options.VhostHTTPPort = 80
in.Options.VhostHTTPSPort = 443
in.AllowPrivilegedVhostPorts = true
server, err := svc.CreateServer(ctx, in)
if err != nil {
t.Fatalf("二次确认后保存应成功:%v", err)
}
// vhost 端口只在存在 http/https/tcpmux 规则时才写入 frps.toml
if _, err := svc.CreateProxy(ctx, ProxyInput{
ServerID: server.ID, Name: "nas-web", Type: "http",
LocalIP: "127.0.0.1", LocalPort: 18080,
CustomDomains: []string{"nas.example.com"}, Enabled: true,
}); err != nil {
t.Fatalf("创建 http 规则失败:%v", err)
}
content, err := svc.buildFRPSServerConfig(ctx, server)
if err != nil {
t.Fatalf("已放行的 80/443 不应导致 frps.toml 生成失败:%v", err)
}
for _, want := range []string{"vhostHTTPPort = 80", "vhostHTTPSPort = 443", "80/443"} {
if !strings.Contains(content, want) {
t.Fatalf("frps.toml 缺少 %q:\n%s", want, content)
}
}
}
// 反代回源地址必须跟着 80/443 走,否则 VPS 上 Nginx 会指向错端口。
func TestAgentUpstreamFollowsPrivilegedVhostPorts(t *testing.T) {
svc, ctx := newVhostPortTestService(t)
in := baseVhostServerInput()
in.Options.VhostHTTPPort = 80
in.Options.VhostHTTPSPort = 443
in.AllowPrivilegedVhostPorts = true
server, err := svc.CreateServer(ctx, in)
if err != nil {
t.Fatalf("保存失败:%v", err)
}
httpProxy, err := svc.CreateProxy(ctx, ProxyInput{
ServerID: server.ID, Name: "nas-web", Type: "http",
LocalIP: "127.0.0.1", LocalPort: 18080,
CustomDomains: []string{"nas.example.com"}, Enabled: true,
})
if err != nil {
t.Fatalf("创建 http 规则失败:%v", err)
}
if got, err := svc.agentUpstreamFor(ctx, httpProxy); err != nil {
t.Fatalf("解析回源地址失败:%v", err)
} else if got != "127.0.0.1:80" {
t.Fatalf("HTTP 回源地址 = %q,期望 127.0.0.1:80", got)
}
httpsProxy, err := svc.CreateProxy(ctx, ProxyInput{
ServerID: server.ID, Name: "nas-web-tls", Type: "https",
LocalIP: "127.0.0.1", LocalPort: 18080,
CustomDomains: []string{"nas-tls.example.com"}, Enabled: true,
})
if err != nil {
t.Fatalf("创建 https 规则失败:%v", err)
}
if got, err := svc.agentUpstreamFor(ctx, httpsProxy); err != nil {
t.Fatalf("解析回源地址失败:%v", err)
} else if got != "127.0.0.1:443" {
t.Fatalf("HTTPS 回源地址 = %q,期望 127.0.0.1:443", got)
}
}
func TestPrivilegedVhostPortsDetection(t *testing.T) {
cases := []struct {
name string
options ServerOptions
want bool
}{
{name: "默认 8080/8443", options: ServerOptions{}, want: false},
{name: "留空但显式 0", options: ServerOptions{VhostHTTPPort: 0, VhostHTTPSPort: 0}, want: false},
{name: "HTTP 80", options: ServerOptions{VhostHTTPPort: 80}, want: true},
{name: "HTTPS 443", options: ServerOptions{VhostHTTPSPort: 443}, want: true},
{name: "自定义高位端口", options: ServerOptions{VhostHTTPPort: 18080, VhostHTTPSPort: 18443}, want: false},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
if got := privilegedVhostPorts(testCase.options); got != testCase.want {
t.Fatalf("privilegedVhostPorts() = %v, 期望 %v", got, testCase.want)
}
})
}
}
// 80/443 回源端口下公网反代不适用:单条部署与全量重部署(含漂移一键修复)都必须被拒绝。
func TestPublicReverseProxyRejectedForPrivilegedVhostPorts(t *testing.T) {
svc, ctx := newVhostPortTestService(t)
in := baseVhostServerInput()
in.Options.VhostHTTPPort = 80
in.Options.VhostHTTPSPort = 443
in.AllowPrivilegedVhostPorts = true
server, err := svc.CreateServer(ctx, in)
if err != nil {
t.Fatalf("保存失败:%v", err)
}
proxy, err := svc.CreateProxy(ctx, ProxyInput{
ServerID: server.ID, Name: "nas-web", Type: "http",
LocalIP: "127.0.0.1", LocalPort: 18080,
CustomDomains: []string{"nas.example.com"}, Enabled: true,
})
if err != nil {
t.Fatalf("创建 http 规则失败:%v", err)
}
if err := svc.DeployAgentRoute(ctx, proxy.ID); err == nil {
t.Fatal("80/443 回源端口下部署公网反代应被拒绝")
} else if !strings.Contains(err.Error(), "80/443") {
t.Fatalf("错误信息应说明 80/443 不适用公网反代:%v", err)
}
if _, err := svc.RedeployAllRoutes(ctx, server.ID); err == nil {
t.Fatal("80/443 回源端口下全量重部署应被拒绝")
} else if !strings.Contains(err.Error(), "80/443") {
t.Fatalf("错误信息应说明 80/443 不适用公网反代:%v", err)
}
}
// 对照组:默认 8080/8443 时不应被 80/443 规则拦下(此时失败是「没配 agent」,属于另一条链路)。
func TestPublicReverseProxyNotBlockedForDefaultVhostPorts(t *testing.T) {
svc, ctx := newVhostPortTestService(t)
server, err := svc.CreateServer(ctx, baseVhostServerInput())
if err != nil {
t.Fatalf("保存失败:%v", err)
}
proxy, err := svc.CreateProxy(ctx, ProxyInput{
ServerID: server.ID, Name: "nas-web", Type: "http",
LocalIP: "127.0.0.1", LocalPort: 18080,
CustomDomains: []string{"nas.example.com"}, Enabled: true,
})
if err != nil {
t.Fatalf("创建 http 规则失败:%v", err)
}
err = svc.DeployAgentRoute(ctx, proxy.ID)
if err == nil {
t.Fatal("未配置 agent 时部署应失败")
}
if strings.Contains(err.Error(), "不适用也无需部署") {
t.Fatalf("默认端口不应被 80/443 规则拦下,实际:%v", err)
}
}
+137 -28
View File
@@ -2,10 +2,14 @@ package logstore
import (
"bufio"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -298,16 +302,9 @@ func hourlyAccessCountsForDay(path string, day time.Time) ([]int, []string) {
counts := make([]int, hourlyBucketCount)
labels := calendarHourlyLabels()
file, err := os.Open(path)
if err != nil {
return counts, labels
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
entry, ok := parseAccess(scanner.Text())
// 轮转后的历史内容在归档文件里:只读当前文件会让「今日统计」在轮转后突然掉一半。
for _, line := range accessLinesForDay(path, day) {
entry, ok := parseAccess(line)
if !ok || !isDashboardAccess(entry) {
continue
}
@@ -324,6 +321,75 @@ func hourlyAccessCountsForDay(path string, day time.Time) ([]int, []string) {
return counts, labels
}
// accessLinesForDay 返回某天的原始日志行:当前文件 + 该日期的归档文件。
// 只挑文件名里带目标日期的归档,避免把几个月的归档都解开。
func accessLinesForDay(path string, day time.Time) []string {
var out []string
stamp := day.Format("20060102")
for _, archive := range archivesForDay(path, stamp) {
out = append(out, readGzipLines(archive)...)
}
out = append(out, readFileLines(path)...)
return out
}
// archivesForDay 列出与目标日期匹配的归档(access-20261024-1305.log.gz):
// 文件名里的日期是按轮转时刻取的,所以既可能属于当天,也可能属于前一天跨零点的那一段。
func archivesForDay(path, day string) []string {
dir := filepath.Dir(path)
base := strings.TrimSuffix(filepath.Base(path), ".log")
prefix := base + "-" + day
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
out := make([]string, 0, 4)
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".log.gz") {
continue
}
out = append(out, filepath.Join(dir, name))
}
sort.Strings(out)
return out
}
func readFileLines(path string) []string {
file, err := os.Open(path)
if err != nil {
return nil
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines
}
func readGzipLines(path string) []string {
file, err := os.Open(path)
if err != nil {
return nil
}
defer file.Close()
gz, err := gzip.NewReader(file)
if err != nil {
return nil
}
defer gz.Close()
var lines []string
scanner := bufio.NewScanner(gz)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines
}
func calendarHourlyLabels() []string {
labels := make([]string, hourlyBucketCount)
for i := 0; i < hourlyBucketCount; i++ {
@@ -358,17 +424,10 @@ func CountTodayAccess(path string) (total int, errors int, avgMs float64) {
}
func CountAccessForDay(path string, day time.Time) (total int, errors int, avgMs float64) {
file, err := os.Open(path)
if err != nil {
return 0, 0, 0
}
defer file.Close()
var sum float64
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
entry, ok := parseAccess(scanner.Text())
// 与小时曲线一致:轮转后的内容在归档里,只扫当前文件会漏掉当天早些时候的请求
for _, line := range accessLinesForDay(path, day) {
entry, ok := parseAccess(line)
if !ok || !isDashboardAccess(entry) {
continue
}
@@ -388,7 +447,14 @@ func CountAccessForDay(path string, day time.Time) (total int, errors int, avgMs
return total, errors, avgMs
}
// tailLines 返回文件末尾的至多 max 行(保持原顺序,旧→新)。
//
// 之所以从文件尾往前读:日志可能上到几百 MB,而调用方只要最后几十行。
// 顺序扫描等于每刷新一次页面就把整个文件读一遍,NAS 这种长期运行的机器上很吃亏。
func tailLines(path string, max int) ([]string, error) {
if max <= 0 {
return nil, nil
}
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
@@ -398,16 +464,59 @@ func tailLines(path string, max int) ([]string, error) {
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
if len(lines) > max {
lines = lines[1:]
info, err := file.Stat()
if err != nil {
return nil, err
}
size := info.Size()
if size == 0 {
return nil, nil
}
// 从尾部往前读块,攒够 max 个换行就停。64KB 起步,最多扩到 8MB:
// 单行上限 1MB(与 scanner 的上限一致),保证超长行不会被截断成多条。
const (
startChunk = 64 << 10
maxChunk = 8 << 20
)
chunk := int64(startChunk)
var tail []byte
offset := size
for offset > 0 {
readSize := chunk
if readSize > offset {
readSize = offset
}
offset -= readSize
buf := make([]byte, readSize)
if _, err := file.ReadAt(buf, offset); err != nil && err != io.EOF {
return nil, err
}
tail = append(buf, tail...)
// 已攒到足够的行就不必继续往前读(末尾若没有换行,也算一行)
if strings.Count(string(tail), "\n") > max {
break
}
if chunk < maxChunk {
chunk *= 2
}
}
return lines, scanner.Err()
text := string(tail)
if offset > 0 {
// 从中间开始读时,第一行很可能是半行:丢掉它
if i := strings.IndexByte(text, '\n'); i >= 0 {
text = text[i+1:]
}
}
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
if len(lines) > max {
lines = lines[len(lines)-max:]
}
if len(lines) == 1 && lines[0] == "" {
return nil, nil
}
return lines, nil
}
func asString(v any) string {
+191
View File
@@ -0,0 +1,191 @@
package logstore
import (
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Nginx 自己不会轮转日志,长期运行的 NAS 上 access.log 会一直长。
// 这里的策略刻意保持简单:
// - 超过 MaxBytes 才轮转,避免一天切一堆小文件;
// - 轮转时把 access.log 改名成 access-<时间戳>.log.gz,再让 Nginx 重开文件;
// - 保留最近 Keep 份,更旧的删掉。
//
// 用「rename + 信号重开」而不是 truncate:truncate 会丢掉读侧(流量采集)还没读到的内容。
const (
// DefaultMaxBytes 单个日志文件的上限(200MB)。NAS 上反代日志增长很快,
// 太大则读取接口变慢,太小则轮转频繁、压缩开销占比升高。
DefaultMaxBytes int64 = 200 << 20
// DefaultKeep 每个日志保留的归档份数。
DefaultKeep = 5
)
// Rotator 负责单个日志文件的轮转与清理。
type Rotator struct {
// Path 当前正在写入的日志路径(例如 <DataDir>/logs/access.log)
Path string
// MaxBytes 超过它就轮转;<= 0 用 DefaultMaxBytes
MaxBytes int64
// Keep 保留份数;<= 0 用 DefaultKeep
Keep int
// MaxAgeDays 归档保留天数;<= 0 表示不按时间清理(只按份数)。
// 对应「设置 → 常规 → 日志保留天数」,填了才生效。
MaxAgeDays int
// reopen 通知写日志的进程重新打开文件(Nginx 用 USR1)
reopen func() error
}
// NewRotator 装配一个日志轮转器。reopen 用于通知写日志的进程重新打开文件
// (Nginx 用 USR1);传 nil 表示该日志由本进程自己写,不需要重开。
func NewRotator(path string, maxAgeDays int, reopen func() error) *Rotator {
return &Rotator{Path: path, MaxAgeDays: maxAgeDays, reopen: reopen}
}
// RotateIfNeeded 在文件超过阈值时轮转,返回是否真的轮转了。
// 未超阈值时不产生任何副作用,可以安全地高频调用。
func (r *Rotator) RotateIfNeeded(now time.Time) (bool, error) {
info, err := os.Stat(r.Path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
limit := r.MaxBytes
if limit <= 0 {
limit = DefaultMaxBytes
}
if info.Size() < limit {
return false, nil
}
target := r.archivePath(now)
if err := compressRename(r.Path, target); err != nil {
return false, err
}
// 文件被改名后,Nginx 仍持有旧 inode 继续往里写。必须让它重开,
// 否则新内容会写进已归档的那份文件,轮转等于没做。
if r.reopen != nil {
if err := r.reopen(); err != nil {
return true, fmt.Errorf("日志已归档,但通知重开失败:%w", err)
}
}
// 归档成功后再清理旧份数:清理失败不该让本轮轮转算失败
r.prune()
return true, nil
}
// archivePath 生成归档名:access.log → access-20261024-1305.log.gz。
// 时间戳精确到分钟,同一分钟内重复轮转时补序号,避免覆盖。
func (r *Rotator) archivePath(now time.Time) string {
dir := filepath.Dir(r.Path)
base := strings.TrimSuffix(filepath.Base(r.Path), ".log")
stamp := now.Format("20060102-1504")
candidate := filepath.Join(dir, fmt.Sprintf("%s-%s.log.gz", base, stamp))
for i := 2; ; i++ {
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
candidate = filepath.Join(dir, fmt.Sprintf("%s-%s-%d.log.gz", base, stamp, i))
}
}
// prune 先按保留天数删超期归档,再按份数保留最近的。
// 两个条件都生效:天数保证「长期不轮转也不会无限留」,份数保证「高频轮转不会堆积」。
func (r *Rotator) prune() {
archives := r.archives()
if r.MaxAgeDays > 0 {
cutoff := time.Now().Add(-time.Duration(r.MaxAgeDays) * 24 * time.Hour)
kept := archives[:0]
for _, path := range archives {
info, err := os.Stat(path)
if err == nil && info.ModTime().Before(cutoff) {
_ = os.Remove(path)
continue
}
kept = append(kept, path)
}
archives = kept
}
keep := r.Keep
if keep <= 0 {
keep = DefaultKeep
}
for i := 0; i < len(archives)-keep; i++ {
_ = os.Remove(archives[i])
}
}
// archives 列出该日志的归档文件,按旧→新排序。
func (r *Rotator) archives() []string {
dir := filepath.Dir(r.Path)
base := strings.TrimSuffix(filepath.Base(r.Path), ".log")
prefix := base + "-"
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
out := make([]string, 0, len(entries))
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".log.gz") {
continue
}
out = append(out, filepath.Join(dir, name))
}
sort.Strings(out)
return out
}
// compressRename 把 src 压缩写到 dst,成功后删除 src。
// 先写 .tmp 再改名:压缩中途失败不会留下一个看起来能用的归档。
func compressRename(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
tmp := dst + ".tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
if err != nil {
_ = in.Close()
return err
}
gz := gzip.NewWriter(out)
if _, err := io.Copy(gz, in); err != nil {
_ = gz.Close()
_ = out.Close()
_ = in.Close()
_ = os.Remove(tmp)
return err
}
// 关闭顺序与错误都要检查:gzip 的尾部写在 Close 里,漏了就是半截归档
if err := gz.Close(); err != nil {
_ = out.Close()
_ = in.Close()
_ = os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
_ = in.Close()
_ = os.Remove(tmp)
return err
}
// 必须显式关闭源文件再删:Windows 上句柄没放掉时 Remove/改名都会失败
if err := in.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return err
}
// 归档成功才删原文件;顺序反了会在压缩失败时丢日志
return os.Remove(src)
}
+188
View File
@@ -0,0 +1,188 @@
package logstore
import (
"compress/gzip"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestRotateIfNeededSkipsSmallFile:未超阈值不能产生任何副作用,否则每小时一次的
// 定时任务会把日志切成无数小文件。
func TestRotateIfNeededSkipsSmallFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "access.log")
if err := os.WriteFile(path, []byte("small\n"), 0o640); err != nil {
t.Fatal(err)
}
reopened := 0
r := NewRotator(path, 0, func() error {
reopened++
return nil
})
rotated, err := r.RotateIfNeeded(time.Now())
if err != nil {
t.Fatal(err)
}
if rotated {
t.Fatal("未超阈值不应轮转")
}
if reopened != 0 {
t.Fatal("未轮转时不应通知重开日志")
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("原文件应保持不动:%v", err)
}
}
// TestRotateCompressesAndReopens:超阈值时要归档成 .gz、通知进程重开文件、并删掉原文件。
func TestRotateCompressesAndReopens(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "access.log")
content := strings.Repeat("2026-10-24T10:00:00+08:00 a.example.com 18080 GET / 200 0.010 1.2.3.4 10.0.0.2:80 100 200\n", 200)
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatal(err)
}
reopened := 0
r := NewRotator(path, 0, func() error {
reopened++
return nil
})
r.MaxBytes = 1024 // 强制触发
now := time.Date(2026, 10, 24, 13, 5, 0, 0, time.Local)
rotated, err := r.RotateIfNeeded(now)
if err != nil {
t.Fatal(err)
}
if !rotated {
t.Fatal("超过阈值应轮转")
}
if reopened != 1 {
t.Fatalf("轮转后必须通知重开日志,实际调用 %d 次", reopened)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("轮转后原文件应被移走")
}
archive := filepath.Join(dir, "access-20261024-1305.log.gz")
file, err := os.Open(archive)
if err != nil {
t.Fatalf("应生成归档文件 %s:%v", archive, err)
}
defer file.Close()
gz, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
defer gz.Close()
data, err := io.ReadAll(gz)
if err != nil {
t.Fatal(err)
}
if string(data) != content {
t.Fatalf("归档内容应与原文件一致:got %d bytes want %d", len(data), len(content))
}
}
// TestRotatePrunesOldArchives:归档份数与保留天数都要生效。
func TestRotatePrunesOldArchives(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "access.log")
// 造 3 个既有归档,其中 2 个已经超期
old := time.Now().Add(-40 * 24 * time.Hour)
stale := []string{"access-20200101-0000.log.gz", "access-20200102-0000.log.gz"}
for _, name := range stale {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte("x"), 0o640); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(p, old, old); err != nil {
t.Fatal(err)
}
}
fresh := filepath.Join(dir, "access-20261024-1200.log.gz")
if err := os.WriteFile(fresh, []byte("x"), 0o640); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(strings.Repeat("line\n", 500)), 0o640); err != nil {
t.Fatal(err)
}
r := NewRotator(path, 7, nil) // 保留 7 天
r.MaxBytes = 1024
if _, err := r.RotateIfNeeded(time.Now()); err != nil {
t.Fatal(err)
}
for _, name := range stale {
if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) {
t.Fatalf("超过保留天数的归档应被删除:%s", name)
}
}
if _, err := os.Stat(fresh); err != nil {
t.Fatalf("未超期的归档应保留:%v", err)
}
}
// TestTailLinesReadsFromEnd:尾部读取既要拿对最后几行,也不能因为文件很大而读全量。
func TestTailLinesReadsFromEnd(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "access.log")
var b strings.Builder
for i := 0; i < 5000; i++ {
b.WriteString("line-")
b.WriteString(strings.Repeat("x", 20))
b.WriteString("\n")
}
b.WriteString("last-line-a\nlast-line-b\n")
if err := os.WriteFile(path, []byte(b.String()), 0o640); err != nil {
t.Fatal(err)
}
lines, err := tailLines(path, 3)
if err != nil {
t.Fatal(err)
}
want := []string{strings.TrimSpace("line-xxxxxxxxxxxxxxxxxxxx"), "last-line-a", "last-line-b"}
if len(lines) != len(want) {
t.Fatalf("应返回 3 行,实际 %d:%v", len(lines), lines)
}
for i := range want {
if lines[i] != want[i] {
t.Fatalf("第 %d 行不符:got %q want %q", i, lines[i], want[i])
}
}
}
// TestTailLinesSingleLongLine:单行超过初始块大小时不能被截断成多行。
func TestTailLinesSingleLongLine(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "access.log")
long := strings.Repeat("y", 300*1024)
if err := os.WriteFile(path, []byte(long+"\n"), 0o640); err != nil {
t.Fatal(err)
}
lines, err := tailLines(path, 5)
if err != nil {
t.Fatal(err)
}
if len(lines) != 1 || lines[0] != long {
t.Fatalf("超长单行应完整返回,实际 %d 行(首行长度 %d)", len(lines), len(lines[0]))
}
}
// TestTailLinesMissingFile:日志还没产生时返回空而不是报错。
func TestTailLinesMissingFile(t *testing.T) {
lines, err := tailLines(filepath.Join(t.TempDir(), "nope.log"), 10)
if err != nil {
t.Fatalf("文件不存在不应报错:%v", err)
}
if len(lines) != 0 {
t.Fatalf("应返回空行列表,实际 %v", lines)
}
}
+5 -5
View File
@@ -16,11 +16,11 @@ func TestGenerateMultiHostCustomPort(t *testing.T) {
}
port6893 := 6893
rules := []proxy.Rule{{
ID: 1,
Upstream: "http://192.168.8.3:6893",
ListenPort: 8011,
ListenIPv4: true,
ListenIPv6: true,
ID: 1,
Upstream: "http://192.168.8.3:6893",
ListenPort: 8011,
ListenIPv4: true,
ListenIPv6: true,
Hosts: []proxy.Host{
{Hostname: "1.example.com"},
{Hostname: "2.example.com"},
+2 -2
View File
@@ -25,12 +25,12 @@ func TestGenerateMixedRulesWithoutCerts(t *testing.T) {
rules := []proxy.Rule{
{
ID: 1, Upstream: "http://192.168.1.10:5666", ListenPort: 443, ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
HTTPSEnabled: true, HTTPRedirect: true, Enabled: true,
},
{
ID: 2, Upstream: "http://192.168.8.3:6893", ListenPort: 8011, ListenIPv4: true, ListenIPv6: true,
Hosts: []proxy.Host{{Hostname: "app.example.com"}},
Hosts: []proxy.Host{{Hostname: "app.example.com"}},
HTTPSEnabled: true, HTTPRedirect: true, Enabled: true,
},
}
+22
View File
@@ -298,6 +298,28 @@ func (m *Manager) Stop(ctx context.Context) error {
return nil
}
// ReopenLogs 让 Nginx 关掉旧日志句柄并按配置里的路径重新打开。
// 日志轮转(把 access.log 改名归档)之后必须调用它,否则 Nginx 会继续写旧 inode,
// 新日志永远落不到 access.log 上。
func (m *Manager) ReopenLogs(ctx context.Context) error {
pid, err := readPIDFile(m.cfg.NginxPIDFile)
if err != nil {
if os.IsNotExist(err) {
// 没在运行就没什么要重开的:轮转本身已经把文件归档了
return nil
}
return err
}
if !isPIDAlive(pid) {
return nil
}
if err := reopenLogsProcess(pid); err != nil {
return err
}
m.logger.Info("nginx log files reopened")
return nil
}
func (m *Manager) runNginx(ctx context.Context, args []string, prefix string) error {
ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout)
defer cancel()
+8
View File
@@ -11,6 +11,14 @@ func reloadProcess(pid int) error {
return syscall.Kill(pid, syscall.SIGHUP)
}
// reopenLogsProcess 让 Nginx 重新打开日志文件(USR1)。
//
// 日志轮转必须用信号重开、而不是 reload 配置:reload 会拉起新 worker 并优雅退出旧
// worker,代价与风险都远大于「关掉旧 fd、按同路径重新 open」这一件事。
func reopenLogsProcess(pid int) error {
return syscall.Kill(pid, syscall.SIGUSR1)
}
func terminateProcess(pid int, sig syscall.Signal) error {
return syscall.Kill(pid, sig)
}
+6
View File
@@ -19,6 +19,12 @@ func reloadProcess(pid int) error {
return fmt.Errorf("signal reload unsupported on windows")
}
// reopenLogsProcess 在 Windows 上没有 USR1 这类单用途信号,
// Nginx 侧只能通过 reload 达成同样的效果(会重启 worker,但日志句柄会被重开)。
func reopenLogsProcess(pid int) error {
return fmt.Errorf("signal reopen unsupported on windows")
}
func terminateProcess(pid int, sig syscall.Signal) error {
args := []string{"/PID", strconv.Itoa(pid)}
if sig == syscall.SIGKILL {
+5 -5
View File
@@ -6,11 +6,11 @@ import (
)
type TrustedProxyConfig struct {
Enabled bool `json:"enabled"`
Preset string `json:"preset"` // "", "cloudflare", "custom"
CIDRs []string `json:"cidrs,omitempty"`
Header string `json:"header,omitempty"` // CF-Connecting-IP, X-Real-IP, X-Forwarded-For
Recursive bool `json:"recursive,omitempty"`
Enabled bool `json:"enabled"`
Preset string `json:"preset"` // "", "cloudflare", "custom"
CIDRs []string `json:"cidrs,omitempty"`
Header string `json:"header,omitempty"` // CF-Connecting-IP, X-Real-IP, X-Forwarded-For
Recursive bool `json:"recursive,omitempty"`
}
var cloudflareIPv4 = []string{
+27
View File
@@ -24,6 +24,7 @@ type ProxyService struct {
nginx *nginx.Manager
notify *notify.Service
exitSync ExitSync
traffic TrafficCleaner
}
// ExitSync 处理服务发布规则变化后的外部出口同步。
@@ -31,6 +32,13 @@ type ExitSync interface {
SyncRules(ctx context.Context) error
}
// TrafficCleaner 清掉某条规则对应的流量累计(内存 + 落库)。
// 用接口注入而不是直接依赖 traffic 包:service 是编排层,不该反过来知道采集细节。
// 参数用 proxy.Endpoint 而不是拼好的字符串,键格式由 traffic 包自己决定。
type TrafficCleaner interface {
ForgetEndpoints(ctx context.Context, endpoints []proxy.Endpoint) error
}
func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService {
return &ProxyService{db: db, cfg: cfg, store: store, certStore: certStore, settings: settingsStore, nginx: nginxMgr}
}
@@ -43,6 +51,10 @@ func (s *ProxyService) SetExitSync(sync ExitSync) {
s.exitSync = sync
}
func (s *ProxyService) SetTrafficCleaner(cleaner TrafficCleaner) {
s.traffic = cleaner
}
func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) {
return s.store.List(ctx)
}
@@ -119,10 +131,16 @@ func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error {
}
func (s *ProxyService) Delete(ctx context.Context, id int64) error {
// 先取出规则:删除后就查不到域名与端口,没法定位要清理的流量键
rule, err := s.store.Get(ctx, id)
if err != nil {
return err
}
if err := s.store.Delete(ctx, id); err != nil {
return err
}
_ = nginx.RemoveRuleCustom(s.cfg, id)
s.forgetTraffic(ctx, rule)
if err := s.applyNginx(ctx); err != nil {
return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err)
}
@@ -132,6 +150,15 @@ func (s *ProxyService) Delete(ctx context.Context, id int64) error {
return nil
}
// forgetTraffic 清掉规则对应端点的流量累计。失败不影响删除本身:
// 残留的统计行只是脏数据,而规则已经删了,没必要为此把删除操作报成失败。
func (s *ProxyService) forgetTraffic(ctx context.Context, rule proxy.Rule) {
if s.traffic == nil {
return
}
_ = s.traffic.ForgetEndpoints(ctx, rule.Endpoints())
}
func (s *ProxyService) ReloadAll(ctx context.Context) error {
return s.applyNginx(ctx)
}
+178
View File
@@ -0,0 +1,178 @@
package settings
// Kind 描述一个设置项的值类型,决定校验方式与默认值的解析方式。
type Kind string
const (
// KindString 任意文本(域名、邮箱、URL、路径等)
KindString Kind = "string"
// KindBool 布尔值,接受 1/true/yes/on
KindBool Kind = "bool"
// KindInt 整数
KindInt Kind = "int"
// KindJSON JSON 文本(对象或数组)
KindJSON Kind = "json"
)
// Spec 是一条设置项的完整声明。
//
// 引入它的原因:设置项此前分散在四个地方——常量、Defaults 默认值表、
// settings_handler 的白名单、前端表单。新增一项时很容易只改其中两三处,
// 于是出现「界面能改、后端没人读」的黑洞设置项(theme / timezone 就是这样来的)。
// 现在白名单、默认值、敏感标记、校验方式都从这张表派生,漏写会被测试挡住。
type Spec struct {
// Key 数据库里的键名(用 Key* 常量赋值,避免拼写漂移)
Key string
// Kind 值类型
Kind Kind
// Default 默认值;空串表示无默认值
Default string
// Writable 是否允许通过 PUT /api/settings 写入。
// 程序内部维护的状态(如 frp_resume_on_start、cidr 更新时间)应为 false。
Writable bool
// Secret 是否为敏感值:读接口会以掩码返回,绝不回明文
Secret bool
// Consumer 说明「谁在读这个键」,用于人工评审时确认不是黑洞设置。
// 约定写包名或功能名,不写具体行号(会漂移)。
Consumer string
}
// Specs 是所有设置项的唯一清单。新增设置项必须在这里登记,
// 否则既不能通过 API 写入,也不会被 Defaults / 白名单识别。
var Specs = []Spec{
// ---- 常规 ----
{Key: KeyTheme, Kind: KindString, Default: "system", Writable: true, Consumer: "web:useTheme(启动时按设置初始化,切换时回写)"},
{Key: KeyTimezone, Kind: KindString, Default: "Asia/Shanghai", Writable: true, Consumer: "app(启动时 time.Local)+ notify(告警时间)"},
{Key: KeyLogRetentionDays, Kind: KindInt, Default: "30", Writable: true, Consumer: "app.rotateNginxLogs(归档保留天数)"},
// ---- DDNS / 证书 ----
{Key: KeyDDNSCheckInterval, Kind: KindInt, Default: "5", Writable: true, Consumer: "app(ddns 任务间隔)"},
{Key: KeyCertRenewThreshold, Kind: KindInt, Default: "30", Writable: true, Consumer: "acme(续期阈值)"},
{Key: KeyRootDomain, Kind: KindString, Writable: true, Consumer: "(保留键,当前无读取点)"},
{Key: KeyACMEEmail, Kind: KindString, Writable: true, Consumer: "acme(注册邮箱)"},
{Key: KeyACMECA, Kind: KindString, Default: "letsencrypt", Writable: true, Consumer: "acme(CA 选择)"},
{Key: KeyACMEDNSResolvers, Kind: KindString, Writable: true, Consumer: "acme(DNS-01 传播检查)"},
{Key: KeyZeroSSLAPIKey, Kind: KindString, Writable: true, Secret: true, Consumer: "acme(ZeroSSL EAB 换发)"},
{Key: KeyLiteSSLEABKid, Kind: KindString, Writable: true, Consumer: "acme(LiteSSL EAB kid)"},
{Key: KeyLiteSSLEABHMAC, Kind: KindString, Writable: true, Secret: true, Consumer: "acme(LiteSSL EAB hmac)"},
// ---- 通知 ----
{Key: KeyNotifyType, Kind: KindString, Writable: true, Consumer: "notify(渠道选择)"},
{Key: KeyNotifyEmailJSON, Kind: KindJSON, Writable: true, Consumer: "notify(SMTP 配置)"},
{Key: KeyNotifySMTPPassword, Kind: KindString, Writable: true, Secret: true, Consumer: "notify(SMTP 密码)"},
{Key: KeyNotifyWebhookJSON, Kind: KindJSON, Writable: true, Consumer: "notify(Webhook 配置)"},
{Key: KeyNotifyWebhookSecret, Kind: KindString, Writable: true, Secret: true, Consumer: "notify(Webhook 签名密钥)"},
{Key: KeyNotifyTelegramJSON, Kind: KindJSON, Writable: true, Consumer: "notify(Telegram 配置)"},
{Key: KeyNotifyTelegramToken, Kind: KindString, Writable: true, Secret: true, Consumer: "notify(Bot Token)"},
{Key: KeyNotifyWebhookURL, Kind: KindString, Writable: true, Consumer: "notify(自定义 Webhook 地址)"},
// ---- 通知事件开关 ----
{Key: KeyNotifyOnDDNSIPChange, Kind: KindBool, Writable: true, Consumer: "notify(DDNS IP 变化事件)"},
{Key: KeyNotifyOnDDNSFailure, Kind: KindBool, Writable: true, Consumer: "notify(DDNS 失败事件)"},
{Key: KeyNotifyOnCertExpiry, Kind: KindBool, Writable: true, Consumer: "notify(证书临期事件)"},
{Key: KeyNotifyOnCertRenewSuccess, Kind: KindBool, Writable: true, Consumer: "notify(证书续期成功)"},
{Key: KeyNotifyOnCertRenewFailure, Kind: KindBool, Writable: true, Consumer: "notify(证书续期失败)"},
{Key: KeyNotifyOnIPFrequentAccess, Kind: KindBool, Writable: true, Consumer: "notify(频繁访问事件)"},
{Key: KeyNotifyOnLoginFailure, Kind: KindBool, Writable: true, Consumer: "notify(登录失败事件)"},
{Key: KeyNotifyOnNginxReloadFailure, Kind: KindBool, Writable: true, Consumer: "notify(Nginx 重载失败)"},
{Key: KeyNotifyOnResourceThreshold, Kind: KindBool, Writable: true, Consumer: "notify(资源超阈值)"},
{Key: KeyNotifyOnTunnelDown, Kind: KindBool, Writable: true, Consumer: "notify(隧道中断)"},
{Key: KeyNotifyOnFrpsDown, Kind: KindBool, Writable: true, Consumer: "notify(frps 不可用)"},
{Key: KeyNotifyOnAgentUnreachable, Kind: KindBool, Writable: true, Consumer: "notify(Agent 不可达)"},
{Key: KeyNotifyOnRouteDrift, Kind: KindBool, Writable: true, Consumer: "notify(路由漂移)"},
{Key: KeyNotifyOnBackupFailure, Kind: KindBool, Writable: true, Consumer: "notify(备份失败)"},
// ---- 通知阈值 ----
{Key: KeyNotifyIPFrequentThreshold, Kind: KindInt, Writable: true, Consumer: "notify(频繁访问阈值)"},
{Key: KeyNotifyIPFrequentWindowSec, Kind: KindInt, Writable: true, Consumer: "notify(频繁访问窗口)"},
{Key: KeyNotifyLoginFailureThreshold, Kind: KindInt, Writable: true, Consumer: "notify(登录失败次数阈值)"},
{Key: KeyNotifyLoginFailureWindowSec, Kind: KindInt, Writable: true, Consumer: "notify(登录失败窗口)"},
{Key: KeyNotifyCPUThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(CPU 阈值)"},
{Key: KeyNotifyMemoryThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(内存阈值)"},
{Key: KeyNotifyDiskThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(磁盘阈值)"},
{Key: KeyNotifyTunnelFailThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(隧道失败次数)"},
{Key: KeyNotifyFrpsFailThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(frps 失败次数)"},
{Key: KeyNotifyAgentFailThreshold, Kind: KindInt, Writable: true, Consumer: "monitor(Agent 失败次数)"},
{Key: KeyNotifyDriftCooldownHours, Kind: KindInt, Writable: true, Consumer: "monitor(漂移告警冷却)"},
// ---- 备份 ----
{Key: KeyBackupAutoEnabled, Kind: KindBool, Writable: true, Consumer: "backup.AutoRunner(自动备份开关)"},
{Key: KeyBackupKeepCount, Kind: KindInt, Writable: true, Consumer: "backup.KeepCount(保留份数)"},
// ---- 功能开关 ----
{Key: KeyRouteHealthEnabled, Kind: KindBool, Writable: true, Consumer: "monitor(路由健康采集)"},
{Key: KeyStatusPageEnabled, Kind: KindBool, Writable: true, Consumer: "api.status_page(公开状态页开关)"},
{Key: KeyStatusPageCode, Kind: KindString, Writable: true, Consumer: "api.status_page(明文只在保存请求里出现,落库为 hash)"},
{Key: KeyMetricsHistoryEnabled, Kind: KindBool, Writable: true, Consumer: "monitor(指标历史采样)"},
{Key: KeyTerminalEnabled, Kind: KindBool, Writable: true, Consumer: "api.terminal(SSH 终端总开关)"},
{Key: KeyTerminalRecording, Kind: KindBool, Writable: true, Consumer: "api.terminal(会话录制开关)"},
// ---- 安全 ----
{Key: KeyTrustedProxy, Kind: KindJSON, Writable: true, Consumer: "nginx(real_ip 信任链)"},
{Key: KeyGlobalIPBlacklist, Kind: KindJSON, Writable: true, Consumer: "nginx(全局黑名单)"},
{Key: KeyGlobalIPWhitelist, Kind: KindJSON, Writable: true, Consumer: "nginx(全局白名单)"},
// ---- 中国 CIDR ----
{Key: KeyChinaCIDRUpdateHours, Kind: KindInt, Default: "24", Writable: true, Consumer: "app(cidr 更新间隔)"},
{Key: KeyChinaCIDRSourceV4, Kind: KindString, Writable: true, Consumer: "chinacidr(IPv4 源地址)"},
{Key: KeyChinaCIDRSourceV6, Kind: KindString, Writable: true, Consumer: "chinacidr(IPv6 源地址)"},
// ---- 内部状态:程序自己维护,不接受 API 写入 ----
{Key: KeyStatusPageCodeHash, Kind: KindString, Secret: true, Consumer: "api.status_page(访问码哈希)"},
{Key: KeyNginxGlobalMode, Kind: KindString, Default: "auto", Consumer: "service.nginx_config(auto/custom 模式)"},
{Key: KeyChinaCIDRUpdatedAt, Kind: KindString, Consumer: "chinacidr(上次更新时间)"},
{Key: KeyChinaCIDRCountV4, Kind: KindInt, Consumer: "chinacidr(IPv4 条目数)"},
{Key: KeyChinaCIDRCountV6, Kind: KindInt, Consumer: "chinacidr(IPv6 条目数)"},
{Key: KeyChinaCIDRLastError, Kind: KindString, Consumer: "chinacidr(最近错误)"},
{Key: KeyFrpResumeOnStart, Kind: KindBool, Consumer: "frp(重启后是否恢复 frpc)"},
// ---- 历史键:已废弃,仅为兼容旧库保留声明 ----
{Key: KeyNotifyOnDDNSError, Kind: KindBool, Consumer: "(历史键,已迁移到 KeyNotifyOnDDNSFailure)"},
{Key: KeyNotifyOnCertError, Kind: KindBool, Consumer: "(历史键,已迁移到 KeyNotifyOnCertExpiry)"},
{Key: KeyNotifyOnNginxError, Kind: KindBool, Consumer: "(历史键,已忽略)"},
{Key: KeyFRPEnabled, Kind: KindBool, Consumer: "(历史键,旧单服务端 FRP,已被 internal/frp 取代)"},
{Key: KeyFRPServerAddr, Kind: KindString, Consumer: "(历史键,旧单服务端 FRP)"},
{Key: KeyFRPServerPort, Kind: KindInt, Consumer: "(历史键,旧单服务端 FRP)"},
{Key: KeyFRPAuthToken, Kind: KindString, Secret: true, Consumer: "(历史键,旧单服务端 FRP)"},
{Key: KeyFRPTLSEnabled, Kind: KindBool, Consumer: "(历史键,旧单服务端 FRP)"},
{Key: KeyFRPCustomDomains, Kind: KindString, Consumer: "(历史键,旧单服务端 FRP)"},
{Key: KeyFRPLastError, Kind: KindString, Consumer: "(历史键,旧单服务端 FRP)"},
{Key: KeyFRPStartedAt, Kind: KindString, Consumer: "(历史键,旧单服务端 FRP)"},
}
// specIndex 是 Specs 的索引,供 O(1) 查找。
var specIndex = func() map[string]Spec {
out := make(map[string]Spec, len(Specs))
for _, spec := range Specs {
out[spec.Key] = spec
}
return out
}()
// Defaults 是从 Specs 派生的默认值表(只含声明了 Default 的项)。
var Defaults = func() map[string]string {
out := make(map[string]string, len(Specs))
for _, spec := range Specs {
if spec.Default != "" {
out[spec.Key] = spec.Default
}
}
return out
}()
// Lookup 返回某个键的声明。
func Lookup(key string) (Spec, bool) {
spec, ok := specIndex[key]
return spec, ok
}
// WritableKeys 返回所有允许 API 写入的键,用于 settings_handler 的白名单。
func WritableKeys() map[string]bool {
out := make(map[string]bool, len(Specs))
for _, spec := range Specs {
if spec.Writable {
out[spec.Key] = true
}
}
return out
}
+153
View File
@@ -0,0 +1,153 @@
package settings
import (
"testing"
)
// TestEveryDeclaredKeyHasConsumer:每个声明出来的键都要有「谁在读」的说明,
// 并且不能只是复述键名本身。这条测试防的是「界面能改、后端没人读」的黑洞设置项
// (theme / timezone 曾经就是这样:常量、默认值、白名单、前端表单四处齐全,唯独没人消费)。
func TestEveryDeclaredKeyHasConsumer(t *testing.T) {
for _, spec := range Specs {
if spec.Key == "" {
t.Fatal("存在未填 Key 的 Spec")
}
if spec.Kind == "" {
t.Fatalf("%s 未填 Kind", spec.Key)
}
if spec.Consumer == "" {
t.Fatalf("%s 未填 Consumer(说明谁在读这个设置项)", spec.Key)
}
}
}
// TestNoDuplicateKeys:同一个键不能声明两次,否则 Lookup / Defaults 会悄悄取其中一个。
func TestNoDuplicateKeys(t *testing.T) {
seen := map[string]bool{}
for _, spec := range Specs {
if seen[spec.Key] {
t.Fatalf("重复声明的设置项:%s", spec.Key)
}
seen[spec.Key] = true
}
if len(seen) != len(Specs) {
t.Fatalf("Specs 数量 %d 与去重后 %d 不一致", len(Specs), len(seen))
}
}
// TestKindableDefaultsMatchKind:默认值必须能被它声明的 Kind 解析,
// 否则 GetInt/GetBool 在没写过值时会拿到脏数据。
func TestKindableDefaultsMatchKind(t *testing.T) {
for _, spec := range Specs {
if spec.Default == "" {
continue
}
switch spec.Kind {
case KindInt:
if _, ok := parseInt(spec.Default); !ok {
t.Fatalf("%s 的默认值 %q 不是合法整数", spec.Key, spec.Default)
}
case KindBool:
if _, ok := parseBool(spec.Default); !ok {
t.Fatalf("%s 的默认值 %q 不是合法布尔值", spec.Key, spec.Default)
}
}
}
}
// TestDefaultsDerivedFromSpecs:Defaults 必须与 Specs 一致,防止两处各写一份。
func TestDefaultsDerivedFromSpecs(t *testing.T) {
index := map[string]string{}
for _, spec := range Specs {
if spec.Default != "" {
index[spec.Key] = spec.Default
}
}
if len(index) != len(Defaults) {
t.Fatalf("Defaults 数量 %d 与 Specs 中带默认值的条目 %d 不一致", len(Defaults), len(index))
}
for key, want := range index {
if got := Defaults[key]; got != want {
t.Fatalf("%s 默认值不符:got %q want %q", key, got, want)
}
}
}
// TestInternalStateKeysNotWritable:程序自己维护的状态不能通过 API 写入,
// 否则用户可以把 frp_resume_on_start 之类改成任意值,破坏重启恢复逻辑。
func TestInternalStateKeysNotWritable(t *testing.T) {
internal := []string{
KeyFrpResumeOnStart, KeyStatusPageCodeHash,
KeyChinaCIDRUpdatedAt, KeyChinaCIDRCountV4, KeyChinaCIDRCountV6, KeyChinaCIDRLastError,
KeyNginxGlobalMode,
}
writable := WritableKeys()
for _, key := range internal {
if writable[key] {
t.Fatalf("%s 是内部状态键,不应出现在可写白名单里", key)
}
}
}
// TestSecretKeysAreDeclared:已知的敏感键必须被标记为 Secret,读接口才会打码。
func TestSecretKeysAreDeclared(t *testing.T) {
secrets := []string{
KeyNotifySMTPPassword, KeyNotifyWebhookSecret, KeyNotifyTelegramToken,
KeyZeroSSLAPIKey, KeyLiteSSLEABHMAC, KeyStatusPageCodeHash,
}
for _, key := range secrets {
spec, ok := Lookup(key)
if !ok {
t.Fatalf("%s 未在注册表里声明", key)
}
if !spec.Secret {
t.Fatalf("%s 应标记为 Secret", key)
}
}
}
// TestWritableWhitelistContainsCoreKeys:核心可配置项必须在白名单里,
// 否则前端保存会静默丢字段(PUT 会按白名单过滤)。
func TestWritableWhitelistContainsCoreKeys(t *testing.T) {
writable := WritableKeys()
core := []string{
KeyTheme, KeyTimezone, KeyLogRetentionDays,
KeyDDNSCheckInterval, KeyCertRenewThreshold,
KeyBackupAutoEnabled, KeyBackupKeepCount,
KeyStatusPageEnabled, KeyTerminalEnabled, KeyTerminalRecording,
}
for _, key := range core {
if !writable[key] {
t.Fatalf("%s 应允许 API 写入", key)
}
}
}
// parseInt / parseBool 是注册表测试用的宽松解析,与 Store 的行为保持一致。
func parseInt(raw string) (int, bool) {
n := 0
neg := false
for i, c := range raw {
if i == 0 && c == '-' {
neg = true
continue
}
if c < '0' || c > '9' {
return 0, false
}
n = n*10 + int(c-'0')
}
if neg {
n = -n
}
return n, true
}
func parseBool(raw string) (bool, bool) {
switch raw {
case "1", "true", "yes", "on", "0", "false", "no", "off":
return raw == "1" || raw == "true" || raw == "yes" || raw == "on", true
default:
return false, false
}
}
+8 -11
View File
@@ -86,19 +86,16 @@ const (
KeyFRPLastError = "frp_last_error"
KeyFRPStartedAt = "frp_started_at"
KeyNginxGlobalMode = "nginx_global_mode"
// KeyTerminalEnabled 内置 SSH 终端总开关:默认关闭,shell 入口必须显式打开
KeyTerminalEnabled = "terminal_enabled"
// KeyTerminalRecording 会话录制开关:默认关闭,开启后把终端输出写入 logs/terminal
KeyTerminalRecording = "terminal_recording_enabled"
// KeyFrpResumeOnStart 记录「上次 frpc 是否处于运行状态」:应用被停用再启用后,
// 用它决定要不要自动把 frpc 拉起来(否则用户会发现应用起来了但 frpc 没起)。
// 该键由程序内部维护,不在设置页暴露。
KeyFrpResumeOnStart = "frp_resume_on_start"
)
var Defaults = map[string]string{
KeyTheme: "system",
KeyTimezone: "Asia/Shanghai",
KeyLogRetentionDays: "30",
KeyDDNSCheckInterval: "5",
KeyCertRenewThreshold: "30",
KeyACMECA: "letsencrypt",
KeyChinaCIDRUpdateHours: "24",
KeyNginxGlobalMode: "auto",
}
type Store struct {
db *sql.DB
}
+45
View File
@@ -0,0 +1,45 @@
package settings
import (
"context"
"fmt"
"strings"
"time"
)
// ApplyTimezone 读取「设置 → 常规 → 时区」并设为进程默认时区(time.Local)。
//
// 为什么必须在这里接一下:Go 的 time.Local 只由 TZ 环境变量决定,而设置页的
// timezone 字段此前完全没人读——用户在界面上改成 Asia/Tokyo,通知里的时间戳
// 依旧是容器 TZ。两者取其一都可以,但必须真的生效。
//
// 返回值是最终生效的时区,供启动日志与调用方确认。
func ApplyTimezone(ctx context.Context, store *Store) (*time.Location, error) {
raw, err := store.Get(ctx, KeyTimezone)
if err != nil {
return nil, err
}
name := strings.TrimSpace(raw)
if name == "" {
name = Defaults[KeyTimezone]
}
loc, err := LoadTimezone(name)
if err != nil {
return nil, err
}
time.Local = loc
return loc, nil
}
// LoadTimezone 解析时区名,并对常见拼写提供明确的中文报错。
func LoadTimezone(name string) (*time.Location, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("时区不能为空")
}
loc, err := time.LoadLocation(name)
if err != nil {
return nil, fmt.Errorf("无效的时区 %q(应形如 Asia/Shanghai)", name)
}
return loc, nil
}
+93
View File
@@ -0,0 +1,93 @@
package settings
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"github.com/havline/havline/internal/db"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
return NewStore(conn)
}
// TestApplyTimezoneSetsLocal:设置里的时区必须真的作用到 time.Local,
// 否则「改了时区但通知时间不变」这个黑洞会一直存在。
func TestApplyTimezoneSetsLocal(t *testing.T) {
original := time.Local
t.Cleanup(func() { time.Local = original })
store := newTestStore(t)
ctx := context.Background()
if err := store.Set(ctx, KeyTimezone, "Asia/Tokyo"); err != nil {
t.Fatal(err)
}
loc, err := ApplyTimezone(ctx, store)
if err != nil {
t.Fatal(err)
}
if loc.String() != "Asia/Tokyo" {
t.Fatalf("应返回 Asia/Tokyo,实际 %s", loc)
}
if time.Local.String() != "Asia/Tokyo" {
t.Fatalf("time.Local 应被设为 Asia/Tokyo,实际 %s", time.Local)
}
// 同一个瞬时在东京比上海早一小时
_, offset := time.Now().In(time.Local).Zone()
if offset != 9*3600 {
t.Fatalf("东京时区偏移应为 +9h,实际 %d 秒", offset)
}
}
// TestApplyTimezoneFallsBackToDefault:没写过设置时用注册表里的默认值。
func TestApplyTimezoneFallsBackToDefault(t *testing.T) {
original := time.Local
t.Cleanup(func() { time.Local = original })
store := newTestStore(t)
loc, err := ApplyTimezone(context.Background(), store)
if err != nil {
t.Fatal(err)
}
if loc.String() != Defaults[KeyTimezone] {
t.Fatalf("应使用默认时区 %q,实际 %s", Defaults[KeyTimezone], loc)
}
}
// TestLoadTimezoneRejectsInvalid:非法时区要给出可读的中文报错,
// 而不是把 "" 或 UTC 悄悄当成合法值写库。
func TestLoadTimezoneRejectsInvalid(t *testing.T) {
cases := []string{"", " ", "Mars/Olympus", "Asia/Shanghai; rm -rf /"}
for _, input := range cases {
if _, err := LoadTimezone(input); err == nil {
t.Fatalf("%q 应被拒绝", input)
}
}
if _, err := LoadTimezone("UTC"); err != nil {
t.Fatalf("UTC 应合法:%v", err)
}
}
// TestLoadTimezoneErrorMentionsName:报错里要带上是哪个值不合法,便于前端提示。
func TestLoadTimezoneErrorMentionsName(t *testing.T) {
_, err := LoadTimezone("Bad/Zone")
if err == nil {
t.Fatal("非法时区应报错")
}
if !strings.Contains(err.Error(), "Bad/Zone") {
t.Fatalf("报错应包含原始输入,实际:%v", err)
}
}
+96
View File
@@ -0,0 +1,96 @@
package terminal
import (
"context"
"database/sql"
"time"
)
// AuditRetention 是审计记录的保留期:超过就由定时任务清理,避免表无限增长。
const AuditRetention = 90 * 24 * time.Hour
// AuditEntry 是一条终端会话审计记录,回答「谁、何时、连了哪台、多久、为什么断开」。
type AuditEntry struct {
ID int64 `json:"id,omitempty"`
SessionID string `json:"session_id"`
ServerID int64 `json:"server_id"`
Target string `json:"target"`
User string `json:"user"`
ClientIP string `json:"client_ip"`
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
DurationMS int64 `json:"duration_ms"`
Reason string `json:"reason,omitempty"`
BytesIn int64 `json:"bytes_in"`
BytesOut int64 `json:"bytes_out"`
}
// AuditSink 由会话管理器在会话结束时调用;未注入时审计只落日志。
type AuditSink interface {
WriteAudit(ctx context.Context, entry AuditEntry) error
}
// AuditStore 把审计记录落到 SQLite,并提供查询与清理。
type AuditStore struct {
db *sql.DB
}
func NewAuditStore(db *sql.DB) *AuditStore {
return &AuditStore{db: db}
}
// WriteAudit 写入一条会话审计。
func (s *AuditStore) WriteAudit(ctx context.Context, entry AuditEntry) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO terminal_audit_logs(
session_id, server_id, target, username, client_ip,
started_at, ended_at, duration_ms, reason, bytes_in, bytes_out
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
entry.SessionID, entry.ServerID, entry.Target, entry.User, entry.ClientIP,
entry.StartedAt, entry.EndedAt, entry.DurationMS, entry.Reason, entry.BytesIn, entry.BytesOut)
return err
}
// ListAudits 按结束时间倒序返回最近的审计记录;limit 缺省 100,上限 500。
func (s *AuditStore) ListAudits(ctx context.Context, limit int) ([]AuditEntry, error) {
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
rows, err := s.db.QueryContext(ctx, `
SELECT id, session_id, server_id, target, username, client_ip,
started_at, ended_at, duration_ms, COALESCE(reason, ''), bytes_in, bytes_out
FROM terminal_audit_logs
ORDER BY id DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
entries := make([]AuditEntry, 0, limit)
for rows.Next() {
var entry AuditEntry
if err := rows.Scan(
&entry.ID, &entry.SessionID, &entry.ServerID, &entry.Target,
&entry.User, &entry.ClientIP, &entry.StartedAt, &entry.EndedAt,
&entry.DurationMS, &entry.Reason, &entry.BytesIn, &entry.BytesOut,
); err != nil {
return nil, err
}
entries = append(entries, entry)
}
return entries, rows.Err()
}
// PruneAudits 清理超过保留期的审计记录,返回删除条数。
func (s *AuditStore) PruneAudits(ctx context.Context) (int64, error) {
cutoff := time.Now().UTC().Add(-AuditRetention).Format(time.RFC3339)
res, err := s.db.ExecContext(ctx, `DELETE FROM terminal_audit_logs WHERE ended_at < ?`, cutoff)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
+88
View File
@@ -0,0 +1,88 @@
package terminal
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/db"
)
// TestAuditStoreRoundTrip:写入的审计能按时间倒序读回,并按保留期清理。
func TestAuditStoreRoundTrip(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatalf("打开数据库失败:%v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("迁移失败:%v", err)
}
store := NewAuditStore(conn)
ctx := context.Background()
now := time.Now().UTC()
entries := []AuditEntry{
{
SessionID: "s1", ServerID: 1, Target: "东京 VPS · root@10.0.0.1",
User: "admin", ClientIP: "192.168.1.2",
StartedAt: now.Add(-2 * time.Minute).Format(time.RFC3339),
EndedAt: now.Add(-time.Minute).Format(time.RFC3339),
DurationMS: 60_000, Reason: "用户关闭", BytesIn: 12, BytesOut: 4096,
},
{
SessionID: "s2", ServerID: 2, Target: "洛杉矶 VPS · root@10.0.0.2",
User: "admin", ClientIP: "10.1.1.1",
StartedAt: now.Format(time.RFC3339),
EndedAt: now.Add(time.Second).Format(time.RFC3339),
DurationMS: 1_000, Reason: "空闲超时",
},
}
for _, entry := range entries {
if err := store.WriteAudit(ctx, entry); err != nil {
t.Fatalf("写入审计失败:%v", err)
}
}
got, err := store.ListAudits(ctx, 10)
if err != nil {
t.Fatalf("读取审计失败:%v", err)
}
if len(got) != 2 {
t.Fatalf("期望 2 条审计,实际 %d 条", len(got))
}
// 按写入顺序倒序:最近结束的 s2 在前
if got[0].SessionID != "s2" || got[1].SessionID != "s1" {
t.Fatalf("排序不符:%+v", got)
}
if got[1].Reason != "用户关闭" || got[1].BytesOut != 4096 || got[1].ClientIP != "192.168.1.2" {
t.Fatalf("字段回读不符:%+v", got[1])
}
// 保留期外的记录会被清理
if _, err := conn.ExecContext(ctx,
`UPDATE terminal_audit_logs SET ended_at = ? WHERE session_id = 's1'`,
now.Add(-AuditRetention-time.Hour).Format(time.RFC3339)); err != nil {
t.Fatalf("准备过期数据失败:%v", err)
}
deleted, err := store.PruneAudits(ctx)
if err != nil {
t.Fatalf("清理失败:%v", err)
}
if deleted != 1 {
t.Fatalf("期望清理 1 条,实际 %d 条", deleted)
}
remaining, err := store.ListAudits(ctx, 10)
if err != nil {
t.Fatalf("读取审计失败:%v", err)
}
if len(remaining) != 1 || remaining[0].SessionID != "s2" {
t.Fatalf("清理结果不符:%+v", remaining)
}
// limit 传 0 时回落到默认值,不应报错
if _, err := store.ListAudits(ctx, 0); err != nil {
t.Fatalf("默认 limit 读取失败:%v", err)
}
}
+104
View File
@@ -0,0 +1,104 @@
package terminal
import (
"context"
"database/sql"
"errors"
"strings"
"time"
)
const (
// maxCommandNameLength 是快捷命令名称的长度上限(按字符算)
maxCommandNameLength = 64
// maxCommandLength 是命令正文的字节上限:够写复杂管道,但塞不进整个脚本文件
maxCommandLength = 4096
)
var (
// ErrInvalidCommand 表示名称或命令内容不合法。
ErrInvalidCommand = errors.New("命令名称与内容都不能为空")
// ErrCommandNotFound 表示快捷命令不存在。
ErrCommandNotFound = errors.New("快捷命令不存在")
)
// TerminalCommand 是保存的一条快捷命令。
type TerminalCommand struct {
ID int64 `json:"id"`
Name string `json:"name"`
Command string `json:"command"`
CreatedAt string `json:"created_at"`
}
// CommandStore 把快捷命令落 SQLite:换浏览器也在,与终端会话无关。
type CommandStore struct {
db *sql.DB
}
func NewCommandStore(db *sql.DB) *CommandStore {
return &CommandStore{db: db}
}
// ListCommands 按新增时间倒序返回全部快捷命令。
func (s *CommandStore) ListCommands(ctx context.Context) ([]TerminalCommand, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, command, created_at FROM terminal_commands ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]TerminalCommand, 0, 16)
for rows.Next() {
var item TerminalCommand
if err := rows.Scan(&item.ID, &item.Name, &item.Command, &item.CreatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
// CreateCommand 保存一条快捷命令:名称与内容必填,尾部的换行会被去掉(发送时再补回车)。
func (s *CommandStore) CreateCommand(ctx context.Context, name, command string) (TerminalCommand, error) {
trimmedName := strings.TrimSpace(name)
trimmedCommand := strings.TrimRight(strings.ReplaceAll(command, "\r\n", "\n"), "\n")
if trimmedName == "" || strings.TrimSpace(trimmedCommand) == "" {
return TerminalCommand{}, ErrInvalidCommand
}
if len([]rune(trimmedName)) > maxCommandNameLength {
return TerminalCommand{}, errors.New("命令名称过长")
}
if len(trimmedCommand) > maxCommandLength {
return TerminalCommand{}, errors.New("命令内容过长")
}
createdAt := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.ExecContext(ctx,
`INSERT INTO terminal_commands(name, command, created_at) VALUES (?, ?, ?)`,
trimmedName, trimmedCommand, createdAt)
if err != nil {
return TerminalCommand{}, err
}
id, err := res.LastInsertId()
if err != nil {
return TerminalCommand{}, err
}
return TerminalCommand{ID: id, Name: trimmedName, Command: trimmedCommand, CreatedAt: createdAt}, nil
}
// DeleteCommand 删除一条快捷命令;不存在时返回 ErrCommandNotFound。
func (s *CommandStore) DeleteCommand(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM terminal_commands WHERE id = ?`, id)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrCommandNotFound
}
return nil
}
+77
View File
@@ -0,0 +1,77 @@
package terminal
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/havline/havline/internal/db"
)
// TestCommandStoreRoundTrip:保存、列表、删除快捷命令,并校验输入约束。
func TestCommandStoreRoundTrip(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "commands.db"))
if err != nil {
t.Fatalf("打开数据库失败:%v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("迁移失败:%v", err)
}
store := NewCommandStore(conn)
ctx := context.Background()
first, err := store.CreateCommand(ctx, " 磁盘占用 ", "df -h\n")
if err != nil {
t.Fatalf("保存命令失败:%v", err)
}
if first.Name != "磁盘占用" {
t.Fatalf("名称应当去掉首尾空格:%q", first.Name)
}
if first.Command != "df -h" {
t.Fatalf("命令应当去掉尾部换行:%q", first.Command)
}
if first.ID == 0 || first.CreatedAt == "" {
t.Fatalf("保存结果缺少 ID 或时间:%+v", first)
}
if _, err := store.CreateCommand(ctx, "systemctl", "systemctl status nginx"); err != nil {
t.Fatalf("保存第二条失败:%v", err)
}
list, err := store.ListCommands(ctx)
if err != nil {
t.Fatalf("列表失败:%v", err)
}
if len(list) != 2 {
t.Fatalf("期望 2 条,实际 %d 条", len(list))
}
// 倒序:后加的在前
if list[0].Name != "systemctl" {
t.Fatalf("排序不符:%+v", list)
}
if err := store.DeleteCommand(ctx, first.ID); err != nil {
t.Fatalf("删除失败:%v", err)
}
if err := store.DeleteCommand(ctx, first.ID); !errors.Is(err, ErrCommandNotFound) {
t.Fatalf("重复删除应当 ErrCommandNotFound,实际 %v", err)
}
rest, err := store.ListCommands(ctx)
if err != nil || len(rest) != 1 {
t.Fatalf("删除后应当剩 1 条:%v %d", err, len(rest))
}
// 输入约束
if _, err := store.CreateCommand(ctx, " ", "ls"); !errors.Is(err, ErrInvalidCommand) {
t.Fatalf("空名称应当报错,实际 %v", err)
}
if _, err := store.CreateCommand(ctx, "空白命令", " "); !errors.Is(err, ErrInvalidCommand) {
t.Fatalf("空命令应当报错,实际 %v", err)
}
if _, err := store.CreateCommand(ctx, strings.Repeat("长", maxCommandNameLength+1), "ls"); err == nil {
t.Fatal("超长名称应当报错")
}
}
+311
View File
@@ -0,0 +1,311 @@
package terminal
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"strconv"
"sync"
"time"
)
var (
// ErrDisabled 表示没有注入 Opener(终端功能未启用)。
ErrDisabled = errors.New("SSH 终端未启用")
// ErrTooManySessions 表示并发会话已达上限。
ErrTooManySessions = errors.New("并发终端会话数已达上限")
// ErrNotFound 表示会话不存在或已被回收。
ErrNotFound = errors.New("终端会话不存在或已关闭")
)
// Opener 由调用方注入:按服务端 ID 建立一个已认证的终端。
// 实现放在 frp 包,复用它的 SSH 凭据解密与 known_hosts 校验,避免重复实现一套认证。
type Opener func(ctx context.Context, serverID int64, cols, rows int) (Terminal, string, error)
// CreateOptions 是建立会话的入参:连哪台、初始尺寸,以及审计需要的调用方身份。
type CreateOptions struct {
ServerID int64
Cols int
Rows int
// User / ClientIP 只用于写审计记录,不参与连接
User string
ClientIP string
// Record 为真且注入了 RecorderFactory 时,本次会话输出会写入录制文件
Record bool
}
// RecorderFactory 按会话创建录制器;录制失败只记日志,不影响会话本身。
type RecorderFactory func(sessionID string, opts CreateOptions) (OutputRecorder, error)
// Options 是并发上限与超时配置。
type Options struct {
MaxSessions int
IdleTimeout time.Duration
MaxLifetime time.Duration
}
// Manager 管理所有终端会话:创建、查找、关闭与超时回收。
type Manager struct {
logger *slog.Logger
open Opener
mu sync.Mutex
sessions map[string]*Session
pending int
max int
idleAfter time.Duration
maxAge time.Duration
audit AuditSink
recorder RecorderFactory
}
// NewManager 创建会话管理器;open 为 nil 时视为功能未启用。
func NewManager(logger *slog.Logger, open Opener, opts Options) *Manager {
if opts.MaxSessions <= 0 {
opts.MaxSessions = 5
}
if opts.IdleTimeout <= 0 {
opts.IdleTimeout = 10 * time.Minute
}
if opts.MaxLifetime <= 0 {
opts.MaxLifetime = 12 * time.Hour
}
return &Manager{
logger: logger, open: open, sessions: map[string]*Session{},
max: opts.MaxSessions, idleAfter: opts.IdleTimeout, maxAge: opts.MaxLifetime,
}
}
// Enabled 返回当前是否具备建立会话的能力。
func (m *Manager) Enabled() bool { return m.open != nil }
// SetAuditSink 注入审计存储;未注入时会话审计只落日志(测试与未启用审计的场景)。
func (m *Manager) SetAuditSink(sink AuditSink) {
m.mu.Lock()
m.audit = sink
m.mu.Unlock()
}
// SetRecorderFactory 注入会话录制工厂;未注入时即使请求录制也不会写文件。
func (m *Manager) SetRecorderFactory(factory RecorderFactory) {
m.mu.Lock()
m.recorder = factory
m.mu.Unlock()
}
// Create 建立新会话:超过并发上限或后端连接失败时返回错误。
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Session, error) {
if m.open == nil {
return nil, ErrDisabled
}
if err := m.reserve(); err != nil {
return nil, err
}
term, label, err := m.open(ctx, opts.ServerID, normCols(opts.Cols), normRows(opts.Rows))
if err != nil {
m.finishReserve(nil)
return nil, err
}
session := newSession(newSessionID(), opts.ServerID, label, opts.User, opts.ClientIP, term)
// 会话自行结束(远端退出、读写出错)时也要移出列表并写审计,因此挂在会话上
session.onClose = m.handleSessionClosed
if opts.Record {
m.attachRecorder(session, opts)
}
session.startPump(m.logger)
m.finishReserve(session)
m.logger.Info("终端会话已建立",
"session_id", session.ID, "server_id", opts.ServerID, "label", label,
"user", opts.User, "client_ip", opts.ClientIP)
return session, nil
}
// attachRecorder 按需挂上录制器;失败只记日志,绝不因此拒绝建立会话。
func (m *Manager) attachRecorder(session *Session, opts CreateOptions) {
m.mu.Lock()
factory := m.recorder
m.mu.Unlock()
if factory == nil {
return
}
recorder, err := factory(session.ID, opts)
if err != nil {
m.logger.Warn("终端录制未启用:无法创建录制文件",
"module", "TERMINAL", "session_id", session.ID, "error", err.Error())
return
}
session.recorder = recorder
m.logger.Info("终端会话开始录制", "module", "TERMINAL", "session_id", session.ID, "server_id", opts.ServerID)
}
// Get 返回会话;不存在时返回 false。会话可能已关闭,调用方需自行判断原因。
func (m *Manager) Get(id string) (*Session, bool) {
m.mu.Lock()
defer m.mu.Unlock()
session, ok := m.sessions[id]
return session, ok
}
// Close 关闭并移除会话;会话不存在时幂等返回。
func (m *Manager) Close(id, reason string) {
m.mu.Lock()
session, ok := m.sessions[id]
if ok {
delete(m.sessions, id)
}
m.mu.Unlock()
if !ok {
return
}
m.logger.Info("终端会话已关闭", "session_id", id, "server_id", session.ServerID, "reason", reason)
// 关闭动作会触发 handleSessionClosed:移出列表 + 写审计
session.Close(reason)
}
// CloseAll 关闭全部会话(服务停止或用户点「全部关闭」)。
func (m *Manager) CloseAll(reason string) {
for _, snap := range m.List() {
m.Close(snap.ID, reason)
}
}
// List 返回当前所有会话的只读快照。
func (m *Manager) List() []Snapshot {
m.mu.Lock()
sessions := make([]*Session, 0, len(m.sessions))
for _, session := range m.sessions {
sessions = append(sessions, session)
}
m.mu.Unlock()
out := make([]Snapshot, 0, len(sessions))
for _, session := range sessions {
out = append(out, session.Snapshot())
}
return out
}
// StartSweeper 定期回收空闲超时、超长与已结束的会话;ctx 取消时关闭全部会话。
func (m *Manager) StartSweeper(ctx context.Context, interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
m.CloseAll("服务停止")
return
case <-ticker.C:
m.sweep()
}
}
}()
}
// reserve 预占一个会话名额:把「正在建立」的会话也计入上限,避免并发点击绕过限制。
func (m *Manager) reserve() error {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.sessions)+m.pending >= m.max {
return ErrTooManySessions
}
m.pending++
return nil
}
// finishReserve 结束预占:session 非空时登记为新会话。
func (m *Manager) finishReserve(session *Session) {
m.mu.Lock()
defer m.mu.Unlock()
m.pending--
// 会话可能在登记前就被远端结束并自行回收,这种情况不再登记,避免列表里留下死会话
if session != nil && !session.Snapshot().Closed {
m.sessions[session.ID] = session
}
}
// handleSessionClosed 是会话的唯一收尾入口:会话无论被谁关闭都会走到这里。
func (m *Manager) handleSessionClosed(session *Session) {
m.mu.Lock()
delete(m.sessions, session.ID)
m.mu.Unlock()
m.recordAudit(session)
}
// recordAudit 写一条审计:注入了存储就落库,否则退化为结构化日志。
func (m *Manager) recordAudit(session *Session) {
entry := session.Audit()
m.mu.Lock()
sink := m.audit
m.mu.Unlock()
if sink == nil {
m.logger.Info("终端会话审计",
"module", "TERMINAL",
"session_id", entry.SessionID,
"server_id", entry.ServerID,
"target", entry.Target,
"user", entry.User,
"client_ip", entry.ClientIP,
"duration_ms", entry.DurationMS,
"reason", entry.Reason)
return
}
// 关闭动作可能来自请求 goroutine(其 ctx 随响应结束而取消),这里用独立超时写库
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sink.WriteAudit(ctx, entry); err != nil {
m.logger.Warn("终端审计写入失败", "module", "TERMINAL", "session_id", entry.SessionID, "error", err.Error())
}
}
func (m *Manager) sweep() {
now := time.Now()
for _, snap := range m.List() {
if snap.Closed {
reason := snap.Reason
if reason == "" {
reason = "会话已结束"
}
m.Close(snap.ID, reason)
continue
}
if now.Sub(snap.LastSeen) > m.idleAfter {
m.Close(snap.ID, "空闲超时")
continue
}
if now.Sub(snap.CreatedAt) > m.maxAge {
m.Close(snap.ID, "超过最长会话时长")
}
}
}
func newSessionID() string {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(buf)
}
// normCols 把前端传来的列数夹到合理范围,避免异常值让远端 PTY 申请失败。
func normCols(cols int) int {
if cols < 20 {
return 80
}
if cols > 500 {
return 500
}
return cols
}
// normRows 把前端传来的行数夹到合理范围。
func normRows(rows int) int {
if rows < 5 {
return 24
}
if rows > 300 {
return 300
}
return rows
}
+223
View File
@@ -0,0 +1,223 @@
package terminal
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
const (
// MaxRecordingBytes 单个录制文件的大小上限:超过就停写,
// 避免 `yes`、无限日志之类的输出把 NAS 磁盘写满。
MaxRecordingBytes = 32 << 20
// backingExt 是录制文件扩展名;内容为 asciinema v2 文本格式。
backingExt = ".cast"
)
// sessionIDPattern 限定会话 ID 形态:录制文件按会话 ID 命名,必须挡住路径穿越。
var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{4,64}$`)
// OutputRecorder 记录一次会话的输出;实现需自行保证并发安全。
type OutputRecorder interface {
Write(p []byte)
Close()
}
// Recording 是一条录制文件的元信息。
type Recording struct {
SessionID string `json:"session_id"`
Size int64 `json:"size"`
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
}
// RecordingStore 管理 logs/terminal 下的录制文件:列表、读取与清理。
type RecordingStore struct {
dir string
}
func NewRecordingStore(dir string) *RecordingStore {
return &RecordingStore{dir: dir}
}
// Dir 返回录制目录(启动时需要先建好)。
func (s *RecordingStore) Dir() string { return s.dir }
// EnsureDir 建好目录:录制开关打开前也应该能列出空目录。
func (s *RecordingStore) EnsureDir() error {
return os.MkdirAll(s.dir, 0o755)
}
// Path 返回某个会话的录制文件路径;会话 ID 非法时返回 false,避免路径穿越。
func (s *RecordingStore) Path(sessionID string) (string, bool) {
if !sessionIDPattern.MatchString(sessionID) {
return "", false
}
return filepath.Join(s.dir, sessionID+backingExt), true
}
// List 按录制结束时间倒序返回全部录制;started_at 取自 cast 文件头。
func (s *RecordingStore) List() ([]Recording, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
if os.IsNotExist(err) {
return []Recording{}, nil
}
return nil, err
}
out := make([]Recording, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), backingExt) {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
sessionID := strings.TrimSuffix(entry.Name(), backingExt)
out = append(out, Recording{
SessionID: sessionID,
Size: info.Size(),
StartedAt: s.readStartedAt(filepath.Join(s.dir, entry.Name())),
EndedAt: info.ModTime().UTC().Format(time.RFC3339),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].EndedAt > out[j].EndedAt })
return out, nil
}
// Prune 删除修改时间早于 before 的录制,返回删除条数。
func (s *RecordingStore) Prune(before time.Time) (int, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
deleted := 0
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), backingExt) {
continue
}
info, err := entry.Info()
if err != nil || !info.ModTime().Before(before) {
continue
}
if err := os.Remove(filepath.Join(s.dir, entry.Name())); err == nil {
deleted++
}
}
return deleted, nil
}
// readStartedAt 读 cast 头里的 timestamp;读不到就用空串。
func (s *RecordingStore) readStartedAt(path string) string {
file, err := os.Open(path)
if err != nil {
return ""
}
defer file.Close()
line, err := bufio.NewReader(file).ReadString('\n')
if err != nil && line == "" {
return ""
}
var header struct {
Timestamp int64 `json:"timestamp"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &header); err != nil || header.Timestamp <= 0 {
return ""
}
return time.Unix(header.Timestamp, 0).UTC().Format(time.RFC3339)
}
// castRecorder 以 asciinema v2 文本格式记录一段会话:
// 第一行是头(版本/宽高/起始时间),之后每行是一条 [相对秒数, "o", 输出]。
type castRecorder struct {
mu sync.Mutex
file *os.File
writer *bufio.Writer
started time.Time
written int64
stopped bool
}
// NewCastRecorder 建好录制文件并写下 cast 头。
func NewCastRecorder(store *RecordingStore, sessionID, title string, cols, rows int, startedAt time.Time) (OutputRecorder, error) {
path, ok := store.Path(sessionID)
if !ok {
return nil, fmt.Errorf("会话 ID 不合法")
}
if err := store.EnsureDir(); err != nil {
return nil, err
}
file, err := os.Create(path)
if err != nil {
return nil, err
}
recorder := &castRecorder{file: file, writer: bufio.NewWriter(file), started: startedAt}
header := map[string]any{
"version": 2,
"width": cols,
"height": rows,
"timestamp": startedAt.Unix(),
"title": title,
"env": map[string]string{"TERM": "xterm-256color", "SHELL": "/bin/sh"},
}
encoded, err := json.Marshal(header)
if err != nil {
_ = file.Close()
return nil, err
}
if _, err := recorder.writer.Write(append(encoded, '\n')); err != nil {
_ = file.Close()
return nil, err
}
return recorder, nil
}
// Write 追加一帧输出;超过大小上限后停止写入,避免把磁盘写满。
func (r *castRecorder) Write(p []byte) {
if len(p) == 0 {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.stopped || r.file == nil || r.written >= MaxRecordingBytes {
return
}
elapsed := time.Since(r.started).Seconds()
chunk := strings.ToValidUTF8(string(p), "\uFFFD")
payload, err := json.Marshal(chunk)
if err != nil {
return
}
line := fmt.Sprintf("[%.3f, \"o\", %s]\n", elapsed, payload)
if _, err := r.writer.WriteString(line); err != nil {
r.stopped = true
return
}
r.written += int64(len(line))
if r.written >= MaxRecordingBytes {
r.stopped = true
}
}
// Close 落盘并关闭文件;可重复调用。
func (r *castRecorder) Close() {
r.mu.Lock()
defer r.mu.Unlock()
if r.file == nil {
return
}
_ = r.writer.Flush()
_ = r.file.Close()
r.file = nil
}
+168
View File
@@ -0,0 +1,168 @@
package terminal
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// fakeRecorder 收集写进来的输出,用于验证会话是否把输出接到录制器上。
type fakeRecorder struct {
mu sync.Mutex
buf strings.Builder
closed bool
}
func (r *fakeRecorder) Write(p []byte) {
r.mu.Lock()
defer r.mu.Unlock()
r.buf.Write(p)
}
func (r *fakeRecorder) Close() {
r.mu.Lock()
defer r.mu.Unlock()
r.closed = true
}
func (r *fakeRecorder) text() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.buf.String()
}
func (r *fakeRecorder) isClosed() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.closed
}
// TestCastRecorderAndStore:录制写出合法的 asciinema v2 文件,列表能读到元信息,非法 ID 被拒。
func TestCastRecorderAndStore(t *testing.T) {
dir := t.TempDir()
store := NewRecordingStore(dir)
started := time.Now().Add(-3 * time.Second)
recorder, err := NewCastRecorder(store, "abcd1234", "服务端 #1", 120, 40, started)
if err != nil {
t.Fatalf("创建录制失败:%v", err)
}
recorder.Write([]byte("hello "))
// 无效 UTF-8 必须被替换,否则会写出非法 JSON
recorder.Write([]byte{0xff, 0xfe})
recorder.Write([]byte("world\r\n"))
recorder.Close()
list, err := store.List()
if err != nil {
t.Fatalf("列表失败:%v", err)
}
if len(list) != 1 {
t.Fatalf("期望 1 条录制,实际 %d 条", len(list))
}
if list[0].SessionID != "abcd1234" || list[0].Size == 0 || list[0].StartedAt == "" || list[0].EndedAt == "" {
t.Fatalf("录制元信息不完整:%+v", list[0])
}
raw, err := os.ReadFile(filepath.Join(dir, "abcd1234.cast"))
if err != nil {
t.Fatalf("读取录制失败:%v", err)
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
if len(lines) != 4 {
t.Fatalf("期望 4 行(头 + 3 帧),实际 %d 行", len(lines))
}
var header struct {
Version int `json:"version"`
Width int `json:"width"`
Height int `json:"height"`
}
if err := json.Unmarshal([]byte(lines[0]), &header); err != nil || header.Version != 2 {
t.Fatalf("cast 头不合法:%s", lines[0])
}
if header.Width != 120 || header.Height != 40 {
t.Fatalf("cast 头宽高不符:%+v", header)
}
var frame []any
if err := json.Unmarshal([]byte(lines[1]), &frame); err != nil {
t.Fatalf("帧不是合法 JSON:%s", lines[1])
}
if len(frame) != 3 || frame[1] != "o" {
t.Fatalf("帧结构不符:%+v", frame)
}
// 路径穿越与畸形 ID 必须被拒
for _, id := range []string{"../etc/passwd", "ab", "a/b", ""} {
if _, ok := store.Path(id); ok {
t.Fatalf("非法会话 ID %q 不应通过校验", id)
}
}
// 过期录制会被清理
stale := filepath.Join(dir, "stale1234.cast")
if err := os.WriteFile(stale, []byte("{}\n"), 0o644); err != nil {
t.Fatalf("准备过期录制失败:%v", err)
}
past := time.Now().Add(-48 * time.Hour)
if err := os.Chtimes(stale, past, past); err != nil {
t.Fatalf("调整时间失败:%v", err)
}
removed, err := store.Prune(time.Now().Add(-24 * time.Hour))
if err != nil || removed != 1 {
t.Fatalf("期望清理 1 条,实际 %d 条 err=%v", removed, err)
}
}
// TestManagerRecordsOutput:开启录制时输出会流进录制器,会话结束时录制器被关闭。
func TestManagerRecordsOutput(t *testing.T) {
open, created := openWith()
manager := NewManager(testLogger(), open, Options{})
recorder := &fakeRecorder{}
manager.SetRecorderFactory(func(string, CreateOptions) (OutputRecorder, error) {
return recorder, nil
})
opts := testCreateOptions(1)
opts.Record = true
if _, err := manager.Create(context.Background(), opts); err != nil {
t.Fatalf("创建会话失败:%v", err)
}
if _, err := (*created)[0].writer.Write([]byte("record-me")); err != nil {
t.Fatalf("向后端写入失败:%v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && !strings.Contains(recorder.text(), "record-me") {
time.Sleep(10 * time.Millisecond)
}
if !strings.Contains(recorder.text(), "record-me") {
t.Fatalf("输出没有进入录制器:%q", recorder.text())
}
manager.CloseAll("测试结束")
if !recorder.isClosed() {
t.Fatal("会话结束后录制器应当被关闭")
}
}
// TestManagerSkipsRecordingWhenOff:未开启录制时不创建录制器。
func TestManagerSkipsRecordingWhenOff(t *testing.T) {
open, _ := openWith()
manager := NewManager(testLogger(), open, Options{})
called := false
manager.SetRecorderFactory(func(string, CreateOptions) (OutputRecorder, error) {
called = true
return &fakeRecorder{}, nil
})
if _, err := manager.Create(context.Background(), testCreateOptions(1)); err != nil {
t.Fatalf("创建会话失败:%v", err)
}
manager.CloseAll("测试结束")
if called {
t.Fatal("未开启录制时不应创建录制器")
}
}
+250
View File
@@ -0,0 +1,250 @@
package terminal
import (
"errors"
"io"
"log/slog"
"sync"
"time"
)
// ErrSessionClosed 表示会话已经关闭,不能再写入。
var ErrSessionClosed = errors.New("终端会话已关闭")
// Terminal 是终端后端的统一抽象:SSH PTY 与本地 PTY 都实现它。
// 会话只依赖这个接口,因此「服务端 SSH」和后续的「本地 Shell」可以共用同一套会话管理。
type Terminal interface {
io.ReadWriteCloser
Resize(cols, rows int) error
}
// backlogLimit 是每个会话保留的回放缓冲字节数:前端重连或刷新后能补回最近的输出。
const backlogLimit = 64 << 10
// Snapshot 是会话的只读快照,供 HTTP 层展示与清理判断使用。
type Snapshot struct {
ID string `json:"id"`
Label string `json:"label"`
ServerID int64 `json:"server_id"`
Closed bool `json:"closed"`
Reason string `json:"reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastSeen time.Time `json:"last_seen"`
}
// Session 是一个已连接的终端会话:把后端输出分发给订阅者,并保留一小段回放缓冲。
type Session struct {
ID string
Label string
ServerID int64
term Terminal
// recorder 非空时把输出同步写一份到磁盘(默认关闭,见设置里的会话录制开关)
recorder OutputRecorder
// user / clientIP 只在建立会话时写入,供审计记录「谁、从哪来」
user string
clientIP string
// onClose 由管理器注入:会话自行结束(远端退出、超时)时也要走同一套清理与审计
onClose func(*Session)
mu sync.Mutex
backlog []byte
subs map[chan []byte]struct{}
closed bool
reason string
createdAt time.Time
lastSeen time.Time
endedAt time.Time
bytesIn int64
bytesOut int64
}
func newSession(id string, serverID int64, label, user, clientIP string, term Terminal) *Session {
now := time.Now()
return &Session{
ID: id, Label: label, ServerID: serverID, term: term, user: user, clientIP: clientIP,
subs: map[chan []byte]struct{}{}, createdAt: now, lastSeen: now,
}
}
// Subscribe 订阅输出:先回放已有缓冲,再接收后续数据。
// cancel 可重复调用;会话关闭时订阅通道会被关闭,读取方据此得知会话结束。
func (s *Session) Subscribe() (<-chan []byte, func()) {
ch := make(chan []byte, 64)
s.mu.Lock()
if s.closed {
s.mu.Unlock()
close(ch)
return ch, func() {}
}
s.subs[ch] = struct{}{}
backlog := append([]byte(nil), s.backlog...)
s.mu.Unlock()
if len(backlog) > 0 {
ch <- backlog
}
var once sync.Once
cancel := func() {
once.Do(func() {
s.mu.Lock()
if _, ok := s.subs[ch]; ok {
delete(s.subs, ch)
close(ch)
}
s.mu.Unlock()
})
}
return ch, cancel
}
// Input 把前端按键写入后端终端。
func (s *Session) Input(p []byte) error {
s.mu.Lock()
closed := s.closed
if !closed {
s.lastSeen = time.Now()
s.bytesIn += int64(len(p))
}
s.mu.Unlock()
if closed {
return ErrSessionClosed
}
_, err := s.term.Write(p)
return err
}
// Resize 同步终端窗口大小。
func (s *Session) Resize(cols, rows int) error {
if s.Snapshot().Closed {
return ErrSessionClosed
}
return s.term.Resize(cols, rows)
}
// Close 关闭会话:结束后端终端,并让所有订阅者收到关闭。
func (s *Session) Close(reason string) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
s.reason = reason
s.endedAt = time.Now()
subs := make([]chan []byte, 0, len(s.subs))
for ch := range s.subs {
delete(s.subs, ch)
subs = append(subs, ch)
}
hook := s.onClose
recorder := s.recorder
s.recorder = nil
s.mu.Unlock()
_ = s.term.Close()
for _, ch := range subs {
close(ch)
}
if recorder != nil {
recorder.Close()
}
// 只在「第一次关闭」时触发:管理器据此把会话移出列表并写审计
if hook != nil {
hook(s)
}
}
// Snapshot 返回会话的只读快照。
func (s *Session) Snapshot() Snapshot {
s.mu.Lock()
defer s.mu.Unlock()
return Snapshot{
ID: s.ID, Label: s.Label, ServerID: s.ServerID,
Closed: s.closed, Reason: s.reason,
CreatedAt: s.createdAt, LastSeen: s.lastSeen,
}
}
// Audit 汇总一条审计记录:会话结束时间、时长、断开原因与收发字节数。
func (s *Session) Audit() AuditEntry {
s.mu.Lock()
defer s.mu.Unlock()
ended := s.endedAt
if ended.IsZero() {
ended = time.Now()
}
reason := s.reason
if reason == "" {
reason = "会话已结束"
}
return AuditEntry{
SessionID: s.ID,
ServerID: s.ServerID,
Target: s.Label,
User: s.user,
ClientIP: s.clientIP,
StartedAt: s.createdAt.UTC().Format(time.RFC3339),
EndedAt: ended.UTC().Format(time.RFC3339),
DurationMS: ended.Sub(s.createdAt).Milliseconds(),
Reason: reason,
BytesIn: s.bytesIn,
BytesOut: s.bytesOut,
}
}
// write 把后端输出写入回放缓冲并分发给订阅者。
func (s *Session) write(p []byte) {
if len(p) == 0 {
return
}
s.mu.Lock()
s.lastSeen = time.Now()
s.bytesOut += int64(len(p))
s.backlog = appendBacklog(s.backlog, p)
recorder := s.recorder
subs := make([]chan []byte, 0, len(s.subs))
for ch := range s.subs {
subs = append(subs, ch)
}
s.mu.Unlock()
chunk := append([]byte(nil), p...)
if recorder != nil {
recorder.Write(chunk)
}
for _, ch := range subs {
select {
case ch <- chunk:
default:
// 订阅者读得慢就丢这一帧:终端是「最新优先」的流,卡住整个会话更糟
}
}
}
// startPump 持续读取后端输出;读到 EOF 说明远端结束,主动关闭会话。
func (s *Session) startPump(logger *slog.Logger) {
go func() {
buf := make([]byte, 32<<10)
for {
n, err := s.term.Read(buf)
if n > 0 {
s.write(buf[:n])
}
if err == nil {
continue
}
if !errors.Is(err, io.EOF) {
logger.Debug("终端读取结束", "session_id", s.ID, "error", err.Error())
}
s.Close("会话已结束")
return
}
}()
}
// appendBacklog 只保留末尾 backlogLimit 字节,避免长会话把内存吃满。
func appendBacklog(buf, p []byte) []byte {
buf = append(buf, p...)
if len(buf) <= backlogLimit {
return buf
}
return append([]byte(nil), buf[len(buf)-backlogLimit:]...)
}
+271
View File
@@ -0,0 +1,271 @@
package terminal
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
)
// fakeTerminal 模拟后端终端:写入的内容原样回显,方便验证「输入 → 输出 → 订阅者」链路。
type fakeTerminal struct {
reader io.ReadCloser
writer *io.PipeWriter
mu sync.Mutex
resizes []string
closed bool
}
func newFakeTerminal() *fakeTerminal {
pr, pw := io.Pipe()
return &fakeTerminal{reader: pr, writer: pw}
}
func (f *fakeTerminal) Read(p []byte) (int, error) { return f.reader.Read(p) }
func (f *fakeTerminal) Write(p []byte) (int, error) { return f.writer.Write(p) }
func (f *fakeTerminal) Resize(cols, rows int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.resizes = append(f.resizes, fmt.Sprintf("%dx%d", cols, rows))
return nil
}
func (f *fakeTerminal) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
f.closed = true
return f.writer.CloseWithError(io.EOF)
}
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// openWith 返回一个总是成功、返回 fakeTerminal 的 Opener。
func openWith(terms ...*fakeTerminal) (Opener, *[]*fakeTerminal) {
created := &[]*fakeTerminal{}
open := func(_ context.Context, _ int64, _, _ int) (Terminal, string, error) {
term := newFakeTerminal()
if len(terms) > 0 {
term = terms[0]
}
*created = append(*created, term)
return term, "测试服务端 · root@127.0.0.1", nil
}
return open, created
}
// TestSessionInputEchoesToSubscriber:输入写入后端后,订阅者应收到回显;新订阅者能从回放缓冲补齐。
func TestSessionInputEchoesToSubscriber(t *testing.T) {
open, _ := openWith()
manager := NewManager(testLogger(), open, Options{})
session, err := manager.Create(context.Background(), testCreateOptions(1))
if err != nil {
t.Fatalf("创建会话失败:%v", err)
}
defer manager.CloseAll("测试结束")
chunks, cancel := session.Subscribe()
defer cancel()
if err := session.Input([]byte("hello")); err != nil {
t.Fatalf("写入失败:%v", err)
}
if got := waitForOutput(t, chunks); !strings.Contains(got, "hello") {
t.Fatalf("订阅者未收到回显,实际 %q", got)
}
// 第二个订阅者应当先拿到回放缓冲
replay, replayCancel := session.Subscribe()
defer replayCancel()
if got := waitForOutput(t, replay); !strings.Contains(got, "hello") {
t.Fatalf("回放缓冲未包含历史输出,实际 %q", got)
}
}
// TestManagerRejectsExtraSessions:并发上限生效。
func TestManagerRejectsExtraSessions(t *testing.T) {
open, _ := openWith()
manager := NewManager(testLogger(), open, Options{MaxSessions: 1})
if _, err := manager.Create(context.Background(), testCreateOptions(1)); err != nil {
t.Fatalf("第一个会话应当成功:%v", err)
}
defer manager.CloseAll("测试结束")
_, err := manager.Create(context.Background(), testCreateOptions(2))
if !errors.Is(err, ErrTooManySessions) {
t.Fatalf("期望 ErrTooManySessions,实际 %v", err)
}
}
// TestSweepClosesIdleSession:空闲超时后会话被回收。
func TestSweepClosesIdleSession(t *testing.T) {
open, _ := openWith()
manager := NewManager(testLogger(), open, Options{IdleTimeout: 10 * time.Millisecond, MaxLifetime: time.Hour})
session, err := manager.Create(context.Background(), testCreateOptions(1))
if err != nil {
t.Fatalf("创建会话失败:%v", err)
}
time.Sleep(30 * time.Millisecond)
manager.sweep()
if got := manager.List(); len(got) != 0 {
t.Fatalf("空闲会话未被回收:%+v", got)
}
if snap := session.Snapshot(); !snap.Closed || snap.Reason != "空闲超时" {
t.Fatalf("关闭原因不符:%+v", snap)
}
}
// TestDisabledManager:未注入 Opener 时视为功能未启用。
func TestDisabledManager(t *testing.T) {
manager := NewManager(testLogger(), nil, Options{})
if manager.Enabled() {
t.Fatal("nil Opener 应当是未启用状态")
}
if _, err := manager.Create(context.Background(), testCreateOptions(1)); !errors.Is(err, ErrDisabled) {
t.Fatalf("期望 ErrDisabled,实际 %v", err)
}
}
// TestResizeForwarded:窗口尺寸会转发给后端终端。
func TestResizeForwarded(t *testing.T) {
term := newFakeTerminal()
open, _ := openWith(term)
manager := NewManager(testLogger(), open, Options{})
session, err := manager.Create(context.Background(), testCreateOptions(1))
if err != nil {
t.Fatalf("创建会话失败:%v", err)
}
defer manager.CloseAll("测试结束")
if err := session.Resize(120, 40); err != nil {
t.Fatalf("resize 失败:%v", err)
}
term.mu.Lock()
defer term.mu.Unlock()
if len(term.resizes) != 1 || term.resizes[0] != "120x40" {
t.Fatalf("resize 未转发到后端:%v", term.resizes)
}
}
// testCreateOptions 是测试用的建会话入参,带上审计需要的调用方身份。
func testCreateOptions(serverID int64) CreateOptions {
return CreateOptions{ServerID: serverID, Cols: 80, Rows: 24, User: "admin", ClientIP: "127.0.0.1"}
}
// captureAudit 收集审计写入,供断言「谁、何时、连了哪台、多久、为什么断开」。
type captureAudit struct {
mu sync.Mutex
entries []AuditEntry
}
func (c *captureAudit) WriteAudit(_ context.Context, entry AuditEntry) error {
c.mu.Lock()
defer c.mu.Unlock()
c.entries = append(c.entries, entry)
return nil
}
func (c *captureAudit) list() []AuditEntry {
c.mu.Lock()
defer c.mu.Unlock()
return append([]AuditEntry(nil), c.entries...)
}
// TestCloseWritesAudit:主动关闭时落一条审计,且重复关闭不会写第二条。
func TestCloseWritesAudit(t *testing.T) {
open, _ := openWith()
manager := NewManager(testLogger(), open, Options{})
sink := &captureAudit{}
manager.SetAuditSink(sink)
session, err := manager.Create(context.Background(), CreateOptions{
ServerID: 7, Cols: 120, Rows: 40, User: "admin", ClientIP: "192.168.1.9",
})
if err != nil {
t.Fatalf("创建会话失败:%v", err)
}
if err := session.Input([]byte("whoami\n")); err != nil {
t.Fatalf("写入失败:%v", err)
}
manager.Close(session.ID, "用户关闭")
entries := sink.list()
if len(entries) != 1 {
t.Fatalf("期望 1 条审计,实际 %d 条", len(entries))
}
entry := entries[0]
if entry.SessionID != session.ID || entry.ServerID != 7 || entry.Target == "" {
t.Fatalf("审计目标字段不符:%+v", entry)
}
if entry.User != "admin" || entry.ClientIP != "192.168.1.9" {
t.Fatalf("审计来源字段不符:%+v", entry)
}
if entry.Reason != "用户关闭" {
t.Fatalf("审计断开原因不符:%+v", entry)
}
if entry.StartedAt == "" || entry.EndedAt == "" || entry.DurationMS < 0 {
t.Fatalf("审计时间字段不符:%+v", entry)
}
if entry.BytesIn != int64(len("whoami\n")) {
t.Fatalf("审计入流量不符:%+v", entry)
}
// 幂等:重复关闭不应写第二条
manager.Close(session.ID, "用户关闭")
if got := sink.list(); len(got) != 1 {
t.Fatalf("重复关闭写入了重复审计:%d 条", len(got))
}
}
// TestRemoteCloseWritesAudit:远端自行退出时同样落审计,并从会话列表里移除。
func TestRemoteCloseWritesAudit(t *testing.T) {
term := newFakeTerminal()
open, _ := openWith(term)
manager := NewManager(testLogger(), open, Options{})
sink := &captureAudit{}
manager.SetAuditSink(sink)
if _, err := manager.Create(context.Background(), testCreateOptions(1)); err != nil {
t.Fatalf("创建会话失败:%v", err)
}
// 远端断开:pump 读到 EOF 后应当自行收尾
_ = term.Close()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(sink.list()) == 1 && len(manager.List()) == 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
entries := sink.list()
if len(entries) != 1 {
t.Fatalf("远端断开未写审计:%d 条", len(entries))
}
if entries[0].Reason != "会话已结束" {
t.Fatalf("远端断开原因不符:%+v", entries[0])
}
if got := manager.List(); len(got) != 0 {
t.Fatalf("已结束的会话仍留在列表:%+v", got)
}
}
// waitForOutput 读取一帧输出,2 秒内没到就返回空串。
func waitForOutput(t *testing.T, chunks <-chan []byte) string {
t.Helper()
select {
case chunk, ok := <-chunks:
if !ok {
return ""
}
return string(chunk)
case <-time.After(2 * time.Second):
return ""
}
}
+72
View File
@@ -0,0 +1,72 @@
package terminal
import (
"fmt"
"io"
"sync"
"golang.org/x/crypto/ssh"
)
// sshTerminal 把 x/crypto/ssh 的交互式会话包装成 Terminal。
// 申请了 PTY 之后,远端 shell 的 stdout/stderr 会合并到同一条输出流,无需分别读取。
type sshTerminal struct {
client *ssh.Client
session *ssh.Session
stdin io.WriteCloser
stdout io.Reader
closeOnce sync.Once
closeErr error
}
// NewSSHTerminal 在已认证的 SSH 连接上申请 PTY 并启动登录 shell。
func NewSSHTerminal(client *ssh.Client, cols, rows int) (Terminal, error) {
session, err := client.NewSession()
if err != nil {
return nil, fmt.Errorf("创建 SSH 会话失败: %w", err)
}
modes := ssh.TerminalModes{ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400}
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
_ = session.Close()
return nil, fmt.Errorf("申请 PTY 失败: %w", err)
}
stdin, err := session.StdinPipe()
if err != nil {
_ = session.Close()
return nil, fmt.Errorf("打开 SSH 输入流失败: %w", err)
}
stdout, err := session.StdoutPipe()
if err != nil {
_ = session.Close()
return nil, fmt.Errorf("打开 SSH 输出流失败: %w", err)
}
if err := session.Shell(); err != nil {
_ = session.Close()
return nil, fmt.Errorf("启动远端 shell 失败: %w", err)
}
return &sshTerminal{client: client, session: session, stdin: stdin, stdout: stdout}, nil
}
// Read 读取远端输出。
func (t *sshTerminal) Read(p []byte) (int, error) { return t.stdout.Read(p) }
// Write 把按键写入远端 PTY。
func (t *sshTerminal) Write(p []byte) (int, error) { return t.stdin.Write(p) }
// Resize 通知远端窗口尺寸变化。
func (t *sshTerminal) Resize(cols, rows int) error {
if cols <= 0 || rows <= 0 {
return nil
}
return t.session.WindowChange(rows, cols)
}
// Close 关闭 SSH 会话与底层连接;重复调用安全。
func (t *sshTerminal) Close() error {
t.closeOnce.Do(func() {
_ = t.session.Close()
t.closeErr = t.client.Close()
})
return t.closeErr
}
+20
View File
@@ -382,3 +382,23 @@ func (c *Collector) ClientsForRule(rule proxy.Rule) []ClientConn {
})
return out
}
// ForgetEndpoints 丢弃这些端点的内存累计与速率样本,并删掉落库的累计行。
// 规则删除时调用:否则同名规则重建会直接继承上一轮的流量总量。
func (c *Collector) ForgetEndpoints(ctx context.Context, endpoints []proxy.Endpoint) error {
if len(endpoints) == 0 {
return nil
}
keys := make([]string, 0, len(endpoints))
for _, ep := range endpoints {
keys = append(keys, EndpointKey(ep.Hostname, ep.Port))
}
c.mu.Lock()
for _, key := range keys {
delete(c.hosts, key)
}
c.mu.Unlock()
return c.store.DeleteHosts(ctx, keys)
}
+62
View File
@@ -0,0 +1,62 @@
package traffic
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/proxy"
)
// TestForgetEndpointsClearsMemoryAndStore:删除规则时要同时清掉内存累计与落库累计,
// 否则同名规则重建后会直接继承上一轮的流量读数。
func TestForgetEndpointsClearsMemoryAndStore(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
ctx := context.Background()
if err := db.Migrate(ctx, conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
c := NewCollector(conn, "", nil)
c.ingestLine("2026-09-17T08:30:00+08:00 a.example.com 18080 GET / 200 0.010 1.2.3.4 192.168.8.3:80 100 200", true)
c.ingestLine("2026-09-17T08:30:01+08:00 b.example.com 18080 GET / 200 0.010 1.2.3.4 192.168.8.3:80 100 200", true)
if err := c.persist(ctx); err != nil {
t.Fatalf("persist: %v", err)
}
endpoints := []proxy.Endpoint{{Hostname: "a.example.com", Port: 18080}}
if err := c.ForgetEndpoints(ctx, endpoints); err != nil {
t.Fatalf("forget: %v", err)
}
count := func(host string) int64 {
var n int64
if err := conn.QueryRowContext(ctx, `SELECT COUNT(1) FROM proxy_traffic WHERE host = ?`, host).Scan(&n); err != nil {
t.Fatalf("count %s: %v", host, err)
}
return n
}
if got := count("a.example.com:18080"); got != 0 {
t.Fatalf("被删规则对应的流量行应清空,实际还有 %d 行", got)
}
if got := count("b.example.com:18080"); got != 1 {
t.Fatalf("其他规则的流量行不应受影响,实际 %d 行", got)
}
// 内存态也要一起清掉:被删端点的累计读数应归零
up, down, _, _, conns := c.aggregateEndpoints(endpoints, time.Now())
if up != 0 || down != 0 || conns != 0 {
t.Fatalf("内存累计应归零:up=%d down=%d conns=%d", up, down, conns)
}
// 空入参是空操作,不能报错(删除路径会无差别调用)
if err := c.ForgetEndpoints(ctx, nil); err != nil {
t.Fatalf("空端点列表不应报错:%v", err)
}
}
+20
View File
@@ -57,6 +57,26 @@ func (s *Store) SaveTotals(ctx context.Context, hosts map[string]hostTotals) err
return tx.Commit()
}
// DeleteHosts 删除指定 host:port 的累计流量。
// 规则被删掉后这些行就成了孤儿:既没有界面能查到,又会在同名规则重建时被当成本规则的既有流量。
func (s *Store) DeleteHosts(ctx context.Context, keys []string) error {
if len(keys) == 0 {
return nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
for _, key := range keys {
if _, err := tx.ExecContext(ctx,
`DELETE FROM proxy_traffic WHERE host = ?`, strings.ToLower(strings.TrimSpace(key))); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
type hostTotals struct {
Upload int64
Download int64
+14
View File
@@ -67,6 +67,20 @@ func EnsureToken(token, tokenFile string) (string, string, error) {
return value, path, nil
}
// EnsureTokenIfEnabled 只在 updater 启用(socket 非空)时准备共享 Token。
//
// updater Token 只服务于「一键升级」sidecar,而 sidecar 与主程序是通过共享卷里的
// Token 文件 + Unix Socket 对接的。原生版(飞牛 Native)与飞牛 Docker 版都显式把
// socket 置空表示「不启用升级」,此时根本不需要这个文件:
// 原生版以非 root 用户(havline)运行,去写 /run/havline-updater 会因权限不足报错,
// 而启动期的这个错误会让整个应用起不来。
func EnsureTokenIfEnabled(socketPath, token, tokenFile string) (string, string, error) {
if strings.TrimSpace(socketPath) == "" {
return "", "", nil
}
return EnsureToken(token, tokenFile)
}
// WaitForToken 等待主程序在共享卷中创建 Token 文件;环境变量已设置时立即返回。
func WaitForToken(token, tokenFile string, timeout time.Duration) (string, string, error) {
if timeout <= 0 {
+34
View File
@@ -1,6 +1,7 @@
package updater
import (
"errors"
"os"
"path/filepath"
"testing"
@@ -105,3 +106,36 @@ func TestWaitForTokenTimesOut(t *testing.T) {
t.Fatal("文件不存在时应等待超时")
}
}
// updater 未启用(socket 为空)时不能去碰 Token 文件:
// 原生版以非 root 运行,写 /run/havline-updater 会失败并拖垮整个启动。
func TestEnsureTokenIfEnabledSkipsWhenSocketDisabled(t *testing.T) {
tokenFile := filepath.Join(t.TempDir(), "nested", "updater.token")
token, source, err := EnsureTokenIfEnabled("", "", tokenFile)
if err != nil {
t.Fatalf("socket 未启用时不应报错:%v", err)
}
if token != "" || source != "" {
t.Fatalf("socket 未启用时应返回空 Token,实际 token=%q source=%q", token, source)
}
if _, statErr := os.Stat(tokenFile); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("socket 未启用时不应创建 Token 文件(statErr=%v)", statErr)
}
}
// 对照组:updater 启用(socket 非空)时仍照旧生成并落盘。
func TestEnsureTokenIfEnabledCreatesWhenSocketSet(t *testing.T) {
tokenFile := filepath.Join(t.TempDir(), "nested", "updater.token")
token, source, err := EnsureTokenIfEnabled("/run/havline-updater/updater.sock", "", tokenFile)
if err != nil {
t.Fatalf("socket 启用时应生成 Token:%v", err)
}
if len(token) != 64 || source != tokenFile {
t.Fatalf("生成的 Token 不正确,实际 token=%q source=%q", token, source)
}
if _, statErr := os.Stat(tokenFile); statErr != nil {
t.Fatalf("socket 启用时应写入 Token 文件:%v", statErr)
}
}
+2
View File
@@ -0,0 +1,2 @@
-- DDNS 服务商支持自定义名称:同一服务商可建多条配置,用名称区分(如「家庭」「公司」)。
ALTER TABLE ddns_configs ADD COLUMN name TEXT NOT NULL DEFAULT '';
+18
View File
@@ -0,0 +1,18 @@
-- 内置 SSH 终端会话审计:每次会话结束写一条,回答「谁、何时、连了哪台、多久、为什么断开」。
-- 只记元数据,不落终端内容——需要留存的场景再单独做录制开关。
CREATE TABLE IF NOT EXISTS terminal_audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
target TEXT NOT NULL,
username TEXT NOT NULL,
client_ip TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
duration_ms INTEGER NOT NULL,
reason TEXT,
bytes_in INTEGER NOT NULL DEFAULT 0,
bytes_out INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_terminal_audit_ended ON terminal_audit_logs(ended_at);
+10
View File
@@ -0,0 +1,10 @@
-- 常用命令:终端页右侧面板保存的快捷命令,点一下发送到当前会话。
-- 只存命令文本本身,不存执行结果、不存凭据。
CREATE TABLE IF NOT EXISTS terminal_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
command TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_terminal_commands_created ON terminal_commands(created_at);
+11 -4
View File
@@ -4,14 +4,21 @@ Manual OCI layout pusher for registries whose token realm is misconfigured.
This script is an emergency fallback. It reads Docker's OCI layout output,
obtains a Bearer token over HTTPS from the Git credential store, and uploads
the blobs and image manifest directly through the Registry HTTP API.
默认面向 GitHub Container Registry(ghcr.io)。如果要用回自建 Gitea registry:
-Registry jayhub.top -Repository mrlee/havline -CredentialHost jayhub.top `
-TokenPath /v2/token -TokenService container_registry
#>
param(
[Parameter(Mandatory = $true)][string]$LayoutDir,
[Parameter(Mandatory = $true)][string]$Platform,
[Parameter(Mandatory = $true)][string]$Tag,
[string]$Registry = "jayhub.top",
[string]$Repository = "mrlee/havline",
[string]$CredentialHost = "jayhub.top"
[string]$Registry = "ghcr.io",
[string]$Repository = "mrlee233/havline",
[string]$CredentialHost = "ghcr.io",
# GHCR: /token?service=ghcr.io;Gitea: /v2/token?service=container_registry
[string]$TokenPath = "/token",
[string]$TokenService = "ghcr.io"
)
$ErrorActionPreference = "Stop"
@@ -33,7 +40,7 @@ function Get-RegistryBasicAuth {
function Get-BearerToken([string]$Scope) {
$basic = Get-RegistryBasicAuth
$url = "https://$Registry/v2/token?service=container_registry&scope=$Scope"
$url = "https://$Registry$TokenPath?service=$TokenService&scope=$Scope"
$response = Invoke-RestMethod -Uri $url -Headers @{ Authorization = $basic }
if (-not $response.token) { throw "Registry did not return a token" }
return $response.token
+2 -2
View File
@@ -5,7 +5,7 @@
#
# 可选环境变量:
# COMPOSE_FILE=docker-compose.hub.yml # 要用的编排文件
# REPO=http://.../mrlee/Havline.git # 源码仓库
# REPO=https://github.com/mrlee233/havline.git # 源码仓库
# BRANCH=main # 分支
#
# 它做四件事:
@@ -16,7 +16,7 @@
# 4) 打印「回滚到上一个 tag」的命令
set -euo pipefail
REPO="${REPO:-https://jayhub.top/mrlee/Havline.git}"
REPO="${REPO:-https://github.com/mrlee233/havline.git}"
BRANCH="${BRANCH:-main}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.hub.yml}"
APP_CONTAINER="${APP_CONTAINER:-havline}"
+103
View File
@@ -0,0 +1,103 @@
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import tseslint from 'typescript-eslint'
// 说明:这份配置刻意只开「低争议」规则。
// 项目里已有 2.7 万行前端代码,一次性打开 recommended 全量会砸出几千条告警,
// 反而没人看。先把「未使用变量 / 未定义变量 / == 与 ===」这类真问题卡住,
// 再逐步加规则。vite.config.ts 里已经在 build 前跑 vue-tsc,类型问题由它兜。
export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', 'src/**/*.d.ts'],
},
js.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/essential'],
{
files: ['**/*.{ts,vue}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
// Vue SFC 的 <script setup lang="ts"> 必须显式指定 TS 解析器,
// 否则 eslint-plugin-vue 用的默认解析器不认识类型标注,会报
// 「Parsing error: Unexpected token XxxType」。
parserOptions: {
parser: tseslint.parser,
extraFileExtensions: ['.vue'],
ecmaFeatures: { jsx: false },
},
globals: {
// 浏览器环境
window: 'readonly',
document: 'readonly',
navigator: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly',
fetch: 'readonly',
FormData: 'readonly',
Blob: 'readonly',
URL: 'readonly',
EventSource: 'readonly',
WebSocket: 'readonly',
ResizeObserver: 'readonly',
MutationObserver: 'readonly',
HTMLElement: 'readonly',
HTMLInputElement: 'readonly',
HTMLTextAreaElement: 'readonly',
HTMLDivElement: 'readonly',
HTMLCanvasElement: 'readonly',
Node: 'readonly',
Element: 'readonly',
Event: 'readonly',
KeyboardEvent: 'readonly',
MouseEvent: 'readonly',
AbortController: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly',
matchMedia: 'readonly',
console: 'readonly',
crypto: 'readonly',
performance: 'readonly',
// 构建期注入
__APP_VERSION__: 'readonly',
},
},
rules: {
// 真问题:未使用变量、未定义变量
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrors: 'none',
},
],
// 宽松处理 any:现有 API 类型里有历史遗留,先不拦
'@typescript-eslint/no-explicit-any': 'off',
// 空块与空接口在这类前端代码里常用于「占位」,不视为错误
'no-empty': ['error', { allowEmptyCatch: true }],
// Vue:多词组件名不强求(现有单文件组件已大量单词名)
'vue/multi-word-component-names': 'off',
// 未使用组件显式注册检查交由 vue-tsc
'vue/no-unused-components': 'off',
// TaskProviderForm / LogsView 里的 form、autoRefresh 都是 v-model 双向绑定的 prop。
// 这是 Vue 官方推荐写法(父组件传 ref,子组件 v-model 直接改),规则默认会误报。
'vue/no-mutating-props': ['error', { shallowOnly: true }],
},
},
{
// 配置文件与脚本允许 CommonJS 风格的环境
files: ['*.config.{ts,js}', 'vite.config.ts'],
languageOptions: {
globals: {
process: 'readonly',
__dirname: 'readonly',
},
},
},
)
+1695 -2
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -1,7 +1,7 @@
{
"name": "havline-web",
"private": true,
"version": "1.9.2",
"version": "2.0.0",
"packageManager": "npm@10.9.4",
"type": "module",
"scripts": {
@@ -9,7 +9,8 @@
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run"
"test": "vitest run",
"lint": "eslint . --max-warnings=0"
},
"dependencies": {
"@codemirror/commands": "^6.11.1",
@@ -20,6 +21,9 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.12",
"@vicons/ionicons5": "^0.13.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"echarts": "^6.1.0",
"naive-ui": "^2.40.1",
@@ -30,9 +34,14 @@
"vue-router": "^4.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^5.2.1",
"eslint": "^9.39.4",
"eslint-plugin-vue": "^9.33.0",
"globals": "^15.15.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.72.0",
"vite": "^6.0.3",
"vitest": "^5.0.1",
"vue-tsc": "^2.2.0"
-1423
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More