Compare commits
15
Commits
d1c498a286
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9d43538b5 | ||
|
|
56f04ae12b | ||
|
|
9d75fb1d3e | ||
|
|
6dd2136dd2 | ||
|
|
e4edbdcef0 | ||
|
|
5b9773b74a | ||
|
|
2c1cf1260b | ||
|
|
211d8e66e0 | ||
|
|
6b8ff0e5a3 | ||
|
|
c1bcdd0b86 | ||
|
|
302248a907 | ||
|
|
a0cc553d2d | ||
|
|
2856040883 | ||
|
|
0f68101503 | ||
|
|
50b8cc1351 |
@@ -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` 作为门禁。
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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` 访问
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 结尾")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "密码已更新"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("退出前没在运行就不该标记为自动恢复")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "..") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("超长名称应当报错")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("未开启录制时不应创建录制器")
|
||||
}
|
||||
}
|
||||
@@ -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:]...)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- DDNS 服务商支持自定义名称:同一服务商可建多条配置,用名称区分(如「家庭」「公司」)。
|
||||
ALTER TABLE ddns_configs ADD COLUMN name TEXT NOT NULL DEFAULT '';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
Generated
+1695
-2
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -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"
|
||||
|
||||
Generated
-1423
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
Reference in New Issue
Block a user