536 lines
18 KiB
Go
536 lines
18 KiB
Go
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())
|
||
|
|
}
|
||
|
|
}
|