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 @@

- 发布版本 +发布版本 飞牛 fnOS 其他环境未测试 Go @@ -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 @@ diff --git a/web/src/views/DdnsView.vue b/web/src/views/DdnsView.vue index 9e71ba5..dc0de3b 100644 --- a/web/src/views/DdnsView.vue +++ b/web/src/views/DdnsView.vue @@ -131,7 +131,8 @@ - {{ providerLabel(providerDraft.provider) }} + {{ providerDraft.name || providerLabel(providerDraft.provider) }} + {{ providerLabel(providerDraft.provider) }} 待添加记录 @@ -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) }}

= { const taskForm = reactive({ provider: 'cloudflare', + name: '', api_token: '', api_token_id: '', api_secret: '', @@ -593,12 +596,15 @@ const draftAsConfig = computed((): DDNSConfig | null => { return { id: -1, provider: providerDraft.value.provider, + name: providerDraft.value.name, root_domain: '', record_name: '@', ipv4_enabled: providerDraft.value.ipv4_enabled, ipv6_enabled: providerDraft.value.ipv6_enabled, enabled: providerDraft.value.enabled, has_token: !!providerDraft.value.api_token, + custom_ipv4: providerDraft.value.custom_ipv4, + custom_ipv6: providerDraft.value.custom_ipv6, } }) @@ -643,7 +649,7 @@ const detailError = computed(() => { } } if (cfg.enabled && cfg.last_status === 'error' && cfg.last_error) { - return { title: `${providerLabel(cfg.provider)} 同步失败`, text: cfg.last_error } + return { title: `${cfg.name || providerLabel(cfg.provider)} 同步失败`, text: cfg.last_error } } return null }) @@ -764,6 +770,7 @@ function normalizeRecordDomain(input: string, rootDomain?: string): string { function resetTaskForm() { Object.assign(taskForm, { provider: 'cloudflare', + name: '', api_token: '', api_token_id: '', api_secret: '', @@ -811,6 +818,7 @@ function startEditTask(cfg: DDNSConfig) { selectedKey.value = String(cfg.id) Object.assign(taskForm, { provider: cfg.provider || 'cloudflare', + name: cfg.name ?? '', api_token: '', api_token_id: '', api_secret: '', @@ -858,6 +866,7 @@ async function saveCustomIP() { try { const updated = await api.updateDDNS(task.id, { provider: task.provider, + name: task.name ?? '', domains: domainsOf(task), enabled: task.enabled, ipv4_enabled: task.ipv4_enabled, @@ -949,6 +958,7 @@ async function saveTask() { try { const updated = await api.updateDDNS(editingId, { provider: taskForm.provider, + name: taskForm.name, domains: domainsOf(existing), ipv4_enabled: taskForm.ipv4_enabled, ipv6_enabled: taskForm.ipv6_enabled, @@ -1045,6 +1055,7 @@ async function saveRecord() { const domains = [...new Set([...domainsOf(task), domain])] const updated = await api.updateDDNS(task.id, { provider: task.provider, + name: task.name ?? '', domains, enabled: task.enabled, ipv4_enabled: task.ipv4_enabled, @@ -1060,6 +1071,7 @@ async function saveRecord() { const unique = [...new Set(domains)] const updated = await api.updateDDNS(task.id, { provider: task.provider, + name: task.name ?? '', domains: unique, enabled: task.enabled, ipv4_enabled: task.ipv4_enabled, @@ -1087,6 +1099,7 @@ async function patchTaskFlags( try { const updated = await api.updateDDNS(row.id, { provider: row.provider, + name: row.name ?? '', domains: domainsOf(row), enabled: flags.enabled ?? row.enabled, ipv4_enabled: flags.ipv4_enabled ?? row.ipv4_enabled, @@ -1177,7 +1190,7 @@ async function saveInterval() { function confirmDelete(row: DDNSConfig) { dialog.warning({ title: '删除 DDNS 配置', - content: `确定删除 ${providerLabel(row.provider)} 的 DDNS 配置吗?`, + content: `确定删除 ${row.name || providerLabel(row.provider)} 的 DDNS 配置吗?`, positiveText: '删除', negativeText: '取消', onPositiveClick: async () => { @@ -1203,6 +1216,7 @@ function confirmDeleteRecord(row: DDNSConfig, record: DDNSDomainRecord) { } else { const updated = await api.updateDDNS(row.id, { provider: row.provider, + name: row.name ?? '', domains, enabled: row.enabled, ipv4_enabled: row.ipv4_enabled, @@ -1368,12 +1382,15 @@ onMounted(init) flex-direction: column; gap: 10px; padding: var(--havline-space-3); - max-height: 720px; + /* 高度跟着视口走:编辑态表单较长,避免出现「表单被截断」或必须整页滚动才能点到保存 */ + max-height: max(420px, calc(100vh - 340px)); overflow: auto; } .task-item { width: 100%; + /* 列表是 max-height 的纵向 flex:不禁止收缩的话,编辑态表单会被压扁并裁掉底部 */ + flex-shrink: 0; text-align: left; border: 1px solid var(--havline-border); border-radius: 12px; @@ -1445,6 +1462,13 @@ onMounted(init) color: var(--havline-text); } +/* 设了名称时,旁边补一个灰字服务商名,避免只看名字不知道用的是哪家 DNS */ +.task-item__provider { + flex-shrink: 0; + font-size: 12px; + color: var(--havline-text-muted); +} + .task-item__row { margin-top: 8px; font-size: 12px; diff --git a/web/src/views/FrpView.vue b/web/src/views/FrpView.vue index 1a40c1c..bdb746c 100644 --- a/web/src/views/FrpView.vue +++ b/web/src/views/FrpView.vue @@ -2,52 +2,90 @@ @@ -25,7 +32,7 @@
- +
- - {{ loadErrorMsg }} - - - -
- 该服务端的 frps 回源端口是 {{ privilegedVhostPorts.join(' / ') }},公网反代不适用 - 80/443 已由 frps 直接对外使用,VPS 上没有 Nginx 的位置:域名访问走 FRP 隧道直达 NAS,不需要再部署 Nginx 反代,本页也不会自动下发任何路由。 - 如需使用公网反代,请到「公网服务端」页把回源端口改回 8080/8443,再回本页部署。 - 若这台 VPS 上还留着以前部署的 Nginx 路由,需先把回源端口改回 8080/8443,才能在本页移除。 -
-
- - - + +
- 检测到 {{ driftItems.length }} 项部署漂移({{ driftSummary }}) - VPS 上的实际配置与 Havline 记录不一致:可能被手工修改过,或 agent 重装后没有重新部署。 - {{ driftPreview }} + {{ actionBanner.title }} + {{ actionBanner.text }} + {{ actionBanner.detail }} + 另有 {{ extraIssueCount }} 项待处理,处理完当前项后会自动显示
- + 重新读取 + 应用配置并重试 + 一键安装 Nginx + 将按 Havline 里的记录重新下发该服务端的全部启用规则,会覆盖 VPS 上的手工修改。确定继续? + + 去改回源端口 +
- - -
-
- {{ nginxBlocking.title }} - {{ nginxBlocking.text }} - {{ nginxBlocking.detail }} + +
+
+
+
- 一键安装 Nginx -
- - - -
- - Agent 与 frps 的安装、升级、启停、配置下发在「公网服务端」页;本页只负责把规则发布到 Nginx - - 前往公网 Agent -
- - -
-
-
-
-
-
-
-
-
-
- - - -
-
-
-
{{ statusLoaded ? (agentStatus?.nginx_running ? '运行中' : '未运行') : '—' }}
-
{{ agentStatus?.nginx_version || '—' }}
-
- - — -
-
{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}
-
{{ agentStatus?.nginx_conf_dir || '—' }}
-
- - -
-
{{ routeDetails.length }} 个
-
-

{{ agentStatus.nginx_error }}

-
+
一键安装 Nginx 重新检测
+ +
+
+ 可部署规则 + {{ candidates.length }} + 来自「内网穿透」的 http 规则 +
+
+ 已生效 + {{ deployedCount }} + {{ pendingCount }} 条未部署 / 未加载 +
+
+ 健康通过 + {{ healthyCount }}/{{ candidates.length }} + DNS · 证书 · 隧道 · 服务 四段全通过 +
+
+ 当前连接 + {{ totalConns }} + frps 侧实时连接数 +
+
+
+ + + +
+ + + +
+
+
{{ statusLoaded ? (agentStatus?.nginx_running ? '运行中' : '未运行') : '—' }}
+
{{ agentStatus?.nginx_version || '—' }}
+
+ + — +
+
{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}
+
{{ agentStatus?.nginx_conf_dir || '—' }}
+
+ + +
+
{{ routeDetails.length }} 个
+
+

{{ agentStatus.nginx_error }}

+
+ 一键安装 Nginx + 重新检测 +
+
+
+
@@ -418,9 +452,11 @@ + + + diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 03d1060..b969e3b 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -192,6 +192,7 @@

恢复配置将覆盖当前数据,操作前请确保已备份。

+
@@ -569,6 +570,30 @@
+ + +

+ 开启后「SSH 终端」页面可用「公网服务端」里已保存的 SSH 凭据登录 VPS。凭据不会下发到浏览器, + 主机密钥仍按首次信任记录校验;会话空闲 10 分钟自动断开。 +

+
+
+
启用内置终端
+
关闭时终端接口一律返回 403,页面只显示提示
+
+ +
+
+
+
录制会话内容
+
+ 开启后把终端输出写入 logs/terminal(asciinema v2 格式,可在「SSH 终端 → 录像审计」里回放)。 + 录像包含屏幕上出现的一切,包括在远端提示符下输入的口令,请按需开启;默认关闭,保留 90 天。 +
+
+ +
+
@@ -895,6 +920,9 @@ const backupError = ref('') // 公开状态页:开关与访问码走设置项(访问码按「掩码往返」处理) const statusPageEnabled = ref(false) +// 内置 SSH 终端总开关:默认关闭,开启后才允许从浏览器连 VPS +const terminalEnabled = ref(false) +const terminalRecordingEnabled = ref(false) const statusPageCode = ref('') const statusPageConfigured = ref(false) @@ -1135,6 +1163,8 @@ function applySettingsToForm(settings: Record) { backupAutoEnabled.value = settings.backup_auto_enabled !== '0' backupKeepCount.value = Number(settings.backup_keep_count || DEFAULT_BACKUP_KEEP_COUNT) statusPageEnabled.value = settings.status_page_enabled === '1' + terminalEnabled.value = settings.terminal_enabled === '1' + terminalRecordingEnabled.value = settings.terminal_recording_enabled === '1' statusPageCode.value = settings.status_page_code ?? '' statusPageConfigured.value = statusPageCode.value === MASKED_SECRET acmeEmail.value = settings.acme_email ?? '' @@ -1580,6 +1610,8 @@ function buildSavePayload() { backup_auto_enabled: backupAutoEnabled.value ? '1' : '0', backup_keep_count: String(backupKeepCount.value || DEFAULT_BACKUP_KEEP_COUNT), status_page_enabled: statusPageEnabled.value ? '1' : '0', + terminal_enabled: terminalEnabled.value ? '1' : '0', + terminal_recording_enabled: terminalRecordingEnabled.value ? '1' : '0', status_page_code: statusPageCode.value.trim(), notify_cpu_threshold: String(notifyCPUThreshold.value || DEFAULT_CPU_THRESHOLD), notify_memory_threshold: String(notifyMemoryThreshold.value || DEFAULT_MEMORY_THRESHOLD), diff --git a/web/src/views/TerminalAuditView.vue b/web/src/views/TerminalAuditView.vue new file mode 100644 index 0000000..78afe35 --- /dev/null +++ b/web/src/views/TerminalAuditView.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/web/src/views/TerminalView.vue b/web/src/views/TerminalView.vue new file mode 100644 index 0000000..fbccae6 --- /dev/null +++ b/web/src/views/TerminalView.vue @@ -0,0 +1,1360 @@ + + + + + diff --git a/web/vite.config.ts b/web/vite.config.ts index a470f2c..70df626 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -16,6 +16,8 @@ export default defineConfig({ proxy: { '/api': { target: 'http://localhost:6893', + // 内置 SSH 终端走 WebSocket:开发代理必须开 ws,否则升级请求会一直挂起 + ws: true, timeout: 300_000, proxyTimeout: 300_000, configure(proxy) {