Skip to content

Commit a4befa0

Browse files
committed
feat(软件商店): 新增基于 f8x 的一站式工具安装平台
新增软件商店功能模块,支持通过 SSH 远程安装 80+ 渗透/开发/运维工具。主要功能包括: - 动态加载远程工具目录(支持 GitHub Pages 自动更新) - 分类浏览、关键词搜索和批量安装功能 - 预设组合安装(渗透全套/开发环境等) - 实时流式安装日志和历史记录 - 多语言支持和权限控制 同时优化了 SSH 配置获取逻辑,仅在 IP 数据不可用时刷新 terraform 输出
1 parent 392478d commit a4befa0

File tree

16 files changed

+1178
-9
lines changed

16 files changed

+1178
-9
lines changed

app_f8x.go

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strings"
7+
"sync"
8+
"time"
9+
10+
"red-cloud/i18n"
11+
redc "red-cloud/mod"
12+
"red-cloud/utils/sshutil"
13+
)
14+
15+
// ansiRegex matches ANSI escape sequences for stripping from terminal output
16+
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
17+
18+
// F8xInstallRecord records a f8x installation
19+
type F8xInstallRecord struct {
20+
ID string `json:"id"`
21+
CaseID string `json:"caseID"`
22+
Flags string `json:"flags"`
23+
Status string `json:"status"` // running, success, failed
24+
Output string `json:"output"`
25+
StartedAt string `json:"startedAt"`
26+
FinishedAt string `json:"finishedAt,omitempty"`
27+
}
28+
29+
// F8xStatus represents f8x deployment status on a VPS
30+
type F8xStatus struct {
31+
Deployed bool `json:"deployed"`
32+
Version string `json:"version,omitempty"`
33+
Error string `json:"error,omitempty"`
34+
}
35+
36+
var (
37+
f8xInstallHistory []F8xInstallRecord
38+
f8xInstallHistoryMu sync.Mutex
39+
f8xRunningTasks = make(map[string]*F8xInstallRecord)
40+
f8xRunningTasksMu sync.Mutex
41+
)
42+
43+
// GetF8xCatalog returns the full f8x tool catalog
44+
func (a *App) GetF8xCatalog() []redc.F8xModule {
45+
return redc.GetF8xCatalog()
46+
}
47+
48+
// GetF8xCategories returns category metadata with counts
49+
func (a *App) GetF8xCategories() []redc.F8xCategoryInfo {
50+
return redc.GetF8xCategories()
51+
}
52+
53+
// GetF8xPresets returns preset install combinations
54+
func (a *App) GetF8xPresets() []redc.F8xPreset {
55+
return redc.GetF8xPresets()
56+
}
57+
58+
// RefreshF8xCatalog forces a refresh of the remote catalog cache
59+
func (a *App) RefreshF8xCatalog() map[string]interface{} {
60+
redc.InvalidateF8xCache()
61+
remote, err := redc.FetchF8xRemoteCatalog()
62+
if err != nil {
63+
return map[string]interface{}{
64+
"success": false,
65+
"error": err.Error(),
66+
"source": "none",
67+
"count": 0,
68+
}
69+
}
70+
return map[string]interface{}{
71+
"success": true,
72+
"source": "remote",
73+
"version": remote.Version,
74+
"updatedAt": remote.UpdatedAt,
75+
"count": len(remote.Modules),
76+
}
77+
}
78+
79+
// GetF8xStatus checks if f8x is deployed on a VPS
80+
func (a *App) GetF8xStatus(caseID string) F8xStatus {
81+
sshConfig, err := a.getSSHConfigForCase(caseID)
82+
if err != nil {
83+
return F8xStatus{Error: err.Error()}
84+
}
85+
86+
// Check both /tmp/f8x (deploy location) and /usr/local/bin/f8x (f8x self-installs there)
87+
result := a.execSSHCommand(sshConfig, "f8x_path=''; for p in /usr/local/bin/f8x /tmp/f8x; do test -f \"$p\" && f8x_path=\"$p\" && break; done; if [ -n \"$f8x_path\" ]; then head -c 200 \"$f8x_path\" | grep -o 'F8x_Version=\"[^\"]*\"'; else echo 'NOT_FOUND'; fi")
88+
if !result.Success {
89+
return F8xStatus{Error: result.Error}
90+
}
91+
92+
output := strings.TrimSpace(result.Stdout)
93+
if strings.Contains(output, "NOT_FOUND") {
94+
return F8xStatus{Deployed: false}
95+
}
96+
97+
version := ""
98+
if strings.Contains(output, "F8x_Version=") {
99+
version = strings.Trim(strings.SplitN(output, "=", 2)[1], "\"")
100+
}
101+
return F8xStatus{Deployed: true, Version: version}
102+
}
103+
104+
// EnsureF8x deploys f8x to target VPS if not present
105+
func (a *App) EnsureF8x(caseID string) ExecCommandResult {
106+
sshConfig, err := a.getSSHConfigForCase(caseID)
107+
if err != nil {
108+
return ExecCommandResult{Error: err.Error(), Success: false}
109+
}
110+
111+
// Check if already present (both deploy and install locations)
112+
checkResult := a.execSSHCommand(sshConfig, "test -f /tmp/f8x -o -f /usr/local/bin/f8x && echo 'EXISTS' || echo 'MISSING'")
113+
if checkResult.Success && strings.Contains(checkResult.Stdout, "EXISTS") {
114+
return ExecCommandResult{Success: true, Stdout: "f8x already deployed"}
115+
}
116+
117+
// Download f8x from CDN
118+
cmd := fmt.Sprintf("wget -q -O /tmp/f8x %s && chmod +x /tmp/f8x && echo 'OK'", redc.F8xDefaultURL)
119+
result := a.execSSHCommand(sshConfig, cmd)
120+
if result.Success && strings.Contains(result.Stdout, "OK") {
121+
return ExecCommandResult{Success: true, Stdout: "f8x deployed successfully"}
122+
}
123+
124+
// Fallback to GitHub raw
125+
cmd = fmt.Sprintf("wget -q -O /tmp/f8x %s && chmod +x /tmp/f8x && echo 'OK'", redc.F8xFallbackURL)
126+
result = a.execSSHCommand(sshConfig, cmd)
127+
if result.Success && strings.Contains(result.Stdout, "OK") {
128+
return ExecCommandResult{Success: true, Stdout: "f8x deployed (fallback)"}
129+
}
130+
131+
// Try curl as last resort
132+
cmd = fmt.Sprintf("curl -sL -o /tmp/f8x %s && chmod +x /tmp/f8x && echo 'OK'", redc.F8xDefaultURL)
133+
result = a.execSSHCommand(sshConfig, cmd)
134+
if result.Success && strings.Contains(result.Stdout, "OK") {
135+
return ExecCommandResult{Success: true, Stdout: "f8x deployed (curl)"}
136+
}
137+
138+
return ExecCommandResult{Error: i18n.T("f8x_deploy_failed"), Success: false}
139+
}
140+
141+
// RunF8xInstall executes f8x with given flags on target VPS
142+
// Returns taskID for tracking; output is streamed via events
143+
func (a *App) RunF8xInstall(caseID string, flags []string) string {
144+
taskID := fmt.Sprintf("f8x-%s-%d", caseID, time.Now().UnixMilli())
145+
146+
record := &F8xInstallRecord{
147+
ID: taskID,
148+
CaseID: caseID,
149+
Flags: strings.Join(flags, " "),
150+
Status: "running",
151+
StartedAt: time.Now().Format(time.RFC3339),
152+
}
153+
154+
f8xRunningTasksMu.Lock()
155+
f8xRunningTasks[taskID] = record
156+
f8xRunningTasksMu.Unlock()
157+
158+
go a.runF8xInstallAsync(taskID, caseID, flags, record)
159+
160+
return taskID
161+
}
162+
163+
func (a *App) runF8xInstallAsync(taskID, caseID string, flags []string, record *F8xInstallRecord) {
164+
defer func() {
165+
f8xRunningTasksMu.Lock()
166+
delete(f8xRunningTasks, taskID)
167+
f8xRunningTasksMu.Unlock()
168+
169+
f8xInstallHistoryMu.Lock()
170+
f8xInstallHistory = append(f8xInstallHistory, *record)
171+
// Keep last 100 records
172+
if len(f8xInstallHistory) > 100 {
173+
f8xInstallHistory = f8xInstallHistory[len(f8xInstallHistory)-100:]
174+
}
175+
f8xInstallHistoryMu.Unlock()
176+
}()
177+
178+
// Ensure f8x is deployed
179+
a.emitEvent("f8x:output", map[string]interface{}{
180+
"taskID": taskID, "type": "info",
181+
"text": "Checking f8x deployment...",
182+
})
183+
184+
ensureResult := a.EnsureF8x(caseID)
185+
if !ensureResult.Success {
186+
record.Status = "failed"
187+
record.Output = ensureResult.Error
188+
record.FinishedAt = time.Now().Format(time.RFC3339)
189+
a.emitEvent("f8x:output", map[string]interface{}{
190+
"taskID": taskID, "type": "error",
191+
"text": "Failed to deploy f8x: " + ensureResult.Error,
192+
})
193+
a.emitEvent("f8x:done", map[string]interface{}{
194+
"taskID": taskID, "status": "failed",
195+
})
196+
return
197+
}
198+
199+
// Get SSH config and run f8x with streaming output
200+
sshConfig, err := a.getSSHConfigForCase(caseID)
201+
if err != nil {
202+
record.Status = "failed"
203+
record.Output = err.Error()
204+
record.FinishedAt = time.Now().Format(time.RFC3339)
205+
a.emitEvent("f8x:done", map[string]interface{}{
206+
"taskID": taskID, "status": "failed",
207+
})
208+
return
209+
}
210+
211+
// touch /tmp/IS_CI to skip interactive prompts (f8x CI mode)
212+
// Use sudo for non-root users (e.g., AWS ec2-user, admin)
213+
// f8x may be at /tmp/f8x (freshly deployed) or /usr/local/bin/f8x (self-installed)
214+
flagStr := strings.Join(flags, " ")
215+
sudo := ""
216+
if sshConfig.User != "root" {
217+
sudo = "sudo "
218+
}
219+
cmd := fmt.Sprintf("touch /tmp/IS_CI && F8X=$(which f8x 2>/dev/null || test -f /tmp/f8x && echo /tmp/f8x || echo /usr/local/bin/f8x) && %sbash \"$F8X\" %s", sudo, flagStr)
220+
221+
a.emitEvent("f8x:output", map[string]interface{}{
222+
"taskID": taskID, "type": "info",
223+
"text": fmt.Sprintf("Running: %s", cmd),
224+
})
225+
226+
client, err := sshutil.NewClient(sshConfig)
227+
if err != nil {
228+
record.Status = "failed"
229+
record.Output = err.Error()
230+
record.FinishedAt = time.Now().Format(time.RFC3339)
231+
a.emitEvent("f8x:done", map[string]interface{}{
232+
"taskID": taskID, "status": "failed",
233+
})
234+
return
235+
}
236+
defer client.Close()
237+
238+
session, err := client.NewSession()
239+
if err != nil {
240+
record.Status = "failed"
241+
record.Output = err.Error()
242+
record.FinishedAt = time.Now().Format(time.RFC3339)
243+
a.emitEvent("f8x:done", map[string]interface{}{
244+
"taskID": taskID, "status": "failed",
245+
})
246+
return
247+
}
248+
defer session.Close()
249+
250+
// Stream stdout
251+
stdout, _ := session.StdoutPipe()
252+
stderr, _ := session.StderrPipe()
253+
254+
if err := session.Start(cmd); err != nil {
255+
record.Status = "failed"
256+
record.Output = err.Error()
257+
record.FinishedAt = time.Now().Format(time.RFC3339)
258+
a.emitEvent("f8x:done", map[string]interface{}{
259+
"taskID": taskID, "status": "failed",
260+
})
261+
return
262+
}
263+
264+
var outputBuf strings.Builder
265+
266+
// Read stdout in goroutine
267+
go func() {
268+
buf := make([]byte, 4096)
269+
for {
270+
n, err := stdout.Read(buf)
271+
if n > 0 {
272+
text := ansiRegex.ReplaceAllString(string(buf[:n]), "")
273+
outputBuf.WriteString(text)
274+
a.emitEvent("f8x:output", map[string]interface{}{
275+
"taskID": taskID, "type": "stdout", "text": text,
276+
})
277+
}
278+
if err != nil {
279+
break
280+
}
281+
}
282+
}()
283+
284+
// Read stderr in goroutine
285+
go func() {
286+
buf := make([]byte, 4096)
287+
for {
288+
n, err := stderr.Read(buf)
289+
if n > 0 {
290+
text := ansiRegex.ReplaceAllString(string(buf[:n]), "")
291+
outputBuf.WriteString(text)
292+
a.emitEvent("f8x:output", map[string]interface{}{
293+
"taskID": taskID, "type": "stderr", "text": text,
294+
})
295+
}
296+
if err != nil {
297+
break
298+
}
299+
}
300+
}()
301+
302+
// Wait for completion
303+
err = session.Wait()
304+
record.FinishedAt = time.Now().Format(time.RFC3339)
305+
306+
// Keep last 10000 chars of output
307+
output := outputBuf.String()
308+
if len(output) > 10000 {
309+
output = output[len(output)-10000:]
310+
}
311+
record.Output = output
312+
313+
if err != nil {
314+
record.Status = "failed"
315+
a.emitEvent("f8x:done", map[string]interface{}{
316+
"taskID": taskID, "status": "failed", "error": err.Error(),
317+
})
318+
} else {
319+
record.Status = "success"
320+
a.emitEvent("f8x:done", map[string]interface{}{
321+
"taskID": taskID, "status": "success",
322+
})
323+
}
324+
}
325+
326+
// GetF8xInstallHistory returns install history for a case (or all)
327+
func (a *App) GetF8xInstallHistory(caseID string) []F8xInstallRecord {
328+
f8xInstallHistoryMu.Lock()
329+
defer f8xInstallHistoryMu.Unlock()
330+
331+
if caseID == "" {
332+
result := make([]F8xInstallRecord, len(f8xInstallHistory))
333+
copy(result, f8xInstallHistory)
334+
return result
335+
}
336+
337+
var filtered []F8xInstallRecord
338+
for _, r := range f8xInstallHistory {
339+
if r.CaseID == caseID {
340+
filtered = append(filtered, r)
341+
}
342+
}
343+
return filtered
344+
}
345+
346+
// GetF8xRunningTasks returns currently running f8x install tasks
347+
func (a *App) GetF8xRunningTasks() []F8xInstallRecord {
348+
f8xRunningTasksMu.Lock()
349+
defer f8xRunningTasksMu.Unlock()
350+
351+
var result []F8xInstallRecord
352+
for _, r := range f8xRunningTasks {
353+
result = append(result, *r)
354+
}
355+
return result
356+
}
357+
358+
// helper to get SSH config for a case (or custom deployment)
359+
func (a *App) getSSHConfigForCase(caseID string) (*sshutil.SSHConfig, error) {
360+
a.mu.Lock()
361+
project := a.project
362+
service := a.customDeploymentService
363+
a.mu.Unlock()
364+
365+
if project == nil {
366+
return nil, fmt.Errorf(i18n.T("app_project_not_loaded"))
367+
}
368+
369+
c, caseErr := project.GetCase(caseID)
370+
if caseErr == nil {
371+
return c.GetSSHConfig()
372+
}
373+
374+
if service != nil {
375+
return a.getDeploymentSSHConfig(caseID)
376+
}
377+
378+
return nil, fmt.Errorf(i18n.T("app_case_not_found"), caseErr)
379+
}

frontend/public/changelog.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"version": "3.1.6",
55
"date": "2026-03-15",
66
"changes": [
7+
"新增:软件商店 — 基于 f8x 的一站式工具安装平台,集成 80+ 渗透/开发/运维工具,支持分类浏览(基础环境/开发环境/渗透侦查/渗透利用/后渗透/蓝队/红队基础设施/靶场/杂项)、关键词搜索、批量勾选安装、快捷预设组合(渗透全套/开发环境/蓝队防御/C2 部署等);通过 SSH 远程拉取 f8x 到目标 VPS 并执行,实时流式显示安装日志,记录安装历史",
8+
"新增:软件商店在线目录 — 工具目录从硬编码改为在线动态加载(https://f8x.wgpsec.org/catalog.json),f8x 仓库通过 GitHub Actions 自动生成目录并部署到 GitHub Pages;redc 启动时自动拉取最新目录(30 分钟缓存),网络不可用时自动回退到本地内置目录;支持手动刷新按钮",
79
"新增:Web 服务用户权限管理 — 支持 Admin/Operator/Viewer 三级角色控制,Admin 拥有完全权限(创建/销毁/配置/用户管理),Operator 可操作场景(创建/启停/SSH/部署)但不可销毁或修改配置,Viewer 仅可查看场景状态和资源信息;主 Token 默认 Admin 权限,可为团队成员创建独立 Token 并分配角色,支持实时修改角色和重新生成 Token",
810
"新增:Web 服务操作审计日志 — 自动记录所有通过 Web 服务执行的写操作(谁在什么时间创建/销毁/操作了什么场景),包含用户名、角色、IP、参数和结果;支持按操作名和用户筛选、分页浏览、导出 TSV 文件;权限不足的请求也会被记录;最多保留 5000 条日志",
911
"优化:控制台页面 UI/UX — 日志自动识别级别并彩色显示(红=错误/黄=警告/绿=成功/灰=普通)、新增搜索框和级别筛选标签页(全部/ERR/WARN/OK)、日志计数统计(总数+错误数+警告数)、自动滚动到底部+跳到底部浮动按钮、点击日志行一键复制、导出日志按钮、清除按钮改图标、空状态增加终端图标和操作提示、长时间间隔日志插入时间分隔线",

0 commit comments

Comments
 (0)