diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3355f28..7f072d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index db2cb58..f04e62e 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
@@ -229,7 +229,7 @@ Havline 提供两种飞牛应用形态,均支持统一网关入口 `/app/havli
### Docker 应用(离线镜像包)
- 独立仓库:
-- 安装包:`havline-1.9.3.fpk`
+- 安装包:`havline-2.0.0.fpk`
- 包内包含 amd64 / arm64 离线镜像,安装时执行 `docker load`
- Compose 使用本地镜像并设置 `pull_policy: never`,不依赖外部 Registry
- 管理界面通过统一网关 `/app/havline` 访问
diff --git a/VERSION b/VERSION
index 77fee73..227cea2 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.9.3
+2.0.0
diff --git a/docker-compose.host.yml b/docker-compose.host.yml
index c3fc6df..411fb80 100644
--- a/docker-compose.host.yml
+++ b/docker-compose.host.yml
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index 200e89b..d2b373e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
diff --git a/go.mod b/go.mod
index 00be7f7..45313f6 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 7261931..701b9a4 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/internal/agent/probe_test.go b/internal/agent/probe_test.go
new file mode 100644
index 0000000..859a546
--- /dev/null
+++ b/internal/agent/probe_test.go
@@ -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)
+ }
+}
diff --git a/internal/agent/server.go b/internal/agent/server.go
index 511bef9..3a280e5 100644
--- a/internal/agent/server.go
+++ b/internal/agent/server.go
@@ -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
diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go
index b2b1dac..c0c7897 100644
--- a/internal/api/auth_handler.go
+++ b/internal/api/auth_handler.go
@@ -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": "密码已更新"})
}
diff --git a/internal/api/ddns_handler.go b/internal/api/ddns_handler.go
index 70a33a9..8c36b38 100644
--- a/internal/api/ddns_handler.go
+++ b/internal/api/ddns_handler.go
@@ -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,
diff --git a/internal/api/deps.go b/internal/api/deps.go
index e4402c2..879faea 100644
--- a/internal/api/deps.go
+++ b/internal/api/deps.go
@@ -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
}
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
index a3aaa56..684df8c 100644
--- a/internal/api/middleware.go
+++ b/internal/api/middleware.go
@@ -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")
}
diff --git a/internal/api/response.go b/internal/api/response.go
index fa53adc..6d78203 100644
--- a/internal/api/response.go
+++ b/internal/api/response.go
@@ -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
diff --git a/internal/api/router.go b/internal/api/router.go
index c3bb373..38a3aae 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -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()
diff --git a/internal/api/settings_handler.go b/internal/api/settings_handler.go
index f91cd0f..1e6fecc 100644
--- a/internal/api/settings_handler.go
+++ b/internal/api/settings_handler.go
@@ -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 {
diff --git a/internal/api/terminal_audit_test.go b/internal/api/terminal_audit_test.go
new file mode 100644
index 0000000..06655a7
--- /dev/null
+++ b/internal/api/terminal_audit_test.go
@@ -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)
+ }
+}
diff --git a/internal/api/terminal_handler.go b/internal/api/terminal_handler.go
new file mode 100644
index 0000000..36afc70
--- /dev/null
+++ b/internal/api/terminal_handler.go
@@ -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())
+ }
+}
diff --git a/internal/api/terminal_unlock.go b/internal/api/terminal_unlock.go
new file mode 100644
index 0000000..c9f9135
--- /dev/null
+++ b/internal/api/terminal_unlock.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/api/terminal_unlock_test.go b/internal/api/terminal_unlock_test.go
new file mode 100644
index 0000000..0672fee
--- /dev/null
+++ b/internal/api/terminal_unlock_test.go
@@ -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
+}
diff --git a/internal/app/app.go b/internal/app/app.go
index 5979ad5..ea6301b 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -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
}
diff --git a/internal/app/gateway_test.go b/internal/app/gateway_test.go
index 09c853c..12a046e 100644
--- a/internal/app/gateway_test.go
+++ b/internal/app/gateway_test.go
@@ -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 ""
+}
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
index 78c5153..6a6db60 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -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
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
index c58a80f..779b33d 100644
--- a/internal/auth/auth_test.go
+++ b/internal/auth/auth_test.go
@@ -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 {
diff --git a/internal/config/config.go b/internal/config/config.go
index fd7bb92..13fc37e 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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",
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index f8cb509..734882b 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -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")
diff --git a/internal/ddns/store.go b/internal/ddns/store.go
index 7379746..40cf4d4 100644
--- a/internal/ddns/store.go
+++ b/internal/ddns/store.go
@@ -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)
diff --git a/internal/frp/agentbin/havline-agent-linux-amd64 b/internal/frp/agentbin/havline-agent-linux-amd64
index eadeeac..2df0f1f 100644
Binary files a/internal/frp/agentbin/havline-agent-linux-amd64 and b/internal/frp/agentbin/havline-agent-linux-amd64 differ
diff --git a/internal/frp/agentbin/havline-agent-linux-arm64 b/internal/frp/agentbin/havline-agent-linux-arm64
index ed9722c..16aece0 100644
Binary files a/internal/frp/agentbin/havline-agent-linux-arm64 and b/internal/frp/agentbin/havline-agent-linux-arm64 differ
diff --git a/internal/frp/service.go b/internal/frp/service.go
index 28a84da..5399980 100644
--- a/internal/frp/service.go
+++ b/internal/frp/service.go
@@ -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 {
diff --git a/internal/frp/ssh_terminal.go b/internal/frp/ssh_terminal.go
new file mode 100644
index 0000000..f6db0ab
--- /dev/null
+++ b/internal/frp/ssh_terminal.go
@@ -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
+}
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
index e2e2588..a94da89 100644
--- a/internal/settings/settings.go
+++ b/internal/settings/settings.go
@@ -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{
diff --git a/internal/terminal/audit.go b/internal/terminal/audit.go
new file mode 100644
index 0000000..56f5202
--- /dev/null
+++ b/internal/terminal/audit.go
@@ -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()
+}
diff --git a/internal/terminal/audit_test.go b/internal/terminal/audit_test.go
new file mode 100644
index 0000000..3541667
--- /dev/null
+++ b/internal/terminal/audit_test.go
@@ -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)
+ }
+}
diff --git a/internal/terminal/command.go b/internal/terminal/command.go
new file mode 100644
index 0000000..e2f50f1
--- /dev/null
+++ b/internal/terminal/command.go
@@ -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
+}
diff --git a/internal/terminal/command_test.go b/internal/terminal/command_test.go
new file mode 100644
index 0000000..2720583
--- /dev/null
+++ b/internal/terminal/command_test.go
@@ -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("超长名称应当报错")
+ }
+}
diff --git a/internal/terminal/manager.go b/internal/terminal/manager.go
new file mode 100644
index 0000000..cb2849c
--- /dev/null
+++ b/internal/terminal/manager.go
@@ -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
+}
diff --git a/internal/terminal/recording.go b/internal/terminal/recording.go
new file mode 100644
index 0000000..e6cc4a1
--- /dev/null
+++ b/internal/terminal/recording.go
@@ -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
+}
diff --git a/internal/terminal/recording_test.go b/internal/terminal/recording_test.go
new file mode 100644
index 0000000..644e4a9
--- /dev/null
+++ b/internal/terminal/recording_test.go
@@ -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("未开启录制时不应创建录制器")
+ }
+}
diff --git a/internal/terminal/session.go b/internal/terminal/session.go
new file mode 100644
index 0000000..24d4108
--- /dev/null
+++ b/internal/terminal/session.go
@@ -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:]...)
+}
diff --git a/internal/terminal/session_test.go b/internal/terminal/session_test.go
new file mode 100644
index 0000000..0ee527e
--- /dev/null
+++ b/internal/terminal/session_test.go
@@ -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 ""
+ }
+}
diff --git a/internal/terminal/ssh.go b/internal/terminal/ssh.go
new file mode 100644
index 0000000..dc6d908
--- /dev/null
+++ b/internal/terminal/ssh.go
@@ -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
+}
diff --git a/migrations/037_ddns_name.sql b/migrations/037_ddns_name.sql
new file mode 100644
index 0000000..3e25570
--- /dev/null
+++ b/migrations/037_ddns_name.sql
@@ -0,0 +1,2 @@
+-- DDNS 服务商支持自定义名称:同一服务商可建多条配置,用名称区分(如「家庭」「公司」)。
+ALTER TABLE ddns_configs ADD COLUMN name TEXT NOT NULL DEFAULT '';
diff --git a/migrations/038_terminal_audit.sql b/migrations/038_terminal_audit.sql
new file mode 100644
index 0000000..228c3a6
--- /dev/null
+++ b/migrations/038_terminal_audit.sql
@@ -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);
diff --git a/migrations/039_terminal_commands.sql b/migrations/039_terminal_commands.sql
new file mode 100644
index 0000000..72a8f7f
--- /dev/null
+++ b/migrations/039_terminal_commands.sql
@@ -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);
diff --git a/web/package-lock.json b/web/package-lock.json
index 39b7bb9..b2e560e 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -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",
diff --git a/web/package.json b/web/package.json
index b6eeff4..1000cc3 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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",
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index be3fe71..bff3f11 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -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(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(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise {
const controller = new AbortController()
@@ -89,15 +113,19 @@ async function requestWithSignal(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('/api/terminal/unlock'),
+ unlockTerminal: (password: string) =>
+ request('/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('/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('/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`)}`
}
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index cd5e477..e13e906 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -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
+}
diff --git a/web/src/components/ConfiguredSecretField.vue b/web/src/components/ConfiguredSecretField.vue
index 04131f6..a6f96d3 100644
--- a/web/src/components/ConfiguredSecretField.vue
+++ b/web/src/components/ConfiguredSecretField.vue
@@ -1,6 +1,6 @@
+
+
+
+
+
+
@@ -66,12 +72,28 @@
IPv6
+
+
+ {{ customIPOpen ? '收起自定义 IP' : '自定义 IP' }}
+
启用
+
+
+
+
+
+
+
+
+
+ 填写后更新解析记录会直接使用该 IP(如 VPS 的公网 IP),不再检测本机出口 IP。
+
+
待添加记录
@@ -166,7 +167,8 @@
- {{ providerLabel(cfg.provider) }}
+ {{ cfg.name || providerLabel(cfg.provider) }}
+ {{ providerLabel(cfg.provider) }}
@@ -207,7 +209,7 @@
-
{{ providerLabel(selectedTask.provider) }}
+
{{ selectedTask.name || providerLabel(selectedTask.provider) }}