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 = '
' + (wsT('webshell.selectOrAdd')) + '
'; + } + getWebshellConnections().then(function (list) { + webshellConnections = list; + renderWebshellList(); + }); +} + +function getWebshellSidebarWidth() { + try { + const w = parseInt(localStorage.getItem(WEBSHELL_SIDEBAR_WIDTH_KEY), 10); + if (!isNaN(w) && w >= 260 && w <= 800) return w; + } catch (e) {} + return WEBSHELL_DEFAULT_SIDEBAR_WIDTH; +} + +function setWebshellSidebarWidth(px) { + localStorage.setItem(WEBSHELL_SIDEBAR_WIDTH_KEY, String(px)); +} + +function applyWebshellSidebarWidth() { + const sidebar = document.getElementById('webshell-sidebar'); + if (sidebar) sidebar.style.width = getWebshellSidebarWidth() + 'px'; +} + +function initWebshellSidebarResize() { + const handle = document.getElementById('webshell-resize-handle'); + const sidebar = document.getElementById('webshell-sidebar'); + if (!handle || !sidebar || handle.dataset.resizeBound === '1') return; + handle.dataset.resizeBound = '1'; + let startX = 0, startW = 0; + function onMove(e) { + const dx = e.clientX - startX; + let w = Math.round(startW + dx); + const min = 260; + const max = Math.min(800, Math.floor((sidebar.parentElement && sidebar.parentElement.offsetWidth || 800) * 0.6)); + w = Math.max(min, Math.min(max, w)); + sidebar.style.width = w + 'px'; + } + function onUp() { + handle.classList.remove('active'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + setWebshellSidebarWidth(parseInt(sidebar.style.width, 10) || WEBSHELL_DEFAULT_SIDEBAR_WIDTH); + } + handle.addEventListener('mousedown', function (e) { + if (e.button !== 0) return; + e.preventDefault(); + startX = e.clientX; + startW = sidebar.offsetWidth; + handle.classList.add('active'); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); +} + +// 销毁当前终端实例(切换连接或离开页面时) +function destroyWebshellTerminal() { + if (webshellTerminalResizeObserver && webshellTerminalResizeContainer) { + try { webshellTerminalResizeObserver.unobserve(webshellTerminalResizeContainer); } catch (e) {} + webshellTerminalResizeObserver = null; + webshellTerminalResizeContainer = null; + } + if (webshellTerminalInstance) { + try { + webshellTerminalInstance.dispose(); + } catch (e) {} + webshellTerminalInstance = null; + } + webshellTerminalFitAddon = null; + webshellLineBuffer = ''; + webshellRunning = false; +} + +// 渲染连接列表 +function renderWebshellList() { + const listEl = document.getElementById('webshell-list'); + if (!listEl) return; + + if (!webshellConnections.length) { + listEl.innerHTML = '
' + (wsT('webshell.noConnections')) + '
'; + return; + } + + listEl.innerHTML = webshellConnections.map(conn => { + const remark = (conn.remark || conn.url || '').replace(//g, '>'); + const url = (conn.url || '').replace(//g, '>'); + const urlTitle = (conn.url || '').replace(/&/g, '&').replace(/"/g, '"').replace(/' + + '
' + remark + '
' + + '
' + url + '
' + + '
' + + ' ' + + '' + + '
' + + '' + ); + }).join(''); + + listEl.querySelectorAll('.webshell-item').forEach(el => { + el.addEventListener('click', function (e) { + if (e.target.closest('.webshell-delete-btn') || e.target.closest('.webshell-edit-conn-btn')) return; + selectWebshell(el.getAttribute('data-id')); + }); + }); + listEl.querySelectorAll('.webshell-edit-conn-btn').forEach(btn => { + btn.addEventListener('click', function (e) { + e.stopPropagation(); + showEditWebshellModal(btn.getAttribute('data-id')); + }); + }); + listEl.querySelectorAll('.webshell-delete-btn').forEach(btn => { + btn.addEventListener('click', function (e) { + e.stopPropagation(); + deleteWebshell(btn.getAttribute('data-id')); + }); + }); +} + +function escapeHtml(s) { + if (!s) return ''; + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} + +// 选择连接:渲染终端 + 文件管理 Tab,并初始化终端 +function selectWebshell(id) { + currentWebshellId = id; + renderWebshellList(); + const conn = webshellConnections.find(c => c.id === id); + const workspace = document.getElementById('webshell-workspace'); + if (!workspace) return; + if (!conn) { + workspace.innerHTML = '
' + wsT('webshell.selectOrAdd') + '
'; + return; + } + + destroyWebshellTerminal(); + webshellCurrentConn = conn; + + workspace.innerHTML = + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
'; + + // Tab 切换 + workspace.querySelectorAll('.webshell-tab').forEach(btn => { + btn.addEventListener('click', function () { + const tab = btn.getAttribute('data-tab'); + workspace.querySelectorAll('.webshell-tab').forEach(b => b.classList.remove('active')); + workspace.querySelectorAll('.webshell-pane').forEach(p => p.classList.remove('active')); + btn.classList.add('active'); + const pane = document.getElementById('webshell-pane-' + tab); + if (pane) pane.classList.add('active'); + if (tab === 'terminal' && webshellTerminalInstance && webshellTerminalFitAddon) { + try { webshellTerminalFitAddon.fit(); } catch (e) {} + } + }); + }); + + // 文件管理:列出目录、上级目录 + const pathInput = document.getElementById('webshell-file-path'); + document.getElementById('webshell-list-dir').addEventListener('click', function () { + // 点击时用当前连接,编辑保存后立即生效 + webshellFileListDir(webshellCurrentConn, pathInput ? pathInput.value.trim() || '.' : '.'); + }); + document.getElementById('webshell-parent-dir').addEventListener('click', function () { + const p = (pathInput && pathInput.value.trim()) || '.'; + if (p === '.' || p === '/') { + pathInput.value = '..'; + } else { + pathInput.value = p.replace(/\/[^/]+$/, '') || '.'; + } + webshellFileListDir(webshellCurrentConn, pathInput.value || '.'); + }); + + initWebshellTerminal(conn); +} + +// ---------- 虚拟终端(xterm + 按行执行) ---------- +function initWebshellTerminal(conn) { + const container = document.getElementById('webshell-terminal-container'); + if (!container || typeof Terminal === 'undefined') { + if (container) { + container.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 = '
' + wsT('common.refresh') + '...
'; + + if (typeof apiFetch === 'undefined') { + listEl.innerHTML = '
apiFetch 未定义
'; + return; + } + + apiFetch('/api/webshell/file', { + 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 || '', + action: 'list', + path: path + }) + }).then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.ok && data.error) { + listEl.innerHTML = '
' + escapeHtml(data.error) + '
' + escapeHtml(data.output || '') + '
'; + return; + } + renderFileList(listEl, path, data.output || '', conn); + }) + .catch(function (err) { + listEl.innerHTML = '
' + escapeHtml(err && err.message ? err.message : wsT('webshell.execError')) + '
'; + }); +} + +function renderFileList(listEl, currentPath, rawOutput, conn) { + // 解析 ls -la 风格输出为简单列表(兼容不同格式) + const lines = rawOutput.split(/\n/).filter(function (l) { return l.trim(); }); + const items = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const m = line.match(/\s*(\S+)\s*$/); // 最后一列作为名称 + const name = m ? m[1].trim() : line.trim(); + if (name === '.' || name === '..') continue; + const isDir = line.startsWith('d') || line.toLowerCase().indexOf('') !== -1; + items.push({ name: name, isDir: isDir, line: line }); + } + + let html = ''; + if (items.length === 0 && rawOutput.trim()) { + html = '
' + escapeHtml(rawOutput) + '
'; + } else { + html = ''; + if (currentPath !== '.' && currentPath !== '') { + html += ''; + } + items.forEach(function (item) { + const pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name; + html += ''; + }); + html += '
' + wsT('webshell.filePath') + '
..
' + escapeHtml(item.name) + (item.isDir ? '/' : '') + ''; + if (!item.isDir) { + html += ' '; + html += ' '; + html += ''; + } + html += '
'; + } + listEl.innerHTML = html; + + listEl.querySelectorAll('.webshell-file-link').forEach(function (a) { + a.addEventListener('click', function (e) { + e.preventDefault(); + const path = a.getAttribute('data-path'); + const isDir = a.getAttribute('data-isdir') === '1'; + const pathInput = document.getElementById('webshell-file-path'); + if (pathInput) pathInput.value = path; + if (isDir) webshellFileListDir(webshellCurrentConn, path); + else webshellFileRead(webshellCurrentConn, path, listEl); + }); + }); + listEl.querySelectorAll('.webshell-file-read').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.preventDefault(); + webshellFileRead(webshellCurrentConn, btn.getAttribute('data-path'), listEl); + }); + }); + listEl.querySelectorAll('.webshell-file-edit').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.preventDefault(); + webshellFileEdit(webshellCurrentConn, btn.getAttribute('data-path'), listEl); + }); + }); + listEl.querySelectorAll('.webshell-file-del').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.preventDefault(); + if (!confirm(wsT('webshell.deleteConfirm'))) return; + webshellFileDelete(webshellCurrentConn, btn.getAttribute('data-path'), function () { + webshellFileListDir(webshellCurrentConn, document.getElementById('webshell-file-path').value.trim() || '.'); + }); + }); + }); +} + +function webshellFileRead(conn, path, listEl) { + if (typeof apiFetch === 'undefined') return; + listEl.innerHTML = '
' + wsT('webshell.readFile') + '...
'; + apiFetch('/api/webshell/file', { + 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 || '', action: 'read', path: path }) + }).then(function (r) { return r.json(); }) + .then(function (data) { + const out = (data && data.output) ? data.output : (data.error || ''); + listEl.innerHTML = '
' + escapeHtml(out) + '
'; + }) + .catch(function (err) { + listEl.innerHTML = '
' + escapeHtml(err && err.message ? err.message : '') + '
'; + }); +} + +function webshellFileEdit(conn, path, listEl) { + if (typeof apiFetch === 'undefined') return; + listEl.innerHTML = '
' + wsT('webshell.editFile') + '...
'; + apiFetch('/api/webshell/file', { + 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 || '', action: 'read', path: path }) + }).then(function (r) { return r.json(); }) + .then(function (data) { + const content = (data && data.output) ? data.output : (data.error || ''); + const pathInput = document.getElementById('webshell-file-path'); + const currentPath = pathInput ? pathInput.value.trim() || '.' : '.'; + listEl.innerHTML = + '
' + + '
' + escapeHtml(path) + '
' + + '' + + '
' + + ' ' + + '' + + '
'; + document.getElementById('webshell-edit-save').addEventListener('click', function () { + const textarea = document.getElementById('webshell-edit-textarea'); + const newContent = textarea ? textarea.value : ''; + webshellFileWrite(webshellCurrentConn, path, newContent, function () { + webshellFileListDir(webshellCurrentConn, currentPath); + }, listEl); + }); + document.getElementById('webshell-edit-cancel').addEventListener('click', function () { + webshellFileListDir(webshellCurrentConn, currentPath); + }); + }) + .catch(function (err) { + listEl.innerHTML = '
' + escapeHtml(err && err.message ? err.message : '') + '
'; + }); +} + +function webshellFileWrite(conn, path, content, onDone, listEl) { + if (typeof apiFetch === 'undefined') return; + if (listEl) listEl.innerHTML = '
' + wsT('webshell.saveFile') + '...
'; + apiFetch('/api/webshell/file', { + 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 || '', action: 'write', path: path, content: content }) + }).then(function (r) { return r.json(); }) + .then(function (data) { + if (data && !data.ok && data.error && listEl) { + listEl.innerHTML = '
' + escapeHtml(data.error) + '
' + escapeHtml(data.output || '') + '
'; + return; + } + if (onDone) onDone(); + }) + .catch(function (err) { + if (listEl) listEl.innerHTML = '
' + escapeHtml(err && err.message ? err.message : wsT('webshell.execError')) + '
'; + }); +} + +function webshellFileDelete(conn, path, onDone) { + if (typeof apiFetch === 'undefined') return; + apiFetch('/api/webshell/file', { + 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 || '', action: 'delete', path: path }) + }).then(function (r) { return r.json(); }) + .then(function () { if (onDone) onDone(); }) + .catch(function () { if (onDone) onDone(); }); +} + +// 删除连接(请求服务端删除后刷新列表) +function deleteWebshell(id) { + if (!confirm(wsT('webshell.deleteConfirm'))) return; + if (currentWebshellId === id) destroyWebshellTerminal(); + if (currentWebshellId === id) currentWebshellId = null; + if (typeof apiFetch === 'undefined') return; + apiFetch('/api/webshell/connections/' + encodeURIComponent(id), { method: 'DELETE' }) + .then(function () { + return refreshWebshellConnectionsFromServer(); + }) + .then(function () { + const workspace = document.getElementById('webshell-workspace'); + if (workspace) { + workspace.innerHTML = '
' + wsT('webshell.selectOrAdd') + '
'; + } + }) + .catch(function (e) { + console.warn('删除 WebShell 连接失败', e); + refreshWebshellConnectionsFromServer(); + }); +} + +// 打开添加连接弹窗 +function showAddWebshellModal() { + var editIdEl = document.getElementById('webshell-edit-id'); + if (editIdEl) editIdEl.value = ''; + document.getElementById('webshell-url').value = ''; + document.getElementById('webshell-password').value = ''; + document.getElementById('webshell-type').value = 'php'; + document.getElementById('webshell-method').value = 'post'; + document.getElementById('webshell-cmd-param').value = ''; + document.getElementById('webshell-remark').value = ''; + var titleEl = document.getElementById('webshell-modal-title'); + if (titleEl) titleEl.textContent = wsT('webshell.addConnection'); + var modal = document.getElementById('webshell-modal'); + if (modal) modal.style.display = 'block'; +} + +// 打开编辑连接弹窗(预填当前连接信息) +function showEditWebshellModal(connId) { + var conn = webshellConnections.find(function (c) { return c.id === connId; }); + if (!conn) return; + var editIdEl = document.getElementById('webshell-edit-id'); + if (editIdEl) editIdEl.value = conn.id; + document.getElementById('webshell-url').value = conn.url || ''; + document.getElementById('webshell-password').value = conn.password || ''; + document.getElementById('webshell-type').value = conn.type || 'php'; + document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase(); + document.getElementById('webshell-cmd-param').value = conn.cmdParam || ''; + document.getElementById('webshell-remark').value = conn.remark || ''; + var titleEl = document.getElementById('webshell-modal-title'); + if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle'); + var modal = document.getElementById('webshell-modal'); + if (modal) modal.style.display = 'block'; +} + +// 关闭弹窗 +function closeWebshellModal() { + var editIdEl = document.getElementById('webshell-edit-id'); + if (editIdEl) editIdEl.value = ''; + var modal = document.getElementById('webshell-modal'); + if (modal) modal.style.display = 'none'; +} + +// 语言切换时刷新 WebShell 页面内所有由 JS 生成的文案(不重建终端) +function refreshWebshellUIOnLanguageChange() { + var page = typeof window.currentPage === 'function' ? window.currentPage() : (window.currentPage || ''); + if (page !== 'webshell') return; + + renderWebshellList(); + + var workspace = document.getElementById('webshell-workspace'); + if (workspace) { + if (!currentWebshellId || !webshellCurrentConn) { + workspace.innerHTML = '
' + wsT('webshell.selectOrAdd') + '
'; + } else { + // 只更新标签文案,不重建终端 + var tabTerminal = workspace.querySelector('.webshell-tab[data-tab="terminal"]'); + var tabFile = workspace.querySelector('.webshell-tab[data-tab="file"]'); + if (tabTerminal) tabTerminal.textContent = wsT('webshell.tabTerminal'); + if (tabFile) tabFile.textContent = wsT('webshell.tabFileManager'); + + var pathLabel = workspace.querySelector('.webshell-file-toolbar label span'); + var listDirBtn = document.getElementById('webshell-list-dir'); + var parentDirBtn = document.getElementById('webshell-parent-dir'); + if (pathLabel) pathLabel.textContent = wsT('webshell.filePath'); + if (listDirBtn) listDirBtn.textContent = wsT('webshell.listDir'); + if (parentDirBtn) parentDirBtn.textContent = wsT('webshell.parentDir'); + + var pathInput = document.getElementById('webshell-file-path'); + var fileListEl = document.getElementById('webshell-file-list'); + if (fileListEl && webshellCurrentConn && pathInput) { + webshellFileListDir(webshellCurrentConn, pathInput.value.trim() || '.'); + } + } + } + + var modal = document.getElementById('webshell-modal'); + if (modal && modal.style.display === 'block') { + var titleEl = document.getElementById('webshell-modal-title'); + var editIdEl = document.getElementById('webshell-edit-id'); + if (titleEl) { + titleEl.textContent = (editIdEl && editIdEl.value) ? wsT('webshell.editConnectionTitle') : wsT('webshell.addConnection'); + } + if (typeof window.applyTranslations === 'function') { + window.applyTranslations(modal); + } + } +} + +document.addEventListener('languagechange', function () { + refreshWebshellUIOnLanguageChange(); +}); + +// 测试连通性(不保存,仅用当前表单参数请求 Shell 执行 echo 1) +function testWebshellConnection() { + var url = (document.getElementById('webshell-url') || {}).value; + if (url && typeof url.trim === 'function') url = url.trim(); + if (!url) { + alert(wsT('webshell.url') ? (wsT('webshell.url') + ' 必填') : '请填写 Shell 地址'); + return; + } + var password = (document.getElementById('webshell-password') || {}).value; + if (password && typeof password.trim === 'function') password = password.trim(); else password = ''; + var type = (document.getElementById('webshell-type') || {}).value || 'php'; + var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase(); + var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value; + if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = ''; + var btn = document.getElementById('webshell-test-btn'); + if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; } + if (typeof apiFetch === 'undefined') { + if (btn) { btn.disabled = false; btn.textContent = wsT('webshell.testConnectivity'); } + alert(wsT('webshell.testFailed') || '连通性测试失败'); + return; + } + apiFetch('/api/webshell/exec', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: url, + password: password || '', + type: type, + method: method === 'get' ? 'get' : 'post', + cmd_param: cmdParam || '', + command: 'echo 1' + }) + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (btn) { btn.disabled = false; btn.textContent = wsT('webshell.testConnectivity'); } + if (!data) { + alert(wsT('webshell.testFailed') || '连通性测试失败'); + return; + } + // 仅 HTTP 200 不算通过,需校验是否真的执行了 echo 1(响应体 trim 后应为 "1") + var output = (data.output != null) ? String(data.output).trim() : ''; + var reallyOk = data.ok && output === '1'; + if (reallyOk) { + alert(wsT('webshell.testSuccess') || '连通性正常,Shell 可访问'); + } else { + var msg; + if (data.ok && output !== '1') + msg = wsT('webshell.testNoExpectedOutput') || 'Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名'; + else + msg = (data.error) ? data.error : (wsT('webshell.testFailed') || '连通性测试失败'); + if (data.http_code) msg += ' (HTTP ' + data.http_code + ')'; + alert(msg); + } + }) + .catch(function (e) { + if (btn) { btn.disabled = false; btn.textContent = wsT('webshell.testConnectivity'); } + alert((wsT('webshell.testFailed') || '连通性测试失败') + ': ' + (e && e.message ? e.message : String(e))); + }); +} + +// 保存连接(新建或更新,请求服务端写入 SQLite 后刷新列表) +function saveWebshellConnection() { + var url = (document.getElementById('webshell-url') || {}).value; + if (url && typeof url.trim === 'function') url = url.trim(); + if (!url) { + alert('请填写 Shell 地址'); + return; + } + var password = (document.getElementById('webshell-password') || {}).value; + if (password && typeof password.trim === 'function') password = password.trim(); else password = ''; + var type = (document.getElementById('webshell-type') || {}).value || 'php'; + var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase(); + var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value; + if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = ''; + var remark = (document.getElementById('webshell-remark') || {}).value; + if (remark && typeof remark.trim === 'function') remark = remark.trim(); else remark = ''; + + var editIdEl = document.getElementById('webshell-edit-id'); + var editId = editIdEl ? editIdEl.value.trim() : ''; + var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, remark: remark || url }; + if (typeof apiFetch === 'undefined') return; + + var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections'; + var reqMethod = editId ? 'PUT' : 'POST'; + apiFetch(reqUrl, { + method: reqMethod, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then(function (r) { return r.json(); }) + .then(function () { + closeWebshellModal(); + return refreshWebshellConnectionsFromServer(); + }) + .then(function (list) { + // 若编辑的是当前选中的连接,同步更新 webshellCurrentConn,使终端/文件管理立即使用新配置 + if (editId && currentWebshellId === editId && Array.isArray(list)) { + var updated = list.find(function (c) { return c.id === editId; }); + if (updated) webshellCurrentConn = updated; + } + }) + .catch(function (e) { + console.warn('保存 WebShell 连接失败', e); + alert(e && e.message ? e.message : '保存失败'); + }); +} diff --git a/web/templates/index.html b/web/templates/index.html index d6c99e87..0c3d4c83 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -135,6 +135,15 @@ 漏洞管理 + + +
+ +
+
+
+
连接列表
+
+
暂无连接,请点击「添加连接」
+
+
+
+
+
+
请从左侧选择连接,或添加新的 WebShell 连接
+
+
+
+
+
+
+ + +