diff --git a/internal/app/app.go b/internal/app/app.go index a09b683b..13e3dad9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,11 +15,11 @@ import ( "cyberstrike-ai/internal/database" "cyberstrike-ai/internal/handler" "cyberstrike-ai/internal/knowledge" - "cyberstrike-ai/internal/robot" "cyberstrike-ai/internal/logger" "cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp/builtin" "cyberstrike-ai/internal/openai" + "cyberstrike-ai/internal/robot" "cyberstrike-ai/internal/security" "cyberstrike-ai/internal/skills" "cyberstrike-ai/internal/storage" @@ -46,7 +46,7 @@ type App struct { knowledgeHandler *handler.KnowledgeHandler // 知识库处理器(用于动态初始化) agentHandler *handler.AgentHandler // Agent处理器(用于更新知识库管理器) robotHandler *handler.RobotHandler // 机器人处理器(钉钉/飞书/企业微信) - robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel + robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启 larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启 } @@ -319,6 +319,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) { authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger) attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger) vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger) + webshellHandler := handler.NewWebShellHandler(log.Logger, db) configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger) externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger) roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger) @@ -429,6 +430,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) { attackChainHandler, app, // 传递 App 实例以便动态获取 knowledgeHandler vulnerabilityHandler, + webshellHandler, roleHandler, skillsHandler, fofaHandler, @@ -556,6 +558,7 @@ func setupRoutes( attackChainHandler *handler.AttackChainHandler, app *App, // 传递 App 实例以便动态获取 knowledgeHandler vulnerabilityHandler *handler.VulnerabilityHandler, + webshellHandler *handler.WebShellHandler, roleHandler *handler.RoleHandler, skillsHandler *handler.SkillsHandler, fofaHandler *handler.FofaHandler, @@ -817,6 +820,14 @@ func setupRoutes( protected.PUT("/vulnerabilities/:id", vulnerabilityHandler.UpdateVulnerability) protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability) + // WebShell 管理(代理执行 + 连接配置存 SQLite) + protected.GET("/webshell/connections", webshellHandler.ListConnections) + protected.POST("/webshell/connections", webshellHandler.CreateConnection) + protected.PUT("/webshell/connections/:id", webshellHandler.UpdateConnection) + protected.DELETE("/webshell/connections/:id", webshellHandler.DeleteConnection) + protected.POST("/webshell/exec", webshellHandler.Exec) + protected.POST("/webshell/file", webshellHandler.FileOp) + // 角色管理 protected.GET("/roles", roleHandler.GetRoles) protected.GET("/roles/:name", roleHandler.GetRole) diff --git a/internal/database/database.go b/internal/database/database.go index 13815a6a..c228edc8 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -227,6 +227,19 @@ func (db *DB) initTables() error { FOREIGN KEY (queue_id) REFERENCES batch_task_queues(id) ON DELETE CASCADE );` + // 创建 WebShell 连接表 + createWebshellConnectionsTable := ` + CREATE TABLE IF NOT EXISTS webshell_connections ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + password TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'php', + method TEXT NOT NULL DEFAULT 'post', + cmd_param TEXT NOT NULL DEFAULT '', + remark TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + );` + // 创建索引 createIndexes := ` CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id); @@ -253,6 +266,7 @@ func (db *DB) initTables() error { CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id); CREATE INDEX IF NOT EXISTS idx_batch_task_queues_created_at ON batch_task_queues(created_at); CREATE INDEX IF NOT EXISTS idx_batch_task_queues_title ON batch_task_queues(title); + CREATE INDEX IF NOT EXISTS idx_webshell_connections_created_at ON webshell_connections(created_at); ` if _, err := db.Exec(createConversationsTable); err != nil { @@ -311,6 +325,10 @@ func (db *DB) initTables() error { return fmt.Errorf("创建batch_tasks表失败: %w", err) } + if _, err := db.Exec(createWebshellConnectionsTable); err != nil { + return fmt.Errorf("创建webshell_connections表失败: %w", err) + } + // 为已有表添加新字段(如果不存在)- 必须在创建索引之前 if err := db.migrateConversationsTable(); err != nil { db.logger.Warn("迁移conversations表失败", zap.Error(err)) diff --git a/internal/database/webshell.go b/internal/database/webshell.go new file mode 100644 index 00000000..abd7b28d --- /dev/null +++ b/internal/database/webshell.go @@ -0,0 +1,112 @@ +package database + +import ( + "database/sql" + "time" + + "go.uber.org/zap" +) + +// WebShellConnection WebShell 连接配置 +type WebShellConnection struct { + ID string `json:"id"` + URL string `json:"url"` + Password string `json:"password"` + Type string `json:"type"` + Method string `json:"method"` + CmdParam string `json:"cmdParam"` + Remark string `json:"remark"` + CreatedAt time.Time `json:"createdAt"` +} + +// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序 +func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) { + query := ` + SELECT id, url, password, type, method, cmd_param, remark, created_at + FROM webshell_connections + ORDER BY created_at DESC + ` + rows, err := db.Query(query) + if err != nil { + db.logger.Error("查询 WebShell 连接列表失败", zap.Error(err)) + return nil, err + } + defer rows.Close() + + var list []WebShellConnection + for rows.Next() { + var c WebShellConnection + err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt) + if err != nil { + db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err)) + continue + } + list = append(list, c) + } + return list, rows.Err() +} + +// GetWebshellConnection 根据 ID 获取一条连接 +func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) { + query := ` + SELECT id, url, password, type, method, cmd_param, remark, created_at + FROM webshell_connections WHERE id = ? + ` + var c WebShellConnection + err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + db.logger.Error("查询 WebShell 连接失败", zap.Error(err), zap.String("id", id)) + return nil, err + } + return &c, nil +} + +// CreateWebshellConnection 创建 WebShell 连接 +func (db *DB) CreateWebshellConnection(c *WebShellConnection) error { + query := ` + INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ` + _, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.CreatedAt) + if err != nil { + db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID)) + return err + } + return nil +} + +// UpdateWebshellConnection 更新 WebShell 连接 +func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error { + query := ` + UPDATE webshell_connections + SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ? + WHERE id = ? + ` + result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.ID) + if err != nil { + db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID)) + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +// DeleteWebshellConnection 删除 WebShell 连接 +func (db *DB) DeleteWebshellConnection(id string) error { + result, err := db.Exec(`DELETE FROM webshell_connections WHERE id = ?`, id) + if err != nil { + db.logger.Error("删除 WebShell 连接失败", zap.Error(err), zap.String("id", id)) + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return sql.ErrNoRows + } + return nil +} diff --git a/internal/handler/webshell.go b/internal/handler/webshell.go new file mode 100644 index 00000000..da7ecd22 --- /dev/null +++ b/internal/handler/webshell.go @@ -0,0 +1,426 @@ +package handler + +import ( + "bytes" + "database/sql" + "io" + "net/http" + "net/url" + "strings" + "time" + + "cyberstrike-ai/internal/database" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// WebShellHandler 代理执行 WebShell 命令(类似冰蝎/蚁剑),避免前端跨域并统一构建请求 +type WebShellHandler struct { + logger *zap.Logger + client *http.Client + db *database.DB +} + +// NewWebShellHandler 创建 WebShell 处理器,db 可为 nil(连接配置接口将不可用) +func NewWebShellHandler(logger *zap.Logger, db *database.DB) *WebShellHandler { + return &WebShellHandler{ + logger: logger, + client: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{DisableKeepAlives: false}, + }, + db: db, + } +} + +// CreateConnectionRequest 创建连接请求 +type CreateConnectionRequest struct { + URL string `json:"url" binding:"required"` + Password string `json:"password"` + Type string `json:"type"` + Method string `json:"method"` + CmdParam string `json:"cmd_param"` + Remark string `json:"remark"` +} + +// UpdateConnectionRequest 更新连接请求 +type UpdateConnectionRequest struct { + URL string `json:"url" binding:"required"` + Password string `json:"password"` + Type string `json:"type"` + Method string `json:"method"` + CmdParam string `json:"cmd_param"` + Remark string `json:"remark"` +} + +// ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections) +func (h *WebShellHandler) ListConnections(c *gin.Context) { + if h.db == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) + return + } + list, err := h.db.ListWebshellConnections() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if list == nil { + list = []database.WebShellConnection{} + } + c.JSON(http.StatusOK, list) +} + +// CreateConnection 创建 WebShell 连接(POST /api/webshell/connections) +func (h *WebShellHandler) CreateConnection(c *gin.Context) { + if h.db == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) + return + } + var req CreateConnectionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + req.URL = strings.TrimSpace(req.URL) + if req.URL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) + return + } + if _, err := url.Parse(req.URL); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"}) + return + } + method := strings.ToLower(strings.TrimSpace(req.Method)) + if method != "get" && method != "post" { + method = "post" + } + shellType := strings.ToLower(strings.TrimSpace(req.Type)) + if shellType == "" { + shellType = "php" + } + conn := &database.WebShellConnection{ + ID: "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12], + URL: req.URL, + Password: strings.TrimSpace(req.Password), + Type: shellType, + Method: method, + CmdParam: strings.TrimSpace(req.CmdParam), + Remark: strings.TrimSpace(req.Remark), + CreatedAt: time.Now(), + } + if err := h.db.CreateWebshellConnection(conn); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, conn) +} + +// UpdateConnection 更新 WebShell 连接(PUT /api/webshell/connections/:id) +func (h *WebShellHandler) UpdateConnection(c *gin.Context) { + if h.db == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) + return + } + id := strings.TrimSpace(c.Param("id")) + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) + return + } + var req UpdateConnectionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + req.URL = strings.TrimSpace(req.URL) + if req.URL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) + return + } + if _, err := url.Parse(req.URL); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"}) + return + } + method := strings.ToLower(strings.TrimSpace(req.Method)) + if method != "get" && method != "post" { + method = "post" + } + shellType := strings.ToLower(strings.TrimSpace(req.Type)) + if shellType == "" { + shellType = "php" + } + conn := &database.WebShellConnection{ + ID: id, + URL: req.URL, + Password: strings.TrimSpace(req.Password), + Type: shellType, + Method: method, + CmdParam: strings.TrimSpace(req.CmdParam), + Remark: strings.TrimSpace(req.Remark), + } + if err := h.db.UpdateWebshellConnection(conn); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + updated, _ := h.db.GetWebshellConnection(id) + if updated != nil { + c.JSON(http.StatusOK, updated) + } else { + c.JSON(http.StatusOK, conn) + } +} + +// DeleteConnection 删除 WebShell 连接(DELETE /api/webshell/connections/:id) +func (h *WebShellHandler) DeleteConnection(c *gin.Context) { + if h.db == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) + return + } + id := strings.TrimSpace(c.Param("id")) + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) + return + } + if err := h.db.DeleteWebshellConnection(id); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// ExecRequest 执行命令请求(前端传入连接信息 + 命令) +type ExecRequest struct { + URL string `json:"url" binding:"required"` + Password string `json:"password"` + Type string `json:"type"` // php, asp, aspx, jsp, custom + Method string `json:"method"` // GET 或 POST,空则默认 POST + CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd + Command string `json:"command" binding:"required"` +} + +// ExecResponse 执行命令响应 +type ExecResponse struct { + OK bool `json:"ok"` + Output string `json:"output"` + Error string `json:"error,omitempty"` + HTTPCode int `json:"http_code,omitempty"` +} + +// FileOpRequest 文件操作请求 +type FileOpRequest struct { + URL string `json:"url" binding:"required"` + Password string `json:"password"` + Type string `json:"type"` + Method string `json:"method"` // GET 或 POST,空则默认 POST + CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd + Action string `json:"action" binding:"required"` // list, read, delete, write + Path string `json:"path"` + Content string `json:"content"` // write 时使用 +} + +// FileOpResponse 文件操作响应 +type FileOpResponse struct { + OK bool `json:"ok"` + Output string `json:"output"` + Error string `json:"error,omitempty"` +} + +func (h *WebShellHandler) Exec(c *gin.Context) { + var req ExecRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + req.URL = strings.TrimSpace(req.URL) + req.Command = strings.TrimSpace(req.Command) + if req.URL == "" || req.Command == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url and command are required"}) + return + } + + parsed, err := url.Parse(req.URL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url: only http(s) allowed"}) + return + } + + useGET := strings.ToUpper(strings.TrimSpace(req.Method)) == "GET" + cmdParam := strings.TrimSpace(req.CmdParam) + if cmdParam == "" { + cmdParam = "cmd" + } + var httpReq *http.Request + if useGET { + targetURL := h.buildExecURL(req.URL, req.Type, req.Password, cmdParam, req.Command) + httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil) + } else { + body := h.buildExecBody(req.Type, req.Password, cmdParam, req.Command) + httpReq, err = http.NewRequest(http.MethodPost, req.URL, bytes.NewReader(body)) + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if err != nil { + h.logger.Warn("webshell exec NewRequest", zap.Error(err)) + c.JSON(http.StatusInternalServerError, ExecResponse{OK: false, Error: err.Error()}) + return + } + httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)") + + resp, err := h.client.Do(httpReq) + if err != nil { + h.logger.Warn("webshell exec Do", zap.String("url", req.URL), zap.Error(err)) + c.JSON(http.StatusOK, ExecResponse{OK: false, Error: err.Error()}) + return + } + defer resp.Body.Close() + + out, _ := io.ReadAll(resp.Body) + output := string(out) + httpCode := resp.StatusCode + + c.JSON(http.StatusOK, ExecResponse{ + OK: resp.StatusCode == http.StatusOK, + Output: output, + HTTPCode: httpCode, + }) +} + +// buildExecBody 按常见 WebShell 约定构建 POST 体(多数使用 pass + cmd,可配置命令参数名) +func (h *WebShellHandler) buildExecBody(shellType, password, cmdParam, command string) []byte { + form := h.execParams(shellType, password, cmdParam, command) + return []byte(form.Encode()) +} + +// buildExecURL 构建 GET 请求的完整 URL(baseURL + ?pass=xxx&cmd=yyy,cmd 可配置) +func (h *WebShellHandler) buildExecURL(baseURL, shellType, password, cmdParam, command string) string { + form := h.execParams(shellType, password, cmdParam, command) + if parsed, err := url.Parse(baseURL); err == nil { + parsed.RawQuery = form.Encode() + return parsed.String() + } + return baseURL + "?" + form.Encode() +} + +func (h *WebShellHandler) execParams(shellType, password, cmdParam, command string) url.Values { + shellType = strings.ToLower(strings.TrimSpace(shellType)) + if shellType == "" { + shellType = "php" + } + if strings.TrimSpace(cmdParam) == "" { + cmdParam = "cmd" + } + form := url.Values{} + form.Set("pass", password) + form.Set(cmdParam, command) + return form +} + +func (h *WebShellHandler) FileOp(c *gin.Context) { + var req FileOpRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + req.URL = strings.TrimSpace(req.URL) + req.Action = strings.ToLower(strings.TrimSpace(req.Action)) + if req.URL == "" || req.Action == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url and action are required"}) + return + } + + parsed, err := url.Parse(req.URL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url: only http(s) allowed"}) + return + } + + // 通过执行系统命令实现文件操作(与通用一句话兼容) + var command string + shellType := strings.ToLower(strings.TrimSpace(req.Type)) + switch req.Action { + case "list": + path := strings.TrimSpace(req.Path) + if path == "" { + path = "." + } + if shellType == "asp" || shellType == "aspx" { + command = "dir " + h.escapePath(path) + } else { + command = "ls -la " + h.escapePath(path) + } + case "read": + if shellType == "asp" || shellType == "aspx" { + command = "type " + h.escapePath(strings.TrimSpace(req.Path)) + } else { + command = "cat " + h.escapePath(strings.TrimSpace(req.Path)) + } + case "delete": + if shellType == "asp" || shellType == "aspx" { + command = "del " + h.escapePath(strings.TrimSpace(req.Path)) + } else { + command = "rm -f " + h.escapePath(strings.TrimSpace(req.Path)) + } + case "write": + path := h.escapePath(strings.TrimSpace(req.Path)) + command = "echo " + h.escapeForEcho(req.Content) + " > " + path + default: + c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "unsupported action: " + req.Action}) + return + } + + useGET := strings.ToUpper(strings.TrimSpace(req.Method)) == "GET" + cmdParam := strings.TrimSpace(req.CmdParam) + if cmdParam == "" { + cmdParam = "cmd" + } + var httpReq *http.Request + if useGET { + targetURL := h.buildExecURL(req.URL, req.Type, req.Password, cmdParam, command) + httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil) + } else { + body := h.buildExecBody(req.Type, req.Password, cmdParam, command) + httpReq, err = http.NewRequest(http.MethodPost, req.URL, bytes.NewReader(body)) + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if err != nil { + c.JSON(http.StatusInternalServerError, FileOpResponse{OK: false, Error: err.Error()}) + return + } + httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)") + + resp, err := h.client.Do(httpReq) + if err != nil { + c.JSON(http.StatusOK, FileOpResponse{OK: false, Error: err.Error()}) + return + } + defer resp.Body.Close() + + out, _ := io.ReadAll(resp.Body) + output := string(out) + + c.JSON(http.StatusOK, FileOpResponse{ + OK: resp.StatusCode == http.StatusOK, + Output: output, + }) +} + +func (h *WebShellHandler) escapePath(p string) string { + if p == "" { + return "." + } + // 简单转义空格与敏感字符,避免命令注入 + return "'" + strings.ReplaceAll(p, "'", "'\\''") + "'" +} + +func (h *WebShellHandler) escapeForEcho(s string) string { + // 仅用于 write:base64 写入更安全,这里简单用单引号包裹 + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} diff --git a/web/static/css/style.css b/web/static/css/style.css index f11b62d1..d8a3f90e 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -8495,6 +8495,528 @@ header { color: var(--text-primary); } +/* WebShell 管理页面样式 - 美化版 */ +.webshell-page-content { + height: calc(100vh - 140px); + overflow: hidden; + display: flex; + flex-direction: column; + background: linear-gradient(165deg, #f0f4f8 0%, #e8eef5 50%, #f5f7fa 100%); +} + +.webshell-layout { + display: flex; + flex: 1; + min-height: 0; + gap: 0; + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + background: var(--bg-primary); + box-shadow: var(--shadow-md); +} + +.webshell-sidebar { + width: 360px; + min-width: 260px; + max-width: 50%; + flex-shrink: 0; + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + background: linear-gradient(180deg, #fafbfd 0%, #f5f7fa 100%); +} + +/* 可拖拽调整连接列表宽度 */ +.webshell-resize-handle { + position: relative; + width: 6px; + min-width: 6px; + flex-shrink: 0; + cursor: col-resize; + background: var(--border-color); + transition: background 0.15s ease; + user-select: none; +} +.webshell-resize-handle:hover, +.webshell-resize-handle.active { + background: var(--accent-color); +} +.webshell-resize-handle::after { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 12px; + margin-left: -3px; +} + +.webshell-sidebar-header { + padding: 14px 18px; + font-weight: 600; + color: var(--text-primary); + border-bottom: 1px solid var(--border-color); + font-size: 0.95rem; + display: flex; + align-items: center; + gap: 10px; + background: rgba(255, 255, 255, 0.6); + flex-shrink: 0; +} + +.webshell-sidebar-header::before { + content: ''; + display: inline-block; + width: 20px; + height: 20px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230066ff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E") center/contain no-repeat; +} + +.webshell-list { + flex: 1; + overflow-y: auto; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; +} + +.webshell-empty { + padding: 32px 20px; + text-align: center; + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.5; + background: rgba(255, 255, 255, 0.5); + border-radius: 10px; + border: 1px dashed var(--border-color); +} + +.webshell-item { + padding: 12px 14px; + border-radius: 10px; + cursor: pointer; + border: 1px solid var(--border-color); + transition: all 0.2s ease; + background: #fff; + box-shadow: var(--shadow-sm); + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.webshell-item:hover { + background: var(--bg-secondary); + border-color: var(--accent-color); + box-shadow: 0 2px 8px rgba(0, 102, 255, 0.08); +} + +.webshell-item.active { + background: linear-gradient(135deg, rgba(0, 102, 255, 0.08) 0%, rgba(0, 102, 255, 0.04) 100%); + border-color: var(--accent-color); + box-shadow: 0 2px 12px rgba(0, 102, 255, 0.12); +} + +.webshell-item-remark { + font-weight: 600; + color: var(--text-primary); + font-size: 0.9rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.webshell-item-url { + font-size: 0.78rem; + color: var(--text-secondary); + font-family: ui-monospace, monospace; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.webshell-item-actions { + margin-top: 6px; + flex-shrink: 0; +} + +.webshell-delete-btn { + padding: 6px 12px; + border-radius: 6px; + font-size: 0.8rem; + color: var(--error-color); + border: 1px solid transparent; + transition: all 0.2s ease; +} + +.webshell-delete-btn:hover { + background: rgba(220, 53, 69, 0.08); + border-color: rgba(220, 53, 69, 0.3); +} + +.webshell-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-primary); +} + +.webshell-workspace { + flex: 1; + overflow: auto; + padding: 20px 24px; + display: flex; + flex-direction: column; + min-height: 0; +} + +.webshell-workspace-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 280px; + color: var(--text-muted); + font-size: 0.95rem; + text-align: center; + padding: 32px; + background: linear-gradient(180deg, rgba(255,255,255,0.8) 0%, rgba(248,249,250,0.9) 100%); + border-radius: 12px; + border: 1px dashed var(--border-color); +} + +.webshell-workspace-placeholder::before { + content: ''; + width: 64px; + height: 64px; + margin-bottom: 16px; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23adb5bd' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' opacity='0.8'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E") center/contain no-repeat; +} + +.webshell-tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 2px solid var(--border-color); + padding-bottom: 0; +} + +.webshell-tab { + padding: 10px 20px; + border: none; + background: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + border-radius: 6px 6px 0 0; + transition: color 0.2s ease, background 0.2s ease; +} + +.webshell-tab:hover { + color: var(--text-primary); + background: var(--bg-secondary); +} + +.webshell-tab.active { + color: var(--accent-color); + border-bottom-color: var(--accent-color); + background: transparent; +} + +.webshell-pane { + display: none; + flex: 1; + min-height: 0; + flex-direction: column; +} + +.webshell-pane.active { + display: flex; +} + +/* 仅外框圆角,内部不做额外装饰,避免挡住文字 */ +.webshell-terminal-container { + flex: 1; + min-height: 360px; + padding: 0; + background: #0d1117; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transform: translateZ(0); + backface-visibility: hidden; +} + +.webshell-terminal-container .terminal { + width: 100%; + height: 100%; + padding: 12px 14px; + box-sizing: border-box; + overflow: hidden; +} + +/* 防止 xterm 辅助层(.xterm-helper-textarea)遮挡终端文字:移出视口并缩小 */ +.webshell-terminal-container .xterm .xterm-helper-textarea { + left: -9999em !important; + width: 1px !important; + height: 1px !important; + min-width: 0 !important; + min-height: 0 !important; + opacity: 0 !important; + position: absolute !important; + z-index: -10 !important; +} +/* 确保光标层不超出单元格,避免整行被蓝条挡住 */ +.webshell-terminal-container .xterm-screen { + overflow: hidden; +} + +.webshell-terminal-container .xterm-viewport { + overflow: hidden; +} + +/* WebShell 终端滚动条:深色主题、细窄、圆角,弱化存在感 */ +.webshell-terminal-container .xterm-viewport { + scrollbar-width: thin; + scrollbar-color: rgba(110, 118, 129, 0.5) transparent; +} +.webshell-terminal-container .xterm-viewport::-webkit-scrollbar { + width: 6px; +} +.webshell-terminal-container .xterm-viewport::-webkit-scrollbar-track { + background: transparent; + margin: 4px 0; + border-radius: 3px; +} +.webshell-terminal-container .xterm-viewport::-webkit-scrollbar-thumb { + background: rgba(110, 118, 129, 0.4); + border-radius: 3px; +} +.webshell-terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: rgba(110, 118, 129, 0.65); +} +.webshell-terminal-container .xterm-viewport::-webkit-scrollbar-thumb:active { + background: rgba(139, 148, 158, 0.7); +} + +.webshell-file-toolbar { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; + flex-wrap: wrap; + padding: 14px 16px; + background: var(--bg-secondary); + border-radius: 10px; + border: 1px solid var(--border-color); +} + +.webshell-file-toolbar label { + display: flex; + align-items: center; + gap: 10px; + font-weight: 500; + color: var(--text-primary); + flex: 1; + min-width: 0; +} + +.webshell-file-toolbar input.form-control { + min-width: 200px; + flex: 1; + max-width: 480px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.webshell-file-toolbar .btn-secondary, +.webshell-file-toolbar .btn-ghost { + padding: 8px 16px; + border-radius: 8px; + font-weight: 500; + transition: all 0.2s ease; +} + +.webshell-file-list { + flex: 1; + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 0; + background: var(--bg-primary); + box-shadow: var(--shadow-sm); +} + +.webshell-file-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.webshell-file-table thead { + background: linear-gradient(180deg, #f8f9fa 0%, #f1f3f5 100%); +} + +.webshell-file-table th { + padding: 12px 16px; + text-align: left; + font-weight: 600; + color: var(--text-primary); + border-bottom: 2px solid var(--border-color); +} + +.webshell-file-table td { + padding: 10px 16px; + text-align: left; + border-bottom: 1px solid var(--border-color); + transition: background 0.15s ease; +} + +.webshell-file-table tbody tr:hover { + background: var(--bg-secondary); +} + +.webshell-file-table tbody tr:last-child td { + border-bottom: none; +} + +.webshell-file-table a.webshell-file-link { + color: var(--accent-color); + text-decoration: none; + font-weight: 500; + padding: 2px 4px; + border-radius: 4px; + transition: background 0.15s ease, color 0.15s ease; +} + +.webshell-file-table a.webshell-file-link:hover { + background: rgba(0, 102, 255, 0.08); + color: var(--accent-hover); +} + +.webshell-file-table .webshell-file-read { + color: var(--accent-color); + margin-right: 8px; + padding: 4px 10px; + border-radius: 6px; + font-size: 0.82rem; +} + +.webshell-file-table .webshell-file-read:hover { + background: rgba(0, 102, 255, 0.1); +} + +.webshell-file-table .webshell-file-del { + color: var(--error-color); + padding: 4px 10px; + border-radius: 6px; + font-size: 0.82rem; +} + +.webshell-file-table .webshell-file-del:hover { + background: rgba(220, 53, 69, 0.08); +} + +.webshell-loading { + padding: 24px 20px; + color: var(--text-muted); + text-align: center; +} + +.webshell-file-error { + padding: 20px; + color: var(--error-color); + background: rgba(220, 53, 69, 0.06); + border-radius: 10px; + margin: 8px; +} + +.webshell-file-raw, +.webshell-file-content pre { + white-space: pre-wrap; + word-break: break-all; + font-size: 0.82rem; + margin-top: 12px; + padding: 16px; + background: var(--bg-secondary); + border-radius: 10px; + border: 1px solid var(--border-color); +} + +.webshell-file-content { + padding: 12px 0; +} + +.webshell-file-content .btn-ghost { + margin-top: 12px; + padding: 8px 16px; + border-radius: 8px; +} + +.webshell-file-edit-wrap { + padding: 16px 0; + display: flex; + flex-direction: column; + gap: 12px; +} +.webshell-file-edit-path { + font-size: 0.85rem; + color: var(--text-secondary); + padding: 8px 12px; + font-family: ui-monospace, monospace; + word-break: break-all; + background: var(--bg-secondary, rgba(0, 0, 0, 0.04)); + border-radius: 8px; + border: 1px solid var(--border-color); +} +.webshell-file-edit-textarea { + width: 100%; + min-height: 280px; + padding: 14px; + font-size: 0.82rem; + font-family: ui-monospace, monospace; + line-height: 1.5; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-primary); + color: var(--text-primary); + resize: vertical; + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; +} +.webshell-file-edit-textarea:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.15); +} +.webshell-file-edit-actions { + margin-top: 4px; + padding-top: 12px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; +} +.webshell-file-edit-actions .btn-primary, +.webshell-file-edit-actions .btn-ghost { + min-width: 72px; +} + /* 仪表盘页面样式(最佳实践布局 + 视觉增强) */ .dashboard-page { height: 100%; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index ad03c37c..da7b5c1a 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -44,6 +44,7 @@ "infoCollect": "Recon", "tasks": "Tasks", "vulnerabilities": "Vulnerabilities", + "webshell": "WebShell Management", "mcp": "MCP", "mcpMonitor": "MCP Monitor", "mcpManagement": "MCP Management", @@ -326,6 +327,49 @@ "loadFailed": "Failed to load vulnerabilities", "deleteConfirm": "Delete this vulnerability?" }, + "webshell": { + "title": "WebShell Management", + "addConnection": "Add connection", + "connections": "Connections", + "noConnections": "No connections. Click \"Add connection\" to add one.", + "selectOrAdd": "Select a connection from the list or add a new WebShell connection.", + "url": "Shell URL", + "urlPlaceholder": "http(s)://target.com/shell.php", + "password": "Password / Key", + "passwordPlaceholder": "e.g. IceSword/AntSword connection password", + "method": "Request method", + "methodPost": "POST", + "methodGet": "GET", + "type": "Shell type", + "typePhp": "PHP", + "typeAsp": "ASP", + "typeAspx": "ASPX", + "typeJsp": "JSP", + "typeCustom": "Custom", + "cmdParam": "Command parameter name", + "cmdParamPlaceholder": "Leave empty for cmd; e.g. xxx for xxx=command", + "remark": "Remark", + "remarkPlaceholder": "Friendly name for this connection", + "deleteConfirm": "Delete this connection?", + "editConnection": "Edit", + "editConnectionTitle": "Edit connection", + "tabTerminal": "Virtual terminal", + "tabFileManager": "File manager", + "terminalWelcome": "WebShell virtual terminal — type a command and press Enter (Ctrl+L clear)", + "filePath": "Current path", + "listDir": "List directory", + "readFile": "Read", + "editFile": "Edit", + "deleteFile": "Delete", + "saveFile": "Save", + "cancelEdit": "Cancel", + "parentDir": "Parent directory", + "execError": "Execution failed", + "testConnectivity": "Test connectivity", + "testSuccess": "Connection OK, shell is reachable", + "testFailed": "Connectivity test failed", + "testNoExpectedOutput": "Shell responded but expected output was not found. Check password and command parameter name." + }, "mcp": { "monitorTitle": "MCP Status Monitor", "execStats": "Execution stats", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 6e16bd15..b629d63b 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -44,6 +44,7 @@ "infoCollect": "信息收集", "tasks": "任务管理", "vulnerabilities": "漏洞管理", + "webshell": "WebShell管理", "mcp": "MCP", "mcpMonitor": "MCP状态监控", "mcpManagement": "MCP管理", @@ -326,6 +327,49 @@ "loadFailed": "加载漏洞失败", "deleteConfirm": "确定要删除此漏洞吗?" }, + "webshell": { + "title": "WebShell 管理", + "addConnection": "添加连接", + "connections": "连接列表", + "noConnections": "暂无连接,请点击「添加连接」", + "selectOrAdd": "请从左侧选择连接,或添加新的 WebShell 连接", + "url": "Shell 地址", + "urlPlaceholder": "http(s)://target.com/shell.php", + "password": "连接密码/密钥", + "passwordPlaceholder": "如冰蝎/蚁剑的连接密码", + "method": "请求方式", + "methodPost": "POST", + "methodGet": "GET", + "type": "Shell 类型", + "typePhp": "PHP", + "typeAsp": "ASP", + "typeAspx": "ASPX", + "typeJsp": "JSP", + "typeCustom": "自定义", + "cmdParam": "命令参数名", + "cmdParamPlaceholder": "不填默认为 cmd,如填 xxx 则请求为 xxx=命令", + "remark": "备注", + "remarkPlaceholder": "便于识别的备注名", + "deleteConfirm": "确定要删除该连接吗?", + "editConnection": "编辑", + "editConnectionTitle": "编辑连接", + "tabTerminal": "虚拟终端", + "tabFileManager": "文件管理", + "terminalWelcome": "WebShell 虚拟终端 — 输入命令后按回车执行(Ctrl+L 清屏)", + "filePath": "当前路径", + "listDir": "列出目录", + "readFile": "读取", + "editFile": "编辑", + "deleteFile": "删除", + "saveFile": "保存", + "cancelEdit": "取消", + "parentDir": "上级目录", + "execError": "执行失败", + "testConnectivity": "测试连通性", + "testSuccess": "连通性正常,Shell 可访问", + "testFailed": "连通性测试失败", + "testNoExpectedOutput": "Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名" + }, "mcp": { "monitorTitle": "MCP 状态监控", "execStats": "执行统计", diff --git a/web/static/js/router.js b/web/static/js/router.js index 31c9520c..f9df4140 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -8,7 +8,7 @@ function initRouter() { if (hash) { const hashParts = hash.split('?'); const pageId = hashParts[0]; - if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'webshell', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) { switchPage(pageId); // 如果是chat页面且带有conversation参数,加载对应对话 @@ -293,6 +293,12 @@ function initPage(pageId) { initVulnerabilityPage(); } break; + case 'webshell': + // 初始化 WebShell 管理页面 + if (typeof initWebshellPage === 'function') { + initWebshellPage(); + } + break; case 'settings': // 初始化设置页面(不需要加载工具列表) if (typeof loadConfig === 'function') { @@ -362,7 +368,7 @@ document.addEventListener('DOMContentLoaded', function() { const hashParts = hash.split('?'); const pageId = hashParts[0]; - if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) { + if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) { switchPage(pageId); // 如果是chat页面且带有conversation参数,加载对应对话 diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js new file mode 100644 index 00000000..388f2b27 --- /dev/null +++ b/web/static/js/webshell.js @@ -0,0 +1,836 @@ +// WebShell 管理(类似冰蝎/蚁剑:虚拟终端、文件管理、命令执行) + +const WEBSHELL_SIDEBAR_WIDTH_KEY = 'webshell_sidebar_width'; +const WEBSHELL_DEFAULT_SIDEBAR_WIDTH = 360; +const WEBSHELL_PROMPT = 'shell> '; +let webshellConnections = []; +let currentWebshellId = null; +let webshellTerminalInstance = null; +let webshellTerminalFitAddon = null; +let webshellTerminalResizeObserver = null; +let webshellTerminalResizeContainer = null; +let webshellCurrentConn = null; +let webshellLineBuffer = ''; +let webshellRunning = false; + +// 从服务端(SQLite)拉取连接列表 +function getWebshellConnections() { + if (typeof apiFetch === 'undefined') { + return Promise.resolve([]); + } + return apiFetch('/api/webshell/connections', { method: 'GET' }) + .then(function (r) { return r.json(); }) + .then(function (list) { return Array.isArray(list) ? list : []; }) + .catch(function (e) { + console.warn('读取 WebShell 连接列表失败', e); + return []; + }); +} + +// 从服务端刷新连接列表并重绘侧栏 +function refreshWebshellConnectionsFromServer() { + return getWebshellConnections().then(function (list) { + webshellConnections = list; + renderWebshellList(); + return list; + }); +} + +// 使用 wsT 避免与全局 window.t 冲突导致无限递归 +function wsT(key) { + var globalT = typeof window !== 'undefined' ? window.t : null; + if (typeof globalT === 'function' && globalT !== wsT) return globalT(key); + var fallback = { + 'webshell.title': 'WebShell 管理', + 'webshell.addConnection': '添加连接', + 'webshell.cmdParam': '命令参数名', + 'webshell.cmdParamPlaceholder': '不填默认为 cmd,如填 xxx 则请求为 xxx=命令', + 'webshell.connections': '连接列表', + 'webshell.noConnections': '暂无连接,请点击「添加连接」', + 'webshell.selectOrAdd': '请从左侧选择连接,或添加新的 WebShell 连接', + 'webshell.deleteConfirm': '确定要删除该连接吗?', + 'webshell.editConnection': '编辑', + 'webshell.editConnectionTitle': '编辑连接', + 'webshell.tabTerminal': '虚拟终端', + 'webshell.tabFileManager': '文件管理', + 'webshell.terminalWelcome': 'WebShell 虚拟终端 — 输入命令后按回车执行(Ctrl+L 清屏)', + 'webshell.filePath': '当前路径', + 'webshell.listDir': '列出目录', + 'webshell.readFile': '读取', + 'webshell.editFile': '编辑', + 'webshell.deleteFile': '删除', + 'webshell.saveFile': '保存', + 'webshell.cancelEdit': '取消', + 'webshell.parentDir': '上级目录', + 'webshell.execError': '执行失败', + 'webshell.testConnectivity': '测试连通性', + 'webshell.testSuccess': '连通性正常,Shell 可访问', + 'webshell.testFailed': '连通性测试失败', + 'webshell.testNoExpectedOutput': 'Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名', + 'common.delete': '删除', + 'common.refresh': '刷新' + }; + return fallback[key] || key; +} + +// 初始化 WebShell 管理页面(从 SQLite 拉取连接列表) +function initWebshellPage() { + destroyWebshellTerminal(); + webshellCurrentConn = null; + currentWebshellId = null; + webshellConnections = []; + renderWebshellList(); + applyWebshellSidebarWidth(); + initWebshellSidebarResize(); + const workspace = document.getElementById('webshell-workspace'); + if (workspace) { + workspace.innerHTML = '
' + escapeHtml('未加载 xterm.js,请刷新页面') + '
'; + } + return; + } + + const term = new Terminal({ + cursorBlink: true, + cursorStyle: 'underline', + fontSize: 13, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + lineHeight: 1.2, + scrollback: 2000, + theme: { + background: '#0d1117', + foreground: '#e6edf3', + cursor: '#58a6ff', + cursorAccent: '#0d1117', + selection: 'rgba(88, 166, 255, 0.3)' + } + }); + + let fitAddon = null; + if (typeof FitAddon !== 'undefined') { + const FitCtor = FitAddon.FitAddon || FitAddon; + fitAddon = new FitCtor(); + term.loadAddon(fitAddon); + } + + term.open(container); + // 先 fit 再写内容,避免未计算尺寸时光标/画布错位挡住文字 + try { + if (fitAddon) fitAddon.fit(); + } catch (e) {} + // 不再输出欢迎行,避免占用空间、挡住输入 + term.write(WEBSHELL_PROMPT); + + // 按行写入输出,与系统设置终端 writeOutput 一致,避免 ls 等输出错位 + function writeWebshellOutput(term, text, isError) { + if (!term || !text) return; + var s = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + var lines = s.split('\n'); + var prefix = isError ? '\x1b[31m' : ''; + var suffix = isError ? '\x1b[0m' : ''; + term.write(prefix); + for (var i = 0; i < lines.length; i++) { + term.writeln(lines[i].replace(/\r/g, '')); + } + term.write(suffix); + } + + term.onData(function (data) { + // Ctrl+L 清屏 + if (data === '\x0c') { + term.clear(); + webshellLineBuffer = ''; + term.write(WEBSHELL_PROMPT); + return; + } + // 回车:发送当前行到后端执行 + if (data === '\r' || data === '\n') { + term.writeln(''); + const cmd = webshellLineBuffer.trim(); + webshellLineBuffer = ''; + if (cmd) { + webshellRunning = true; + // 执行时用当前连接(编辑保存后 webshellCurrentConn 已更新),避免闭包持有旧 conn + execWebshellCommand(webshellCurrentConn, cmd).then(function (out) { + webshellRunning = false; + if (out && out.length) writeWebshellOutput(term, out, false); + term.write(WEBSHELL_PROMPT); + }).catch(function (err) { + webshellRunning = false; + writeWebshellOutput(term, err && err.message ? err.message : wsT('webshell.execError'), true); + term.write(WEBSHELL_PROMPT); + }); + } else { + term.write(WEBSHELL_PROMPT); + } + return; + } + // 退格 + if (data === '\x7f' || data === '\b') { + if (webshellLineBuffer.length > 0) { + webshellLineBuffer = webshellLineBuffer.slice(0, -1); + term.write('\b \b'); + } + return; + } + webshellLineBuffer += data; + term.write(data); + }); + + webshellTerminalInstance = term; + webshellTerminalFitAddon = fitAddon; + // 延迟再次 fit,确保容器尺寸稳定后光标与文字不错位 + setTimeout(function () { + try { if (fitAddon) fitAddon.fit(); } catch (e) {} + }, 100); + // 容器尺寸变化时重新 fit,避免光标/文字被遮挡 + if (fitAddon && typeof ResizeObserver !== 'undefined' && container) { + webshellTerminalResizeContainer = container; + webshellTerminalResizeObserver = new ResizeObserver(function () { + try { fitAddon.fit(); } catch (e) {} + }); + webshellTerminalResizeObserver.observe(container); + } +} + +// 调用后端执行命令 +function execWebshellCommand(conn, command) { + return new Promise(function (resolve, reject) { + if (typeof apiFetch === 'undefined') { + reject(new Error('apiFetch 未定义')); + return; + } + apiFetch('/api/webshell/exec', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: conn.url, + password: conn.password || '', + type: conn.type || 'php', + method: (conn.method || 'post').toLowerCase(), + cmd_param: conn.cmdParam || '', + command: command + }) + }).then(function (r) { return r.json(); }) + .then(function (data) { + if (data && data.output !== undefined) resolve(data.output || ''); + else if (data && data.error) reject(new Error(data.error)); + else resolve(''); + }) + .catch(reject); + }); +} + +// ---------- 文件管理 ---------- +function webshellFileListDir(conn, path) { + const listEl = document.getElementById('webshell-file-list'); + if (!listEl) return; + listEl.innerHTML = '' + escapeHtml(data.output || '') + ''; + return; + } + renderFileList(listEl, path, data.output || '', conn); + }) + .catch(function (err) { + listEl.innerHTML = '
' + escapeHtml(rawOutput) + ''; + } else { + html = '
| ' + wsT('webshell.filePath') + ' | |
|---|---|
| .. | |
| ' + escapeHtml(item.name) + (item.isDir ? '/' : '') + ' | '; + if (!item.isDir) { + html += ' '; + html += ' '; + html += ''; + } + html += ' |
' + escapeHtml(out) + '
' + escapeHtml(data.output || '') + ''; + return; + } + if (onDone) onDone(); + }) + .catch(function (err) { + if (listEl) listEl.innerHTML = '