- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验 - 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令 - DDNS 服务商名称与自定义 IP,新增迁移 037/038/039 - CHANGELOG 与 VERSION 更新至 2.0.0
This commit is contained in:
+26
-1
@@ -2,7 +2,32 @@
|
||||
|
||||
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` 都视为开启,避免开关显示已打开、接口却按未启用拒绝
|
||||
|
||||
### 测试
|
||||
|
||||
- 新增终端会话回放缓冲、并发上限、空闲回收、resize 转发用例
|
||||
- 新增二次认证解锁票据、错误密码限流与锁定、API Token 拦截用例
|
||||
- 新增终端审计写入、远端断开回收、保留期清理用例
|
||||
- 新增会话录制(cast 头与帧格式、非法会话 ID 拦截、过期清理、输出接入录制器)与常用命令校验用例
|
||||
- `go test ./...`、`npm run typecheck`、`npm run build` 通过
|
||||
|
||||
## [v1.9.3] - 2026-10-11
|
||||
|
||||
|
||||
@@ -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.3-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" />
|
||||
@@ -229,7 +229,7 @@ Havline 提供两种飞牛应用形态,均支持统一网关入口 `/app/havli
|
||||
### Docker 应用(离线镜像包)
|
||||
|
||||
- 独立仓库:<https://github.com/mrlee233/havline>
|
||||
- 安装包:`havline-1.9.3.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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+50
-18
@@ -31,7 +31,7 @@ import (
|
||||
"github.com/havline/havline/internal/sysinfo"
|
||||
)
|
||||
|
||||
const AgentVersion = "0.15.2"
|
||||
const AgentVersion = "0.15.3"
|
||||
|
||||
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||
type Config struct {
|
||||
@@ -1029,6 +1029,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 +1069,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 +1341,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
|
||||
|
||||
@@ -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": "密码已更新"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+51
-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()
|
||||
@@ -226,6 +242,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 +300,7 @@ type Router struct {
|
||||
frpmultiHandler *FRPMultiHandler
|
||||
cloudflareHandler *CloudflareHandler
|
||||
updateHandler *UpdateHandler
|
||||
terminalHandler *TerminalHandler
|
||||
authSvc *auth.Service
|
||||
staticFS fs.FS
|
||||
}
|
||||
@@ -302,6 +341,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()
|
||||
|
||||
@@ -102,6 +102,8 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
settings.KeyChinaCIDRUpdateHours: true,
|
||||
settings.KeyChinaCIDRSourceV4: true,
|
||||
settings.KeyChinaCIDRSourceV6: true,
|
||||
settings.KeyTerminalEnabled: true,
|
||||
settings.KeyTerminalRecording: true,
|
||||
}
|
||||
filtered := map[string]string{}
|
||||
for key, value := range req {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+88
-31
@@ -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"
|
||||
)
|
||||
@@ -135,26 +136,46 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
startedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
|
||||
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)
|
||||
@@ -169,6 +190,8 @@ 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)
|
||||
@@ -211,6 +234,27 @@ 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
sched.Start(ctx)
|
||||
trafficCollector.Start(ctx)
|
||||
@@ -238,7 +282,11 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
logger.Warn("initial frp autostart skipped", "module", "FRP", "error", err.Error())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -250,31 +298,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
+16
-11
@@ -12,6 +12,7 @@ import (
|
||||
type Config struct {
|
||||
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"`
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+72
-1
@@ -1854,6 +1854,19 @@ func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error {
|
||||
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 未保存")
|
||||
@@ -1919,7 +1932,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)
|
||||
}
|
||||
@@ -1928,6 +1941,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -86,6 +86,10 @@ 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"
|
||||
)
|
||||
|
||||
var Defaults = map[string]string{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
Generated
+26
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "havline-web",
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "havline-web",
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
@@ -16,6 +16,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",
|
||||
@@ -1440,6 +1443,27 @@
|
||||
"integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
|
||||
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "havline-web",
|
||||
"private": true,
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.0",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -20,6 +20,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",
|
||||
|
||||
+77
-1
@@ -46,6 +46,14 @@ import type {
|
||||
CfTunnelRoutes,
|
||||
CfPreflightResult,
|
||||
CfRouteTestResult,
|
||||
TerminalTarget,
|
||||
TerminalAuditEntry,
|
||||
TerminalSessionInfo,
|
||||
TerminalCreateResult,
|
||||
TerminalUnlockStatus,
|
||||
TerminalUnlockResult,
|
||||
TerminalCommand,
|
||||
TerminalRecording,
|
||||
} from './types'
|
||||
import { withBase } from './base'
|
||||
|
||||
@@ -56,6 +64,22 @@ export function asList<T>(value: T[] | null | undefined): T[] {
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000
|
||||
const AGENT_OPERATION_TIMEOUT_MS = 5 * 60_000
|
||||
// SSH 建连包含认证 + 申请 PTY,给到 60 秒(后端自身 30 秒超时)
|
||||
const TERMINAL_CREATE_TIMEOUT_MS = 60_000
|
||||
|
||||
// ApiRequestError 带上 HTTP 状态码与后端返回的稳定错误码,
|
||||
// 让调用方能按 code 分支(例如 terminal_locked),而不是去匹配提示文案。
|
||||
export class ApiRequestError extends Error {
|
||||
readonly status: number
|
||||
readonly code: string
|
||||
|
||||
constructor(message: string, status: number, code = '') {
|
||||
super(message)
|
||||
this.name = 'ApiRequestError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
@@ -89,15 +113,19 @@ async function requestWithSignal<T>(path: string, init?: RequestInit, signal?: A
|
||||
|
||||
if (!response.ok) {
|
||||
let message = '请求失败'
|
||||
let code = ''
|
||||
try {
|
||||
const body = (await response.json()) as ApiError
|
||||
if (body.error) {
|
||||
message = body.error
|
||||
}
|
||||
if (body.code) {
|
||||
code = body.code
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message)
|
||||
throw new ApiRequestError(message, response.status, code)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
@@ -550,4 +578,52 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
|
||||
// ===== 内置 SSH 终端 =====
|
||||
// 二次认证:解锁一次 10 分钟,期间新建会话不再重复输入管理员密码
|
||||
getTerminalUnlock: () => request<TerminalUnlockStatus>('/api/terminal/unlock'),
|
||||
unlockTerminal: (password: string) =>
|
||||
request<TerminalUnlockResult>('/api/terminal/unlock', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
lockTerminal: () => request<{ ok: boolean }>('/api/terminal/unlock', { method: 'DELETE' }),
|
||||
listTerminalTargets: () => request<{ targets: TerminalTarget[] }>('/api/terminal/targets'),
|
||||
listTerminalSessions: () => request<{ sessions: TerminalSessionInfo[] }>('/api/terminal/sessions'),
|
||||
createTerminalSession: (payload: { server_id: number; cols: number; rows: number }) =>
|
||||
request<TerminalCreateResult>('/api/terminal/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}, TERMINAL_CREATE_TIMEOUT_MS),
|
||||
closeTerminalSession: (id: string) =>
|
||||
request<{ ok: boolean }>(`/api/terminal/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
// 会话审计:谁、何时、连了哪台、多久、为什么断开
|
||||
listTerminalAudits: (limit = 100) =>
|
||||
request<{ entries: TerminalAuditEntry[] }>(`/api/terminal/audits?limit=${limit}`),
|
||||
// 常用命令
|
||||
listTerminalCommands: () => request<{ commands: TerminalCommand[] }>('/api/terminal/commands'),
|
||||
createTerminalCommand: (payload: { name: string; command: string }) =>
|
||||
request<TerminalCommand>('/api/terminal/commands', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteTerminalCommand: (id: number) =>
|
||||
request<{ ok: boolean }>(`/api/terminal/commands/${id}`, { method: 'DELETE' }),
|
||||
// 录像审计:列表是 JSON,内容本身是 asciinema v2 文本,单独取
|
||||
listTerminalRecordings: () => request<{ recordings: TerminalRecording[] }>('/api/terminal/recordings'),
|
||||
getTerminalRecording: async (id: string) => {
|
||||
const response = await fetch(withBase(`/api/terminal/recordings/${encodeURIComponent(id)}`), {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError('读取录像失败,可能已被清理', response.status)
|
||||
}
|
||||
return await response.text()
|
||||
},
|
||||
}
|
||||
|
||||
// terminalSocketURL 拼出终端通道地址:按当前协议选择 ws/wss,并带上部署前缀。
|
||||
export function terminalSocketURL(id: string): string {
|
||||
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${scheme}//${window.location.host}${withBase(`/api/terminal/sessions/${encodeURIComponent(id)}/ws`)}`
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ export interface UpdateStatus {
|
||||
|
||||
export interface ApiError {
|
||||
error: string
|
||||
// 稳定错误码(如 terminal_locked),用于前端分支判断
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface DashboardStatus {
|
||||
@@ -187,6 +189,7 @@ export interface DDNSDomainRecord {
|
||||
export interface DDNSConfig {
|
||||
id: number
|
||||
provider: string
|
||||
name?: string
|
||||
root_domain: string
|
||||
record_name: string
|
||||
record_names?: string[]
|
||||
@@ -323,6 +326,7 @@ export interface DiscoveredService {
|
||||
|
||||
export interface DDNSSavePayload {
|
||||
provider?: string
|
||||
name?: string
|
||||
root_domain?: string
|
||||
record_name?: string
|
||||
record_names?: string[]
|
||||
@@ -1160,3 +1164,71 @@ export interface CfAccessStatus {
|
||||
enabled: boolean
|
||||
name?: string
|
||||
}
|
||||
|
||||
// ===== 内置 SSH 终端(/terminal)=====
|
||||
|
||||
export interface TerminalTarget {
|
||||
value: string
|
||||
label: string
|
||||
detail: string
|
||||
ready: boolean
|
||||
disabled_reason?: string
|
||||
}
|
||||
|
||||
export interface TerminalSessionInfo {
|
||||
id: string
|
||||
label: string
|
||||
server_id: number
|
||||
closed: boolean
|
||||
reason?: string
|
||||
created_at: string
|
||||
last_seen: string
|
||||
}
|
||||
|
||||
export interface TerminalCreateResult {
|
||||
session_id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// 终端二次认证状态:unlocked 为 true 时带到期时间
|
||||
export interface TerminalUnlockStatus {
|
||||
unlocked: boolean
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
// 终端解锁结果:票据到期时间(RFC3339)
|
||||
export interface TerminalUnlockResult {
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
// 常用命令:右侧面板保存的快捷命令
|
||||
export interface TerminalCommand {
|
||||
id: number
|
||||
name: string
|
||||
command: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 会话录制文件(asciinema v2),用于「录像审计」列表与回放
|
||||
export interface TerminalRecording {
|
||||
session_id: string
|
||||
size: number
|
||||
started_at: string
|
||||
ended_at: string
|
||||
}
|
||||
|
||||
// 终端会话审计:谁、何时、连了哪台、多久、为什么断开
|
||||
export interface TerminalAuditEntry {
|
||||
id?: number
|
||||
session_id: string
|
||||
server_id: number
|
||||
target: string
|
||||
user: string
|
||||
client_ip: string
|
||||
started_at: string
|
||||
ended_at: string
|
||||
duration_ms: number
|
||||
reason?: string
|
||||
bytes_in: number
|
||||
bytes_out: number
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="settings-field">
|
||||
<div class="settings-field__label configured-secret-label">
|
||||
<div v-if="!hideLabel" class="settings-field__label configured-secret-label">
|
||||
<span>{{ label }}</span>
|
||||
<n-tag v-if="configured" size="small" type="success" :bordered="false">{{ CONFIGURED_SECRET_TAG }}</n-tag>
|
||||
</div>
|
||||
@@ -24,10 +24,12 @@ withDefaults(
|
||||
modelValue: string
|
||||
configured?: boolean
|
||||
placeholder?: string
|
||||
hideLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
configured: false,
|
||||
placeholder: '',
|
||||
hideLabel: false,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 名称用于区分同一服务商下的多条配置(如「家庭」「公司」),留空则列表显示服务商名 -->
|
||||
<div class="provider-form__field">
|
||||
<label class="provider-form__label">名称</label>
|
||||
<n-input v-model:value="form.name" size="small" placeholder="可选,例如:家庭 / 公司 / 主域名" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.provider === 'dnspod'" class="provider-form__field">
|
||||
<label class="provider-form__label">Token ID</label>
|
||||
<n-input v-model:value="form.api_token_id" placeholder="DNSPod ID" size="small" />
|
||||
@@ -66,12 +72,28 @@
|
||||
<span>IPv6</span>
|
||||
<n-switch v-model:value="form.ipv6_enabled" size="small" />
|
||||
</div>
|
||||
<!-- 自定义 IP:填了就用填的地址,不填仍自动检测本机公网 IP -->
|
||||
<n-button size="tiny" quaternary :type="customIPOpen || hasCustomIP ? 'primary' : 'default'" @click="customIPOpen = !customIPOpen">
|
||||
{{ customIPOpen ? '收起自定义 IP' : '自定义 IP' }}
|
||||
</n-button>
|
||||
<div class="settings-switch">
|
||||
<span>启用</span>
|
||||
<n-switch v-model:value="form.enabled" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="customIPOpen">
|
||||
<div class="provider-form__field">
|
||||
<label class="provider-form__label">自定义 IPv4(可选)</label>
|
||||
<n-input v-model:value="form.custom_ipv4" size="small" placeholder="留空则自动检测本机公网 IPv4" />
|
||||
</div>
|
||||
<div class="provider-form__field">
|
||||
<label class="provider-form__label">自定义 IPv6(可选)</label>
|
||||
<n-input v-model:value="form.custom_ipv6" size="small" placeholder="留空则自动检测本机公网 IPv6" />
|
||||
</div>
|
||||
<p class="provider-form__hint">填写后更新解析记录会直接使用该 IP(如 VPS 的公网 IP),不再检测本机出口 IP。</p>
|
||||
</template>
|
||||
|
||||
<div class="provider-form__actions">
|
||||
<n-button size="small" quaternary @click="emit('cancel')">取消</n-button>
|
||||
<n-button size="small" :loading="testing" @click="emit('test')">测试连接</n-button>
|
||||
@@ -81,7 +103,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { NButton, NInput, NSelect, NSwitch, NTag } from 'naive-ui'
|
||||
import { CONFIGURED_SECRET_PLACEHOLDER, CONFIGURED_SECRET_TAG } from '../constants/secretField'
|
||||
import aliyunIcon from '../assets/brand/dns/aliyun.png'
|
||||
@@ -93,6 +115,7 @@ import type { DDNSConfig } from '../api/types'
|
||||
|
||||
export type ProviderForm = {
|
||||
provider: string
|
||||
name: string
|
||||
api_token: string
|
||||
api_token_id: string
|
||||
api_secret: string
|
||||
@@ -124,6 +147,10 @@ const providerOptions = [
|
||||
{ label: '火山引擎 DNS', value: 'volcengine' },
|
||||
]
|
||||
|
||||
// 编辑已有配置时若已设置自定义 IP,默认展开输入区,避免用户以为没保存
|
||||
const customIPOpen = ref(!!(props.form.custom_ipv4.trim() || props.form.custom_ipv6.trim()))
|
||||
const hasCustomIP = computed(() => !!(props.form.custom_ipv4.trim() || props.form.custom_ipv6.trim()))
|
||||
|
||||
const providerMap: Record<string, { label: string; icon: string }> = {
|
||||
cloudflare: { label: 'Cloudflare', icon: cloudflareIcon },
|
||||
dnspod: { label: 'DNSPod', icon: dnspodIcon },
|
||||
@@ -247,6 +274,13 @@ function providerIcon(v: string) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.provider-form__hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--havline-text-muted);
|
||||
}
|
||||
|
||||
.provider-secret-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
PersonOutline,
|
||||
SunnyOutline,
|
||||
SwapHorizontalOutline,
|
||||
TerminalOutline,
|
||||
HardwareChipOutline,
|
||||
ShieldCheckmarkOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
@@ -203,6 +204,7 @@ const menuDefs = [
|
||||
{ label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline },
|
||||
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
|
||||
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
|
||||
{ label: 'SSH 终端', key: 'terminal', icon: TerminalOutline },
|
||||
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
|
||||
{ label: '系统设置', key: 'settings', icon: CogOutline },
|
||||
{ label: '关于项目', key: 'about', icon: InformationCircleOutline },
|
||||
@@ -218,6 +220,8 @@ const menuOptions = computed<MenuOption[]>(() =>
|
||||
|
||||
const activeKey = computed(() => {
|
||||
const name = route.name
|
||||
// 终端审计是终端页的子页面:菜单里只有「SSH 终端」,这里让它保持高亮
|
||||
if (name === 'terminal-audits') return 'terminal'
|
||||
return typeof name === 'string' ? name : 'dashboard'
|
||||
})
|
||||
|
||||
|
||||
@@ -89,6 +89,20 @@ const router = createRouter({
|
||||
component: LogsView,
|
||||
meta: { title: '日志中心', description: '查看系统、访问与 Nginx 错误日志' },
|
||||
},
|
||||
{
|
||||
path: 'terminal',
|
||||
name: 'terminal',
|
||||
// 懒加载:xterm 体积不小,只有真正打开终端时才下载
|
||||
component: () => import('../views/TerminalView.vue'),
|
||||
meta: { title: 'SSH 终端', description: '在浏览器里直接连接公网服务端的 SSH 终端' },
|
||||
},
|
||||
{
|
||||
path: 'terminal/audits',
|
||||
name: 'terminal-audits',
|
||||
// 终端页面里的「审计记录」入口,同样懒加载
|
||||
component: () => import('../views/TerminalAuditView.vue'),
|
||||
meta: { title: '终端审计', description: '查看 SSH 会话审计记录' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
|
||||
@@ -55,6 +55,20 @@ export function formatDate(iso?: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
/** 会话/任务时长:按量级切换单位,短时长不能被「0 分钟」吞掉 */
|
||||
export function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '-'
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`
|
||||
const totalSeconds = Math.round(ms / 1000)
|
||||
if (totalSeconds < 60) return `${totalSeconds} 秒`
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
if (minutes < 60) return seconds ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时`
|
||||
}
|
||||
|
||||
/** Normalize log timestamps to YYYY-MM-DD HH:mm:ss */
|
||||
export function formatLogTime(value?: string): string {
|
||||
if (!value) return '-'
|
||||
|
||||
+873
-89
File diff suppressed because it is too large
Load Diff
@@ -274,7 +274,11 @@
|
||||
<img :src="providerIcon(cfg.provider)" :alt="providerLabel(cfg.provider)" />
|
||||
</span>
|
||||
<div class="verify-option__body">
|
||||
<div class="verify-option__title">{{ providerLabel(cfg.provider) }}</div>
|
||||
<!-- 有自定义名称时优先显示名称,服务商名退到后面,多个同服务商任务才分得清 -->
|
||||
<div class="verify-option__title">
|
||||
{{ cfg.name || providerLabel(cfg.provider) }}
|
||||
<span v-if="cfg.name" class="verify-option__provider">{{ providerLabel(cfg.provider) }}</span>
|
||||
</div>
|
||||
<div class="verify-option__sub">上次同步 {{ formatRelativeTime(cfg.last_updated_at) || '从未' }}</div>
|
||||
<div class="verify-option__zones">{{ ddnsTaskSummary(cfg) }}</div>
|
||||
</div>
|
||||
@@ -1804,6 +1808,13 @@ html.dark .apply-log-box {
|
||||
color: var(--havline-text);
|
||||
}
|
||||
|
||||
.verify-option__provider {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--havline-text-muted);
|
||||
}
|
||||
|
||||
.verify-option__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
<template>
|
||||
<PageHeader :title="`配置 · ${tunnel?.name || 'Cloudflare 隧道'}`" description="配置隧道、查看副本指标与已发布路由">
|
||||
<template #actions>
|
||||
<!-- 进程操作提到页头:原来在页面最底部的「运行控制」卡,要滚动到底才能点到 -->
|
||||
<n-button
|
||||
v-if="runtimeState === 'connected'"
|
||||
type="warning"
|
||||
:loading="actionRunning === '停止'"
|
||||
:disabled="actionRunning !== null"
|
||||
@click="runAction('停止')"
|
||||
>停止</n-button>
|
||||
<n-button
|
||||
v-else
|
||||
type="success"
|
||||
:loading="actionRunning === '启动'"
|
||||
:disabled="actionRunning !== null"
|
||||
@click="runAction('启动')"
|
||||
>启动</n-button>
|
||||
<n-button :loading="actionRunning === '重启'" :disabled="actionRunning !== null" @click="runAction('重启')">重启</n-button>
|
||||
<n-button @click="router.push('/cloudflare')">
|
||||
<template #icon><n-icon :component="ArrowBackOutline" /></template>
|
||||
返回列表
|
||||
@@ -22,21 +38,32 @@
|
||||
<template #icon><n-icon :component="PulseOutline" /></template>
|
||||
<template #value><StatusBadge :kind="statusKind(runtimeState)" :text="statusText(runtimeState)" /></template>
|
||||
</StatCard>
|
||||
<StatCard label="运行时间" :value="status?.running ? '运行中' : '未运行'" :sub="status?.metrics_port ? `metrics :${status.metrics_port}` : '无 metrics 端口'" tone="brand" value-small>
|
||||
<template #icon><n-icon :component="TimeOutline" /></template>
|
||||
<!-- 原来叫「运行时间」但值是进程状态,名实不符:改为「进程」,并换成服务端图标 -->
|
||||
<StatCard label="进程" :value="status?.running ? '运行中' : '未运行'" :sub="status?.metrics_port ? `metrics :${status.metrics_port}` : '无 metrics 端口'" tone="brand" value-small>
|
||||
<template #icon><n-icon :component="ServerOutline" /></template>
|
||||
</StatCard>
|
||||
</div>
|
||||
|
||||
<!-- 原来这张卡只有一个按钮/一条提示,等于白占一屏;改成直接展示关键字段,编辑按钮放卡头 -->
|
||||
<HavlineCard class="section-card" title="基础配置" subtitle="手工隧道可在这里修改;托管隧道由反向代理规则派生">
|
||||
<template #header>
|
||||
<n-button v-if="!tunnel.managed" size="small" @click="basicVisible = true">编辑基础配置</n-button>
|
||||
</template>
|
||||
<div class="section-body">
|
||||
<n-alert v-if="tunnel.managed" type="info" :bordered="false">
|
||||
这是自动托管隧道。暴露域名与回源请到
|
||||
<div class="config-facts">
|
||||
<div class="config-fact"><label>名称</label><span>{{ tunnel.name || '—' }}</span></div>
|
||||
<div class="config-fact"><label>模式</label><span>{{ tunnel.managed ? '自动托管' : '手工隧道' }}</span></div>
|
||||
<div class="config-fact"><label>隧道 ID</label><span class="mono">{{ tunnel.tunnel_id || '—' }}</span></div>
|
||||
<div class="config-fact"><label>完整主机名</label><span class="mono">{{ tunnel.hostnames?.join('、') || '—' }}</span></div>
|
||||
<div class="config-fact"><label>回源服务</label><span class="mono">{{ tunnel.origin_service || '—' }}</span></div>
|
||||
<div class="config-fact"><label>随 Havline 启动</label><span>{{ tunnel.auto_start ? '是' : '否' }}</span></div>
|
||||
</div>
|
||||
<n-alert v-if="tunnel.managed" type="info" :bordered="false" class="config-managed-hint">
|
||||
自动托管隧道:暴露域名与回源在
|
||||
<router-link to="/proxies">反向代理</router-link>
|
||||
的 Cloudflare 出口中维护。
|
||||
</n-alert>
|
||||
<div v-else class="config-basic-action">
|
||||
<n-button type="primary" @click="basicVisible = true">编辑基础配置</n-button>
|
||||
</div>
|
||||
<p v-else class="config-hint">名称、主机名与回源服务可在右上角「编辑基础配置」里调整;路由在下方「路由」卡片维护。</p>
|
||||
</div>
|
||||
</HavlineCard>
|
||||
|
||||
@@ -114,17 +141,13 @@
|
||||
<span class="mono">{{ route.service }}</span>
|
||||
<n-tag size="small" :bordered="false" round>{{ route.source === 'rule' ? '反向代理规则' : 'config.yml' }}</n-tag>
|
||||
<div class="routes-table__actions">
|
||||
<n-button size="tiny" quaternary :disabled="tunnel.managed || index === 0" @click="moveRoute(index, -1)">上移</n-button>
|
||||
<n-button size="tiny" quaternary :disabled="tunnel.managed || index === (routeData?.routes.length ?? 1) - 1" @click="moveRoute(index, 1)">下移</n-button>
|
||||
<n-button size="tiny" quaternary :disabled="tunnel.managed" @click="copyRoute(route)">复制</n-button>
|
||||
<n-button size="tiny" quaternary @click="testRoute(route)">测试</n-button>
|
||||
<!-- 原来一行平铺 6 个按钮,把这列撑到 360px;改为主操作「编辑」+「更多」 -->
|
||||
<n-button size="tiny" :disabled="tunnel.managed" @click="openEditRoute(route, index)">编辑</n-button>
|
||||
<n-popconfirm :disabled="tunnel.managed" @positive-click="deleteRoute(index)">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" type="error" quaternary :disabled="tunnel.managed">删除</n-button>
|
||||
</template>
|
||||
删除路由 {{ route.hostname }}?保存后会同步到 Cloudflare。
|
||||
</n-popconfirm>
|
||||
<n-dropdown trigger="click" :options="routeActionOptions(index)" @select="handleRouteAction($event, route, index)">
|
||||
<n-button size="tiny" quaternary circle title="更多操作">
|
||||
<template #icon><n-icon :component="EllipsisHorizontalOutline" /></template>
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,15 +167,6 @@
|
||||
</div>
|
||||
</HavlineCard>
|
||||
|
||||
<HavlineCard class="section-card" title="运行控制" subtitle="进程操作会立即作用在本机 cloudflared">
|
||||
<div class="section-body">
|
||||
<n-space>
|
||||
<n-button type="success" :disabled="runtimeState === 'connected'" @click="runAction('启动')">启动</n-button>
|
||||
<n-button type="warning" :disabled="runtimeState !== 'connected'" @click="runAction('停止')">停止</n-button>
|
||||
<n-button @click="runAction('重启')">重启</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</HavlineCard>
|
||||
</template>
|
||||
|
||||
<n-modal v-model:show="basicVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" title="基础配置">
|
||||
@@ -238,8 +252,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NAlert, NButton, NForm, NFormItem, NIcon, NInput, NModal, NPopconfirm, NSelect, NSpace, NSwitch, NTag, useMessage } from 'naive-ui'
|
||||
import { AddOutline, ArrowBackOutline, CloudOutline, GitNetworkOutline, LayersOutline, PulseOutline, ServerOutline, TimeOutline } from '@vicons/ionicons5'
|
||||
import { NAlert, NButton, NDropdown, NForm, NFormItem, NIcon, NInput, NModal, NSelect, NSwitch, NTag, useDialog, useMessage, type DropdownOption } from 'naive-ui'
|
||||
import { AddOutline, ArrowBackOutline, CloudOutline, EllipsisHorizontalOutline, GitNetworkOutline, LayersOutline, PulseOutline, ServerOutline } from '@vicons/ionicons5'
|
||||
import { api, asList } from '../api/client'
|
||||
import type { CfRouteView, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfTunnelRoutes, CfZone } from '../api/types'
|
||||
import HavlineCard from '../components/HavlineCard.vue'
|
||||
@@ -253,12 +267,15 @@ import type { StatusKind } from '../utils/status'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const tunnel = ref<CfTunnel | null>(null)
|
||||
const status = ref<CfRuntimeStatus | null>(null)
|
||||
const routeData = ref<CfTunnelRoutes | null>(null)
|
||||
const zones = ref<CfZone[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
// 进程操作(启动 / 停止 / 重启)进行中:按钮显示 loading 并互相禁用
|
||||
const actionRunning = ref<'启动' | '停止' | '重启' | null>(null)
|
||||
const basicVisible = ref(false)
|
||||
const routeVisible = ref(false)
|
||||
const routeSaving = ref(false)
|
||||
@@ -435,6 +452,40 @@ async function saveRoute() {
|
||||
}
|
||||
}
|
||||
|
||||
// 路由行的「更多」菜单:托管隧道与首/末行按情况禁用,避免点了才发现不可用
|
||||
function routeActionOptions(index: number): DropdownOption[] {
|
||||
const locked = tunnel.value?.managed ?? false
|
||||
const isLast = index === ((routeData.value?.routes.length ?? 1) - 1)
|
||||
return [
|
||||
{ label: '上移', key: 'up', disabled: locked || index === 0 },
|
||||
{ label: '下移', key: 'down', disabled: locked || isLast },
|
||||
{ type: 'divider', key: 'd1' },
|
||||
{ label: '复制主机名与服务', key: 'copy', disabled: locked },
|
||||
{ label: '测试连通性', key: 'test' },
|
||||
{ type: 'divider', key: 'd2' },
|
||||
{ label: '删除路由', key: 'delete', disabled: locked },
|
||||
]
|
||||
}
|
||||
|
||||
function handleRouteAction(key: string | number, route: CfRouteView, index: number) {
|
||||
if (key === 'up') return void moveRoute(index, -1)
|
||||
if (key === 'down') return void moveRoute(index, 1)
|
||||
if (key === 'copy') return void copyRoute(route)
|
||||
if (key === 'test') return void testRoute(route)
|
||||
if (key === 'delete') confirmDeleteRoute(route, index)
|
||||
}
|
||||
|
||||
// 删除走对话框二次确认,替代原来包在行内的 n-popconfirm(菜单里无法嵌套 Popconfirm)
|
||||
function confirmDeleteRoute(route: CfRouteView, index: number) {
|
||||
dialog.warning({
|
||||
title: '删除路由',
|
||||
content: `删除路由 ${route.hostname}?保存后会同步到 Cloudflare。`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => deleteRoute(index),
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteRoute(index: number) {
|
||||
if (!tunnel.value || !routeData.value || tunnel.value.managed) return
|
||||
const next = routeData.value.routes
|
||||
@@ -515,6 +566,7 @@ function manualHostname() {
|
||||
|
||||
async function runAction(action: '启动' | '停止' | '重启') {
|
||||
if (!tunnel.value) return
|
||||
actionRunning.value = action
|
||||
try {
|
||||
if (action === '启动') await api.startCfTunnel(tunnel.value.id)
|
||||
if (action === '停止') await api.stopCfTunnel(tunnel.value.id)
|
||||
@@ -523,6 +575,8 @@ async function runAction(action: '启动' | '停止' | '重启') {
|
||||
await load()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : `${action}失败`)
|
||||
} finally {
|
||||
actionRunning.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,7 +608,17 @@ onMounted(load)
|
||||
margin-bottom: var(--havline-space-4);
|
||||
}
|
||||
.section-card + .section-card { margin-top: var(--havline-space-4); }
|
||||
.config-basic-action { display: flex; justify-content: flex-end; }
|
||||
/* 基础配置:把原来空着的一张卡换成关键字段网格 */
|
||||
.config-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--havline-space-3) var(--havline-space-4);
|
||||
}
|
||||
.config-fact { display: grid; gap: 4px; min-width: 0; }
|
||||
.config-fact label { font-size: 12px; color: var(--havline-text-secondary); }
|
||||
.config-fact span { font-size: 13px; color: var(--havline-text); overflow-wrap: anywhere; }
|
||||
.config-managed-hint { margin-top: var(--havline-space-3); }
|
||||
.config-hint { margin: var(--havline-space-3) 0 0; font-size: 12px; color: var(--havline-text-muted); }
|
||||
.topology-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -691,10 +755,11 @@ onMounted(load)
|
||||
.routes-table__head,
|
||||
.routes-table__row {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(180px, 1fr) 90px minmax(220px, 1.2fr) 130px 360px;
|
||||
/* 操作列从 360px 收到 120px(编辑 + 更多),窄屏不必再横向滚动才能点到操作 */
|
||||
grid-template-columns: 56px minmax(180px, 1fr) 90px minmax(200px, 1.2fr) 130px 120px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 1080px;
|
||||
min-width: 860px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.routes-table__actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||
|
||||
@@ -1,27 +1,78 @@
|
||||
<template>
|
||||
<PageHeader title="Cloudflare 隧道" description="通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘,无需公网 IP">
|
||||
<template #actions>
|
||||
<n-button :loading="preflightLoading" @click="openPreflight">接入向导</n-button>
|
||||
<!-- 应用配置是常用入口,放在外面直接可见;接入向导与反向代理入口留在「更多」里 -->
|
||||
<n-button :loading="settingsLoading" @click="openSettings">应用配置</n-button>
|
||||
<n-button type="primary" @click="openCreate">新建隧道</n-button>
|
||||
<n-button type="primary" @click="openCreate">
|
||||
<template #icon><n-icon :component="AddOutline" /></template>
|
||||
新建隧道
|
||||
</n-button>
|
||||
<n-dropdown trigger="click" :options="headerMenuOptions" @select="handleHeaderMenu">
|
||||
<n-button>
|
||||
<template #icon><n-icon :component="EllipsisHorizontalOutline" /></template>
|
||||
更多
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="cf-global" :class="{ 'cf-global--off': !appSettings.enabled }">
|
||||
<div class="cf-global__text">
|
||||
<strong>全局托管</strong>
|
||||
<span>{{ appSettings.enabled ? '已启用:开启时和下次启动 Havline 时都会启动全部隧道' : '已停用:所有隧道已停止,应用重启后也不会自动拉起' }}</span>
|
||||
<!-- 状态 Hero:合并原来的「全局托管条 + 二进制状态提示」,首屏直接给出就绪情况与隧道汇总 -->
|
||||
<section class="cf-hero" aria-label="Cloudflare 隧道概览">
|
||||
<div class="cf-hero__top">
|
||||
<div class="cf-hero__status">
|
||||
<n-icon
|
||||
:component="CloudOutline"
|
||||
class="cf-hero__icon"
|
||||
:class="binaryVersion ? 'cf-hero__icon--ok' : 'cf-hero__icon--warn'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="cf-hero__headline">
|
||||
<div class="cf-hero__title">
|
||||
<span class="cf-hero__title-label">cloudflared</span>
|
||||
<StatusBadge v-if="binaryVersion" kind="success" :text="displayBinaryVersion(binaryVersion)" />
|
||||
<StatusBadge v-else kind="warning" text="未安装" />
|
||||
</div>
|
||||
<div class="cf-hero__sub">
|
||||
{{ binaryVersion ? '已就绪,可用于创建与运行隧道' : '尚未安装:点右侧「下载安装」,装好后再创建隧道' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cf-hero__actions">
|
||||
<n-button v-if="!binaryVersion" size="small" type="primary" :loading="settingsLoading" @click="openSettings">下载安装</n-button>
|
||||
<div class="cf-hero__managed">
|
||||
<span class="cf-hero__managed-label">全局托管</span>
|
||||
<n-switch :value="appSettings.enabled" :loading="globalLoading" @update:value="toggleGlobal" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<n-switch :value="appSettings.enabled" :loading="globalLoading" @update:value="toggleGlobal" />
|
||||
</div>
|
||||
|
||||
<n-alert v-if="binaryVersion" type="info" :bordered="false" class="cf-alert">
|
||||
已安装:<span class="mono">{{ displayBinaryVersion(binaryVersion) }}</span>
|
||||
</n-alert>
|
||||
<n-alert v-else type="warning" :bordered="false" class="cf-alert">
|
||||
尚未安装 cloudflared。请先点右上角「cloudflared 二进制」下载,再创建隧道。
|
||||
</n-alert>
|
||||
<n-alert type="info" :bordered="false" class="cf-alert">
|
||||
<div class="cf-hero__metrics">
|
||||
<div class="cf-hero__metric">
|
||||
<span class="cf-hero__metric-label">隧道</span>
|
||||
<span class="cf-hero__metric-value">{{ tunnels.length }}</span>
|
||||
<span class="cf-hero__metric-sub">{{ managedTunnelCount ? `含 ${managedTunnelCount} 条自动托管` : '暂无自动托管' }}</span>
|
||||
</div>
|
||||
<div class="cf-hero__metric">
|
||||
<span class="cf-hero__metric-label">已连接</span>
|
||||
<span class="cf-hero__metric-value">{{ connectedTunnelCount }}</span>
|
||||
<span class="cf-hero__metric-sub">cloudflared 已连到边缘</span>
|
||||
</div>
|
||||
<div class="cf-hero__metric">
|
||||
<span class="cf-hero__metric-label">待修复</span>
|
||||
<span class="cf-hero__metric-value">{{ driftTunnelCount }}</span>
|
||||
<span class="cf-hero__metric-sub">{{ driftTunnelCount ? '与 Cloudflare 云端不一致' : '云端与本地一致' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cf-hero__foot" :class="{ 'cf-hero__foot--off': !appSettings.enabled }">
|
||||
{{ appSettings.enabled
|
||||
? '全局托管已启用:开启时和下次启动 Havline 时都会启动全部隧道。'
|
||||
: '全局托管已停用:所有隧道已停止,应用重启后也不会自动拉起。' }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 免费套餐说明属于一次性认知信息,允许关闭(本地记住) -->
|
||||
<n-alert v-if="showFreeTierHint" type="info" :bordered="false" class="cf-alert" closable @close="dismissFreeTierHint">
|
||||
Cloudflare Tunnel 适合 NAS 面板、相册和小型 Web 服务。免费套餐不适合持续大文件或视频分发,请不要把它当作大流量媒体出口。
|
||||
</n-alert>
|
||||
|
||||
@@ -157,33 +208,89 @@
|
||||
<pre class="cf-pre">{{ detailConfig || '暂无配置' }}</pre>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="logs" tab="日志">
|
||||
<div class="cf-code-toolbar"><n-button size="tiny" :loading="detailLoading" @click="refreshDetail">刷新</n-button></div>
|
||||
<div class="cf-code-toolbar cf-code-toolbar--split">
|
||||
<!-- 默认不自动刷新:原来每 3 秒无条件替换内容,读日志时会被打断 -->
|
||||
<div class="cf-switch-inline">
|
||||
<n-switch v-model:value="logAutoRefresh" size="small" />
|
||||
<!-- 文字也可点:不用去精确点那个小开关(switch 自身已绑定,这里只负责文字区) -->
|
||||
<span @click="logAutoRefresh = !logAutoRefresh">自动刷新(每 3 秒)</span>
|
||||
</div>
|
||||
<n-button size="tiny" :loading="detailLoading" @click="refreshDetail">刷新</n-button>
|
||||
</div>
|
||||
<p class="cf-hint cf-hint--tight">需要盯实时输出时再打开自动刷新;默认关闭,避免阅读时内容被替换。</p>
|
||||
<pre class="cf-pre">{{ detailLogs || '暂无日志' }}</pre>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="diagnostics" tab="漂移与诊断">
|
||||
<div class="cf-code-toolbar">
|
||||
<n-button size="tiny" :loading="driftLoading" @click="loadDrift">检测漂移</n-button>
|
||||
<n-button size="tiny" :disabled="!detailDrift?.has_drift" :loading="driftSyncing" @click="syncDrift">以本地覆盖</n-button>
|
||||
<n-button size="tiny" :loading="diagnosticsLoading" @click="loadDiagnostics">运行诊断</n-button>
|
||||
<n-button size="tiny" :loading="accessLoading" @click="loadAccessStatus">检查 Access</n-button>
|
||||
<n-button size="tiny" :loading="credentialLoading" @click="refreshCredentials">刷新凭据</n-button>
|
||||
<!-- 原来 5 个按钮平铺 + 3 块结果混在一起,改为按「漂移 / 连通性 / Access / 凭据」分组 -->
|
||||
<n-tab-pane name="diagnostics" tab="诊断">
|
||||
<div class="cf-diag-groups">
|
||||
<section class="cf-diag-group">
|
||||
<div class="cf-diag-group__head">
|
||||
<div class="cf-diag-group__text">
|
||||
<div class="cf-diag-group__title">云端漂移</div>
|
||||
<p class="cf-diag-group__desc">对比本地 config.yml 与 Cloudflare 上实际生效的路由,不一致时可用本地覆盖。</p>
|
||||
</div>
|
||||
<div class="cf-diag-group__actions">
|
||||
<n-button size="tiny" :loading="driftLoading" @click="loadDrift">检测漂移</n-button>
|
||||
<n-button size="tiny" type="primary" :disabled="!detailDrift?.has_drift" :loading="driftSyncing" @click="syncDrift">以本地覆盖</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<n-alert v-if="detailDrift" :type="detailDrift.has_drift ? 'warning' : 'success'" :bordered="false" class="cf-mode-alert">
|
||||
<template v-if="detailDrift.has_drift">
|
||||
发现 {{ detailDrift.items.length }} 项云端漂移:
|
||||
<div v-for="item in detailDrift.items" :key="item" class="mono">{{ item }}</div>
|
||||
</template>
|
||||
<template v-else>本地规则与 Cloudflare 云端配置一致。</template>
|
||||
</n-alert>
|
||||
<p v-else class="cf-diag-group__empty">还没有检测结果,点「检测漂移」开始。</p>
|
||||
</section>
|
||||
|
||||
<section class="cf-diag-group">
|
||||
<div class="cf-diag-group__head">
|
||||
<div class="cf-diag-group__text">
|
||||
<div class="cf-diag-group__title">连通性诊断</div>
|
||||
<p class="cf-diag-group__desc">检查本机进程、metrics 端口,以及到 Cloudflare 边缘的连通情况。</p>
|
||||
</div>
|
||||
<div class="cf-diag-group__actions">
|
||||
<n-button size="tiny" :loading="diagnosticsLoading" @click="loadDiagnostics">运行诊断</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detailDiagnostics" class="cf-diagnostics">
|
||||
<div v-for="check in detailDiagnostics.checks" :key="check.name" class="cf-diagnostic-row">
|
||||
<n-tag size="small" :type="diagnosticTag(check.status)" :bordered="false">{{ check.name }}</n-tag>
|
||||
<span>{{ check.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="cf-diag-group__empty">还没有诊断结果。</p>
|
||||
</section>
|
||||
|
||||
<section class="cf-diag-group">
|
||||
<div class="cf-diag-group__head">
|
||||
<div class="cf-diag-group__text">
|
||||
<div class="cf-diag-group__title">Access 保护(只读)</div>
|
||||
<p class="cf-diag-group__desc">检测首个主机名是否被 Cloudflare Access 覆盖;Havline 只读检测,不代配策略。</p>
|
||||
</div>
|
||||
<div class="cf-diag-group__actions">
|
||||
<n-button size="tiny" :loading="accessLoading" @click="loadAccessStatus">检查 Access</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<n-alert v-if="detailAccess" type="info" :bordered="false" class="cf-mode-alert">
|
||||
{{ detailAccess.enabled ? `已启用 Cloudflare Access${detailAccess.name ? ':' + detailAccess.name : ''}` : '未检测到 Cloudflare Access 保护。' }}
|
||||
</n-alert>
|
||||
<p v-else class="cf-diag-group__empty">还没有检测结果。</p>
|
||||
</section>
|
||||
|
||||
<section class="cf-diag-group">
|
||||
<div class="cf-diag-group__head">
|
||||
<div class="cf-diag-group__text">
|
||||
<div class="cf-diag-group__title">凭据维护</div>
|
||||
<p class="cf-diag-group__desc">重新从 Cloudflare 下载该隧道的凭据文件;凭据失效或换账号后使用。</p>
|
||||
</div>
|
||||
<div class="cf-diag-group__actions">
|
||||
<n-button size="tiny" :loading="credentialLoading" @click="refreshCredentials">刷新凭据</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<n-alert v-if="detailDrift" :type="detailDrift.has_drift ? 'warning' : 'success'" :bordered="false" class="cf-mode-alert">
|
||||
<template v-if="detailDrift.has_drift">
|
||||
发现 {{ detailDrift.items.length }} 项云端漂移:
|
||||
<div v-for="item in detailDrift.items" :key="item" class="mono">{{ item }}</div>
|
||||
</template>
|
||||
<template v-else>本地规则与 Cloudflare 云端配置一致。</template>
|
||||
</n-alert>
|
||||
<div v-if="detailDiagnostics" class="cf-diagnostics">
|
||||
<div v-for="check in detailDiagnostics.checks" :key="check.name" class="cf-diagnostic-row">
|
||||
<n-tag size="small" :type="diagnosticTag(check.status)" :bordered="false">{{ check.name }}</n-tag>
|
||||
<span>{{ check.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<n-alert v-if="detailAccess" type="info" :bordered="false" class="cf-mode-alert">
|
||||
{{ detailAccess.enabled ? `已启用 Cloudflare Access${detailAccess.name ? ':' + detailAccess.name : ''}` : '未检测到 Cloudflare Access 保护。Havline 只做只读检测,不代配策略。' }}
|
||||
</n-alert>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
@@ -195,59 +302,69 @@
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="settingsVisible" preset="card" :style="{ width: 'min(640px, 96vw)' }" title="Cloudflare 应用配置">
|
||||
<n-modal v-model:show="settingsVisible" preset="card" :style="{ width: 'min(680px, 96vw)' }" title="Cloudflare 应用配置">
|
||||
<div class="cf-modal__body">
|
||||
<n-divider title-placement="left">程序安装</n-divider>
|
||||
<div class="cf-binary">
|
||||
<div><label>当前版本</label><strong class="mono">{{ displayBinaryVersion(binaryVersion) || '未安装' }}</strong></div>
|
||||
<div><label>检测到的最新版本</label><strong class="mono">{{ latestBinaryVersion || '未检查' }}</strong></div>
|
||||
<div><label>目标文件</label><strong class="mono">cloudflared</strong></div>
|
||||
</div>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="下载镜像">
|
||||
<n-select v-model:value="settingsForm.mirror" :options="mirrorOptions" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="cf-code-toolbar">
|
||||
<n-button size="small" :loading="binaryChecking" @click="checkLatestBinary">检查更新</n-button>
|
||||
<n-button size="small" :loading="binaryLoading" @click="downloadBinary">下载并安装</n-button>
|
||||
<n-button size="small" :loading="binaryRollbackLoading" @click="rollbackBinary">回滚上一版</n-button>
|
||||
</div>
|
||||
<p class="cf-hint">下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。</p>
|
||||
<!-- 三段原是纵向平铺的长表单,改为 Tab:每段各自一屏,不用来回滚动 -->
|
||||
<n-tabs v-model:value="settingsTab" type="line" animated>
|
||||
<n-tab-pane name="binary" tab="程序安装">
|
||||
<div class="cf-binary">
|
||||
<div><label>当前版本</label><strong class="mono">{{ displayBinaryVersion(binaryVersion) || '未安装' }}</strong></div>
|
||||
<div><label>检测到的最新版本</label><strong class="mono">{{ latestBinaryVersion || '未检查' }}</strong></div>
|
||||
<div><label>目标文件</label><strong class="mono">cloudflared</strong></div>
|
||||
</div>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="下载镜像">
|
||||
<n-select v-model:value="settingsForm.mirror" :options="mirrorOptions" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="cf-code-toolbar">
|
||||
<n-button size="small" :loading="binaryChecking" @click="checkLatestBinary">检查更新</n-button>
|
||||
<n-button size="small" type="primary" :loading="binaryLoading" @click="downloadBinary">下载并安装</n-button>
|
||||
<n-button size="small" :loading="binaryRollbackLoading" @click="rollbackBinary">回滚上一版</n-button>
|
||||
</div>
|
||||
<p class="cf-hint">
|
||||
下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像;<strong>镜像选择要点右下角「保存配置」才会长期记住</strong>,「下载并安装」会立即使用当前选择。
|
||||
</p>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-divider title-placement="left">Cloudflare 账号</n-divider>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="Account ID">
|
||||
<n-input v-model:value="settingsForm.account_id" :placeholder="appSettings.account_id || '例如 b590ee0f16701d36f2819b0e3c938e81'" />
|
||||
</n-form-item>
|
||||
<n-form-item label="API Token">
|
||||
<n-input
|
||||
v-model:value="settingsForm.api_token"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="appSettings.api_token_configured ? `已配置 ${appSettings.api_token_masked},留空保留` : 'cfat_...'"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="cf-code-toolbar">
|
||||
<n-button size="small" :loading="tokenTesting" @click="testToken">测试连接</n-button>
|
||||
<n-button size="small" tag="a" href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" rel="noopener">获取 API Token</n-button>
|
||||
</div>
|
||||
<n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
|
||||
{{ tokenTestResult }}
|
||||
</n-alert>
|
||||
<p class="cf-hint">Havline 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限,且 Zone Resources 要包含所有要发布的根域名。</p>
|
||||
<n-tab-pane name="account" tab="Cloudflare 账号">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="Account ID">
|
||||
<n-input v-model:value="settingsForm.account_id" :placeholder="appSettings.account_id || '例如 b590ee0f16701d36f2819b0e3c938e81'" />
|
||||
</n-form-item>
|
||||
<n-form-item label="API Token">
|
||||
<n-input
|
||||
v-model:value="settingsForm.api_token"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="appSettings.api_token_configured ? `已配置 ${appSettings.api_token_masked},留空保留` : 'cfat_...'"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="cf-code-toolbar">
|
||||
<n-button size="small" :loading="tokenTesting" @click="testToken">测试连接</n-button>
|
||||
<n-button size="small" tag="a" href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" rel="noopener">获取 API Token</n-button>
|
||||
</div>
|
||||
<n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
|
||||
{{ tokenTestResult }}
|
||||
</n-alert>
|
||||
<p class="cf-hint">Havline 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限,且 Zone Resources 要包含所有要发布的根域名。</p>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-divider title-placement="left">默认网络设置</n-divider>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="传输协议"><n-select v-model:value="settingsForm.network.transport_protocol" :options="protocolOptions" /></n-form-item>
|
||||
<n-form-item label="边缘地址族"><n-select v-model:value="settingsForm.network.edge_ip_version" :options="edgeOptions" /></n-form-item>
|
||||
<n-form-item label="HA 连接数"><n-input-number v-model:value="settingsForm.network.ha_connections" :min="1" :max="4" /></n-form-item>
|
||||
<n-form-item label="代理模式"><n-select v-model:value="settingsForm.network.proxy_mode" :options="proxyOptions" /></n-form-item>
|
||||
</n-form>
|
||||
<n-tab-pane name="network" tab="网络设置">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="传输协议"><n-select v-model:value="settingsForm.network.transport_protocol" :options="protocolOptions" /></n-form-item>
|
||||
<n-form-item label="边缘地址族"><n-select v-model:value="settingsForm.network.edge_ip_version" :options="edgeOptions" /></n-form-item>
|
||||
<n-form-item label="HA 连接数"><n-input-number v-model:value="settingsForm.network.ha_connections" :min="1" :max="4" /></n-form-item>
|
||||
<n-form-item label="代理模式"><n-select v-model:value="settingsForm.network.proxy_mode" :options="proxyOptions" /></n-form-item>
|
||||
</n-form>
|
||||
<p class="cf-hint">这些是新建隧道的默认值:保存后只影响之后创建的隧道,已存在的隧道不受影响。</p>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="cf-modal__footer">
|
||||
<span class="cf-modal__foot-hint">程序安装是即时操作,其余两项点「保存配置」生效</span>
|
||||
<n-button @click="settingsVisible = false">取消</n-button>
|
||||
<n-button type="primary" :loading="settingsSaving" @click="saveSettings">保存配置</n-button>
|
||||
</div>
|
||||
@@ -277,7 +394,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NAlert, NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
|
||||
import { NAlert, NButton, NDivider, NDropdown, NForm, NFormItem, NIcon, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage, type DropdownOption } from 'naive-ui'
|
||||
import { AddOutline, CloudOutline, EllipsisHorizontalOutline } from '@vicons/ionicons5'
|
||||
import { api, asList } from '../api/client'
|
||||
import type { CfAccessStatus, CfAppSettings, CfAppSettingsInput, CfDiagnosticsResult, CfDriftResult, CfMirror, CfPreflightResult, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfZone } from '../api/types'
|
||||
import HavlineCard from '../components/HavlineCard.vue'
|
||||
@@ -305,9 +423,37 @@ const globalLoading = ref(false)
|
||||
const settingsVisible = ref(false)
|
||||
const settingsLoading = ref(false)
|
||||
const settingsSaving = ref(false)
|
||||
// 应用配置弹窗的当前 Tab:binary / account / network
|
||||
const settingsTab = ref<string>('account')
|
||||
const tokenTesting = ref(false)
|
||||
const tokenTestResult = ref('')
|
||||
const tokenTestOk = ref(false)
|
||||
|
||||
// Header「更多」:应用配置已移出为常驻按钮,这里保留接入向导与反向代理入口
|
||||
const headerMenuOptions = computed<DropdownOption[]>(() => [
|
||||
{ label: '接入向导(环境预检)', key: 'preflight', disabled: preflightLoading.value },
|
||||
{ type: 'divider', key: 'divider' },
|
||||
{ label: '前往反向代理页(托管隧道来源)', key: 'proxies' },
|
||||
])
|
||||
|
||||
function handleHeaderMenu(key: string | number) {
|
||||
if (key === 'preflight') return void openPreflight()
|
||||
if (key === 'proxies') return void router.push({ name: 'proxies' })
|
||||
}
|
||||
|
||||
// 免费套餐说明可关闭,并记在本地:这是固定不变的认知信息,不该每次都占首屏
|
||||
const FREE_TIER_HINT_KEY = 'havline.cloudflare.freeTierHintDismissed'
|
||||
const showFreeTierHint = ref(readFreeTierHintVisible())
|
||||
|
||||
function readFreeTierHintVisible() {
|
||||
try { return localStorage.getItem(FREE_TIER_HINT_KEY) !== '1' } catch { return true }
|
||||
}
|
||||
|
||||
function dismissFreeTierHint() {
|
||||
showFreeTierHint.value = false
|
||||
try { localStorage.setItem(FREE_TIER_HINT_KEY, '1') } catch { /* 隐私模式下忽略写入失败 */ }
|
||||
}
|
||||
|
||||
const appSettings = ref<CfAppSettings>({
|
||||
account_id: '',
|
||||
enabled: true,
|
||||
@@ -321,6 +467,9 @@ const appSettings = ref<CfAppSettings>({
|
||||
const settingsForm = ref<CfAppSettingsInput>({ account_id: '', token: '', api_token: '', mirror: 'official', network: emptyNetwork() })
|
||||
const editorVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
// 详情弹窗的日志自动刷新:默认关闭,由日志页签里的开关显式打开
|
||||
const logAutoRefresh = ref(false)
|
||||
const logAutoRefreshEnabled = computed(() => detailVisible.value && logAutoRefresh.value)
|
||||
const detailTunnel = ref<CfTunnel | null>(null)
|
||||
const detailStatus = ref<CfRuntimeStatus | null>(null)
|
||||
const detailConfig = ref('')
|
||||
@@ -426,6 +575,11 @@ function runtimeStatus(item: CfTunnel): CfRuntimeStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Hero 汇总:隧道总数里的已连接条数、自动托管条数,以及检测到云端漂移的条数
|
||||
const connectedTunnelCount = computed(() => tunnels.value.filter((item) => runtimeStatus(item).status === 'connected').length)
|
||||
const managedTunnelCount = computed(() => tunnels.value.filter((item) => item.managed).length)
|
||||
const driftTunnelCount = computed(() => Object.values(driftByTunnel.value).filter((item) => item.has_drift).length)
|
||||
|
||||
function replicaCount(item: CfTunnel): number {
|
||||
return runtimeStatus(item).connections || 0
|
||||
}
|
||||
@@ -615,6 +769,8 @@ async function openDetail(item: CfTunnel) {
|
||||
detailDrift.value = null
|
||||
detailDiagnostics.value = null
|
||||
detailAccess.value = null
|
||||
// 每次打开都回到「不自动刷新」:否则上次开过会让人以为是默认行为
|
||||
logAutoRefresh.value = false
|
||||
detailVisible.value = true
|
||||
await refreshDetail()
|
||||
}
|
||||
@@ -730,6 +886,8 @@ async function copyConfig() {
|
||||
|
||||
async function openSettings() {
|
||||
preflightVisible.value = false
|
||||
// 未安装 cloudflared 时先落到「程序安装」,否则默认停在最常改的「Cloudflare 账号」
|
||||
settingsTab.value = binaryVersion.value ? 'account' : 'binary'
|
||||
settingsVisible.value = true
|
||||
await loadAppSettings()
|
||||
}
|
||||
@@ -862,24 +1020,56 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
useVisibilityPolling(() => load(), 5000)
|
||||
useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
|
||||
// 只有用户显式打开「自动刷新」才轮询详情,默认不打扰阅读
|
||||
useVisibilityPolling(refreshDetail, 3000, { enabled: logAutoRefreshEnabled })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cf-global {
|
||||
/* 状态 Hero:cloudflared 就绪情况 + 全局托管 + 隧道汇总(原来是两条并列的常驻区块) */
|
||||
.cf-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--havline-space-4);
|
||||
padding: var(--havline-space-4) var(--havline-space-5);
|
||||
margin-bottom: var(--havline-space-4);
|
||||
background: var(--havline-surface);
|
||||
border: 1px solid var(--havline-border);
|
||||
border-radius: var(--havline-radius);
|
||||
box-shadow: var(--havline-shadow);
|
||||
}
|
||||
.cf-hero__top { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--havline-space-4); }
|
||||
.cf-hero__status { display: flex; align-items: center; gap: var(--havline-space-3); min-width: 0; }
|
||||
.cf-hero__icon { font-size: 26px; flex-shrink: 0; }
|
||||
.cf-hero__icon--ok { color: var(--havline-success); }
|
||||
.cf-hero__icon--warn { color: var(--havline-warning); }
|
||||
.cf-hero__headline { min-width: 0; }
|
||||
.cf-hero__title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-2); }
|
||||
.cf-hero__title-label { font-size: 13px; font-weight: 600; color: var(--havline-text-secondary); }
|
||||
.cf-hero__sub { margin-top: 6px; font-size: 13px; color: var(--havline-text-secondary); }
|
||||
.cf-hero__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-3); }
|
||||
.cf-hero__managed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--havline-space-4);
|
||||
padding: 12px var(--havline-space-5);
|
||||
margin-bottom: var(--havline-space-4);
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--havline-border);
|
||||
border-radius: var(--havline-radius-sm);
|
||||
background: var(--havline-surface);
|
||||
background: var(--havline-bg);
|
||||
}
|
||||
.cf-global--off { border-color: var(--havline-warning); background: var(--havline-bg); }
|
||||
.cf-global__text { display: grid; gap: 4px; font-size: 13px; }
|
||||
.cf-global__text span { color: var(--havline-text-secondary); }
|
||||
.cf-hero__managed-label { font-size: 13px; font-weight: 600; color: var(--havline-text); }
|
||||
.cf-hero__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--havline-space-4);
|
||||
padding-top: var(--havline-space-4);
|
||||
border-top: 1px solid var(--havline-border);
|
||||
}
|
||||
.cf-hero__metric { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.cf-hero__metric-label { font-size: 12px; color: var(--havline-text-muted); }
|
||||
.cf-hero__metric-value { font-size: 22px; font-weight: 700; line-height: 1.15; color: var(--havline-text); letter-spacing: -0.02em; }
|
||||
.cf-hero__metric-sub { font-size: 12px; color: var(--havline-text-muted); }
|
||||
.cf-hero__foot { font-size: 12.5px; color: var(--havline-text-secondary); }
|
||||
.cf-hero__foot--off { color: var(--havline-warning); }
|
||||
.cf-alert { margin-bottom: var(--havline-space-4); }
|
||||
.cf-empty { padding: 24px 0; color: var(--havline-text-muted); }
|
||||
.cf-table { width: 100%; overflow-x: auto; }
|
||||
@@ -916,6 +1106,7 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
|
||||
.cf-meta { font-size: 12px; color: var(--havline-text-secondary); }
|
||||
.cf-modal__body { max-height: 68vh; overflow: auto; padding-right: 4px; }
|
||||
.cf-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.cf-modal__foot-hint { margin-right: auto; align-self: center; font-size: 12px; color: var(--havline-text-muted); }
|
||||
.cf-mode-alert { margin-bottom: 4px; }
|
||||
.cf-preflight { display: grid; gap: 10px; }
|
||||
.cf-preflight__row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); }
|
||||
@@ -950,6 +1141,21 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
|
||||
.cf-binary > div { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-bg); }
|
||||
.cf-binary label { font-size: 12px; color: var(--havline-text-muted); }
|
||||
.cf-hint { font-size: 12px; color: var(--havline-text-muted); }
|
||||
.cf-hint--tight { margin: 0 0 8px; }
|
||||
.cf-code-toolbar--split { justify-content: space-between; align-items: center; }
|
||||
.cf-switch-inline { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--havline-text-secondary); }
|
||||
.cf-switch-inline span { cursor: pointer; }
|
||||
/* 详情弹窗「诊断」页:按检查项分组,每组标题 + 说明 + 动作 + 结果 */
|
||||
.cf-diag-groups { display: grid; gap: var(--havline-space-3); }
|
||||
.cf-diag-group { padding: 12px 14px; border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-surface); }
|
||||
.cf-diag-group__head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--havline-space-3); }
|
||||
.cf-diag-group__text { min-width: 0; }
|
||||
.cf-diag-group__title { font-size: 13px; font-weight: 600; color: var(--havline-text); }
|
||||
.cf-diag-group__desc { margin: 4px 0 0; font-size: 12px; line-height: 1.5; color: var(--havline-text-muted); }
|
||||
.cf-diag-group__actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; flex-shrink: 0; }
|
||||
.cf-diag-group__empty { margin: 10px 0 0; font-size: 12px; color: var(--havline-text-muted); }
|
||||
.cf-diag-group .cf-mode-alert,
|
||||
.cf-diag-group .cf-diagnostics { margin-top: 10px; }
|
||||
.cf-pre {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
@@ -962,4 +1168,9 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.cf-hero__top { flex-direction: column; align-items: flex-start; }
|
||||
.cf-hero__actions { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -131,7 +131,8 @@
|
||||
<span class="provider-logo">
|
||||
<img :src="providerIcon(providerDraft.provider)" :alt="providerLabel(providerDraft.provider)" />
|
||||
</span>
|
||||
<span class="task-item__name">{{ providerLabel(providerDraft.provider) }}</span>
|
||||
<span class="task-item__name">{{ providerDraft.name || providerLabel(providerDraft.provider) }}</span>
|
||||
<span v-if="providerDraft.name" class="task-item__provider">{{ providerLabel(providerDraft.provider) }}</span>
|
||||
</div>
|
||||
<span class="task-item__draft-tag">待添加记录</span>
|
||||
</div>
|
||||
@@ -166,7 +167,8 @@
|
||||
<span class="provider-logo">
|
||||
<img :src="providerIcon(cfg.provider)" :alt="providerLabel(cfg.provider)" />
|
||||
</span>
|
||||
<span class="task-item__name">{{ providerLabel(cfg.provider) }}</span>
|
||||
<span class="task-item__name">{{ cfg.name || providerLabel(cfg.provider) }}</span>
|
||||
<span v-if="cfg.name" class="task-item__provider">{{ providerLabel(cfg.provider) }}</span>
|
||||
</div>
|
||||
<StatusBadge :value="taskStatusKind(cfg)" :text="taskStatusText(cfg)" />
|
||||
</div>
|
||||
@@ -207,7 +209,7 @@
|
||||
<img :src="providerIcon(selectedTask.provider)" :alt="providerLabel(selectedTask.provider)" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="task-detail__title">{{ providerLabel(selectedTask.provider) }}</h3>
|
||||
<h3 class="task-detail__title">{{ selectedTask.name || providerLabel(selectedTask.provider) }}</h3>
|
||||
<div class="task-detail__meta">
|
||||
<StatusBadge
|
||||
v-if="!isDraftSelected"
|
||||
@@ -567,6 +569,7 @@ const providerMap: Record<string, { label: string; icon: string }> = {
|
||||
|
||||
const taskForm = reactive<ProviderForm>({
|
||||
provider: 'cloudflare',
|
||||
name: '',
|
||||
api_token: '',
|
||||
api_token_id: '',
|
||||
api_secret: '',
|
||||
@@ -593,12 +596,15 @@ const draftAsConfig = computed((): DDNSConfig | null => {
|
||||
return {
|
||||
id: -1,
|
||||
provider: providerDraft.value.provider,
|
||||
name: providerDraft.value.name,
|
||||
root_domain: '',
|
||||
record_name: '@',
|
||||
ipv4_enabled: providerDraft.value.ipv4_enabled,
|
||||
ipv6_enabled: providerDraft.value.ipv6_enabled,
|
||||
enabled: providerDraft.value.enabled,
|
||||
has_token: !!providerDraft.value.api_token,
|
||||
custom_ipv4: providerDraft.value.custom_ipv4,
|
||||
custom_ipv6: providerDraft.value.custom_ipv6,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -643,7 +649,7 @@ const detailError = computed(() => {
|
||||
}
|
||||
}
|
||||
if (cfg.enabled && cfg.last_status === 'error' && cfg.last_error) {
|
||||
return { title: `${providerLabel(cfg.provider)} 同步失败`, text: cfg.last_error }
|
||||
return { title: `${cfg.name || providerLabel(cfg.provider)} 同步失败`, text: cfg.last_error }
|
||||
}
|
||||
return null
|
||||
})
|
||||
@@ -764,6 +770,7 @@ function normalizeRecordDomain(input: string, rootDomain?: string): string {
|
||||
function resetTaskForm() {
|
||||
Object.assign(taskForm, {
|
||||
provider: 'cloudflare',
|
||||
name: '',
|
||||
api_token: '',
|
||||
api_token_id: '',
|
||||
api_secret: '',
|
||||
@@ -811,6 +818,7 @@ function startEditTask(cfg: DDNSConfig) {
|
||||
selectedKey.value = String(cfg.id)
|
||||
Object.assign(taskForm, {
|
||||
provider: cfg.provider || 'cloudflare',
|
||||
name: cfg.name ?? '',
|
||||
api_token: '',
|
||||
api_token_id: '',
|
||||
api_secret: '',
|
||||
@@ -858,6 +866,7 @@ async function saveCustomIP() {
|
||||
try {
|
||||
const updated = await api.updateDDNS(task.id, {
|
||||
provider: task.provider,
|
||||
name: task.name ?? '',
|
||||
domains: domainsOf(task),
|
||||
enabled: task.enabled,
|
||||
ipv4_enabled: task.ipv4_enabled,
|
||||
@@ -949,6 +958,7 @@ async function saveTask() {
|
||||
try {
|
||||
const updated = await api.updateDDNS(editingId, {
|
||||
provider: taskForm.provider,
|
||||
name: taskForm.name,
|
||||
domains: domainsOf(existing),
|
||||
ipv4_enabled: taskForm.ipv4_enabled,
|
||||
ipv6_enabled: taskForm.ipv6_enabled,
|
||||
@@ -1045,6 +1055,7 @@ async function saveRecord() {
|
||||
const domains = [...new Set([...domainsOf(task), domain])]
|
||||
const updated = await api.updateDDNS(task.id, {
|
||||
provider: task.provider,
|
||||
name: task.name ?? '',
|
||||
domains,
|
||||
enabled: task.enabled,
|
||||
ipv4_enabled: task.ipv4_enabled,
|
||||
@@ -1060,6 +1071,7 @@ async function saveRecord() {
|
||||
const unique = [...new Set(domains)]
|
||||
const updated = await api.updateDDNS(task.id, {
|
||||
provider: task.provider,
|
||||
name: task.name ?? '',
|
||||
domains: unique,
|
||||
enabled: task.enabled,
|
||||
ipv4_enabled: task.ipv4_enabled,
|
||||
@@ -1087,6 +1099,7 @@ async function patchTaskFlags(
|
||||
try {
|
||||
const updated = await api.updateDDNS(row.id, {
|
||||
provider: row.provider,
|
||||
name: row.name ?? '',
|
||||
domains: domainsOf(row),
|
||||
enabled: flags.enabled ?? row.enabled,
|
||||
ipv4_enabled: flags.ipv4_enabled ?? row.ipv4_enabled,
|
||||
@@ -1177,7 +1190,7 @@ async function saveInterval() {
|
||||
function confirmDelete(row: DDNSConfig) {
|
||||
dialog.warning({
|
||||
title: '删除 DDNS 配置',
|
||||
content: `确定删除 ${providerLabel(row.provider)} 的 DDNS 配置吗?`,
|
||||
content: `确定删除 ${row.name || providerLabel(row.provider)} 的 DDNS 配置吗?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
@@ -1203,6 +1216,7 @@ function confirmDeleteRecord(row: DDNSConfig, record: DDNSDomainRecord) {
|
||||
} else {
|
||||
const updated = await api.updateDDNS(row.id, {
|
||||
provider: row.provider,
|
||||
name: row.name ?? '',
|
||||
domains,
|
||||
enabled: row.enabled,
|
||||
ipv4_enabled: row.ipv4_enabled,
|
||||
@@ -1368,12 +1382,15 @@ onMounted(init)
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: var(--havline-space-3);
|
||||
max-height: 720px;
|
||||
/* 高度跟着视口走:编辑态表单较长,避免出现「表单被截断」或必须整页滚动才能点到保存 */
|
||||
max-height: max(420px, calc(100vh - 340px));
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
width: 100%;
|
||||
/* 列表是 max-height 的纵向 flex:不禁止收缩的话,编辑态表单会被压扁并裁掉底部 */
|
||||
flex-shrink: 0;
|
||||
text-align: left;
|
||||
border: 1px solid var(--havline-border);
|
||||
border-radius: 12px;
|
||||
@@ -1445,6 +1462,13 @@ onMounted(init)
|
||||
color: var(--havline-text);
|
||||
}
|
||||
|
||||
/* 设了名称时,旁边补一个灰字服务商名,避免只看名字不知道用的是哪家 DNS */
|
||||
.task-item__provider {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--havline-text-muted);
|
||||
}
|
||||
|
||||
.task-item__row {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
+525
-181
@@ -2,52 +2,90 @@
|
||||
<PageHeader title="FRP 穿透" description="管理 NAS 到公网 VPS 的 frpc 客户端连接">
|
||||
<template #actions>
|
||||
<n-button @click="showExport = true; loadExport()">查看脱敏配置</n-button>
|
||||
<n-button @click="openRuntimeModal">
|
||||
<template #icon><n-icon :component="SettingsOutline" /></template>
|
||||
应用配置
|
||||
</n-button>
|
||||
<n-button @click="showGuide = true">
|
||||
<template #icon><n-icon :component="DocumentTextOutline" /></template>
|
||||
使用说明
|
||||
</n-button>
|
||||
<n-button v-if="status.status !== 'running'" type="primary" :loading="actionLoading === 'start'" @click="startClient">
|
||||
<template #icon><n-icon :component="PlayOutline" /></template>
|
||||
启动 frpc
|
||||
</n-button>
|
||||
<n-button v-else type="error" ghost :loading="actionLoading === 'stop'" @click="stopClient">
|
||||
<template #icon><n-icon :component="StopOutline" /></template>
|
||||
停止 frpc
|
||||
</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<LoadError v-if="loadError" :message="loadError" @retry="load" />
|
||||
|
||||
<template v-else>
|
||||
<section class="stats-row" aria-label="frpc 状态">
|
||||
<div class="stat-card">
|
||||
<n-icon :component="GitNetworkOutline" class="stat-card__icon" :class="`stat-card__icon--${status.status}`" aria-hidden="true" />
|
||||
<div class="stat-card__value stat-card__value--sm"><StatusBadge :kind="statusKind" :text="statusLabel" /></div>
|
||||
<div class="stat-card__label">客户端状态</div>
|
||||
<div class="stat-card__sub">frpc 进程状态</div>
|
||||
<!-- FRP 状态 Hero:全局 frpc 状态 + 关键指标 + 应用配置与启停入口;当前服务端信息在规则区标题展示 -->
|
||||
<section class="frp-hero" aria-label="FRP 运行状态">
|
||||
<div class="frp-hero__top">
|
||||
<div class="frp-hero__status">
|
||||
<n-icon
|
||||
:component="GitNetworkOutline"
|
||||
class="frp-hero__icon"
|
||||
:class="`frp-hero__icon--${status.status}`"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="frp-hero__headline">
|
||||
<div class="frp-hero__title">
|
||||
<span class="frp-hero__title-label">全局 frpc</span>
|
||||
<StatusBadge :kind="statusKind" :text="statusLabel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="frp-hero__actions">
|
||||
<n-button @click="openRuntimeModal">
|
||||
<template #icon><n-icon :component="SettingsOutline" /></template>
|
||||
应用配置
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="status.status === 'running'"
|
||||
type="error"
|
||||
ghost
|
||||
:loading="actionLoading === 'stop'"
|
||||
@click="stopClient"
|
||||
>
|
||||
<template #icon><n-icon :component="StopOutline" /></template>
|
||||
停止 frpc
|
||||
</n-button>
|
||||
<n-button
|
||||
v-else
|
||||
type="primary"
|
||||
:loading="actionLoading === 'start'"
|
||||
:disabled="servers.length === 0"
|
||||
@click="startClient"
|
||||
>
|
||||
<template #icon><n-icon :component="PlayOutline" /></template>
|
||||
启动 frpc
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<n-icon :component="LayersOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
|
||||
<div class="stat-card__value">{{ status.enabled_proxies }}</div>
|
||||
<div class="stat-card__label">启用规则</div>
|
||||
<div class="stat-card__sub">已启用的穿透规则数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<n-icon :component="TerminalOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
|
||||
<div class="stat-card__value stat-card__value--sm">{{ status.frpc_version || '未检测' }}</div>
|
||||
<div class="stat-card__label">frpc 版本</div>
|
||||
<div class="stat-card__sub">本机运行的 frpc</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<n-icon :component="TimeOutline" class="stat-card__icon stat-card__icon--amber" aria-hidden="true" />
|
||||
<div class="stat-card__value stat-card__value--sm">{{ formatDate(status.last_reloaded_at) || '尚未应用' }}</div>
|
||||
<div class="stat-card__label">最近应用</div>
|
||||
<div class="stat-card__sub">配置最近一次生效</div>
|
||||
|
||||
<div class="frp-hero__metrics">
|
||||
<div class="frp-hero__metric">
|
||||
<span class="frp-hero__metric-label">公网服务端</span>
|
||||
<span class="frp-hero__metric-value">{{ servers.length }}</span>
|
||||
<span class="frp-hero__metric-sub">{{ serverSummaryText }}</span>
|
||||
</div>
|
||||
<div class="frp-hero__metric">
|
||||
<span class="frp-hero__metric-label">启用规则</span>
|
||||
<span class="frp-hero__metric-value">{{ status.enabled_proxies }}</span>
|
||||
<span class="frp-hero__metric-sub">当前服务端 {{ selectedServerEnabledCount }} 条</span>
|
||||
</div>
|
||||
<div class="frp-hero__metric">
|
||||
<span class="frp-hero__metric-label">
|
||||
<n-icon :component="PulseOutline" class="frp-hero__metric-icon" :class="healthIconClass" />
|
||||
健康检查
|
||||
</span>
|
||||
<span class="frp-hero__metric-value">{{ healthValue }}</span>
|
||||
<span class="frp-hero__metric-sub">{{ healthSub }}</span>
|
||||
</div>
|
||||
<div class="frp-hero__metric">
|
||||
<span class="frp-hero__metric-label">frpc 版本</span>
|
||||
<span class="frp-hero__metric-value">{{ status.frpc_version || '未检测' }}</span>
|
||||
<span class="frp-hero__metric-sub">本机运行的 frpc</span>
|
||||
</div>
|
||||
<div class="frp-hero__metric">
|
||||
<span class="frp-hero__metric-label">最近应用</span>
|
||||
<span class="frp-hero__metric-value">{{ formatDate(status.last_reloaded_at) || '尚未应用' }}</span>
|
||||
<span class="frp-hero__metric-sub">配置最近一次生效</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -102,10 +140,16 @@
|
||||
<div class="rule-pane__head">
|
||||
<div class="rule-pane__intro">
|
||||
<h3 class="rule-pane__title">穿透规则</h3>
|
||||
<span class="rule-pane__sub">{{ selectedServer?.name || '未选择服务端' }} · {{ serverProxies.length }} 条</span>
|
||||
<span class="rule-pane__sub">
|
||||
<template v-if="selectedServer">
|
||||
{{ selectedServer.name }} ·
|
||||
<span class="rule-pane__state" :class="`rule-pane__state--${selectedServer.boot_status}`">{{ serverBootLabel(selectedServer) }}</span>
|
||||
<template v-if="selectedServerHealthText"> · {{ selectedServerHealthText }}</template>
|
||||
</template>
|
||||
<template v-else>未选择服务端</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="rule-pane__actions">
|
||||
<n-button size="small" @click="showExport = true; loadExport()">查看脱敏配置</n-button>
|
||||
<router-link v-if="selectedServerId" :to="{ name: 'frp-agent', query: { server: String(selectedServerId) } }">
|
||||
<n-button size="small" quaternary>管理该服务端</n-button>
|
||||
</router-link>
|
||||
@@ -125,8 +169,8 @@
|
||||
:columns="columns"
|
||||
:data="serverProxies"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
:scroll-x="1890"
|
||||
size="small"
|
||||
:scroll-x="1520"
|
||||
:row-key="(row: FrpProxy) => row.id"
|
||||
/>
|
||||
</div>
|
||||
@@ -158,39 +202,72 @@
|
||||
<div class="proxy-modal__scroll">
|
||||
<n-form label-placement="top" class="proxy-modal__pane" @submit.prevent="saveProxy">
|
||||
<div v-show="proxyFormTab === 'basic'" class="frp-proxy-modal__basic">
|
||||
<div class="form-grid">
|
||||
<n-form-item label="公网服务端" required><n-select v-model:value="proxyForm.server_id" :options="proxyServerOptions" placeholder="选择 FRP 服务端" /></n-form-item>
|
||||
<n-form-item label="规则名称" required><n-input v-model:value="proxyForm.name" placeholder="例如:nas-web" /></n-form-item>
|
||||
<n-form-item label="类型" required><n-select v-model:value="proxyForm.type" :options="proxyTypeOptions" /></n-form-item>
|
||||
<n-form-item v-if="!proxyForm.plugin_type" label="内网地址" required><n-input v-model:value="proxyForm.local_ip" placeholder="127.0.0.1" /></n-form-item>
|
||||
<n-form-item v-if="!proxyForm.plugin_type" label="内网端口" required><n-input-number v-model:value="proxyForm.local_port" :min="1" :max="65535" /></n-form-item>
|
||||
<n-form-item v-if="['tcp', 'udp'].includes(proxyForm.type)" label="公网远程端口" required><n-input-number v-model:value="proxyForm.remote_port" :min="1" :max="65535" /></n-form-item>
|
||||
<n-form-item v-if="['http', 'https', 'tcpmux'].includes(proxyForm.type)" label="自定义域名"><n-input v-model:value="proxyForm.domains_text" type="textarea" :rows="2" placeholder="nas.example.com app.example.com" /></n-form-item>
|
||||
</div>
|
||||
<section class="proxy-form-section" v-if="['http', 'https'].includes(proxyForm.type)">
|
||||
<h4>HTTP / HTTPS 配置</h4>
|
||||
<div class="form-grid">
|
||||
<n-form-item label="Subdomain"><n-input v-model:value="proxyForm.options.subdomain" placeholder="例如:nas" /></n-form-item>
|
||||
<n-form-item label="公网反代选项" :show-feedback="true">
|
||||
<span class="form-hint">WebSocket / 强制跳转 / IP 白名单 / Basic Auth 已移至「公网反代」页逐条规则的「部署设置」中配置;此处填写的值会保留。已填的 IP 白名单:{{ (proxyForm.options.allow_ips || []).join(', ') || '无' }}</span>
|
||||
</n-form-item>
|
||||
<n-form-item label="上游 Host 重写"><n-input v-model:value="proxyForm.host_header_rewrite" placeholder="例如:nas.example.com" /></n-form-item>
|
||||
<n-form-item label="路径匹配(Locations)"><n-input v-model:value="proxyForm.locations_text" placeholder="多个路径用逗号或换行分隔" /></n-form-item>
|
||||
<n-form-item label="按 HTTP 用户路由"><n-input v-model:value="proxyForm.options.route_by_http_user" placeholder="可选" /></n-form-item>
|
||||
<n-form-item label="HTTP 基础认证用户名"><n-input v-model:value="proxyForm.options.http_user" placeholder="可选" /></n-form-item>
|
||||
<n-form-item label="HTTP 基础认证密码"><n-input v-model:value="proxyForm.options.http_password" type="password" show-password-on="click" placeholder="可选" /></n-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="proxy-form-section" v-if="proxyForm.type === 'tcpmux'">
|
||||
<h4>TCPMUX 配置</h4>
|
||||
<div class="form-grid"><n-form-item label="多路复用器" required><n-select v-model:value="proxyForm.options.multiplexer" :options="[{ label: 'HTTP CONNECT', value: 'httpconnect' }]" /></n-form-item><n-form-item label="按 HTTP 用户路由"><n-input v-model:value="proxyForm.options.route_by_http_user" placeholder="可选" /></n-form-item></div>
|
||||
</section>
|
||||
<section class="proxy-form-section" v-if="['stcp', 'sudp', 'xtcp'].includes(proxyForm.type)">
|
||||
<h4>访问控制</h4>
|
||||
<div class="form-grid"><n-form-item label="访问密钥" required><n-input v-model:value="proxyForm.options.secret_key" type="password" show-password-on="click" placeholder="与访客端保持一致" /></n-form-item><n-form-item label="允许的用户"><n-input v-model:value="proxyForm.allow_users_text" placeholder="多个用户用逗号分隔,* 表示全部" /></n-form-item></div>
|
||||
</section>
|
||||
<n-form-item label="备注"><n-input v-model:value="proxyForm.remark" placeholder="可选" /></n-form-item>
|
||||
<div class="switch-row"><span>启用规则</span><n-switch v-model:value="proxyForm.enabled" /></div>
|
||||
<!-- ① 归属与类型:决定规则写到哪台服务端 -->
|
||||
<section class="proxy-form-section proxy-form-section--first">
|
||||
<h4>归属与类型</h4>
|
||||
<div class="form-grid">
|
||||
<n-form-item label="公网服务端" required><n-select v-model:value="proxyForm.server_id" :options="proxyServerOptions" placeholder="选择 FRP 服务端" /></n-form-item>
|
||||
<n-form-item label="规则名称" required><n-input v-model:value="proxyForm.name" placeholder="例如:nas-web" /></n-form-item>
|
||||
<n-form-item label="类型" required><n-select v-model:value="proxyForm.type" :options="proxyTypeOptions" /></n-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ② 内网目标:启用插件后由插件接管,地址与端口不生效 -->
|
||||
<section class="proxy-form-section">
|
||||
<h4>内网目标</h4>
|
||||
<div class="form-grid">
|
||||
<template v-if="!proxyForm.plugin_type">
|
||||
<n-form-item label="内网地址" required><n-input v-model:value="proxyForm.local_ip" placeholder="127.0.0.1" /></n-form-item>
|
||||
<n-form-item label="内网端口" required><n-input-number v-model:value="proxyForm.local_port" :min="1" :max="65535" /></n-form-item>
|
||||
</template>
|
||||
<n-form-item v-else label="内网目标" class="form-item--full">
|
||||
<span class="form-hint">已启用插件,内网地址与端口不生效;插件参数在「高级配置 → 插件与 NAT 穿透」中设置。</span>
|
||||
</n-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ③ 公网入口:按类型只显示需要的字段 -->
|
||||
<section class="proxy-form-section">
|
||||
<h4>公网入口</h4>
|
||||
<div class="form-grid">
|
||||
<template v-if="['tcp', 'udp'].includes(proxyForm.type)">
|
||||
<n-form-item label="公网远程端口" required><n-input-number v-model:value="proxyForm.remote_port" :min="1" :max="65535" /></n-form-item>
|
||||
</template>
|
||||
<template v-else-if="['http', 'https', 'tcpmux'].includes(proxyForm.type)">
|
||||
<n-form-item label="自定义域名" class="form-item--full"><n-input v-model:value="proxyForm.domains_text" type="textarea" :rows="2" placeholder="nas.example.com app.example.com" /></n-form-item>
|
||||
<n-form-item label="Subdomain"><n-input v-model:value="proxyForm.options.subdomain" placeholder="例如:nas" /></n-form-item>
|
||||
<n-form-item v-if="['http', 'https'].includes(proxyForm.type)" label="上游 Host 重写"><n-input v-model:value="proxyForm.host_header_rewrite" placeholder="例如:nas.example.com" /></n-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-form-item label="访问密钥" required><n-input v-model:value="proxyForm.options.secret_key" type="password" show-password-on="click" placeholder="与访客端保持一致" /></n-form-item>
|
||||
<n-form-item label="允许的用户"><n-input v-model:value="proxyForm.allow_users_text" placeholder="多个用户用逗号分隔,* 表示全部" /></n-form-item>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="['http', 'https'].includes(proxyForm.type)" class="proxy-form-note">
|
||||
<n-icon :component="InformationCircleOutline" class="proxy-form-note__icon" />
|
||||
<span>WebSocket / 强制跳转 / IP 白名单 / Basic Auth 在「公网反代」页的「部署设置」里配置;此处已填写的值会保留。已填的 IP 白名单:{{ (proxyForm.options.allow_ips || []).join(', ') || '无' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ④ TCPMUX 专属:多路复用器 -->
|
||||
<section v-if="proxyForm.type === 'tcpmux'" class="proxy-form-section">
|
||||
<h4>TCPMUX 配置</h4>
|
||||
<div class="form-grid">
|
||||
<n-form-item label="多路复用器" required><n-select v-model:value="proxyForm.options.multiplexer" :options="[{ label: 'HTTP CONNECT', value: 'httpconnect' }]" /></n-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ⑤ 备注与启用 -->
|
||||
<section class="proxy-form-section">
|
||||
<div class="form-grid">
|
||||
<n-form-item label="备注" class="form-item--full"><n-input v-model:value="proxyForm.remark" placeholder="可选" /></n-form-item>
|
||||
</div>
|
||||
<div class="switch-row proxy-switch-row">
|
||||
<n-switch v-model:value="proxyForm.enabled" />
|
||||
<span>启用规则</span>
|
||||
<span class="form-hint">停用后该规则不会写入 frpc 配置</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-show="proxyFormTab === 'advanced'" class="frp-proxy-modal__advanced">
|
||||
<n-collapse class="advanced-collapse proxy-advanced-collapse">
|
||||
@@ -210,18 +287,31 @@
|
||||
<n-collapse-item title="负载均衡与请求头" name="proxy-advanced">
|
||||
<div class="form-grid"><n-form-item label="负载均衡组"><n-input v-model:value="proxyForm.options.load_balancer.group" placeholder="同组规则共享流量" /></n-form-item><n-form-item label="负载均衡组密钥"><n-input v-model:value="proxyForm.options.load_balancer.group_key" type="password" show-password-on="click" /></n-form-item><n-form-item label="元数据"><n-input v-model:value="proxyForm.metadatas_text" type="textarea" :rows="2" placeholder="每行 key=value" /></n-form-item><n-form-item label="请求头(set)"><n-input v-model:value="proxyForm.request_headers_text" type="textarea" :rows="2" placeholder="每行 key=value" /></n-form-item><n-form-item label="响应头(set)"><n-input v-model:value="proxyForm.response_headers_text" type="textarea" :rows="2" placeholder="每行 key=value" /></n-form-item></div>
|
||||
</n-collapse-item>
|
||||
<!-- HTTP 低频项:路径匹配、按用户路由与基础认证从基础页下沉到这里 -->
|
||||
<n-collapse-item v-if="['http', 'https', 'tcpmux'].includes(proxyForm.type)" title="HTTP 匹配与鉴权" name="proxy-http">
|
||||
<div class="form-grid">
|
||||
<n-form-item v-if="['http', 'https'].includes(proxyForm.type)" label="路径匹配(Locations)"><n-input v-model:value="proxyForm.locations_text" placeholder="多个路径用逗号或换行分隔" /></n-form-item>
|
||||
<n-form-item label="按 HTTP 用户路由"><n-input v-model:value="proxyForm.options.route_by_http_user" placeholder="可选" /></n-form-item>
|
||||
<n-form-item v-if="['http', 'https'].includes(proxyForm.type)" label="HTTP 基础认证用户名"><n-input v-model:value="proxyForm.options.http_user" placeholder="可选" /></n-form-item>
|
||||
<n-form-item v-if="['http', 'https'].includes(proxyForm.type)" label="HTTP 基础认证密码"><n-input v-model:value="proxyForm.options.http_password" type="password" show-password-on="click" placeholder="可选" /></n-form-item>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
<n-collapse-item title="插件与 NAT 穿透" name="proxy-plugin">
|
||||
<div class="form-grid"><n-form-item label="插件类型"><n-select v-model:value="proxyForm.plugin_type" clearable :options="pluginTypeOptions" /></n-form-item><n-form-item v-if="proxyForm.plugin_type" label="插件本地地址"><n-input v-model:value="proxyForm.plugin_local_addr" placeholder="127.0.0.1:80" /></n-form-item><n-form-item v-if="proxyForm.plugin_type === 'unix_domain_socket'" label="Unix Socket 路径"><n-input v-model:value="proxyForm.plugin_unix_path" /></n-form-item><n-form-item v-if="proxyForm.plugin_type === 'static_file'" label="静态文件目录"><n-input v-model:value="proxyForm.plugin_local_path" /></n-form-item><n-form-item v-if="proxyForm.plugin_type" label="插件用户名"><n-input v-model:value="proxyForm.plugin_username" /></n-form-item><n-form-item v-if="proxyForm.plugin_type" label="插件密码"><n-input v-model:value="proxyForm.plugin_password" type="password" show-password-on="click" /></n-form-item></div>
|
||||
<div class="switch-row"><span>禁用 NAT 辅助地址</span><n-switch v-model:value="proxyForm.options.nat_traversal.disable_assisted_addrs" /></div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</div>
|
||||
<div class="modal-actions"><n-button @click="showProxyModal = false">取消</n-button><n-button type="primary" :loading="savingProxy" @click="saveProxy">保存</n-button></div>
|
||||
</n-form>
|
||||
</div>
|
||||
<!-- 操作栏固定在弹窗底部,长表单滚动时仍可随时保存 -->
|
||||
<div class="proxy-modal__footer">
|
||||
<n-button @click="showProxyModal = false">取消</n-button>
|
||||
<n-button type="primary" :loading="savingProxy" :disabled="savingProxy" @click="saveProxy">保存</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-modal__help">
|
||||
<h4>{{ proxyFormTab === 'basic' ? '配置说明' : '高级配置说明' }}</h4>
|
||||
<h4>{{ proxyFormTab === 'basic' ? `${proxyTypeLabel} 配置说明` : '高级配置说明' }}</h4>
|
||||
<ol>
|
||||
<li v-for="item in proxyHelpItems" :key="item.title"><strong>{{ item.title }}</strong>:{{ item.text }}</li>
|
||||
</ol>
|
||||
@@ -568,73 +658,109 @@
|
||||
<h3 class="modal-title">FRP 应用配置</h3>
|
||||
<n-button size="small" quaternary @click="showRuntimeModal = false">关闭</n-button>
|
||||
</div>
|
||||
<div class="proxy-modal__scroll">
|
||||
<div class="runtime-actions">
|
||||
<n-button size="small" :loading="runtimeLoading" @click="loadRuntimeConfig">
|
||||
<template #icon><n-icon :component="RefreshOutline" /></template>
|
||||
刷新
|
||||
</n-button>
|
||||
<n-button size="small" type="primary" :loading="actionLoading === 'reload'" @click="applyRuntimeConfig">
|
||||
校验并应用配置
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<section class="runtime-section">
|
||||
<h3>本机运行环境</h3>
|
||||
<n-descriptions :column="1" label-placement="left" bordered size="small">
|
||||
<n-descriptions-item label="系统架构">{{ runtime.platform || '检测中' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="当前 frpc">{{ runtime.active_bin || '未检测到' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="检测版本">{{ runtime.detected_version || '未检测到' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="配置文件"><span v-if="runtime.config_paths?.length">{{ runtime.config_paths.join('、') }}</span><span v-else>{{ runtime.config_path }}</span>{{ runtime.config_exists ? '(已生成)' : '(尚未生成)' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</section>
|
||||
|
||||
<section class="runtime-section">
|
||||
<div class="runtime-section__title"><h3>下载加速代理</h3><span>仅代理 FRP 官方 Release 下载地址</span></div>
|
||||
<div class="download-proxy-form">
|
||||
<n-select v-model:value="downloadProxy" :options="downloadProxyOptions" placeholder="选择下载方式" />
|
||||
<n-button type="primary" :loading="savingDownloadProxy" @click="saveDownloadProxy">保存</n-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="runtime-section">
|
||||
<div class="runtime-section__title"><h3>已下载客户端</h3><span>{{ binaries.length }} 个版本</span></div>
|
||||
<n-empty v-if="!runtimeLoading && binaries.length === 0" size="small" description="尚未下载 FRP 客户端" />
|
||||
<div v-else class="binary-list">
|
||||
<div v-for="binary in binaries" :key="binary.version" class="binary-row">
|
||||
<div><strong>{{ binary.version }}</strong><div class="binary-row__meta">{{ formatBinarySize(binary.size) }} · {{ formatDate(binary.installed_at) }}</div></div>
|
||||
<div class="binary-row__actions">
|
||||
<n-tag v-if="binary.active" size="small" type="success" :bordered="false">当前使用</n-tag>
|
||||
<n-button v-else size="small" :loading="activatingVersion === binary.version" @click="confirmActivate(binary.version)">启用</n-button>
|
||||
<n-button v-if="!binary.active" size="small" quaternary type="error" :loading="deletingVersion === binary.version" @click="confirmDeleteBinary(binary)">删除</n-button>
|
||||
</div>
|
||||
<!-- 顶部摘要:不展开折叠项也能确认当前运行状态 -->
|
||||
<div class="runtime-summary">
|
||||
<div class="runtime-summary__item">
|
||||
<span class="runtime-summary__label">当前 frpc</span>
|
||||
<span class="runtime-summary__value">{{ runtime.detected_version || '未检测到' }}</span>
|
||||
</div>
|
||||
<div class="runtime-summary__item">
|
||||
<span class="runtime-summary__label">运行平台</span>
|
||||
<span class="runtime-summary__value">{{ runtime.platform || '检测中' }}</span>
|
||||
</div>
|
||||
<div class="runtime-summary__item">
|
||||
<span class="runtime-summary__label">配置文件</span>
|
||||
<n-tag size="small" :bordered="false" :type="runtime.config_exists ? 'success' : 'warning'">
|
||||
{{ runtime.config_exists ? '已生成' : '尚未生成' }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="runtime-section">
|
||||
<div class="runtime-section__title"><h3>FRP 官方下载</h3><span>仅显示当前系统 {{ runtime.platform || '' }} 可用的稳定版本</span></div>
|
||||
<n-empty v-if="!runtimeLoading && releases.length === 0" size="small" description="未获取到官方版本列表" />
|
||||
<div v-else class="release-list">
|
||||
<div v-for="release in visibleReleases" :key="release.version" class="release-row">
|
||||
<div class="release-row__info"><strong>{{ release.version }}</strong><div class="binary-row__meta">{{ release.asset_name }} · {{ formatBinarySize(release.size) }}</div></div>
|
||||
<div v-if="downloadTask.version === release.version && downloadTask.status !== 'idle'" class="release-progress" :class="`release-progress--${downloadTask.status}`" :title="downloadTask.error || downloadTask.message">
|
||||
<span class="release-progress__message">{{ downloadTask.error || downloadTask.message }}</span>
|
||||
<n-progress v-if="downloadTask.status !== 'failed'" class="release-progress__bar" type="line" :percentage="downloadPercent" :status="downloadProgressStatus" :show-indicator="false" />
|
||||
<span v-if="downloadTask.status !== 'failed'" class="release-progress__value">{{ downloadTask.total > 0 ? `${downloadPercent}%` : '...' }}</span>
|
||||
</div>
|
||||
<div v-else class="release-progress-placeholder" />
|
||||
<n-button size="small" :loading="downloadingVersion === release.version" :disabled="isDownloaded(release.version) || downloadInProgress" @click="confirmDownload(release)">
|
||||
{{ isDownloaded(release.version) ? '已下载' : '下载' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="releases.length > releaseLimit" class="release-list__more">
|
||||
<n-button text type="primary" @click="releasesExpanded = !releasesExpanded">
|
||||
{{ releasesExpanded ? '收起列表' : `显示更多(剩余 ${releases.length - releaseLimit} 条)` }}
|
||||
<!-- 操作栏固定在滚动区之外,滚动版本列表时仍可刷新 / 应用 -->
|
||||
<div class="runtime-toolbar">
|
||||
<n-button size="small" :loading="runtimeLoading" @click="loadRuntimeConfig">
|
||||
<template #icon><n-icon :component="RefreshOutline" /></template>
|
||||
刷新
|
||||
</n-button>
|
||||
<n-button size="small" type="primary" :loading="actionLoading === 'reload'" @click="applyRuntimeConfig">
|
||||
校验并应用配置
|
||||
</n-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="proxy-modal__scroll">
|
||||
<!-- 三个折叠分组:运行环境 / 版本管理 / 下载代理,默认只展开版本管理 -->
|
||||
<n-collapse :default-expanded-names="['versions']" class="runtime-collapse">
|
||||
<n-collapse-item name="environment">
|
||||
<template #header>
|
||||
<span class="runtime-collapse__title"><n-icon :component="TerminalOutline" />运行环境</span>
|
||||
</template>
|
||||
<n-descriptions :column="1" label-placement="left" bordered size="small">
|
||||
<n-descriptions-item label="系统架构">{{ runtime.platform || '检测中' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="当前 frpc">{{ runtime.active_bin || '未检测到' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="检测版本">{{ runtime.detected_version || '未检测到' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="配置文件"><span v-if="runtime.config_paths?.length">{{ runtime.config_paths.join('、') }}</span><span v-else>{{ runtime.config_path }}</span>{{ runtime.config_exists ? '(已生成)' : '(尚未生成)' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item name="versions">
|
||||
<template #header>
|
||||
<span class="runtime-collapse__title">
|
||||
<n-icon :component="LayersOutline" />版本管理
|
||||
<span class="runtime-collapse__meta">{{ binaries.length }} 个已下载</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="runtime-subsection">
|
||||
<div class="runtime-section__title"><h3>已下载客户端</h3><span>点击启用可切换当前运行版本</span></div>
|
||||
<n-empty v-if="!runtimeLoading && binaries.length === 0" size="small" description="尚未下载 FRP 客户端" />
|
||||
<div v-else class="binary-list">
|
||||
<div v-for="binary in binaries" :key="binary.version" class="binary-row">
|
||||
<div><strong>{{ binary.version }}</strong><div class="binary-row__meta">{{ formatBinarySize(binary.size) }} · {{ formatDate(binary.installed_at) }}</div></div>
|
||||
<div class="binary-row__actions">
|
||||
<n-tag v-if="binary.active" size="small" type="success" :bordered="false">当前使用</n-tag>
|
||||
<n-button v-else size="small" :loading="activatingVersion === binary.version" @click="confirmActivate(binary.version)">启用</n-button>
|
||||
<n-button v-if="!binary.active" size="small" quaternary type="error" :loading="deletingVersion === binary.version" @click="confirmDeleteBinary(binary)">删除</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="runtime-subsection">
|
||||
<div class="runtime-section__title"><h3>FRP 官方下载</h3><span>仅显示当前系统 {{ runtime.platform || '' }} 可用的稳定版本</span></div>
|
||||
<n-empty v-if="!runtimeLoading && releases.length === 0" size="small" description="未获取到官方版本列表" />
|
||||
<div v-else class="release-list">
|
||||
<div v-for="release in visibleReleases" :key="release.version" class="release-row">
|
||||
<div class="release-row__info"><strong>{{ release.version }}</strong><div class="binary-row__meta">{{ release.asset_name }} · {{ formatBinarySize(release.size) }}</div></div>
|
||||
<div v-if="downloadTask.version === release.version && downloadTask.status !== 'idle'" class="release-progress" :class="`release-progress--${downloadTask.status}`" :title="downloadTask.error || downloadTask.message">
|
||||
<span class="release-progress__message">{{ downloadTask.error || downloadTask.message }}</span>
|
||||
<n-progress v-if="downloadTask.status !== 'failed'" class="release-progress__bar" type="line" :percentage="downloadPercent" :status="downloadProgressStatus" :show-indicator="false" />
|
||||
<span v-if="downloadTask.status !== 'failed'" class="release-progress__value">{{ downloadTask.total > 0 ? `${downloadPercent}%` : '...' }}</span>
|
||||
</div>
|
||||
<div v-else class="release-progress-placeholder" />
|
||||
<n-button size="small" :loading="downloadingVersion === release.version" :disabled="isDownloaded(release.version) || downloadInProgress" @click="confirmDownload(release)">
|
||||
{{ isDownloaded(release.version) ? '已下载' : '下载' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="releases.length > releaseLimit" class="release-list__more">
|
||||
<n-button text type="primary" @click="releasesExpanded = !releasesExpanded">
|
||||
{{ releasesExpanded ? '收起列表' : `显示更多(剩余 ${releases.length - releaseLimit} 条)` }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item name="proxy">
|
||||
<template #header>
|
||||
<span class="runtime-collapse__title"><n-icon :component="FlashOutline" />下载代理</span>
|
||||
</template>
|
||||
<div class="runtime-section__title"><h3>下载加速代理</h3><span>仅代理 FRP 官方 Release 下载地址</span></div>
|
||||
<div class="download-proxy-form">
|
||||
<n-select v-model:value="downloadProxy" :options="downloadProxyOptions" placeholder="选择下载方式" />
|
||||
<n-button type="primary" :loading="savingDownloadProxy" @click="saveDownloadProxy">保存</n-button>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</div>
|
||||
<div class="modal-footer"><n-button @click="showRuntimeModal = false">关闭</n-button></div>
|
||||
</div>
|
||||
@@ -645,17 +771,19 @@
|
||||
import { computed, h, onMounted, reactive, ref, type VNode } from 'vue'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { FrpProxyTraffic, FrpProxiesTraffic } from '../api/types'
|
||||
import { NButton, NIcon, NSpace, NSwitch, NTag, useDialog, useMessage } from 'naive-ui'
|
||||
import { NButton, NIcon, NSwitch, NTag, useDialog, useMessage } from 'naive-ui'
|
||||
import {
|
||||
AddOutline, CloseOutline, DocumentTextOutline, FlashOutline, GitNetworkOutline, InformationCircleOutline,
|
||||
LayersOutline, PeopleOutline, PlayOutline, RefreshOutline, SettingsOutline, StopOutline, TerminalOutline, TimeOutline,
|
||||
AddOutline, CloseOutline, CopyOutline, DocumentTextOutline, FlashOutline, GitNetworkOutline, InformationCircleOutline,
|
||||
LayersOutline, OpenOutline, PeopleOutline, PlayOutline, PulseOutline, RefreshOutline, SettingsOutline, StopOutline,
|
||||
TerminalOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { api, asList } from '../api/client'
|
||||
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
|
||||
import { formatBytes, formatDate, formatRate } from '../utils/format'
|
||||
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
|
||||
import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpStatus } from '../api/types'
|
||||
import type { FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyOptions, FrpProxyPayload, FrpRelease, FrpRuntimeInfo, FrpServer, FrpServerDiagnostics, FrpStatus } from '../api/types'
|
||||
import HavlineCard from '../components/HavlineCard.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import LoadError from '../components/LoadError.vue'
|
||||
@@ -667,6 +795,7 @@ const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const status = ref<FrpStatus>({ status: 'unknown', configured: false, enabled_proxies: 0 })
|
||||
const servers = ref<FrpServer[]>([])
|
||||
const serverDiagnostics = ref<Record<number, FrpServerDiagnostics>>({})
|
||||
const proxies = ref<FrpProxy[]>([])
|
||||
const proxyTraffic = ref<Record<number, FrpProxyTraffic>>({})
|
||||
const proxyTrafficErrors = ref<Record<number, string>>({})
|
||||
@@ -732,6 +861,8 @@ const proxyQuickExamples: Record<FrpProxy['type'], {
|
||||
xtcp: { title: 'XTCP P2P 穿透', description: '通过访客端和服务端建立点对点连接。', lines: ['name = "ssh-xtcp"', 'type = "xtcp"', 'localPort = 22', 'secretKey = "请替换为强密钥"'], name: 'ssh-xtcp', localPort: 22, remotePort: 0, domains: '', remark: 'P2P SSH 服务', secretKey: '请替换为强密钥', multiplexer: '' },
|
||||
}
|
||||
const proxyQuickExample = computed(() => proxyQuickExamples[proxyForm.type])
|
||||
// 右侧说明栏标题带上当前类型名,避免只看“配置说明”不知道在讲哪种规则
|
||||
const proxyTypeLabel = computed(() => proxyTypeOptions.find((item) => item.value === proxyForm.type)?.label ?? '规则')
|
||||
const pluginTypeOptions = [
|
||||
{ label: 'Unix Domain Socket', value: 'unix_domain_socket' }, { label: 'HTTP Proxy', value: 'http_proxy' }, { label: 'Socks5', value: 'socks5' }, { label: 'Static File', value: 'static_file' },
|
||||
{ label: 'HTTP 转 HTTPS', value: 'http2https' }, { label: 'HTTPS 转 HTTP', value: 'https2http' }, { label: 'HTTPS 转 HTTPS', value: 'https2https' }, { label: 'HTTP 转 HTTP', value: 'http2http' }, { label: 'TLS 转 Raw', value: 'tls2raw' },
|
||||
@@ -792,46 +923,75 @@ const frpTypeTagType: Record<FrpProxy['type'], 'info' | 'success' | 'warning' |
|
||||
tcp: 'info', udp: 'default', http: 'success', https: 'success', tcpmux: 'info', stcp: 'warning', sudp: 'warning', xtcp: 'warning',
|
||||
}
|
||||
|
||||
// 穿透规则表格:列宽固定,各行列天然对齐(单元格由 render 创建,样式必须 :deep())
|
||||
// 穿透规则台账:版式对齐「公网反代 → 规则台账」,列宽固定、逐行对齐(单元格由 render 创建,样式必须 :deep())
|
||||
const columns = computed<DataTableColumns<FrpProxy>>(() => [
|
||||
{ title: '名称', key: 'name', minWidth: 150, render: (row) => h('div', [h('strong', row.name), row.remark ? h('div', { class: 'table-muted' }, row.remark) : null]) },
|
||||
{ title: '类型', key: 'type', width: 92, render: (row) => h(NTag, { size: 'small', type: frpTypeTagType[row.type], bordered: false, round: true }, () => row.type.toUpperCase()) },
|
||||
{ title: '内网目标', key: 'local', minWidth: 170, render: (row) => h('code', proxyLocalTarget(row)) },
|
||||
{ title: '公网入口', key: 'public', minWidth: 200, render: (row) => h('code', proxyPublicEndpoint(row)) },
|
||||
{ title: '传输', key: 'transport', width: 150, render: (row) => {
|
||||
const tags = proxyTransportTags(row)
|
||||
return tags.length ? h(NSpace, { size: 4, wrap: false }, () => tags) : h('span', { class: 'table-muted' }, '标准传输')
|
||||
} },
|
||||
{ title: '健康检查', key: 'health_check', width: 96, render: (row) => {
|
||||
const checkType = row.options?.health_check?.type
|
||||
return checkType
|
||||
? h(NTag, { size: 'small', type: 'success', bordered: false, round: true }, () => checkType.toUpperCase())
|
||||
: h('span', { class: 'table-muted' }, '—')
|
||||
} },
|
||||
{ title: '负载均衡', key: 'load_balancer', width: 104, render: (row) => {
|
||||
{
|
||||
title: '规则', key: 'name', width: 240,
|
||||
render: (row) => h('div', { class: 'cell-name' }, [
|
||||
h('div', { class: 'cell-name__head' }, [
|
||||
h('strong', row.name),
|
||||
h(NTag, { size: 'small', type: frpTypeTagType[row.type], bordered: false, round: true }, () => row.type.toUpperCase()),
|
||||
row.enabled ? null : h('span', { class: 'cell-muted' }, '已停用'),
|
||||
]),
|
||||
h('div', { class: 'cell-domains' }, proxyEntriesOf(row).map((entry) => h('div', { class: 'cell-domain' }, [
|
||||
h('span', { class: 'mono cell-wrap' }, entry.text),
|
||||
h(NButton, { size: 'tiny', quaternary: true, title: `复制 ${entry.text}`, onClick: () => copyEntry(entry.text) },
|
||||
{ icon: () => h(NIcon, { component: CopyOutline }) }),
|
||||
entry.url ? h(NButton, { size: 'tiny', quaternary: true, title: `打开 ${entry.text}`, onClick: () => openEntry(entry.url) },
|
||||
{ icon: () => h(NIcon, { component: OpenOutline }) }) : null,
|
||||
]))),
|
||||
row.remark ? h('span', { class: 'cell-muted cell-wrap' }, row.remark) : null,
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '内网目标', key: 'local', width: 190,
|
||||
render: (row) => h('div', { class: 'cell-upstream' }, [
|
||||
h('span', { class: 'mono cell-wrap' }, `${row.local_ip}:${row.local_port}`),
|
||||
row.options?.plugin?.type ? h('span', { class: 'mono cell-wrap cell-muted' }, `插件 · ${row.options.plugin.type}`) : null,
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '传输 · 健康', key: 'transport', width: 210,
|
||||
render: (row) => {
|
||||
const items = [...proxyTransportTags(row)]
|
||||
const group = row.options?.load_balancer?.group
|
||||
return group
|
||||
? h(NTag, { size: 'small', type: 'info', bordered: false, round: true }, () => group)
|
||||
: h('span', { class: 'table-muted' }, '—')
|
||||
} },
|
||||
{ title: '状态', key: 'enabled', width: 100, render: (row) => h('div', { onClick: (event: MouseEvent) => event.stopPropagation() }, h(NSwitch, { value: row.enabled, size: 'small', onUpdateValue: (value: boolean) => toggleProxy(row, value) })) },
|
||||
{ title: '当前连接', key: 'cur_conns', width: 88, render: (row) => h('span', { class: 'mono' }, String(proxyTraffic.value[row.id]?.cur_conns ?? 0)) },
|
||||
{ title: '当前上传', key: 'traffic_in_rate', width: 96, render: (row) => h('span', { class: 'mono text-secondary' }, formatRate(proxyTraffic.value[row.id]?.traffic_in_rate ?? 0)) },
|
||||
{ title: '当前下载', key: 'traffic_out_rate', width: 96, render: (row) => h('span', { class: 'mono text-secondary' }, formatRate(proxyTraffic.value[row.id]?.traffic_out_rate ?? 0)) },
|
||||
{ title: '总上传', key: 'traffic_in', width: 96, render: (row) => h('span', { class: 'mono' }, formatBytes(proxyTraffic.value[row.id]?.traffic_in ?? 0)) },
|
||||
{ title: '总下载', key: 'traffic_out', width: 96, render: (row) => h('span', { class: 'mono' }, formatBytes(proxyTraffic.value[row.id]?.traffic_out ?? 0)) },
|
||||
{ title: '更新时间', key: 'updated_at', width: 110, render: (row) => h('span', { class: 'table-muted' }, formatDate(row.updated_at)) },
|
||||
{ title: '操作', key: 'actions', width: 200, render: (row) => h(NSpace, { size: 2, wrap: false }, () => [
|
||||
h(NButton, { size: 'small', quaternary: true, onClick: () => openProxyDetail(row) }, () => '详情'),
|
||||
h(NButton, { size: 'small', quaternary: true, onClick: () => openProxyModal(row) }, () => '编辑'),
|
||||
h(NButton, { size: 'small', quaternary: true, type: 'error', onClick: () => confirmDeleteProxy(row) }, () => '删除'),
|
||||
]) },
|
||||
if (group) items.push(h(NTag, { size: 'small', type: 'info', bordered: false, round: true }, () => `LB ${group}`))
|
||||
if (row.options?.health_check?.type) items.push(h(NTag, { size: 'small', type: 'success', bordered: false, round: true }, () => `健康 ${row.options.health_check.type?.toUpperCase()}`))
|
||||
return items.length ? h('div', { class: 'cell-health' }, items) : h('span', { class: 'cell-muted' }, '标准传输')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'enabled', width: 84, align: 'center',
|
||||
render: (row) => h('div', { class: 'cell-switch', onClick: (event: MouseEvent) => event.stopPropagation() },
|
||||
h(NSwitch, { value: row.enabled, size: 'small', onUpdateValue: (value: boolean) => toggleProxy(row, value) })),
|
||||
},
|
||||
{ title: '连接', key: 'cur_conns', width: 80, align: 'right', render: (row) => h('span', { class: 'mono' }, String(proxyTraffic.value[row.id]?.cur_conns ?? 0)) },
|
||||
{ title: '当前上传', key: 'traffic_in_rate', width: 100, align: 'right', render: (row) => h('span', { class: 'mono' }, formatRate(proxyTraffic.value[row.id]?.traffic_in_rate ?? 0)) },
|
||||
{ title: '当前下载', key: 'traffic_out_rate', width: 100, align: 'right', render: (row) => h('span', { class: 'mono' }, formatRate(proxyTraffic.value[row.id]?.traffic_out_rate ?? 0)) },
|
||||
{ title: '总上传', key: 'traffic_in', width: 95, align: 'right', render: (row) => h('span', { class: 'mono' }, formatBytes(proxyTraffic.value[row.id]?.traffic_in ?? 0)) },
|
||||
{ title: '总下载', key: 'traffic_out', width: 95, align: 'right', render: (row) => h('span', { class: 'mono' }, formatBytes(proxyTraffic.value[row.id]?.traffic_out ?? 0)) },
|
||||
{
|
||||
title: '更新时间', key: 'updated_at', width: 155,
|
||||
render: (row) => h('div', { class: 'cell-time' }, [
|
||||
h('span', { class: 'mono' }, formatDate(row.updated_at)),
|
||||
h('span', { class: 'cell-muted' }, formatRelativeTime(row.updated_at)),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 170, align: 'right',
|
||||
render: (row) => h('div', { class: 'cell-actions' }, [
|
||||
h(NButton, { size: 'small', quaternary: true, onClick: () => openProxyDetail(row) }, () => '详情'),
|
||||
h(NButton, { size: 'small', quaternary: true, onClick: () => openProxyModal(row) }, () => '编辑'),
|
||||
h(NButton, { size: 'small', quaternary: true, type: 'error', onClick: () => confirmDeleteProxy(row) }, () => '删除'),
|
||||
]),
|
||||
},
|
||||
])
|
||||
|
||||
// 左侧选中服务端 → 右侧只列该服务端的规则;存 id 而不是对象,避免列表刷新后指向旧快照
|
||||
const selectedServerId = ref<number | null>(null)
|
||||
const selectedServer = computed(() => servers.value.find((item) => item.id === selectedServerId.value) ?? null)
|
||||
const serverProxies = computed(() => proxies.value.filter((item) => item.server_id === selectedServerId.value))
|
||||
const selectedServerEnabledCount = computed(() => serverProxies.value.filter((item) => item.enabled).length)
|
||||
|
||||
const showProxyDetail = ref(false)
|
||||
const detailProxy = ref<FrpProxy | null>(null)
|
||||
@@ -860,6 +1020,56 @@ function serverRuleCount(serverID: number) {
|
||||
function serverEnabledRuleCount(serverID: number) {
|
||||
return proxies.value.filter((item) => item.server_id === serverID && item.enabled).length
|
||||
}
|
||||
|
||||
// 全局指标卡:服务端统计
|
||||
const serverRunningCount = computed(() => servers.value.filter((item) => item.boot_status === 'running').length)
|
||||
const serverAbnormalCount = computed(() => servers.value.filter((item) => item.boot_status === 'error').length)
|
||||
const serverSummaryText = computed(() => {
|
||||
if (servers.value.length === 0) return '尚未添加服务端'
|
||||
const parts = [`${serverRunningCount.value} 台运行中`]
|
||||
if (serverAbnormalCount.value > 0) parts.push(`${serverAbnormalCount.value} 台异常`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
// 全局指标卡:健康检查(取自服务端诊断的可达性与延迟)
|
||||
const probedDiagnostics = computed(() =>
|
||||
servers.value.map((item) => serverDiagnostics.value[item.id]).filter((item): item is FrpServerDiagnostics => Boolean(item)))
|
||||
const reachableServerCount = computed(() => probedDiagnostics.value.filter((item) => item.available).length)
|
||||
const averageLatency = computed(() => {
|
||||
const values = probedDiagnostics.value.filter((item) => item.available && typeof item.latency_ms === 'number').map((item) => item.latency_ms as number)
|
||||
if (values.length === 0) return null
|
||||
return Math.round(values.reduce((sum, value) => sum + value, 0) / values.length)
|
||||
})
|
||||
const healthValue = computed(() => {
|
||||
if (servers.value.length === 0) return '未配置'
|
||||
if (probedDiagnostics.value.length === 0) return '检测中'
|
||||
return `${reachableServerCount.value}/${probedDiagnostics.value.length} 可达`
|
||||
})
|
||||
const healthSub = computed(() => {
|
||||
if (servers.value.length === 0) return '添加服务端后可检测'
|
||||
if (probedDiagnostics.value.length === 0) return '正在探测服务端连通性'
|
||||
const pending = servers.value.length - probedDiagnostics.value.length
|
||||
if (pending > 0) return `另有 ${pending} 台待检测`
|
||||
if (averageLatency.value === null) return '暂无可达服务端'
|
||||
return `平均延迟 ${averageLatency.value} ms`
|
||||
})
|
||||
const healthIconClass = computed(() => {
|
||||
if (servers.value.length === 0 || probedDiagnostics.value.length === 0) return 'stat-card__icon--blue'
|
||||
if (reachableServerCount.value === 0) return 'stat-card__icon--red'
|
||||
if (reachableServerCount.value < probedDiagnostics.value.length) return 'stat-card__icon--amber'
|
||||
return 'stat-card__icon--green'
|
||||
})
|
||||
|
||||
// 当前选中服务端的探测结果,与全局状态并列展示,避免两级状态脱节
|
||||
const selectedServerHealthText = computed(() => {
|
||||
const server = selectedServer.value
|
||||
if (!server) return ''
|
||||
const diagnostics = serverDiagnostics.value[server.id]
|
||||
if (!diagnostics) return ''
|
||||
if (!diagnostics.available) return diagnostics.error || '不可达'
|
||||
return diagnostics.latency_ms ? `延迟 ${diagnostics.latency_ms} ms` : '可达'
|
||||
})
|
||||
|
||||
const proxyLogLines = ref<string[]>([])
|
||||
const proxyLogsLoading = ref(false)
|
||||
|
||||
@@ -971,6 +1181,7 @@ async function load() {
|
||||
selectedServerId.value = servers.value[0]?.id ?? null
|
||||
}
|
||||
void loadProxyTraffic()
|
||||
void loadServerDiagnostics()
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '加载 FRP 配置失败'
|
||||
}
|
||||
@@ -1148,10 +1359,46 @@ async function loadExport() {
|
||||
catch (error) { message.error(error instanceof Error ? error.message : '导出失败') }
|
||||
}
|
||||
|
||||
// 公网入口条目:TCP/UDP 补全「服务端地址:远程端口」,域名类逐条给出可跳转的完整地址
|
||||
function proxyEntriesOf(proxy: FrpProxy): Array<{ text: string; url: string }> {
|
||||
if (proxy.type === 'tcp' || proxy.type === 'udp') {
|
||||
const host = servers.value.find((item) => item.id === proxy.server_id)?.server_addr ?? ''
|
||||
const port = proxy.remote_port
|
||||
if (!host || !port) return [{ text: '未设置公网入口', url: '' }]
|
||||
return [{ text: `${host}:${port}`, url: `http://${host}:${port}` }]
|
||||
}
|
||||
if (proxy.type === 'stcp' || proxy.type === 'sudp' || proxy.type === 'xtcp') return [{ text: '密钥访问(无公网端口)', url: '' }]
|
||||
const domains = proxy.custom_domains.length ? proxy.custom_domains : (proxy.options?.subdomain ? [proxy.options.subdomain] : [])
|
||||
if (domains.length === 0) return [{ text: '未设置公网入口', url: '' }]
|
||||
const scheme = proxy.type === 'https' ? 'https' : 'http'
|
||||
return domains.map((domain) => ({ text: domain, url: `${scheme}://${domain}` }))
|
||||
}
|
||||
|
||||
function proxyPublicEndpoint(proxy: FrpProxy) {
|
||||
if (proxy.type === 'tcp' || proxy.type === 'udp') return `:${proxy.remote_port ?? '-'}`
|
||||
if (proxy.type === 'stcp' || proxy.type === 'sudp' || proxy.type === 'xtcp') return '密钥访问'
|
||||
return proxy.custom_domains.join(', ') || proxy.options?.subdomain || '未设置入口'
|
||||
return proxyEntriesOf(proxy).map((entry) => entry.text).join('、')
|
||||
}
|
||||
|
||||
// 入口快捷操作:复制完整地址 / 新窗口打开(复制文字来自上层 tooltip,见列渲染)
|
||||
async function copyEntry(text: string) {
|
||||
if (await copyText(text)) message.success(`已复制 ${text}`)
|
||||
else message.error('复制失败,请手动选择文本复制')
|
||||
}
|
||||
|
||||
function openEntry(url: string) {
|
||||
window.open(url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
// 服务端健康检查:探测各服务端 TCP 可达与延迟,30 秒内不重复探测(与服务端页同一策略)
|
||||
let diagnosticsFetchedAt = 0
|
||||
async function loadServerDiagnostics() {
|
||||
if (servers.value.length === 0) { serverDiagnostics.value = {}; return }
|
||||
if (Date.now() - diagnosticsFetchedAt < 30_000) return
|
||||
diagnosticsFetchedAt = Date.now()
|
||||
const results = await Promise.all(servers.value.map(async (server) => {
|
||||
try { return await api.getFrpServerDiagnostics(server.id) }
|
||||
catch { return { server_id: server.id, available: false, error: '不可达' } as FrpServerDiagnostics }
|
||||
}))
|
||||
serverDiagnostics.value = Object.fromEntries(results.map((item) => [item.server_id, item]))
|
||||
}
|
||||
|
||||
/** 拉取穿透流量快照并按规则 ID 展开;失败的服务端错误记录到 proxyTrafficErrors */
|
||||
@@ -1203,7 +1450,7 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
|
||||
|
||||
<style scoped>
|
||||
/* 统计区:对齐标准页(CertificatesView)的 .stats-row / .stat-card 规格 */
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--havline-space-4); margin-bottom: var(--havline-space-4); }
|
||||
.stats-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--havline-space-4); margin-bottom: var(--havline-space-4); }
|
||||
.stat-card { background: var(--havline-surface); border: 1px solid var(--havline-border); border-radius: var(--havline-radius); box-shadow: var(--havline-shadow); padding: var(--havline-space-4) var(--havline-space-5); min-height: 118px; }
|
||||
.stat-card__icon { font-size: 18px; margin-bottom: 4px; }
|
||||
.stat-card__icon--blue { color: var(--havline-info); }
|
||||
@@ -1216,6 +1463,40 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
|
||||
.stat-card__value--sm { font-size: 18px; }
|
||||
.stat-card__label { margin-top: 8px; font-size: 13px; font-weight: 600; color: var(--havline-text); }
|
||||
.stat-card__sub { margin-top: 4px; font-size: 12px; color: var(--havline-text-muted); }
|
||||
/* FRP 状态 Hero:把全局 frpc 状态与当前选中服务端放进同一结论区,解决两级状态脱节 */
|
||||
.frp-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--havline-space-4);
|
||||
padding: var(--havline-space-4) var(--havline-space-5);
|
||||
margin-bottom: var(--havline-space-4);
|
||||
background: var(--havline-surface);
|
||||
border: 1px solid var(--havline-border);
|
||||
border-radius: var(--havline-radius);
|
||||
box-shadow: var(--havline-shadow);
|
||||
}
|
||||
.frp-hero__top { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--havline-space-4); }
|
||||
.frp-hero__status { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-3); min-width: 0; }
|
||||
.frp-hero__icon { font-size: 26px; flex-shrink: 0; color: var(--havline-text-muted); }
|
||||
.frp-hero__icon--running { color: var(--havline-brand); }
|
||||
.frp-hero__icon--stopped { color: var(--havline-disabled); }
|
||||
.frp-hero__icon--unknown { color: var(--havline-warning); }
|
||||
.frp-hero__headline { min-width: 0; }
|
||||
.frp-hero__title { display: flex; align-items: center; gap: var(--havline-space-2); }
|
||||
.frp-hero__title-label { font-size: 13px; font-weight: 600; color: var(--havline-text-secondary); }
|
||||
.frp-hero__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-2); }
|
||||
.frp-hero__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--havline-space-4);
|
||||
padding-top: var(--havline-space-4);
|
||||
border-top: 1px solid var(--havline-border);
|
||||
}
|
||||
.frp-hero__metric { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.frp-hero__metric-label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--havline-text-muted); }
|
||||
.frp-hero__metric-icon { font-size: 14px; }
|
||||
.frp-hero__metric-value { font-size: 20px; font-weight: 700; line-height: 1.2; color: var(--havline-text); overflow-wrap: anywhere; }
|
||||
.frp-hero__metric-sub { font-size: 12px; color: var(--havline-text-muted); }
|
||||
/* 公网服务端列表:n-data-table(与规则表一致);单元格由 render 创建,样式必须 :deep() */
|
||||
.rule-detail__logs { max-height: 420px; }
|
||||
.rule-detail__log-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--havline-space-3); }
|
||||
@@ -1331,8 +1612,16 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
|
||||
.frp-proxy-modal .proxy-modal__tab--active { color: var(--havline-brand-text); font-weight: 600; border-bottom-color: var(--havline-brand); }
|
||||
.frp-proxy-modal .proxy-modal__scroll { flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.frp-proxy-modal .proxy-modal__pane { width: 100%; box-sizing: border-box; padding: var(--havline-space-4) var(--havline-space-5) 0; }
|
||||
.frp-proxy-modal .proxy-modal__pane > .modal-actions { padding-bottom: var(--havline-space-5); }
|
||||
.frp-proxy-modal__basic .form-grid { grid-template-columns: 1fr; }
|
||||
/* 基础页分组:两列排布,长字段用 .form-item--full 跨列 */
|
||||
.frp-proxy-modal__basic .form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.frp-proxy-modal .form-item--full { grid-column: 1 / -1; }
|
||||
/* 保存栏固定在弹窗底部(滚动区之外) */
|
||||
.frp-proxy-modal .proxy-modal__footer { display: flex; justify-content: flex-end; gap: var(--havline-space-3); padding: var(--havline-space-4) var(--havline-space-5); border-top: 1px solid var(--havline-border); flex-shrink: 0; }
|
||||
/* 公网反代提示:替代原来占一整格的说明项 */
|
||||
.frp-proxy-modal .proxy-form-note { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--havline-space-2); padding: 10px 12px; border-radius: 8px; background: var(--havline-info-soft); color: var(--havline-info); font-size: 12px; line-height: 1.6; }
|
||||
.frp-proxy-modal .proxy-form-note__icon { flex-shrink: 0; margin-top: 2px; font-size: 15px; }
|
||||
.frp-proxy-modal .proxy-switch-row { padding-bottom: 0; }
|
||||
.frp-proxy-modal .proxy-form-section--first { margin-top: 0; padding-top: 0; border-top: 0; }
|
||||
.frp-server-modal__basic .form-grid { grid-template-columns: 1fr; }
|
||||
.frp-proxy-modal .proxy-form-section { margin-top: var(--havline-space-2); padding: var(--havline-space-4) 0 0; border-top: 1px solid var(--havline-border); }
|
||||
.frp-proxy-modal .proxy-form-section h4 { margin: 0 0 var(--havline-space-4); font-size: 14px; font-weight: 600; color: var(--havline-text); }
|
||||
@@ -1366,7 +1655,39 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
|
||||
.frp-card-modal .modal-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--havline-text); }
|
||||
.frp-card-modal .proxy-modal__scroll { flex: 1; min-height: 0; overflow-y: auto; padding: var(--havline-space-4) var(--havline-space-5) 0; }
|
||||
.frp-card-modal .modal-footer { display: flex; justify-content: flex-end; gap: var(--havline-space-3); padding: var(--havline-space-4) var(--havline-space-5) var(--havline-space-5); border-top: 1px solid var(--havline-border); margin-top: var(--havline-space-2); flex-shrink: 0; }
|
||||
.frp-runtime-modal { width: min(880px, 92vw); }
|
||||
.frp-runtime-modal { width: min(780px, 92vw); }
|
||||
.frp-runtime-modal .proxy-modal__scroll { padding-bottom: var(--havline-space-4); }
|
||||
/* FRP 应用配置弹窗:顶部摘要 + 固定操作栏 + 折叠分组,替代原来一整列平铺的分段 */
|
||||
.runtime-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--havline-space-5);
|
||||
padding: var(--havline-space-4) var(--havline-space-5);
|
||||
border-bottom: 1px solid var(--havline-border);
|
||||
background: var(--havline-bg-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.runtime-summary__item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.runtime-summary__label { font-size: 12px; color: var(--havline-text-muted); }
|
||||
.runtime-summary__value { font-size: 14px; font-weight: 600; color: var(--havline-text); }
|
||||
.runtime-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--havline-space-3);
|
||||
padding: var(--havline-space-3) var(--havline-space-5);
|
||||
border-bottom: 1px solid var(--havline-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.runtime-collapse { border: 1px solid var(--havline-border); border-radius: 10px; overflow: hidden; background: var(--havline-surface); }
|
||||
.runtime-collapse :deep(.n-collapse-item) { margin: 0 !important; border: 0 !important; border-radius: 0 !important; }
|
||||
.runtime-collapse :deep(.n-collapse-item + .n-collapse-item) { border-top: 1px solid var(--havline-border) !important; }
|
||||
.runtime-collapse :deep(.n-collapse-item__header) { min-height: 46px; padding: 0 16px !important; background: var(--havline-bg); font-weight: 600; box-sizing: border-box; }
|
||||
.runtime-collapse :deep(.n-collapse-item__content-inner) { padding: 14px 16px 16px; }
|
||||
.runtime-collapse__title { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; color: var(--havline-text); }
|
||||
.runtime-collapse__meta { margin-left: 6px; font-size: 12px; font-weight: 400; color: var(--havline-text-muted); }
|
||||
.runtime-collapse h3 { margin: 0; font-size: 15px; }
|
||||
.runtime-subsection + .runtime-subsection { margin-top: var(--havline-space-5); padding-top: var(--havline-space-4); border-top: 1px dashed var(--havline-border); }
|
||||
/* 服务端详情弹窗(仍用 naive-ui preset=card):排版对齐 .proxy-modal */
|
||||
|
||||
.frp-guide-modal { display: flex; width: 900px; max-width: 95vw; max-height: 90vh; background: var(--havline-surface); border-radius: var(--havline-radius); overflow: hidden; box-shadow: var(--havline-shadow-md); }
|
||||
@@ -1465,10 +1786,33 @@ useVisibilityPolling(refreshDownloadTask, 500, { enabled: downloadPolling })
|
||||
.rule-pane__intro { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--havline-space-3); min-width: 0; }
|
||||
.rule-pane__title { margin: 0; font-size: 15px; font-weight: 600; color: var(--havline-text); }
|
||||
.rule-pane__sub { font-size: 12.5px; color: var(--havline-text-secondary); }
|
||||
/* 当前服务端运行状态按状态着色,与左侧列表标签一致 */
|
||||
.rule-pane__state { font-weight: 600; }
|
||||
.rule-pane__state--running { color: var(--havline-success); }
|
||||
.rule-pane__state--starting { color: var(--havline-warning); }
|
||||
.rule-pane__state--error { color: var(--havline-error); }
|
||||
.rule-pane__state--stopped { color: var(--havline-text-muted); }
|
||||
.rule-pane__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-2); }
|
||||
.rule-table-wrap { border: 1px solid var(--havline-border); border-radius: var(--havline-radius); background: var(--havline-surface); box-shadow: var(--havline-shadow); overflow: hidden; padding: var(--havline-space-2) 0; }
|
||||
.rule-table-wrap { border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-surface); box-shadow: var(--havline-shadow); overflow: hidden; }
|
||||
/* 穿透规则台账:版式对齐「公网反代 → 规则台账」;单元格由 render 创建,必须 :deep() 才能命中 */
|
||||
.frp-rules-table :deep(.table-muted) { color: var(--havline-text-muted); font-size: 12px; }
|
||||
.frp-rules-table :deep(.mono) { font-family: var(--havline-mono); font-size: 13px; }
|
||||
.frp-rules-table :deep(.text-secondary) { color: var(--havline-text-secondary); }
|
||||
.frp-rules-table :deep(.cell-name) { display: grid; gap: 4px; align-content: center; }
|
||||
.frp-rules-table :deep(.cell-name__head) { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.frp-rules-table :deep(.cell-name strong) { font-size: 13.5px; color: var(--havline-text); }
|
||||
.frp-rules-table :deep(.cell-muted) { font-size: 12px; color: var(--havline-text-muted); }
|
||||
.frp-rules-table :deep(.cell-wrap) { overflow-wrap: anywhere; }
|
||||
.frp-rules-table :deep(.cell-upstream) { display: grid; gap: 2px; }
|
||||
.frp-rules-table :deep(.cell-domains) { display: grid; gap: 2px; }
|
||||
.frp-rules-table :deep(.cell-domain) { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
/* 地址不撑满整格:复制 / 跳转按钮紧跟地址,不贴近右侧列 */
|
||||
.frp-rules-table :deep(.cell-domain .mono) { flex: 0 1 auto; min-width: 0; }
|
||||
.frp-rules-table :deep(.cell-health) { display: flex; align-items: center; gap: var(--havline-space-2); flex-wrap: wrap; }
|
||||
.frp-rules-table :deep(.cell-switch) { display: flex; justify-content: center; }
|
||||
.frp-rules-table :deep(.cell-time) { display: grid; gap: 2px; }
|
||||
.frp-rules-table :deep(.cell-actions) { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; }
|
||||
/* 行高被多行内容撑高时,单元格内容保持上下居中 */
|
||||
.frp-rules-table :deep(.n-data-table-td) { vertical-align: middle; }
|
||||
@media (max-width: 1100px) { .frp-layout { grid-template-columns: 1fr; } .server-sidebar__list { max-height: none; } }
|
||||
</style>
|
||||
|
||||
+310
-119
@@ -8,6 +8,13 @@
|
||||
<template #icon><n-icon :component="RefreshOutline" /></template>
|
||||
刷新
|
||||
</n-button>
|
||||
<!-- 原「入口行」的跳转与说明收进「更多」,不再占用首屏高度 -->
|
||||
<n-dropdown trigger="click" :options="headerMenuOptions" @select="handleHeaderMenu">
|
||||
<n-button>
|
||||
<template #icon><n-icon :component="EllipsisHorizontalOutline" /></template>
|
||||
更多
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@@ -25,7 +32,7 @@
|
||||
</EmptyState>
|
||||
|
||||
<div v-else-if="server" class="proxy-layout">
|
||||
<!-- 左:服务端列表;右:该服务端的漂移提示 / 统计 / Nginx 与反代台账 -->
|
||||
<!-- 左:服务端列表;右:该服务端的行动横幅 / 状态 Hero / Nginx 详情 / 反代台账 -->
|
||||
<aside class="server-sidebar">
|
||||
<div class="server-sidebar__head">
|
||||
<div class="server-sidebar__head-row">
|
||||
@@ -58,28 +65,38 @@
|
||||
</aside>
|
||||
|
||||
<div class="proxy-detail">
|
||||
<!-- 数据读取失败:必须可见(原先 loadErrorMsg 写了但从未渲染) -->
|
||||
<n-alert v-if="loadErrorMsg" type="error" :bordered="false" class="page-alert">{{ loadErrorMsg }}</n-alert>
|
||||
|
||||
<!-- frps 回源端口 80/443:VPS 上由 frps 直接对外,公网反代没有位置(也不需要自动部署) -->
|
||||
<n-alert v-if="publicProxyInapplicable" type="info" :bordered="false" class="page-alert">
|
||||
<div class="alert-body__text">
|
||||
<strong>该服务端的 frps 回源端口是 {{ privilegedVhostPorts.join(' / ') }},公网反代不适用</strong>
|
||||
<span>80/443 已由 frps 直接对外使用,VPS 上没有 Nginx 的位置:域名访问走 FRP 隧道直达 NAS,不需要再部署 Nginx 反代,本页也不会自动下发任何路由。</span>
|
||||
<span>如需使用公网反代,请到「公网服务端」页把回源端口改回 8080/8443,再回本页部署。</span>
|
||||
<span>若这台 VPS 上还留着以前部署的 Nginx 路由,需先把回源端口改回 8080/8443,才能在本页移除。</span>
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<!-- 部署漂移:库里的期望与 VPS 上的实际不一致时才出现(P0-2) -->
|
||||
<n-alert v-if="driftItems.length > 0 && !publicProxyInapplicable" type="warning" :bordered="false" class="page-alert">
|
||||
<!-- 行动横幅:原先并列的「读取失败 / 回源端口不适用 / 部署漂移 / Nginx 阻塞」合并为一条,按优先级只显示最该处理的那条 -->
|
||||
<n-alert v-if="actionBanner" :type="actionBanner.type" :bordered="false" class="page-alert">
|
||||
<div class="alert-body">
|
||||
<div class="alert-body__text">
|
||||
<strong>检测到 {{ driftItems.length }} 项部署漂移({{ driftSummary }})</strong>
|
||||
<span>VPS 上的实际配置与 Havline 记录不一致:可能被手工修改过,或 agent 重装后没有重新部署。</span>
|
||||
<span class="mono alert-body__detail">{{ driftPreview }}</span>
|
||||
<strong>{{ actionBanner.title }}</strong>
|
||||
<span>{{ actionBanner.text }}</span>
|
||||
<span v-if="actionBanner.detail" class="mono alert-body__detail">{{ actionBanner.detail }}</span>
|
||||
<span v-if="extraIssueCount > 0" class="alert-body__more">另有 {{ extraIssueCount }} 项待处理,处理完当前项后会自动显示</span>
|
||||
</div>
|
||||
<n-popconfirm @positive-click="redeployAll">
|
||||
<n-button
|
||||
v-if="actionBanner.action === 'reload'"
|
||||
size="small"
|
||||
:loading="manualRefreshing"
|
||||
@click="refreshAll({ silent: false })"
|
||||
>重新读取</n-button>
|
||||
<n-button
|
||||
v-else-if="actionBanner.action === 'retry-deploy'"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="busy === 'reload-frp'"
|
||||
:disabled="busy !== null && busy !== 'reload-frp'"
|
||||
@click="applyFrpConfigAndRetry"
|
||||
>应用配置并重试</n-button>
|
||||
<n-button
|
||||
v-else-if="actionBanner.action === 'install-nginx'"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="busy === 'install-nginx'"
|
||||
:disabled="!agentStatusOk || (busy !== null && busy !== 'install-nginx')"
|
||||
@click="installNginx"
|
||||
>一键安装 Nginx</n-button>
|
||||
<n-popconfirm v-else-if="actionBanner.action === 'redeploy-all'" @positive-click="redeployAll">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="primary" :loading="redeploying" :disabled="busy !== null">
|
||||
重新部署全部
|
||||
@@ -87,96 +104,113 @@
|
||||
</template>
|
||||
将按 Havline 里的记录重新下发该服务端的全部启用规则,会覆盖 VPS 上的手工修改。确定继续?
|
||||
</n-popconfirm>
|
||||
<router-link
|
||||
v-else-if="actionBanner.action === 'fix-vhost-port' && server"
|
||||
:to="{ name: 'frp-agent', query: { server: String(server.id) } }"
|
||||
>
|
||||
<n-button size="small" type="primary">去改回源端口</n-button>
|
||||
</router-link>
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<!-- Nginx 前置条件:缺失 / 校验未通过 -->
|
||||
<n-alert v-if="nginxBlocking && !publicProxyInapplicable" :type="nginxBlocking.type" :bordered="false" class="page-alert">
|
||||
<div class="alert-body">
|
||||
<div class="alert-body__text">
|
||||
<strong>{{ nginxBlocking.title }}</strong>
|
||||
<span>{{ nginxBlocking.text }}</span>
|
||||
<span v-if="nginxBlocking.detail" class="mono alert-body__detail">{{ nginxBlocking.detail }}</span>
|
||||
<!-- ① 状态 Hero:Nginx 结论 + 台账汇总 + 主操作(原 4 张统计卡与 Nginx 卡头部合并) -->
|
||||
<section v-if="!publicProxyInapplicable" class="proxy-hero" aria-label="公网反代概览">
|
||||
<div class="proxy-hero__top">
|
||||
<div class="proxy-hero__status">
|
||||
<n-icon :component="ShieldCheckmarkOutline" class="proxy-hero__icon" :class="nginxIconClass" aria-hidden="true" />
|
||||
<div class="proxy-hero__headline">
|
||||
<div class="proxy-hero__title">
|
||||
<span class="proxy-hero__title-label">Nginx</span>
|
||||
<StatusBadge :kind="nginxKind" :text="nginxText" />
|
||||
<span v-if="agentStatus?.nginx_version" class="proxy-hero__version mono">{{ agentStatus.nginx_version }}</span>
|
||||
</div>
|
||||
<div class="proxy-hero__sub">
|
||||
<template v-if="statusLoaded">
|
||||
进程{{ agentStatus?.nginx_running ? '运行中' : '未运行' }} · {{ agentStatus?.nginx_ok ? 'nginx -t 通过' : 'nginx -t 未通过' }}
|
||||
</template>
|
||||
<template v-else>正在检测 Nginx 运行状态</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<n-button
|
||||
v-if="agentStatus?.nginx_missing"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="busy === 'install-nginx'"
|
||||
:disabled="!agentStatusOk || (busy !== null && busy !== 'install-nginx')"
|
||||
@click="installNginx"
|
||||
>一键安装 Nginx</n-button>
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<!-- 入口行:agent / frps 生命周期在另一页 -->
|
||||
<div v-if="!publicProxyInapplicable" class="entrance-row">
|
||||
<span class="entrance-row__text">
|
||||
Agent 与 frps 的安装、升级、启停、配置下发在「公网服务端」页;本页只负责把规则发布到 Nginx
|
||||
</span>
|
||||
<router-link to="/frp-agent"><n-button size="small" quaternary>前往公网 Agent</n-button></router-link>
|
||||
</div>
|
||||
|
||||
<!-- ① 统计区 -->
|
||||
<section v-if="!publicProxyInapplicable" class="stats-row" aria-label="反代概览">
|
||||
<div class="stat-card">
|
||||
<n-icon :component="LayersOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
|
||||
<div class="stat-card__value">{{ candidates.length }}</div>
|
||||
<div class="stat-card__label">可部署规则</div>
|
||||
<div class="stat-card__sub">来自「内网穿透」的 http 规则</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<n-icon :component="CheckmarkCircleOutline" class="stat-card__icon" :class="deployedCount ? 'stat-card__icon--green' : 'stat-card__icon--amber'" aria-hidden="true" />
|
||||
<div class="stat-card__value">{{ deployedCount }}</div>
|
||||
<div class="stat-card__label">已生效</div>
|
||||
<div class="stat-card__sub">{{ pendingCount }} 条未部署 / 未加载</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<n-icon :component="PulseOutline" class="stat-card__icon" :class="healthyCount ? 'stat-card__icon--green' : 'stat-card__icon--amber'" aria-hidden="true" />
|
||||
<div class="stat-card__value">{{ healthyCount }}<span class="stat-card__value-total">/{{ candidates.length }}</span></div>
|
||||
<div class="stat-card__label">健康通过</div>
|
||||
<div class="stat-card__sub">DNS · 证书 · 隧道 · 服务 四段全通过</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<n-icon :component="SwapHorizontalOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
|
||||
<div class="stat-card__value">{{ totalConns }}</div>
|
||||
<div class="stat-card__label">当前连接</div>
|
||||
<div class="stat-card__sub">frps 侧实时连接数</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ② 环境与 Nginx -->
|
||||
<HavlineCard v-if="!publicProxyInapplicable" class="section-card nginx-card" title="Nginx(公网反代依赖)">
|
||||
<div class="section-body">
|
||||
<div class="field-grid">
|
||||
<div><label>可用性</label><span class="field-tags"><StatusBadge :kind="nginxKind" :text="nginxText" /></span></div>
|
||||
<div><label>进程</label><span>{{ statusLoaded ? (agentStatus?.nginx_running ? '运行中' : '未运行') : '—' }}</span></div>
|
||||
<div><label>版本</label><span class="mono">{{ agentStatus?.nginx_version || '—' }}</span></div>
|
||||
<div><label>配置校验</label><span class="field-tags">
|
||||
<StatusBadge v-if="statusLoaded" :kind="agentStatus?.nginx_ok ? 'success' : 'error'" :text="agentStatus?.nginx_ok ? 'nginx -t 通过' : '未通过'" />
|
||||
<span v-else>—</span>
|
||||
</span></div>
|
||||
<div><label>可执行文件</label><span class="mono mono-wrap">{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}</span></div>
|
||||
<div><label>vhost 配置目录</label><span class="mono mono-wrap">{{ agentStatus?.nginx_conf_dir || '—' }}</span></div>
|
||||
<div><label>端口监听</label><span class="field-tags">
|
||||
<StatusBadge :kind="agentStatus?.ports?.['80'] ? 'success' : 'warning'" :text="`80 ${agentStatus?.ports?.['80'] ? '✓' : '✗'}`" />
|
||||
<StatusBadge :kind="agentStatus?.ports?.['443'] ? 'success' : 'warning'" :text="`443 ${agentStatus?.ports?.['443'] ? '✓' : '✗'}`" />
|
||||
</span></div>
|
||||
<div><label>反代配置文件</label><span>{{ routeDetails.length }} 个</span></div>
|
||||
</div>
|
||||
<p v-if="agentStatus?.nginx_error" class="form-hint nginx-error mono">{{ agentStatus.nginx_error }}</p>
|
||||
<div class="agent-actions">
|
||||
<div class="proxy-hero__actions">
|
||||
<n-button
|
||||
v-if="agentStatus?.nginx_missing"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="busy === 'install-nginx'"
|
||||
:disabled="busy !== null && busy !== 'install-nginx'"
|
||||
:disabled="!agentStatusOk || (busy !== null && busy !== 'install-nginx')"
|
||||
@click="installNginx"
|
||||
>一键安装 Nginx</n-button>
|
||||
<n-button size="small" :loading="manualRefreshing" @click="refreshAll({ silent: false })">重新检测</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="proxy-hero__metrics">
|
||||
<div class="proxy-hero__metric">
|
||||
<span class="proxy-hero__metric-label"><n-icon :component="LayersOutline" class="proxy-hero__metric-icon" />可部署规则</span>
|
||||
<span class="proxy-hero__metric-value">{{ candidates.length }}</span>
|
||||
<span class="proxy-hero__metric-sub">来自「内网穿透」的 http 规则</span>
|
||||
</div>
|
||||
<div class="proxy-hero__metric">
|
||||
<span class="proxy-hero__metric-label"><n-icon :component="CheckmarkCircleOutline" class="proxy-hero__metric-icon" />已生效</span>
|
||||
<span class="proxy-hero__metric-value">{{ deployedCount }}</span>
|
||||
<span class="proxy-hero__metric-sub">{{ pendingCount }} 条未部署 / 未加载</span>
|
||||
</div>
|
||||
<div class="proxy-hero__metric">
|
||||
<span class="proxy-hero__metric-label"><n-icon :component="PulseOutline" class="proxy-hero__metric-icon" />健康通过</span>
|
||||
<span class="proxy-hero__metric-value">{{ healthyCount }}<span class="proxy-hero__metric-total">/{{ candidates.length }}</span></span>
|
||||
<span class="proxy-hero__metric-sub">DNS · 证书 · 隧道 · 服务 四段全通过</span>
|
||||
</div>
|
||||
<div class="proxy-hero__metric">
|
||||
<span class="proxy-hero__metric-label"><n-icon :component="SwapHorizontalOutline" class="proxy-hero__metric-icon" />当前连接</span>
|
||||
<span class="proxy-hero__metric-value">{{ totalConns }}</span>
|
||||
<span class="proxy-hero__metric-sub">frps 侧实时连接数</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ② Nginx 详情:默认折叠,缺失或 nginx -t 未通过时自动展开 -->
|
||||
<HavlineCard v-if="!publicProxyInapplicable" class="section-card nginx-card" title="Nginx 环境详情">
|
||||
<div class="section-body">
|
||||
<n-collapse
|
||||
:expanded-names="nginxDetailOpen ? ['nginx-detail'] : []"
|
||||
@update:expanded-names="setNginxDetailOpen"
|
||||
>
|
||||
<n-collapse-item name="nginx-detail">
|
||||
<template #header>
|
||||
<span class="nginx-detail__title">路径 / 端口 / 配置文件与一键操作</span>
|
||||
</template>
|
||||
<div class="field-grid">
|
||||
<div><label>可用性</label><span class="field-tags"><StatusBadge :kind="nginxKind" :text="nginxText" /></span></div>
|
||||
<div><label>进程</label><span>{{ statusLoaded ? (agentStatus?.nginx_running ? '运行中' : '未运行') : '—' }}</span></div>
|
||||
<div><label>版本</label><span class="mono">{{ agentStatus?.nginx_version || '—' }}</span></div>
|
||||
<div><label>配置校验</label><span class="field-tags">
|
||||
<StatusBadge v-if="statusLoaded" :kind="agentStatus?.nginx_ok ? 'success' : 'error'" :text="agentStatus?.nginx_ok ? 'nginx -t 通过' : '未通过'" />
|
||||
<span v-else>—</span>
|
||||
</span></div>
|
||||
<div><label>可执行文件</label><span class="mono mono-wrap">{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}</span></div>
|
||||
<div><label>vhost 配置目录</label><span class="mono mono-wrap">{{ agentStatus?.nginx_conf_dir || '—' }}</span></div>
|
||||
<div><label>端口监听</label><span class="field-tags">
|
||||
<StatusBadge :kind="agentStatus?.ports?.['80'] ? 'success' : 'warning'" :text="`80 ${agentStatus?.ports?.['80'] ? '✓' : '✗'}`" />
|
||||
<StatusBadge :kind="agentStatus?.ports?.['443'] ? 'success' : 'warning'" :text="`443 ${agentStatus?.ports?.['443'] ? '✓' : '✗'}`" />
|
||||
</span></div>
|
||||
<div><label>反代配置文件</label><span>{{ routeDetails.length }} 个</span></div>
|
||||
</div>
|
||||
<p v-if="agentStatus?.nginx_error" class="form-hint nginx-error mono">{{ agentStatus.nginx_error }}</p>
|
||||
<div class="agent-actions">
|
||||
<n-button
|
||||
v-if="agentStatus?.nginx_missing"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="busy === 'install-nginx'"
|
||||
:disabled="busy !== null && busy !== 'install-nginx'"
|
||||
@click="installNginx"
|
||||
>一键安装 Nginx</n-button>
|
||||
<n-button size="small" :loading="manualRefreshing" @click="refreshAll({ silent: false })">重新检测</n-button>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</div>
|
||||
</HavlineCard>
|
||||
|
||||
<!-- ③ 规则台账 -->
|
||||
@@ -418,9 +452,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import { NButton, NIcon, NPopconfirm, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NButton, NDropdown, NIcon, NPopconfirm, useMessage, type DataTableColumns, type DropdownOption } from 'naive-ui'
|
||||
import {
|
||||
CheckmarkCircleOutline, CopyOutline, InformationCircleOutline, LayersOutline, OpenOutline, PulseOutline, RefreshOutline, SwapHorizontalOutline,
|
||||
CheckmarkCircleOutline, CopyOutline, EllipsisHorizontalOutline, InformationCircleOutline, LayersOutline, OpenOutline,
|
||||
PulseOutline, RefreshOutline, ShieldCheckmarkOutline, SwapHorizontalOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import ConfigDiffView from '../components/ConfigDiffView.vue'
|
||||
@@ -438,15 +474,34 @@ import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../util
|
||||
import { copyText } from '../utils/clipboard'
|
||||
|
||||
type Action =
|
||||
| `deploy:${number}` | `remove:${number}` | `conf:${number}` | `save-settings:${number}` | 'install-nginx'
|
||||
| `deploy:${number}` | `remove:${number}` | `conf:${number}` | `save-settings:${number}` | 'install-nginx' | 'reload-frp'
|
||||
type RouteState = 'loaded' | 'written' | 'broken' | 'none'
|
||||
type BadgeKind = 'success' | 'warning' | 'error' | 'disabled'
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
// Header「更多」:原「入口行」的说明与跳转收进菜单,不再占用首屏高度
|
||||
const headerMenuOptions = computed<DropdownOption[]>(() => [
|
||||
{ label: '前往公网服务端页(Agent / frps 管理)', key: 'frp-agent' },
|
||||
{ label: '前往内网穿透页(规则来源)', key: 'frp' },
|
||||
{ type: 'divider', key: 'divider' },
|
||||
{ key: 'hint', type: 'render', render: () => h('div', { class: 'header-menu-hint' }, '本页只负责把「内网穿透」的规则发布到 VPS 的 Nginx') } as DropdownOption,
|
||||
])
|
||||
|
||||
function handleHeaderMenu(key: string | number) {
|
||||
if (key === 'frp-agent') void router.push({ name: 'frp-agent' })
|
||||
if (key === 'frp') void router.push({ name: 'frp' })
|
||||
}
|
||||
const servers = ref<FrpServer[]>([])
|
||||
const selectedId = ref<number | null>(null)
|
||||
const loadError = ref('')
|
||||
const loadErrorMsg = ref('')
|
||||
// 操作类失败(部署 / 移除 / 保存设置)与「数据读取失败」分开:前者是动作反馈,后者是刷新缺数据
|
||||
const actionErrorMsg = ref('')
|
||||
// 部署因「frps 里查不到该域名的隧道」失败时记下规则 ID,横幅据此给出「应用配置并重试」
|
||||
const lastTunnelMissingProxyId = ref<number | null>(null)
|
||||
// 两种隧道缺失说法:agent 自检报的「没有找到域名…的隧道注册」,以及部署前预检的「还没有下发给本机 frpc」
|
||||
const tunnelMissingRe = /没有找到域名|还没有下发给本机 frpc/
|
||||
const manualRefreshing = ref(false)
|
||||
const busy = ref<Action | null>(null)
|
||||
const candidates = ref<FrpProxy[]>([])
|
||||
@@ -547,6 +602,21 @@ const nginxText = computed(() => {
|
||||
return agentStatus.value?.nginx_running === false ? '已安装未运行' : '正常'
|
||||
})
|
||||
|
||||
// Hero 里的 Nginx 状态图标:与徽标同口径着色
|
||||
const nginxIconClass = computed(() => {
|
||||
if (!statusLoaded.value) return 'proxy-hero__icon--unknown'
|
||||
if (agentStatus.value?.nginx_missing || !agentStatus.value?.nginx_ok) return 'proxy-hero__icon--error'
|
||||
if (agentStatus.value?.nginx_running === false) return 'proxy-hero__icon--warning'
|
||||
return 'proxy-hero__icon--success'
|
||||
})
|
||||
|
||||
// Nginx 详情默认折叠;缺失或 nginx -t 未通过时自动展开,方便直接看到错误与操作
|
||||
const nginxDetailOpen = ref(false)
|
||||
function setNginxDetailOpen(names: Array<string | number>) {
|
||||
nginxDetailOpen.value = names.includes('nginx-detail')
|
||||
}
|
||||
watch(nginxBlocking, (blocking) => { if (blocking) nginxDetailOpen.value = true }, { immediate: true })
|
||||
|
||||
async function load() {
|
||||
loadError.value = ''
|
||||
try {
|
||||
@@ -574,6 +644,7 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
||||
return
|
||||
}
|
||||
if (options.silent === false) manualRefreshing.value = true
|
||||
if (options.silent === false) actionErrorMsg.value = ''
|
||||
const failures: string[] = []
|
||||
const track = async <T>(label: string, fn: () => Promise<T>, assign: (value: T) => void) => {
|
||||
try { assign(await fn()) } catch (error) {
|
||||
@@ -604,13 +675,14 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
||||
async function run<T>(action: Action, source: string, fn: () => Promise<T>) {
|
||||
if (busy.value) return undefined
|
||||
busy.value = action
|
||||
actionErrorMsg.value = ''
|
||||
try {
|
||||
const result = await fn()
|
||||
message.success(`${source}完成`)
|
||||
return result
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : `${source}失败`
|
||||
loadErrorMsg.value = `${source}失败 —— ${text}`
|
||||
actionErrorMsg.value = `${source}失败 —— ${text}`
|
||||
message.error(`${source}失败`)
|
||||
return undefined
|
||||
} finally { busy.value = null }
|
||||
@@ -694,6 +766,72 @@ const driftPreview = computed(() => {
|
||||
return driftItems.value.length > 3 ? `${preview.join(';')} …` : preview.join(';')
|
||||
})
|
||||
|
||||
// 首屏行动横幅:把原先并列的 4 条 alert 收敛成按优先级排序的列表,模板只渲染第一条
|
||||
type BannerAction = '' | 'reload' | 'install-nginx' | 'redeploy-all' | 'fix-vhost-port' | 'retry-deploy'
|
||||
interface ActionBanner {
|
||||
type: 'error' | 'warning' | 'info'
|
||||
title: string
|
||||
text: string
|
||||
detail?: string
|
||||
action: BannerAction
|
||||
}
|
||||
|
||||
const activeIssues = computed<ActionBanner[]>(() => {
|
||||
const items: ActionBanner[] = []
|
||||
if (actionErrorMsg.value) {
|
||||
const tunnelMissing = lastTunnelMissingProxyId.value !== null
|
||||
items.push(tunnelMissing ? {
|
||||
type: 'error',
|
||||
title: '部署反代未生效:frps 里还没有该域名的隧道',
|
||||
text: 'Nginx 反代配置已经写到 VPS,但 frps 查不到该域名的隧道注册。通常是穿透规则改动后还没应用 frpc 配置,或该服务端的 frpc 没有运行;点右侧按钮会先应用 frpc 配置,再自动重试部署。',
|
||||
detail: actionErrorMsg.value,
|
||||
action: 'retry-deploy',
|
||||
} : {
|
||||
type: 'error',
|
||||
title: '上次操作没有成功',
|
||||
text: '部署 / 移除等操作被中断,按下方原因处理后重试即可;若只是刚改过穿透规则,稍等几秒再试一次通常就能通过。',
|
||||
detail: actionErrorMsg.value,
|
||||
action: 'reload',
|
||||
})
|
||||
}
|
||||
if (loadErrorMsg.value) {
|
||||
items.push({ type: 'error', title: '数据读取失败', text: '部分数据没有取到,页面内容可能不完整。', detail: loadErrorMsg.value, action: 'reload' })
|
||||
}
|
||||
// 回源端口 80/443 时不适用公网反代:此时 Nginx 与漂移都不再检查,直接给唯一动作
|
||||
if (publicProxyInapplicable.value) {
|
||||
items.push({
|
||||
type: 'info',
|
||||
title: `该服务端的 frps 回源端口是 ${privilegedVhostPorts.value.join(' / ')},公网反代不适用`,
|
||||
text: '80/443 已由 frps 直接对外使用,VPS 上没有 Nginx 的位置:域名访问走 FRP 隧道直达 NAS,不需要再部署 Nginx 反代,本页也不会自动下发任何路由。',
|
||||
detail: '需使用公网反代时请把回源端口改回 8080/8443;若 VPS 上还留着以前部署的 Nginx 路由,也要先改回才能在本页移除。',
|
||||
action: 'fix-vhost-port',
|
||||
})
|
||||
return items
|
||||
}
|
||||
if (nginxBlocking.value) {
|
||||
items.push({
|
||||
type: nginxBlocking.value.type,
|
||||
title: nginxBlocking.value.title,
|
||||
text: nginxBlocking.value.text,
|
||||
detail: nginxBlocking.value.detail,
|
||||
action: agentStatus.value?.nginx_missing ? 'install-nginx' : '',
|
||||
})
|
||||
}
|
||||
if (driftItems.value.length > 0) {
|
||||
items.push({
|
||||
type: 'warning',
|
||||
title: `检测到 ${driftItems.value.length} 项部署漂移(${driftSummary.value})`,
|
||||
text: 'VPS 上的实际配置与 Havline 记录不一致:可能被手工修改过,或 agent 重装后没有重新部署。',
|
||||
detail: driftPreview.value,
|
||||
action: 'redeploy-all',
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const actionBanner = computed(() => activeIssues.value[0] ?? null)
|
||||
const extraIssueCount = computed(() => Math.max(0, activeIssues.value.length - 1))
|
||||
|
||||
// 全量重部署:逐条下发,失败不中断(后端已逐条汇总)
|
||||
async function redeployAll() {
|
||||
const current = server.value
|
||||
@@ -882,7 +1020,12 @@ async function deployRoute(candidate: FrpProxy) {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
const result = await run(`deploy:${candidate.id}`, '部署反代', () => api.deployFrpAgentRoute(current.id, candidate.id))
|
||||
if (result === undefined) return
|
||||
if (result === undefined) {
|
||||
// frps 里还没有该域名的隧道:多半是穿透规则改动后没应用 frpc 配置,横幅据此给出「应用配置并重试」
|
||||
lastTunnelMissingProxyId.value = tunnelMissingRe.test(actionErrorMsg.value) ? candidate.id : null
|
||||
return
|
||||
}
|
||||
lastTunnelMissingProxyId.value = null
|
||||
// 自动生成的 Basic Auth 密码只在这一次响应里返回:不再写进 nginx 配置,错过就得在「部署设置」里重设
|
||||
if (result.basic_auth_password) {
|
||||
message.warning(`Basic Auth 已生成(仅显示这一次,请立即保存):${result.basic_auth_user} / ${result.basic_auth_password}`, { duration: 15000 })
|
||||
@@ -890,6 +1033,16 @@ async function deployRoute(candidate: FrpProxy) {
|
||||
await refreshAll()
|
||||
}
|
||||
|
||||
// 部署失败在「隧道未注册」时:先应用 frpc 配置(等同「内网穿透」页的校验并应用),再自动重试一次部署
|
||||
async function applyFrpConfigAndRetry() {
|
||||
const candidate = candidates.value.find((item) => item.id === lastTunnelMissingProxyId.value)
|
||||
const applied = await run('reload-frp', '应用 frpc 配置', () => api.reloadFrp())
|
||||
if (applied === undefined || !candidate) return
|
||||
// frpc 重启后要几秒才重新向 frps 注册隧道;就算 agent 版本较老没有重试,也先等一拍再探测
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500))
|
||||
await deployRoute(candidate)
|
||||
}
|
||||
|
||||
async function removeRoute(candidate: FrpProxy) {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
@@ -1067,24 +1220,52 @@ onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 分区:提示 → 入口行 → ① 统计区 → ② 环境与 Nginx → ③ 规则台账 */
|
||||
/* 分区:行动横幅 → ① 状态 Hero → ② Nginx 详情 → ③ 规则台账 */
|
||||
.page-alert { margin-bottom: var(--havline-space-3); }
|
||||
.alert-body { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-4); }
|
||||
.alert-body__text { display: grid; gap: 4px; font-size: 13px; }
|
||||
.alert-body__detail { font-size: 12px; color: var(--havline-error); overflow-wrap: anywhere; }
|
||||
.entrance-row { display: flex; align-items: center; justify-content: space-between; gap: var(--havline-space-4); margin-bottom: var(--havline-space-5); padding: 10px var(--havline-space-5); border: 1px solid var(--havline-border); border-radius: var(--havline-radius-sm); background: var(--havline-info-soft); }
|
||||
.entrance-row__text { font-size: 13px; color: var(--havline-text); }
|
||||
.alert-body__more { font-size: 12px; color: var(--havline-text-muted); }
|
||||
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--havline-space-4); margin-bottom: var(--havline-space-5); }
|
||||
.stat-card { background: var(--havline-surface); border: 1px solid var(--havline-border); border-radius: var(--havline-radius); box-shadow: var(--havline-shadow); padding: var(--havline-space-4) var(--havline-space-5); min-height: 118px; }
|
||||
.stat-card__icon { font-size: 18px; margin-bottom: 4px; }
|
||||
.stat-card__icon--blue { color: var(--havline-info); }
|
||||
.stat-card__icon--green { color: var(--havline-brand); }
|
||||
.stat-card__icon--amber { color: var(--havline-warning); }
|
||||
.stat-card__value { margin-top: 4px; font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--havline-text); letter-spacing: -0.03em; }
|
||||
.stat-card__value-total { margin-left: 2px; font-size: 15px; font-weight: 600; color: var(--havline-text-muted); }
|
||||
.stat-card__label { margin-top: 8px; font-size: 13px; font-weight: 600; color: var(--havline-text); }
|
||||
.stat-card__sub { margin-top: 4px; font-size: 12px; color: var(--havline-text-muted); }
|
||||
/* 状态 Hero:Nginx 结论 + 台账汇总 + 主操作(原 4 张统计卡与 Nginx 卡头部合并) */
|
||||
.proxy-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--havline-space-4);
|
||||
padding: var(--havline-space-4) var(--havline-space-5);
|
||||
margin-bottom: var(--havline-space-5);
|
||||
background: var(--havline-surface);
|
||||
border: 1px solid var(--havline-border);
|
||||
border-radius: var(--havline-radius);
|
||||
box-shadow: var(--havline-shadow);
|
||||
}
|
||||
.proxy-hero__top { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--havline-space-4); }
|
||||
.proxy-hero__status { display: flex; align-items: center; gap: var(--havline-space-3); min-width: 0; }
|
||||
.proxy-hero__icon { font-size: 26px; flex-shrink: 0; color: var(--havline-text-muted); }
|
||||
.proxy-hero__icon--success { color: var(--havline-success); }
|
||||
.proxy-hero__icon--warning { color: var(--havline-warning); }
|
||||
.proxy-hero__icon--error { color: var(--havline-error); }
|
||||
.proxy-hero__icon--unknown { color: var(--havline-text-muted); }
|
||||
.proxy-hero__headline { min-width: 0; }
|
||||
.proxy-hero__title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-2); }
|
||||
.proxy-hero__title-label { font-size: 13px; font-weight: 600; color: var(--havline-text-secondary); }
|
||||
.proxy-hero__version { font-size: 12px; color: var(--havline-text-muted); }
|
||||
.proxy-hero__sub { margin-top: 6px; font-size: 13px; color: var(--havline-text-secondary); }
|
||||
.proxy-hero__actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--havline-space-2); }
|
||||
.proxy-hero__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--havline-space-4);
|
||||
padding-top: var(--havline-space-4);
|
||||
border-top: 1px solid var(--havline-border);
|
||||
}
|
||||
.proxy-hero__metric { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.proxy-hero__metric-label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--havline-text-muted); }
|
||||
.proxy-hero__metric-icon { font-size: 14px; }
|
||||
.proxy-hero__metric-value { font-size: 22px; font-weight: 700; line-height: 1.15; color: var(--havline-text); letter-spacing: -0.02em; }
|
||||
.proxy-hero__metric-total { margin-left: 2px; font-size: 14px; font-weight: 600; color: var(--havline-text-muted); }
|
||||
.proxy-hero__metric-sub { font-size: 12px; color: var(--havline-text-muted); }
|
||||
.nginx-detail__title { font-size: 13px; font-weight: 500; color: var(--havline-text-secondary); }
|
||||
|
||||
/* HavlineCard 自身已有 24px 内边距,这里不再叠加(与 FrpView 的用法一致) */
|
||||
.section-card { margin-bottom: var(--havline-space-5); }
|
||||
@@ -1219,13 +1400,12 @@ onMounted(() => { void load() })
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.stats-row, .entrance-row, .section-card { animation: havline-fade-up 160ms ease-out both; }
|
||||
.proxy-hero, .page-alert, .section-card { animation: havline-fade-up 160ms ease-out both; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stats-row, .entrance-row, .section-card { animation: none; }
|
||||
.proxy-hero, .page-alert, .section-card { animation: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.nginx-card .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
/* 台账为表格:窄屏靠 scroll-x 横向滚动,不改列 */
|
||||
.proxy-modal__help { display: none; }
|
||||
@@ -1236,11 +1416,22 @@ onMounted(() => { void load() })
|
||||
.nginx-card .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.stats-row { grid-template-columns: 1fr; }
|
||||
.nginx-card .field-grid { grid-template-columns: 1fr; }
|
||||
.entrance-row { flex-direction: column; align-items: flex-start; }
|
||||
.proxy-hero__top { flex-direction: column; align-items: flex-start; }
|
||||
.proxy-hero__actions { width: 100%; }
|
||||
.route-table-wrap :deep(.cell-actions) { width: 100%; justify-content: flex-start; }
|
||||
.route-table-wrap :deep(.cell-actions .n-button) { min-height: 44px; }
|
||||
.alert-body { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 下拉面板由 naive-ui teleport 到 body,scoped 样式命不中,渲染型选项用全局样式约束换行 -->
|
||||
<style>
|
||||
.header-menu-hint {
|
||||
width: 236px;
|
||||
white-space: normal;
|
||||
line-height: 1.5;
|
||||
font-size: 12px;
|
||||
color: var(--havline-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -192,6 +192,7 @@
|
||||
|
||||
<p class="field-hint data-note">恢复配置将覆盖当前数据,操作前请确保已备份。</p>
|
||||
</HavlineCard>
|
||||
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -569,6 +570,30 @@
|
||||
</n-button>
|
||||
</div>
|
||||
</HavlineCard>
|
||||
|
||||
<HavlineCard title="SSH 终端" subtitle="在浏览器里直接连到公网服务端(默认关闭)">
|
||||
<p class="field-hint">
|
||||
开启后「SSH 终端」页面可用「公网服务端」里已保存的 SSH 凭据登录 VPS。凭据不会下发到浏览器,
|
||||
主机密钥仍按首次信任记录校验;会话空闲 10 分钟自动断开。
|
||||
</p>
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">启用内置终端</div>
|
||||
<div class="form-switch-row__hint">关闭时终端接口一律返回 403,页面只显示提示</div>
|
||||
</div>
|
||||
<n-switch v-model:value="terminalEnabled" />
|
||||
</div>
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">录制会话内容</div>
|
||||
<div class="form-switch-row__hint">
|
||||
开启后把终端输出写入 logs/terminal(asciinema v2 格式,可在「SSH 终端 → 录像审计」里回放)。
|
||||
录像包含屏幕上出现的一切,包括在远端提示符下输入的口令,请按需开启;默认关闭,保留 90 天。
|
||||
</div>
|
||||
</div>
|
||||
<n-switch v-model:value="terminalRecordingEnabled" />
|
||||
</div>
|
||||
</HavlineCard>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -895,6 +920,9 @@ const backupError = ref('')
|
||||
|
||||
// 公开状态页:开关与访问码走设置项(访问码按「掩码往返」处理)
|
||||
const statusPageEnabled = ref(false)
|
||||
// 内置 SSH 终端总开关:默认关闭,开启后才允许从浏览器连 VPS
|
||||
const terminalEnabled = ref(false)
|
||||
const terminalRecordingEnabled = ref(false)
|
||||
const statusPageCode = ref('')
|
||||
const statusPageConfigured = ref(false)
|
||||
|
||||
@@ -1135,6 +1163,8 @@ function applySettingsToForm(settings: Record<string, string>) {
|
||||
backupAutoEnabled.value = settings.backup_auto_enabled !== '0'
|
||||
backupKeepCount.value = Number(settings.backup_keep_count || DEFAULT_BACKUP_KEEP_COUNT)
|
||||
statusPageEnabled.value = settings.status_page_enabled === '1'
|
||||
terminalEnabled.value = settings.terminal_enabled === '1'
|
||||
terminalRecordingEnabled.value = settings.terminal_recording_enabled === '1'
|
||||
statusPageCode.value = settings.status_page_code ?? ''
|
||||
statusPageConfigured.value = statusPageCode.value === MASKED_SECRET
|
||||
acmeEmail.value = settings.acme_email ?? ''
|
||||
@@ -1580,6 +1610,8 @@ function buildSavePayload() {
|
||||
backup_auto_enabled: backupAutoEnabled.value ? '1' : '0',
|
||||
backup_keep_count: String(backupKeepCount.value || DEFAULT_BACKUP_KEEP_COUNT),
|
||||
status_page_enabled: statusPageEnabled.value ? '1' : '0',
|
||||
terminal_enabled: terminalEnabled.value ? '1' : '0',
|
||||
terminal_recording_enabled: terminalRecordingEnabled.value ? '1' : '0',
|
||||
status_page_code: statusPageCode.value.trim(),
|
||||
notify_cpu_threshold: String(notifyCPUThreshold.value || DEFAULT_CPU_THRESHOLD),
|
||||
notify_memory_threshold: String(notifyMemoryThreshold.value || DEFAULT_MEMORY_THRESHOLD),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<PageHeader
|
||||
title="终端审计"
|
||||
description="每次 SSH 会话的发起人、来源、目标、时长与断开原因;记录保留 90 天,不落终端内容"
|
||||
>
|
||||
<template #actions>
|
||||
<n-select
|
||||
v-model:value="limit"
|
||||
size="small"
|
||||
class="audit-limit"
|
||||
:options="limitOptions"
|
||||
@update:value="load"
|
||||
/>
|
||||
<n-button size="small" :loading="loading" @click="load">
|
||||
<template #icon><n-icon :component="RefreshOutline" /></template>
|
||||
刷新
|
||||
</n-button>
|
||||
<n-button size="small" type="primary" @click="openTerminal">
|
||||
<template #icon><n-icon :component="TerminalOutline" /></template>
|
||||
返回终端
|
||||
</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<LoadError v-if="loadError" :message="loadError" @retry="load" />
|
||||
|
||||
<HavlineCard v-else title="会话记录" :subtitle="summary">
|
||||
<EmptyState
|
||||
v-if="!loading && entries.length === 0"
|
||||
title="还没有会话记录"
|
||||
description="在「SSH 终端」里建立会话后,这里会记录谁、何时、连了哪台、多久、为什么断开。"
|
||||
>
|
||||
<template #action>
|
||||
<n-button type="primary" size="small" @click="openTerminal">去 SSH 终端</n-button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
<div v-else class="audit-table-wrap">
|
||||
<n-data-table
|
||||
class="audit-table"
|
||||
:columns="columns"
|
||||
:data="entries"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:scroll-x="1120"
|
||||
:row-key="rowKey"
|
||||
/>
|
||||
</div>
|
||||
</HavlineCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NDataTable, NIcon, NSelect, NTag } from 'naive-ui'
|
||||
import { RefreshOutline, TerminalOutline } from '@vicons/ionicons5'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, asList } from '../api/client'
|
||||
import { formatBytes, formatDate, formatDuration, formatRelativeTime } from '../utils/format'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import HavlineCard from '../components/HavlineCard.vue'
|
||||
import LoadError from '../components/LoadError.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import type { TerminalAuditEntry } from '../api/types'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const entries = ref<TerminalAuditEntry[]>([])
|
||||
const limit = ref(100)
|
||||
|
||||
const limitOptions = [
|
||||
{ label: '最近 50 条', value: 50 },
|
||||
{ label: '最近 100 条', value: 100 },
|
||||
{ label: '最近 200 条', value: 200 },
|
||||
]
|
||||
|
||||
const summary = computed(() => {
|
||||
if (loading.value && entries.value.length === 0) return '正在读取记录…'
|
||||
if (entries.value.length === 0) return '暂无记录'
|
||||
const latest = entries.value[0]?.ended_at
|
||||
return `共 ${entries.value.length} 条 · 最近一次 ${formatRelativeTime(latest)}`
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const result = await api.listTerminalAudits(limit.value)
|
||||
entries.value = asList(result?.entries)
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '读取审计记录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openTerminal() {
|
||||
void router.push({ name: 'terminal' })
|
||||
}
|
||||
|
||||
// session_id 已经唯一,id 只是自增主键:优先用 id,避免极端情况下同 id 冲突
|
||||
function rowKey(row: TerminalAuditEntry): string {
|
||||
return row.id ? `a-${row.id}` : row.session_id
|
||||
}
|
||||
|
||||
// 断开原因是审计结论,用颜色区分「主动关闭」和「被动断开」
|
||||
function reasonTagType(reason?: string): 'info' | 'warning' | 'default' {
|
||||
switch (reason) {
|
||||
case '用户关闭':
|
||||
return 'info'
|
||||
case '空闲超时':
|
||||
case '超过最长会话时长':
|
||||
case '前端断开':
|
||||
case '服务停止':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<TerminalAuditEntry>>(() => [
|
||||
{
|
||||
title: '结束时间',
|
||||
key: 'ended_at',
|
||||
width: 170,
|
||||
render: (row) => h('div', { class: 'cell-time' }, [
|
||||
h('span', { class: 'cell-time__main' }, formatDate(row.ended_at)),
|
||||
h('span', { class: 'cell-muted' }, formatRelativeTime(row.ended_at)),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
key: 'user',
|
||||
width: 170,
|
||||
render: (row) => h('div', { class: 'cell-stack' }, [
|
||||
h('strong', { class: 'cell-strong' }, row.user || '未知'),
|
||||
h('span', { class: 'mono cell-muted cell-wrap' }, row.client_ip || '-'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '目标服务端',
|
||||
key: 'target',
|
||||
width: 250,
|
||||
render: (row) => h('div', { class: 'cell-stack' }, [
|
||||
h('span', { class: 'cell-wrap' }, row.target || `服务端 #${row.server_id}`),
|
||||
h('span', { class: 'mono cell-muted' }, `#${row.server_id} · ${row.session_id}`),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '时长',
|
||||
key: 'duration_ms',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (row) => h('span', { class: 'mono' }, formatDuration(row.duration_ms)),
|
||||
},
|
||||
{
|
||||
title: '断开原因',
|
||||
key: 'reason',
|
||||
width: 160,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: 'small', bordered: false, round: true, type: reasonTagType(row.reason) },
|
||||
() => row.reason || '未知',
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '流量',
|
||||
key: 'traffic',
|
||||
width: 170,
|
||||
render: (row) => h('div', { class: 'cell-stack cell-traffic' }, [
|
||||
h('span', { class: 'mono' }, `输入 ${formatBytes(row.bytes_in)}`),
|
||||
h('span', { class: 'mono cell-muted' }, `输出 ${formatBytes(row.bytes_out)}`),
|
||||
]),
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-limit { width: 140px; }
|
||||
/* 台账外框对齐「穿透规则台账」的版式 */
|
||||
.audit-table-wrap {
|
||||
border: 1px solid var(--havline-border);
|
||||
border-radius: var(--havline-radius-sm);
|
||||
background: var(--havline-surface);
|
||||
box-shadow: var(--havline-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 单元格由 render 创建,作用域样式必须 :deep() 才能命中表格内部节点 */
|
||||
.audit-table :deep(td) { vertical-align: middle; }
|
||||
.audit-table :deep(.mono) { font-family: var(--havline-mono); font-size: 13px; }
|
||||
.audit-table :deep(.cell-time),
|
||||
.audit-table :deep(.cell-stack) { display: grid; gap: 2px; }
|
||||
.audit-table :deep(.cell-time__main),
|
||||
.audit-table :deep(.cell-strong) { font-size: 13px; color: var(--havline-text); }
|
||||
.audit-table :deep(.cell-muted) { font-size: 12px; color: var(--havline-text-muted); }
|
||||
.audit-table :deep(.cell-wrap) { overflow-wrap: anywhere; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,8 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:6893',
|
||||
// 内置 SSH 终端走 WebSocket:开发代理必须开 ws,否则升级请求会一直挂起
|
||||
ws: true,
|
||||
timeout: 300_000,
|
||||
proxyTimeout: 300_000,
|
||||
configure(proxy) {
|
||||
|
||||
Reference in New Issue
Block a user