- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验 - 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令 - DDNS 服务商名称与自定义 IP,新增迁移 037/038/039 - CHANGELOG 与 VERSION 更新至 2.0.0
This commit is contained in:
@@ -14,13 +14,15 @@ import (
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
auth *auth.Service
|
||||
notify *notify.Service
|
||||
guard *loginGuard
|
||||
auth *auth.Service
|
||||
notify *notify.Service
|
||||
guard *loginGuard
|
||||
unlocks *terminalUnlockStore
|
||||
}
|
||||
|
||||
func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler {
|
||||
return &AuthHandler{auth: authSvc, notify: notifySvc, guard: newLoginGuard()}
|
||||
// NewAuthHandler 装配登录处理器;unlocks 是终端二次认证票据,改密后要让当前会话立即失效。
|
||||
func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service, unlocks *terminalUnlockStore) *AuthHandler {
|
||||
return &AuthHandler{auth: authSvc, notify: notifySvc, guard: newLoginGuard(), unlocks: unlocks}
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -155,6 +157,10 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// 改密后当前会话的终端解锁票据立即作废:密码都换了,不该还留着已解锁的 shell
|
||||
if h.unlocks != nil {
|
||||
h.unlocks.revoke(sessionID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "密码已更新"})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ func NewDDNSHandler(svc *ddns.Service) *DDNSHandler {
|
||||
|
||||
type ddnsRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
RootDomain string `json:"root_domain"`
|
||||
RecordName string `json:"record_name"`
|
||||
RecordNames []string `json:"record_names"`
|
||||
@@ -35,6 +36,7 @@ type ddnsRequest struct {
|
||||
func (h *DDNSHandler) toInput(req ddnsRequest) ddns.SaveInput {
|
||||
return ddns.SaveInput{
|
||||
Provider: req.Provider,
|
||||
Name: req.Name,
|
||||
RootDomain: req.RootDomain,
|
||||
RecordName: req.RecordName,
|
||||
RecordNames: req.RecordNames,
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/havline/havline/internal/notify"
|
||||
"github.com/havline/havline/internal/service"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
"github.com/havline/havline/internal/terminal"
|
||||
"github.com/havline/havline/internal/traffic"
|
||||
"github.com/havline/havline/internal/updater"
|
||||
)
|
||||
@@ -40,4 +41,12 @@ type Deps struct {
|
||||
Updater *updater.Client
|
||||
StaticFS fs.FS
|
||||
StartedAt string
|
||||
// Terminal 是内置 SSH 终端的会话管理器;为 nil 时相关接口不注册
|
||||
Terminal *terminal.Manager
|
||||
// TerminalAudits 是终端会话审计存储;为 nil 时不注册审计接口
|
||||
TerminalAudits *terminal.AuditStore
|
||||
// TerminalCommands 是终端常用命令存储;为 nil 时常用命令接口返回空
|
||||
TerminalCommands *terminal.CommandStore
|
||||
// TerminalRecordings 是会话录制存储;为 nil 时录制接口返回空
|
||||
TerminalRecordings *terminal.RecordingStore
|
||||
}
|
||||
|
||||
@@ -69,11 +69,15 @@ func bearerToken(r *http.Request) string {
|
||||
return strings.TrimSpace(header[len(prefix):])
|
||||
}
|
||||
|
||||
// tokenDeniedPath 是只允许浏览器会话的路径(账号与令牌管理)。
|
||||
// tokenDeniedPath 是只允许浏览器会话的路径(账号与令牌管理,以及需要二次认证的终端)。
|
||||
func tokenDeniedPath(path string) bool {
|
||||
if path == "/api/auth/password" || path == "/api/auth/logout" {
|
||||
return true
|
||||
}
|
||||
// 终端是 root shell 入口:API Token 不能绕过二次认证,一律要求浏览器会话
|
||||
if strings.HasPrefix(path, "/api/terminal") {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(path, "/api/settings/tokens") ||
|
||||
strings.HasPrefix(path, "/api/system/update")
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
type ErrorBody struct {
|
||||
Error string `json:"error"`
|
||||
// Code 是给前端分支用的稳定标识(例如 terminal_locked),文案可能改,code 不改
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
@@ -40,6 +42,12 @@ func writeError(r *http.Request, w http.ResponseWriter, status int, message stri
|
||||
writeJSON(w, status, ErrorBody{Error: message})
|
||||
}
|
||||
|
||||
// writeErrorCode 与 writeError 相同,但多带一个稳定错误码,供前端区分分支。
|
||||
func writeErrorCode(r *http.Request, w http.ResponseWriter, status int, code, message string, cause ...error) {
|
||||
logAPIError(r, status, message, cause)
|
||||
writeJSON(w, status, ErrorBody{Error: message, Code: code})
|
||||
}
|
||||
|
||||
func logAPIError(r *http.Request, status int, message string, cause []error) {
|
||||
if status < 400 {
|
||||
return
|
||||
|
||||
+51
-3
@@ -1,8 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -24,8 +27,10 @@ func NewRouter(deps Deps) http.Handler {
|
||||
|
||||
func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
|
||||
logsHandler := NewLogsHandler(deps.Config)
|
||||
// 终端二次认证票据:登录处理器与终端处理器共用一份,改密即可让当前会话失效
|
||||
terminalUnlocks := newTerminalUnlockStore()
|
||||
r := &Router{
|
||||
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
|
||||
authHandler: NewAuthHandler(deps.Auth, deps.Notify, terminalUnlocks),
|
||||
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
|
||||
nginxHandler: NewNginxHandler(deps.Proxy),
|
||||
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.Cloudflare, deps.StartedAt, deps.Config.NginxPIDFile),
|
||||
@@ -41,8 +46,19 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
|
||||
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth),
|
||||
cloudflareHandler: NewCloudflareHandler(deps.Cloudflare),
|
||||
updateHandler: NewUpdateHandler(deps.Updater),
|
||||
authSvc: deps.Auth,
|
||||
staticFS: deps.StaticFS,
|
||||
terminalHandler: NewTerminalHandler(TerminalHandlerOptions{
|
||||
FRP: deps.FRPMulti,
|
||||
Settings: deps.Settings,
|
||||
Auth: deps.Auth,
|
||||
Manager: deps.Terminal,
|
||||
Audits: deps.TerminalAudits,
|
||||
Commands: deps.TerminalCommands,
|
||||
Recordings: deps.TerminalRecordings,
|
||||
Unlocks: terminalUnlocks,
|
||||
Logger: deps.Logger,
|
||||
}),
|
||||
authSvc: deps.Auth,
|
||||
staticFS: deps.StaticFS,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -226,6 +242,28 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
|
||||
protect("POST /api/cloudflare/routes/test", cf.TestRoute)
|
||||
}
|
||||
|
||||
if deps.Terminal != nil {
|
||||
term := r.terminalHandler
|
||||
protect("GET /api/terminal/targets", term.ListTargets)
|
||||
protect("GET /api/terminal/unlock", term.UnlockStatus)
|
||||
protect("POST /api/terminal/unlock", term.Unlock)
|
||||
protect("DELETE /api/terminal/unlock", term.Lock)
|
||||
protect("GET /api/terminal/commands", term.ListCommands)
|
||||
protect("POST /api/terminal/commands", term.CreateCommand)
|
||||
protect("DELETE /api/terminal/commands/{id}", term.DeleteCommand)
|
||||
protect("GET /api/terminal/recordings", term.ListRecordings)
|
||||
protect("GET /api/terminal/recordings/{id}", term.GetRecording)
|
||||
protect("GET /api/terminal/sessions", term.ListSessions)
|
||||
protect("POST /api/terminal/sessions", term.CreateSession)
|
||||
protect("GET /api/terminal/sessions/{id}/ws", term.Stream)
|
||||
protect("DELETE /api/terminal/sessions/{id}", term.CloseSession)
|
||||
}
|
||||
|
||||
// 审计不依赖终端开关:功能关掉后,历史记录仍然应该查得到
|
||||
if deps.TerminalAudits != nil {
|
||||
protect("GET /api/terminal/audits", r.terminalHandler.ListAudits)
|
||||
}
|
||||
|
||||
if deps.StaticFS != nil {
|
||||
mux.Handle("/", r.spaHandler())
|
||||
}
|
||||
@@ -262,6 +300,7 @@ type Router struct {
|
||||
frpmultiHandler *FRPMultiHandler
|
||||
cloudflareHandler *CloudflareHandler
|
||||
updateHandler *UpdateHandler
|
||||
terminalHandler *TerminalHandler
|
||||
authSvc *auth.Service
|
||||
staticFS fs.FS
|
||||
}
|
||||
@@ -302,6 +341,15 @@ func (w *statusRecorder) Flush() {
|
||||
}
|
||||
}
|
||||
|
||||
// Hijack 转发给底层 ResponseWriter:WebSocket 升级(内置 SSH 终端)需要拿到原始连接。
|
||||
func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hijacker, ok := w.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("底层 ResponseWriter 不支持 Hijack")
|
||||
}
|
||||
return hijacker.Hijack()
|
||||
}
|
||||
|
||||
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
@@ -102,6 +102,8 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
settings.KeyChinaCIDRUpdateHours: true,
|
||||
settings.KeyChinaCIDRSourceV4: true,
|
||||
settings.KeyChinaCIDRSourceV6: true,
|
||||
settings.KeyTerminalEnabled: true,
|
||||
settings.KeyTerminalRecording: true,
|
||||
}
|
||||
filtered := map[string]string{}
|
||||
for key, value := range req {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/db"
|
||||
"github.com/havline/havline/internal/terminal"
|
||||
)
|
||||
|
||||
// TestListAuditsHandler:审计接口返回落库记录;未接存储时返回空数组而不是 null。
|
||||
func TestListAuditsHandler(t *testing.T) {
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "audit.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("打开数据库失败:%v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("迁移失败:%v", err)
|
||||
}
|
||||
|
||||
store := terminal.NewAuditStore(conn)
|
||||
now := time.Now().UTC()
|
||||
entry := terminal.AuditEntry{
|
||||
SessionID: "s1", ServerID: 3, Target: "东京 VPS · root@10.0.0.1",
|
||||
User: "admin", ClientIP: "192.168.1.9",
|
||||
StartedAt: now.Format(time.RFC3339), EndedAt: now.Add(time.Second).Format(time.RFC3339),
|
||||
DurationMS: 1_000, Reason: "用户关闭",
|
||||
}
|
||||
if err := store.WriteAudit(context.Background(), entry); err != nil {
|
||||
t.Fatalf("写入审计失败:%v", err)
|
||||
}
|
||||
|
||||
handler := NewTerminalHandler(TerminalHandlerOptions{Audits: store})
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ListAudits(rec, httptest.NewRequest(http.MethodGet, "/api/terminal/audits", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("状态码不符:%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Entries []terminal.AuditEntry `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("解析响应失败:%v", err)
|
||||
}
|
||||
if len(body.Entries) != 1 {
|
||||
t.Fatalf("期望 1 条审计,实际 %d 条", len(body.Entries))
|
||||
}
|
||||
if body.Entries[0].User != "admin" || body.Entries[0].Reason != "用户关闭" {
|
||||
t.Fatalf("审计字段不符:%+v", body.Entries[0])
|
||||
}
|
||||
|
||||
// 未接存储时也要给前端一个数组,避免前端拿到 null 崩掉
|
||||
empty := NewTerminalHandler(TerminalHandlerOptions{})
|
||||
emptyRec := httptest.NewRecorder()
|
||||
empty.ListAudits(emptyRec, httptest.NewRequest(http.MethodGet, "/api/terminal/audits", nil))
|
||||
if got := emptyRec.Body.String(); got != "{\"entries\":[]}\n" {
|
||||
t.Fatalf("空审计响应不符:%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
"github.com/havline/havline/internal/frp"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
"github.com/havline/havline/internal/terminal"
|
||||
)
|
||||
|
||||
// 终端通道的读超时:前端每 30 秒发一次 ping,这里留 3 倍余量。
|
||||
const terminalReadTimeout = 90 * time.Second
|
||||
|
||||
// TerminalHandlerOptions 是终端处理器的装配参数;Manager 为 nil 时相关接口返回不可用。
|
||||
type TerminalHandlerOptions struct {
|
||||
FRP *frp.Service
|
||||
Settings *settings.Store
|
||||
Auth *auth.Service
|
||||
Manager *terminal.Manager
|
||||
Audits *terminal.AuditStore
|
||||
// Commands 是「常用命令」存储;为 nil 时相关接口返回空列表
|
||||
Commands *terminal.CommandStore
|
||||
// Recordings 是会话录制存储;为 nil 时录制接口返回空
|
||||
Recordings *terminal.RecordingStore
|
||||
// Unlocks 与 AuthHandler 共用同一份解锁票据:改密后要让当前会话立即失效
|
||||
Unlocks *terminalUnlockStore
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// TerminalHandler 提供内置 SSH 终端:目标列表、会话创建、WebSocket 双向通道与关闭。
|
||||
type TerminalHandler struct {
|
||||
frp *frp.Service
|
||||
settings *settings.Store
|
||||
auth *auth.Service
|
||||
manager *terminal.Manager
|
||||
audits *terminal.AuditStore
|
||||
commands *terminal.CommandStore
|
||||
recordings *terminal.RecordingStore
|
||||
unlocks *terminalUnlockStore
|
||||
guard *loginGuard
|
||||
logger *slog.Logger
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// NewTerminalHandler 装配终端处理器。
|
||||
func NewTerminalHandler(opts TerminalHandlerOptions) *TerminalHandler {
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
unlocks := opts.Unlocks
|
||||
if unlocks == nil {
|
||||
unlocks = newTerminalUnlockStore()
|
||||
}
|
||||
return &TerminalHandler{
|
||||
frp: opts.FRP, settings: opts.Settings, auth: opts.Auth, manager: opts.Manager,
|
||||
audits: opts.Audits, commands: opts.Commands, recordings: opts.Recordings,
|
||||
unlocks: unlocks, guard: newLoginGuard(), logger: logger,
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: allowSameOrigin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// allowSameOrigin 只允许同源页面建立 WebSocket,避免第三方页面借用已登录会话。
|
||||
func allowSameOrigin(r *http.Request) bool {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(parsed.Host, r.Host)
|
||||
}
|
||||
|
||||
type terminalTarget struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Detail string `json:"detail"`
|
||||
Ready bool `json:"ready"`
|
||||
Disabled string `json:"disabled_reason,omitempty"`
|
||||
}
|
||||
|
||||
type terminalCreateRequest struct {
|
||||
ServerID int64 `json:"server_id"`
|
||||
Cols int `json:"cols"`
|
||||
Rows int `json:"rows"`
|
||||
}
|
||||
|
||||
// terminalUnlockRequest 是终端二次认证的请求体。
|
||||
type terminalUnlockRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// terminalCommandRequest 是新增快捷命令的请求体。
|
||||
type terminalCommandRequest struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type terminalClientMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Cols int `json:"cols,omitempty"`
|
||||
Rows int `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type terminalServerMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ListTargets 返回可选目标:只列出已配置 SSH 的服务端,未配置的给出原因。
|
||||
func (h *TerminalHandler) ListTargets(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireEnabled(r, w) {
|
||||
return
|
||||
}
|
||||
servers, err := h.frp.ListServers(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取服务端列表失败", err)
|
||||
return
|
||||
}
|
||||
targets := make([]terminalTarget, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
item := terminalTarget{
|
||||
Value: strconv.FormatInt(server.ID, 10),
|
||||
Label: server.Name,
|
||||
Detail: fmt.Sprintf("%s@%s:%d", server.SSHUser, server.SSHHost, server.SSHPort),
|
||||
Ready: server.SSHConfigured,
|
||||
}
|
||||
if !server.SSHConfigured {
|
||||
item.Disabled = "未配置 SSH,请到「公网服务端」填写"
|
||||
}
|
||||
targets = append(targets, item)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"targets": targets})
|
||||
}
|
||||
|
||||
// ListRecordings 返回磁盘上的会话录制,供「录像审计」列表使用。
|
||||
func (h *TerminalHandler) ListRecordings(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireEnabled(r, w) {
|
||||
return
|
||||
}
|
||||
if h.recordings == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"recordings": []terminal.Recording{}})
|
||||
return
|
||||
}
|
||||
items, err := h.recordings.List()
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取录制列表失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"recordings": items})
|
||||
}
|
||||
|
||||
// GetRecording 读取一段录制(asciinema v2 文本),供前端回放。
|
||||
func (h *TerminalHandler) GetRecording(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireEnabled(r, w) {
|
||||
return
|
||||
}
|
||||
if h.recordings == nil {
|
||||
writeError(r, w, http.StatusNotFound, "录制不存在或已被清理")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
path, ok := h.recordings.Path(id)
|
||||
if !ok {
|
||||
writeError(r, w, http.StatusBadRequest, "录制 ID 不合法")
|
||||
return
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusNotFound, "录制不存在或已被清理")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取录制失败", err)
|
||||
return
|
||||
}
|
||||
// 录制内容按原样返回:前端按 asciinema v2 解析后逐帧回放
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
http.ServeContent(w, r, id+".cast", info.ModTime(), file)
|
||||
}
|
||||
|
||||
// ListCommands 返回已保存的快捷命令(右侧「常用命令」面板)。
|
||||
func (h *TerminalHandler) ListCommands(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireEnabled(r, w) {
|
||||
return
|
||||
}
|
||||
if h.commands == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"commands": []terminal.TerminalCommand{}})
|
||||
return
|
||||
}
|
||||
commands, err := h.commands.ListCommands(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取常用命令失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"commands": commands})
|
||||
}
|
||||
|
||||
// CreateCommand 保存一条快捷命令。
|
||||
func (h *TerminalHandler) CreateCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireEnabled(r, w) {
|
||||
return
|
||||
}
|
||||
if h.commands == nil {
|
||||
writeError(r, w, http.StatusServiceUnavailable, "常用命令当前不可用")
|
||||
return
|
||||
}
|
||||
var req terminalCommandRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
command, err := h.commands.CreateCommand(r.Context(), req.Name, req.Command)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, command)
|
||||
}
|
||||
|
||||
// DeleteCommand 删除一条快捷命令。
|
||||
func (h *TerminalHandler) DeleteCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireEnabled(r, w) {
|
||||
return
|
||||
}
|
||||
if h.commands == nil {
|
||||
writeError(r, w, http.StatusServiceUnavailable, "常用命令当前不可用")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(r, w, http.StatusBadRequest, "命令 ID 无效")
|
||||
return
|
||||
}
|
||||
if err := h.commands.DeleteCommand(r.Context(), id); err != nil {
|
||||
if errors.Is(err, terminal.ErrCommandNotFound) {
|
||||
writeError(r, w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(r, w, http.StatusInternalServerError, "删除常用命令失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// UnlockStatus 返回当前登录会话的终端解锁状态,供前端显示剩余时间。
|
||||
func (h *TerminalHandler) UnlockStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireEnabled(r, w) {
|
||||
return
|
||||
}
|
||||
expires, ok := h.unlocks.expiresAt(sessionIDFromContext(r.Context()))
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"unlocked": false})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"unlocked": true,
|
||||
"expires_at": expires.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// Unlock 校验管理员密码,换取终端短时解锁。
|
||||
// 这是「登录态点一下就能拿到 root shell」与「还需要知道一个密码」之间的那道门。
|
||||
func (h *TerminalHandler) Unlock(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireEnabled(r, w) {
|
||||
return
|
||||
}
|
||||
sessionID := sessionIDFromContext(r.Context())
|
||||
if sessionID == "" {
|
||||
// 终端不接受 API Token,二次认证也必须走浏览器会话
|
||||
writeErrorCode(r, w, http.StatusForbidden, "terminal_session_required", "终端二次认证需要使用浏览器登录会话")
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
if h.guard.blocked(ip) {
|
||||
writeErrorCode(r, w, http.StatusTooManyRequests, "too_many_attempts", "密码错误次数过多,请 5 分钟后再试")
|
||||
return
|
||||
}
|
||||
var req terminalUnlockRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
adminID, err := h.auth.AdminIDForSession(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
|
||||
return
|
||||
}
|
||||
// 密码原样比对:不做 TrimSpace,避免把首尾空格当作无关字符
|
||||
if err := h.auth.VerifyPassword(r.Context(), adminID, req.Password); err != nil {
|
||||
h.guard.fail(ip)
|
||||
h.logger.Warn("终端二次认证失败", "module", "TERMINAL", "client_ip", ip, "admin_id", adminID)
|
||||
writeErrorCode(r, w, http.StatusUnauthorized, "bad_password", "管理员密码不正确")
|
||||
return
|
||||
}
|
||||
h.guard.reset(ip)
|
||||
expires := h.unlocks.grant(sessionID)
|
||||
h.logger.Info("终端二次认证通过",
|
||||
"module", "TERMINAL", "client_ip", ip, "admin_id", adminID,
|
||||
"expires_at", expires.UTC().Format(time.RFC3339))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"expires_at": expires.UTC().Format(time.RFC3339)})
|
||||
}
|
||||
|
||||
// Lock 主动锁定当前会话的终端解锁,之后新建会话需要重新输入密码。
|
||||
func (h *TerminalHandler) Lock(w http.ResponseWriter, r *http.Request) {
|
||||
h.unlocks.revoke(sessionIDFromContext(r.Context()))
|
||||
h.logger.Info("终端已锁定", "module", "TERMINAL", "client_ip", clientIP(r))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// CreateSession 建立一条新的 SSH 终端会话。
|
||||
func (h *TerminalHandler) CreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireEnabled(r, w) {
|
||||
return
|
||||
}
|
||||
// 二次认证:没有有效解锁票据(或已过期)就不许建会话
|
||||
if _, unlocked := h.unlocks.expiresAt(sessionIDFromContext(r.Context())); !unlocked {
|
||||
writeErrorCode(r, w, http.StatusForbidden, "terminal_locked", "终端需要二次认证:请先输入管理员密码解锁")
|
||||
return
|
||||
}
|
||||
var req terminalCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if req.ServerID <= 0 {
|
||||
writeError(r, w, http.StatusBadRequest, "请选择要连接的服务端")
|
||||
return
|
||||
}
|
||||
// SSH 认证 + PTY 申请需要时间,单独给 30 秒上限
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
user, clientIP := h.terminalActor(r)
|
||||
// 录制开关默认关闭:开启后本次会话输出会写入 logs/terminal
|
||||
record, err := h.settings.GetBool(r.Context(), settings.KeyTerminalRecording)
|
||||
if err != nil {
|
||||
record = false
|
||||
}
|
||||
session, err := h.manager.Create(ctx, terminal.CreateOptions{
|
||||
ServerID: req.ServerID,
|
||||
Cols: req.Cols,
|
||||
Rows: req.Rows,
|
||||
User: user,
|
||||
ClientIP: clientIP,
|
||||
Record: record,
|
||||
})
|
||||
if err != nil {
|
||||
writeTerminalError(r, w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"session_id": session.ID, "label": session.Label})
|
||||
}
|
||||
|
||||
// ListAudits 返回最近的终端会话审计:谁、何时、连了哪台、多久、为什么断开。
|
||||
func (h *TerminalHandler) ListAudits(w http.ResponseWriter, r *http.Request) {
|
||||
if h.audits == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": []terminal.AuditEntry{}})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
entries, err := h.audits.ListAudits(r.Context(), limit)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取终端审计记录失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
||||
}
|
||||
|
||||
// terminalActor 解析审计需要的调用方身份:登录用户名与来源 IP。
|
||||
// 用 API Token 调用时没有会话 Cookie,这里如实记成「API Token」而不是伪造用户名。
|
||||
func (h *TerminalHandler) terminalActor(r *http.Request) (string, string) {
|
||||
ip := clientIP(r)
|
||||
if bearerToken(r) != "" {
|
||||
return "API Token", ip
|
||||
}
|
||||
sessionID := sessionIDFromContext(r.Context())
|
||||
if sessionID == "" || h.auth == nil {
|
||||
return "未知", ip
|
||||
}
|
||||
adminID, err := h.auth.AdminIDForSession(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
return "未知", ip
|
||||
}
|
||||
username, err := h.auth.AdminUsername(r.Context(), adminID)
|
||||
if err != nil || strings.TrimSpace(username) == "" {
|
||||
return "未知", ip
|
||||
}
|
||||
return username, ip
|
||||
}
|
||||
|
||||
// ListSessions 返回当前会话快照(前端刷新后清理与排查用)。
|
||||
func (h *TerminalHandler) ListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sessions": h.manager.List()})
|
||||
}
|
||||
|
||||
// CloseSession 主动关闭会话。用户点「关闭」或页面卸载时调用。
|
||||
func (h *TerminalHandler) CloseSession(w http.ResponseWriter, r *http.Request) {
|
||||
h.manager.Close(r.PathValue("id"), "用户关闭")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// Stream 是终端双向通道:浏览器发送按键/尺寸,服务端推送输出与结束原因。
|
||||
func (h *TerminalHandler) Stream(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireEnabled(r, w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
session, ok := h.manager.Get(id)
|
||||
if !ok {
|
||||
writeError(r, w, http.StatusNotFound, "终端会话不存在或已关闭")
|
||||
return
|
||||
}
|
||||
conn, err := h.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
go h.readClient(conn, session, id)
|
||||
h.writeClient(conn, session)
|
||||
}
|
||||
|
||||
// writeClient 把会话输出推给浏览器;订阅通道关闭表示会话结束。
|
||||
func (h *TerminalHandler) writeClient(conn *websocket.Conn, session *terminal.Session) {
|
||||
chunks, cancel := session.Subscribe()
|
||||
defer cancel()
|
||||
for chunk := range chunks {
|
||||
message := terminalServerMessage{Type: "output", Data: base64.StdEncoding.EncodeToString(chunk)}
|
||||
if err := writeSocketMessage(conn, message); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = writeSocketMessage(conn, terminalServerMessage{Type: "exit", Reason: session.Snapshot().Reason})
|
||||
}
|
||||
|
||||
// readClient 处理按键与窗口尺寸;连接断开时立刻回收会话。
|
||||
func (h *TerminalHandler) readClient(conn *websocket.Conn, session *terminal.Session, id string) {
|
||||
conn.SetReadLimit(1 << 20)
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(terminalReadTimeout))
|
||||
_, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
h.manager.Close(id, "前端断开")
|
||||
return
|
||||
}
|
||||
var message terminalClientMessage
|
||||
if err := json.Unmarshal(payload, &message); err != nil {
|
||||
continue
|
||||
}
|
||||
h.dispatchClientMessage(session, message)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TerminalHandler) dispatchClientMessage(session *terminal.Session, message terminalClientMessage) {
|
||||
switch message.Type {
|
||||
case "input":
|
||||
data, err := base64.StdEncoding.DecodeString(message.Data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = session.Input(data)
|
||||
case "resize":
|
||||
_ = session.Resize(message.Cols, message.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSocketMessage(conn *websocket.Conn, message terminalServerMessage) error {
|
||||
payload, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
return conn.WriteMessage(websocket.TextMessage, payload)
|
||||
}
|
||||
|
||||
// terminalEnabled 读设置开关:缺省视为关闭(shell 入口必须显式打开)。
|
||||
func (h *TerminalHandler) terminalEnabled(r *http.Request) (bool, error) {
|
||||
// 用 GetBool 而不是只认 "1":设置页写 "1"/"0",但 true/on 这类写法也应被认作开启,
|
||||
// 否则开关看着是开的、接口却按未启用拒绝,排查起来很费劲
|
||||
return h.settings.GetBool(r.Context(), settings.KeyTerminalEnabled)
|
||||
}
|
||||
|
||||
// requireEnabled 检查设置开关与运行能力;未通过时直接写出错误并返回 false。
|
||||
func (h *TerminalHandler) requireEnabled(r *http.Request, w http.ResponseWriter) bool {
|
||||
enabled, err := h.terminalEnabled(r)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
|
||||
return false
|
||||
}
|
||||
if !enabled {
|
||||
writeError(r, w, http.StatusForbidden, "SSH 终端未启用:请到「系统设置」打开后再使用")
|
||||
return false
|
||||
}
|
||||
if h.manager == nil || !h.manager.Enabled() {
|
||||
writeError(r, w, http.StatusServiceUnavailable, "SSH 终端当前不可用")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeTerminalError(r *http.Request, w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, terminal.ErrDisabled):
|
||||
writeError(r, w, http.StatusForbidden, "SSH 终端未启用")
|
||||
case errors.Is(err, terminal.ErrTooManySessions):
|
||||
writeError(r, w, http.StatusTooManyRequests, err.Error())
|
||||
case errors.Is(err, terminal.ErrNotFound):
|
||||
writeError(r, w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// terminalUnlockTTL 是一次二次认证的有效期:输一次管理员密码,十分钟内新建会话不再重复输入。
|
||||
const terminalUnlockTTL = 10 * time.Minute
|
||||
|
||||
// terminalUnlockStore 记录「哪个登录会话已通过终端二次认证」。
|
||||
//
|
||||
// 只放内存、不落盘:进程重启即全部失效,符合「短时解锁」的定位;
|
||||
// 键是会话 Cookie 的会话 ID 而不是管理员 ID,换个浏览器/会话就要重新解锁。
|
||||
type terminalUnlockStore struct {
|
||||
mu sync.Mutex
|
||||
expires map[string]time.Time
|
||||
}
|
||||
|
||||
func newTerminalUnlockStore() *terminalUnlockStore {
|
||||
return &terminalUnlockStore{expires: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
// grant 记下这次解锁并返回到期时间。
|
||||
func (s *terminalUnlockStore) grant(sessionID string) time.Time {
|
||||
now := time.Now()
|
||||
expires := now.Add(terminalUnlockTTL)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pruneLocked(now)
|
||||
s.expires[sessionID] = expires
|
||||
return expires
|
||||
}
|
||||
|
||||
// expiresAt 返回该会话的解锁到期时间;未解锁或已过期返回 false。
|
||||
func (s *terminalUnlockStore) expiresAt(sessionID string) (time.Time, bool) {
|
||||
if sessionID == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
expires, ok := s.expires[sessionID]
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if time.Now().After(expires) {
|
||||
delete(s.expires, sessionID)
|
||||
return time.Time{}, false
|
||||
}
|
||||
return expires, true
|
||||
}
|
||||
|
||||
// revoke 主动锁定:用户点锁定,或改密后让当前会话立即失效。
|
||||
func (s *terminalUnlockStore) revoke(sessionID string) {
|
||||
if sessionID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.expires, sessionID)
|
||||
}
|
||||
|
||||
// pruneLocked 清理过期条目,避免长期运行后 map 里堆积失效的旧会话(调用方需持锁)。
|
||||
func (s *terminalUnlockStore) pruneLocked(now time.Time) {
|
||||
for sessionID, expires := range s.expires {
|
||||
if now.After(expires) {
|
||||
delete(s.expires, sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/auth"
|
||||
"github.com/havline/havline/internal/db"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
"github.com/havline/havline/internal/terminal"
|
||||
)
|
||||
|
||||
const (
|
||||
testAdminPassword = "admin-password-123"
|
||||
)
|
||||
|
||||
// TestTerminalUnlockStore:票据按时失效、可主动撤销,过期条目会被顺带清理。
|
||||
func TestTerminalUnlockStore(t *testing.T) {
|
||||
store := newTerminalUnlockStore()
|
||||
const testSessionID = "session-abc"
|
||||
if _, ok := store.expiresAt(testSessionID); ok {
|
||||
t.Fatal("未解锁的会话不应有效")
|
||||
}
|
||||
expires := store.grant(testSessionID)
|
||||
if !expires.After(time.Now()) {
|
||||
t.Fatalf("解锁到期时间应当在未来:%v", expires)
|
||||
}
|
||||
if _, ok := store.expiresAt(testSessionID); !ok {
|
||||
t.Fatal("解锁后应当有效")
|
||||
}
|
||||
store.revoke(testSessionID)
|
||||
if _, ok := store.expiresAt(testSessionID); ok {
|
||||
t.Fatal("锁定后应当失效")
|
||||
}
|
||||
|
||||
// 手动塞一条过期记录,grant 时应当被清掉
|
||||
store.expires["stale"] = time.Now().Add(-time.Minute)
|
||||
store.grant("other")
|
||||
if _, ok := store.expires["stale"]; ok {
|
||||
t.Fatal("过期条目未被清理")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalUnlockFlow:未解锁不能建会话 → 密码错误被拒并计数 → 解锁后可建会话 → 可主动锁定。
|
||||
func TestTerminalUnlockFlow(t *testing.T) {
|
||||
handler, openerCalled, sessionID := newUnlockTestHandler(t)
|
||||
|
||||
// 1) 未解锁:建会话被拒,并给出稳定错误码
|
||||
rec := callTerminal(t, handler.CreateSession, http.MethodPost, `{"server_id":1,"cols":80,"rows":24}`, sessionID)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("未解锁建会话应当 403,实际 %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if code := errorCode(t, rec); code != "terminal_locked" {
|
||||
t.Fatalf("错误码应当是 terminal_locked,实际 %q", code)
|
||||
}
|
||||
if *openerCalled {
|
||||
t.Fatal("未解锁就触发了后端连接")
|
||||
}
|
||||
|
||||
// 2) 密码错误:401,且不发放票据
|
||||
rec = callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"wrong-password"}`, sessionID)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("错误密码应当 401,实际 %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if code := errorCode(t, rec); code != "bad_password" {
|
||||
t.Fatalf("错误码应当是 bad_password,实际 %q", code)
|
||||
}
|
||||
if _, ok := handler.unlocks.expiresAt(sessionID); ok {
|
||||
t.Fatal("密码错误却发放了解锁票据")
|
||||
}
|
||||
|
||||
// 3) 密码正确:解锁成功
|
||||
rec = callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"`+testAdminPassword+`"}`, sessionID)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("正确密码应当 200,实际 %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var unlocked struct {
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &unlocked); err != nil || unlocked.ExpiresAt == "" {
|
||||
t.Fatalf("解锁响应缺少 expires_at:%s", rec.Body.String())
|
||||
}
|
||||
|
||||
// 状态接口能读到剩余时间
|
||||
rec = callTerminal(t, handler.UnlockStatus, http.MethodGet, "", sessionID)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"unlocked":true`) {
|
||||
t.Fatalf("解锁状态不符:%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 4) 已解锁:放行到后端连接(测试用 Opener 固定失败,所以这里是 502 而不是 403)
|
||||
rec = callTerminal(t, handler.CreateSession, http.MethodPost, `{"server_id":1,"cols":80,"rows":24}`, sessionID)
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Fatalf("解锁后仍被拦:%s", rec.Body.String())
|
||||
}
|
||||
if !*openerCalled {
|
||||
t.Fatal("解锁后应当尝试连接后端")
|
||||
}
|
||||
|
||||
// 5) 主动锁定
|
||||
rec = callTerminal(t, handler.Lock, http.MethodDelete, "", sessionID)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("锁定失败:%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := handler.unlocks.expiresAt(sessionID); ok {
|
||||
t.Fatal("锁定后票据仍有效")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalUnlockRateLimit:同一 IP 连续失败到阈值后被锁定。
|
||||
func TestTerminalUnlockRateLimit(t *testing.T) {
|
||||
handler, _, sessionID := newUnlockTestHandler(t)
|
||||
for i := 0; i < loginFailureLimit; i++ {
|
||||
rec := callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"wrong-password"}`, sessionID)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("第 %d 次失败应当 401,实际 %d", i+1, rec.Code)
|
||||
}
|
||||
}
|
||||
rec := callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"wrong-password"}`, sessionID)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("超过阈值应当 429,实际 %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if code := errorCode(t, rec); code != "too_many_attempts" {
|
||||
t.Fatalf("错误码应当是 too_many_attempts,实际 %q", code)
|
||||
}
|
||||
// 锁定期间即使密码正确也不放行
|
||||
rec = callTerminal(t, handler.Unlock, http.MethodPost, `{"password":"`+testAdminPassword+`"}`, sessionID)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("锁定期间应当继续 429,实际 %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalCommandsAPI:常用命令的新增、列表与删除。
|
||||
func TestTerminalCommandsAPI(t *testing.T) {
|
||||
handler, _, sessionID := newUnlockTestHandler(t)
|
||||
|
||||
rec := callTerminal(t, handler.CreateCommand, http.MethodPost, `{"name":"磁盘占用","command":"df -h"}`, sessionID)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("新增命令应当 201,实际 %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created terminal.TerminalCommand
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil || created.ID == 0 {
|
||||
t.Fatalf("新增响应缺少 ID:%s", rec.Body.String())
|
||||
}
|
||||
|
||||
rec = callTerminal(t, handler.ListCommands, http.MethodGet, "", sessionID)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "df -h") {
|
||||
t.Fatalf("列表未包含新增命令:%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 空名称应当被拒
|
||||
rec = callTerminal(t, handler.CreateCommand, http.MethodPost, `{"name":" ","command":"ls"}`, sessionID)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("空名称应当 400,实际 %d", rec.Code)
|
||||
}
|
||||
|
||||
// 删除要走带路径参数的分支,这里手工塞 PathValue
|
||||
if code := deleteCommand(t, handler, sessionID, created.ID); code != http.StatusOK {
|
||||
t.Fatalf("删除应当 200,实际 %d", code)
|
||||
}
|
||||
if code := deleteCommand(t, handler, sessionID, created.ID); code != http.StatusNotFound {
|
||||
t.Fatalf("重复删除应当 404,实际 %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteCommand 按路由形态调用删除接口(PathValue 由 ServeMux 注入,测试里手工设置)。
|
||||
func deleteCommand(t *testing.T, handler *TerminalHandler, sessionID string, id int64) int {
|
||||
t.Helper()
|
||||
raw := strconv.FormatInt(id, 10)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/terminal/commands/"+raw, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "havline_session", Value: sessionID})
|
||||
req.SetPathValue("id", raw)
|
||||
rec := httptest.NewRecorder()
|
||||
SessionMiddleware(nil)(http.HandlerFunc(handler.DeleteCommand)).ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
// TestTokenDeniedPathCoversTerminal:终端不接受 API Token,否则二次认证可以被绕过。
|
||||
func TestTokenDeniedPathCoversTerminal(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/api/terminal/targets",
|
||||
"/api/terminal/unlock",
|
||||
"/api/terminal/sessions",
|
||||
} {
|
||||
if !tokenDeniedPath(path) {
|
||||
t.Fatalf("%s 应当只允许浏览器会话", path)
|
||||
}
|
||||
}
|
||||
if tokenDeniedPath("/api/proxies") {
|
||||
t.Fatal("普通接口不应被限制")
|
||||
}
|
||||
}
|
||||
|
||||
// newUnlockTestHandler 造一套「终端已开启 + 已初始化管理员」的处理器,并返回一个真实会话 ID。
|
||||
func newUnlockTestHandler(t *testing.T) (*TerminalHandler, *bool, string) {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("打开数据库失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("迁移失败:%v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
authSvc := auth.New(conn)
|
||||
if err := authSvc.Setup(ctx, "admin", testAdminPassword); err != nil {
|
||||
t.Fatalf("初始化管理员失败:%v", err)
|
||||
}
|
||||
settingsStore := settings.NewStore(conn)
|
||||
if err := settingsStore.SetBool(ctx, settings.KeyTerminalEnabled, true); err != nil {
|
||||
t.Fatalf("打开终端开关失败:%v", err)
|
||||
}
|
||||
// 解锁票据绑定真实会话:没有这条会话记录,AdminIDForSession 会先拒绝
|
||||
sessionID, err := authSvc.Login(ctx, "admin", testAdminPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("登录失败:%v", err)
|
||||
}
|
||||
|
||||
openerCalled := false
|
||||
manager := terminal.NewManager(
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
func(context.Context, int64, int, int) (terminal.Terminal, string, error) {
|
||||
openerCalled = true
|
||||
return nil, "", errors.New("测试环境不建立真实连接")
|
||||
},
|
||||
terminal.Options{},
|
||||
)
|
||||
|
||||
handler := NewTerminalHandler(TerminalHandlerOptions{
|
||||
Auth: authSvc,
|
||||
Settings: settingsStore,
|
||||
Manager: manager,
|
||||
Unlocks: newTerminalUnlockStore(),
|
||||
Commands: terminal.NewCommandStore(conn),
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
return handler, &openerCalled, sessionID
|
||||
}
|
||||
|
||||
// callTerminal 带上会话 Cookie 调用处理器;SessionMiddleware 只读 Cookie,这里复用它注入会话。
|
||||
func callTerminal(t *testing.T, next http.HandlerFunc, method, body, sessionID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, "/api/terminal", reader)
|
||||
req.AddCookie(&http.Cookie{Name: "havline_session", Value: sessionID})
|
||||
rec := httptest.NewRecorder()
|
||||
SessionMiddleware(nil)(next).ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// errorCode 读出响应体里的稳定错误码。
|
||||
func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
var body ErrorBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("解析错误响应失败:%v", err)
|
||||
}
|
||||
return body.Code
|
||||
}
|
||||
Reference in New Issue
Block a user