chore(release): 发布 v2.0.0
CI / verify (push) Canceled after 0s

- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验
- 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令
- DDNS 服务商名称与自定义 IP,新增迁移 037/038/039
- CHANGELOG 与 VERSION 更新至 2.0.0
This commit is contained in:
2026-10-16 20:18:04 +08:00
parent a0cc553d2d
commit 302248a907
65 changed files with 7263 additions and 606 deletions
+11 -5
View File
@@ -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": "密码已更新"})
}
+2
View File
@@ -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,
+9
View File
@@ -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
}
+5 -1
View File
@@ -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")
}
+8
View File
@@ -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
View File
@@ -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()
+2
View File
@@ -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 {
+65
View File
@@ -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)
}
}
+535
View File
@@ -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())
}
}
+70
View File
@@ -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)
}
}
}
+273
View File
@@ -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
}