mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-23 19:51:52 +08:00
v2.1.2 核心优化与多架构发布 (#561)
* feat: v2.1.0 核心重构与功能增强
## 架构重构
- 全局变量消除,迁移至 Config/State 对象
- SMB 插件融合(smb/smb2/smbghost/smbinfo)
- 服务探测重构,实现 Nmap 风格 fallback 机制
- 输出系统重构,TXT 实时刷盘 + 双写机制
- i18n 框架升级至 go-i18n
## 性能优化
- 正则表达式预编译
- 内存优化 map[string]struct{}
- 并发指纹匹配
- SOCKS5 连接复用
- 滑动窗口调度 + 自适应线程池
## 新功能
- Web 管理界面
- 多格式 POC 适配(xray/afrog)
- 增强指纹库(3139条)
- Favicon hash 指纹识别
- 插件选择性编译(Build Tags)
- fscan-lab 靶场环境
- 默认端口扩展(62→133)
## 构建系统
- 添加 no_local tag 支持排除本地插件
- 多版本构建:fscan/fscan-nolocal/fscan-web
- CI 添加 snapshot 模式支持仅测试构建
## Bug 修复
- 修复 120+ 个问题,包括 RDP panic、批量扫描漏报、
JSON 输出格式、Redis 检测、Context 超时等
## 测试增强
- 单元测试覆盖率 74-100%
- 并发安全测试
- 集成测试(Web/端口/服务/SSH/ICMP)
* fix(ci): 移除 PR 对 Project 自动化的触发
* fix: Elasticsearch未授权检测优先于爆破 (#554)
* fix: 修复RDP爆破高误报率问题 (#555)
- 移除 screen.go 中错误的认证结果覆盖逻辑
- 启用 NLA 协议的 ErrorCode 字段检测
- 添加 PubKeyAuth 验证确保认证真正成功
- 修复 io.go 中错误被静默忽略的问题
- 修复 socket.go/io.go 中可能导致 panic 的代码
- 修复 screen.go 中文件句柄泄漏和 log.Panic
* fix: 修复-user/-pwd凭据参数不生效的问题
问题原因:
- Parse()解析凭据后更新globalConfig
- 但BuildConfigFromFlags()创建新Config时使用默认字典
- 导致解析的UserPassPairs等凭据信息被丢弃
修复内容:
1. initialize.go: 将Parse解析的凭据结果应用到新Config
2. credential.go: 单用户密码对时创建UserPassPairs
3. rdp.go: 单凭据测试时跳过指纹识别,减少连接次数
* feat: RDP使用NLA仅验证模式,避免挤掉已登录用户
- 添加ErrNLAAuthSuccess标志用于NLA验证成功信号
- tpkt层支持nlaAuthOnly模式,验证成功后不建立完整会话
- x224层正确传播NLA验证结果
- rdpCrack改用NlaAuth进行凭据验证
* fix: 修复进度条在Windows终端满屏重复输出的问题
- 添加终端宽度检测,动态调整进度条长度
- 使用空格覆盖清除旧内容,避免残留
- 简化进度条格式,确保不超过终端宽度
* feat: 优化日志颜色方案,区分漏洞和普通信息
- 新增 LogVuln 级别(红色),用于漏洞和重要发现
- 密码爆破成功、未授权访问、POC漏洞等改用红色显示
- 普通信息(扫描统计等)改为白色
- Web指纹保持绿色
* refactor: 精简化输出,移除冗余启动信息
- 移除showParseSummary开局配置输出
- 移除LogPluginInfo/LogPluginInfoWithPort插件信息输出
- 移除alive_scanner冗余统计输出
- 移除port_scan_start扫描开始提示
- 移除handleUDPPorts SNMP死代码
- 移除相关i18n条目
* chore: 版本号更新为2.1.1
* fix: 降级依赖版本以保持Go 1.20兼容性
* feat(ldap): 添加NTLM Hash认证支持 (#433)
* chore: 清理无用的 replace 指令
* fix(ping): 修复 TTL expired 导致主机误判为存活的问题
在 ExecCommandPing 中增加错误关键词检测,当 ping 输出包含
TTL expired、Destination unreachable 等错误信息时,不再将
目标主机标记为存活。
Fixes #454
* fix(proxy): 修复透明代理导致输出全端口的问题
在代理初始化时主动探测代理行为,通过连接 RFC 5737 保留的
测试地址来检测是否存在"全回显"问题。如果探测到代理不可靠,
则在端口扫描时跳过所有端口,避免误报。
- 新增 proxyReliable 标志位标记代理可靠性
- 新增 ProbeProxyBehavior 函数探测代理行为
- 端口扫描前检查代理可靠性并输出警告
Fixes #495
* refactor: 移动debug模块到common/debug子包
* fix(web): 修复-u模式下Web插件未执行的问题
* fix: 优化输出格式和颜色显示
- 网段统计格式改为 10.253.0.0/16 网段存活: 26
- WebTitle基础信息改为白色,指纹识别单独绿色输出
- 移除重复的端口数量输出
* fix: URL解析自动补全协议头
-uf 文件中 192.168.1.1:8080 自动转为 http://192.168.1.1:8080
* fix: 修复-u/-uf模式下URLs丢失导致0目标扫描的问题
Parse阶段将URLs设置到全局状态,但Initialize随后创建新状态
并覆盖了全局状态,导致URLs数据丢失。现在在创建新状态前
先保存并迁移Parse阶段设置的URLs和HostPorts数据。
* fix: 智能检测HTTP/HTTPS协议并优化URL显示
- 修复-u/-uf模式URLs丢失导致0目标扫描问题
- detectProtocol改为主动TLS握手检测,不依赖服务名
- WebTitle输出显示完整协议(http/https)
- 隐藏标准端口(80/443)使输出更简洁
* refactor: 精简parsers包,统一配置构建入口
- 删除冗余的中间层(XXXInput、XXXParser类)
- 新增 config_builder.go 统一配置构建
- parsers包从3000+行精简至~540行
- 保留核心函数:ParseIP、ParsePort、文件读取、凭据解析
* test: 扩展parsers单元测试覆盖边缘情况
- 新增内网简写解析测试(192/172/10)
- 新增完整IP范围和无效CIDR测试
- 新增Windows行尾(CRLF)处理测试
- 新增凭据和哈希文件解析测试
- 新增端口解析边缘情况测试
- 测试覆盖率达到94.2%
* refactor: 优化控制台输出格式
- 去掉时间戳,保留[*][+]前缀
- Web输出合并WebTitle和WebFinger为一行
- 有指纹显示绿色[+],无指纹显示白色[*]
- 格式: code:xxx len:xxx title:xxx server:xxx [指纹]
- 服务探测格式: [Product:xxx ||Version:xxx] Banner:(xxx)
- 字段对齐,输出更清爽
* feat: 添加凭据测试未发现弱密码的提示
- credential_tester.go: 失败时设置 Type=ResultTypeCredential
- scanner.go: 根据结果类型在 error 级别输出'未发现弱密码'提示
- 新增 i18n 翻译 brute_no_weak_pass
使用 -log all 或 -log error 可看到此提示
* refactor(logging): 重构日志级别为层级过滤设计
- LogLevel 从 string 改为 int 类型,支持层级比较
- 层级设计:Debug(0) < Base(1) < Info(2) < Success(3) < Vuln(4) < Error(5)
- 设置一个级别后,显示该级别及以上的日志
- Error 级别始终显示,不会被配置过滤掉
- 保留向后兼容别名(LevelAll, LevelInfoSuccess 等)
- 更新测试以匹配新的层级过滤行为
* style(logging): Error级别日志改为黄色显示
* style(findnet): NetInfo输出改为每行一个IP
* refactor(ms17010): 优化错误提示,明确指出SMBv1不支持等情况
* fix(credential): 修复凭据测试结果不一致的问题
问题原因:
1. 未知错误类型不重试,导致服务端限流时跳过正确密码
2. SSH 错误分类不够准确,某些临时错误未被识别
修复内容:
1. 未知错误改为可重试(可能是临时问题)
2. 增加 SSH 特有的网络错误识别(handshake failed, disconnect 等)
* fix(portfinger): 修复SMB2服务指纹识别和NetInfo输出问题
- 添加SMB2ProgNeg探针支持现代Windows的SMB2协议
- 修复Go regexp对高位字节的UTF-8兼容问题,使用Latin-1转换
- 修复探针失败后连接重建逻辑
- 修复vendor_product字段名不匹配问题
- 修复NetInfo多行输出被其他日志打断的问题
* fix(config): 从默认端口移除9100,避免触发打印机打印 (#517)
* feat(proxy): 增强代理端口扫描的深度验证机制
- 新增4阶段深度验证:Banner读取→探测发送→响应等待→最终判定
- 新增SOCKS5错误码和代理错误文本检测
- 优化ProbeProxyBehavior探测逻辑,发送数据验证连接可达性
- 解决透明代理/全回显代理导致的假阳性问题
* fix(proxy): 修复代理深度验证的若干问题
- detector.go: 修复 AutoConfigureProxy 覆盖探测结果的问题
只有未探测过时才设置默认 proxyReliable 值
- port_scan.go: 改进深度验证机制
- 使用带 Host header 的 HTTP GET 请求替代 OPTIONS
- 延长响应等待超时至 2s 以适配慢速服务器
- 正确重置连接 deadline 避免影响后续操作
* refactor: 统一 common 包文件命名风格
Flag.go -> flag.go
* refactor(proxy): 删除自定义 contains() 函数,改用标准库
- 用 strings.Contains() 替代手写的 contains()
- 删除过时的注释
* fix(parsers): 修复带横杠域名被误识别为IP范围的问题
如 111-555.sss.com 这类域名因包含 - 被错误解析为 IP 范围,
添加 looksLikeIPRange() 检查,只有 - 前是有效 IP 才走范围解析
* fix(proxy): 修复代理模式下服务识别错误和端口漏扫问题
- port_scan.go: 验证通过后重建干净连接,避免HTTP GET探测污染服务识别
- port_scan.go: 优化验证策略,用轻量CRLF探测替代HTTP GET,超时从2.2s降至0.6s
- manager.go: 修正ProbeProxyBehavior判断逻辑,超时应视为代理正常转发
* fix(pool): 移除线程池预分配,优化大规模扫描内存占用
WithPreAlloc(true) 会预先创建所有 worker goroutine,
在大规模扫描(如 25域名×65535端口)时可能导致内存问题
* refactor(logging): 统一日志前缀,删除废弃的 LogBase
- 删除 LogBase 函数,所有调用迁移到 LogInfo/LogError
- 新增 PrefixDebug ([.]) 前缀,所有日志级别现在都有前缀
- 修复日志输出缩进不一致的问题
- 删除未使用的 PrefixDefault 常量
* perf(icmp): 实现自适应等待算法优化存活检测性能
- 新增 waitAdaptive 函数,监控响应增量实现智能提前结束
- 算法保守原则:最小等待1s + 连续500ms无新响应才提前结束
- 添加100ms检查间隔避免CPU空转
- 保留原有最大等待时间(3s/6s)作为兜底
- 添加完整单元测试覆盖各种场景
优化效果:
- 全部响应:~100ms (原3s)
- 无响应:~1s (原3s)
- 部分响应后稳定:~1.5s (原3s)
* perf(scan): 实现启发式优化提升扫描体验
1. 端口优先级排序:高价值端口(80,443,22,3389等)优先扫描
- 用户能更快看到有意义的结果
- 不影响端口喷洒策略
2. TCP 补充探测:ICMP 响应率<10%时自动启用
- 对未响应主机用 TCP 80/443/22/445 补充探测
- 解决防火墙过滤 ICMP 导致漏检的问题
* refactor(grdp): 精简RDP库,删除认证检测不需要的代码
- 删除 VNC 协议支持 (protocol/rfb, client/rfb.go)
- 删除完整客户端框架 (client/)
- 删除 RemoteApp 等插件 (plugin/)
- 删除 RLE 图形解压 (core/rle.go)
- 删除绘图指令处理 (pdu/orders.go, pdu/gdi.go)
- 精简 screen.go,移除截图和完整会话功能
- 移除未使用的 RGB 转换函数
grdp 代码从 13,044 行精简至 7,581 行,削减 42%
* refactor(common): 删除死代码,优化代码风格
- 删除未使用的 joinStrings/joinInts 函数
- 删除未使用的 memStats 字段和 getMemoryInfo 方法
- 简化 parsePasswords 中的循环为 append(...) 形式
* refactor(services): 统一数据库插件的DBWrapper
4个数据库插件(MySQL、PostgreSQL、MSSQL、Oracle)都有相同的sql.DB包装代码,
合并为通用的SQLDBWrapper,减少重复。
* refactor(core,grdp): 删除未使用的死代码
- 移除 BaseScanStrategy.LogPluginInfoWithPort 方法(无调用者)
- 移除 mcs.go 中被注释的旧 connect 函数实现
* refactor: 删除 deadcode 检测出的未使用函数
- proxy/detector.go: 删除 IsSOCKS5Standard, IsProxyInitialized
- findnet.go: 删除 NetworkInfo.OneLine, TreeFormat 方法
- port_scan.go: 删除 estimateScanTime 函数
- web_scanner.go: 删除 GetFingerprints 函数
- 清理相关测试代码
* refactor: 删除更多未使用的死代码
- parse.go: 删除 RemoveDuplicate 函数及其测试
- parsers.go: 删除 excludeHosts, removeDuplicates 别名函数
- 更新测试使用真正的函数名
* fix(test): 修复 TestParseIP_InvalidIPRange 测试用例
- 删除不合理的测试用例(无效IP被当作普通主机名处理是设计行为)
- 修复测试逻辑,只在真正通过时输出"正确"
* fix(scan): 移除域名预解析,保留原始域名进行扫描
域名预解析会将域名转换为IP,导致虚拟主机场景下HTTP访问失败
(Host头变成IP而非域名,无法正确路由)
* fix(scan): 修复 -hf 参数无法单独使用的问题
* fix(proxy): 修复透明代理环境下 SOCKS5 代理全端口误报问题
问题:在透明代理(TUN模式)环境下使用 SOCKS5 代理扫描时,
会出现全端口开放的误报,因为代理可靠性检测被透明代理污染。
修复方案(参考 fscanx):
1. 将探针从 CRLF 改为 HTTP GET,更有效检测真实连接状态
2. 删除 "uncertain" 状态,无响应一律判定为端口关闭
3. 调整超时时间以适应代理链路延迟
Fixes #524
* feat(telnet): 新增 telnetd RCE 命令执行验证,修复未授权访问日志级别
* fix: 修复 i18n.Tr vet 报错、Unicode 测试用例,移除过期域名
- 移除 i18n.Tr 中错误的 fmt.Sprintf fallback,消除 go vet 误报
- 修复 match_engine_test Unicode 测试用例与 Latin-1 转换逻辑不匹配
- README 移除过期的 fscan.club 域名
- 添加 .gitattributes 统一换行符为 LF
* refactor: 统一控制台输出风格,使用统一的日志函数
手动合并 PR #558 的改动,适配重构后的代码路径
* fix(ci): 修复版本注入和CI触发配置
- goreleaser ldflags 指向正确的包路径 common.version/commit/date
- version 改为 var 支持 ldflags 注入,banner 显示 commit 和构建日期
- test-build 触发分支增加 dev-* 通配
* fix(ci): 修复 Windows 产物 .exe.exe 双后缀问题
* feat(ci): 扩展构建架构支持 MIPS/ARM/FreeBSD/Solaris
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'
|
||||
import { Target, Map, Trophy, Languages } from 'lucide-react'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Topology from './pages/Topology'
|
||||
import Challenges from './pages/Challenges'
|
||||
import { useI18n } from './contexts/I18nContext'
|
||||
import { Button } from './components/ui/button'
|
||||
|
||||
function App() {
|
||||
const { language, setLanguage, t } = useI18n()
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-background">
|
||||
<nav className="border-b">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-8">
|
||||
<h1 className="text-2xl font-bold text-primary">
|
||||
{t('nav.title')}
|
||||
</h1>
|
||||
<div className="flex space-x-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-accent"
|
||||
>
|
||||
<Trophy className="w-4 h-4" />
|
||||
<span>{t('nav.dashboard')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/topology"
|
||||
className="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-accent"
|
||||
>
|
||||
<Map className="w-4 h-4" />
|
||||
<span>{t('nav.network')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/challenges"
|
||||
className="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-accent"
|
||||
>
|
||||
<Target className="w-4 h-4" />
|
||||
<span>{t('nav.challenges')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('nav.subtitle')}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLanguage(language === 'zh' ? 'en' : 'zh')}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Languages className="w-4 h-4" />
|
||||
<span>{language === 'zh' ? 'EN' : '中文'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/topology" element={<Topology />} />
|
||||
<Route path="/challenges" element={<Challenges />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||
import { translations, Language, TranslationKey } from '@/lib/i18n'
|
||||
|
||||
interface I18nContextType {
|
||||
language: Language
|
||||
setLanguage: (lang: Language) => void
|
||||
t: (key: TranslationKey) => string
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextType | undefined>(undefined)
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguage] = useState<Language>(() => {
|
||||
const saved = localStorage.getItem('language')
|
||||
return (saved === 'zh' || saved === 'en') ? saved : 'zh'
|
||||
})
|
||||
|
||||
const handleSetLanguage = (lang: Language) => {
|
||||
setLanguage(lang)
|
||||
localStorage.setItem('language', lang)
|
||||
}
|
||||
|
||||
const t = (key: TranslationKey): string => {
|
||||
return translations[language][key] || key
|
||||
}
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ language, setLanguage: handleSetLanguage, t }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const context = useContext(I18nContext)
|
||||
if (!context) {
|
||||
throw new Error('useI18n must be used within I18nProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:8888'
|
||||
|
||||
export interface Challenge {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
difficulty: string
|
||||
points: number
|
||||
network: string
|
||||
targets: string[]
|
||||
order?: number // 渗透顺序
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
user_id: string
|
||||
completed_challenges: number[]
|
||||
total_score: number
|
||||
start_time: string
|
||||
last_update: string
|
||||
submission_history: Submission[]
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
challenge_id: number
|
||||
flag: string
|
||||
correct: boolean
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface NetworkNode {
|
||||
id: string
|
||||
name: string
|
||||
ip: string
|
||||
services: string[]
|
||||
network: string
|
||||
status: 'unknown' | 'discovered' | 'compromised'
|
||||
}
|
||||
|
||||
export interface NetworkEdge {
|
||||
from: string
|
||||
to: string
|
||||
access: 'allowed' | 'blocked' | 'vpn'
|
||||
}
|
||||
|
||||
export interface NetworkTopology {
|
||||
nodes: NetworkNode[]
|
||||
edges: NetworkEdge[]
|
||||
}
|
||||
|
||||
export interface SubmitFlagResponse {
|
||||
correct: boolean
|
||||
message: string
|
||||
points_earned?: number
|
||||
total_score?: number
|
||||
already_solved?: boolean
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
export const getChallenges = async (): Promise<Challenge[]> => {
|
||||
const response = await api.get('/api/challenges')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getChallenge = async (id: number): Promise<Challenge> => {
|
||||
const response = await api.get(`/api/challenges/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const submitFlag = async (
|
||||
challengeId: number,
|
||||
flag: string
|
||||
): Promise<SubmitFlagResponse> => {
|
||||
const response = await api.post('/api/submit', {
|
||||
challenge_id: challengeId,
|
||||
flag: flag.trim(),
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getProgress = async (): Promise<Progress> => {
|
||||
const response = await api.get('/api/progress')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const resetProgress = async (): Promise<void> => {
|
||||
await api.post('/api/reset')
|
||||
}
|
||||
|
||||
export const getTopology = async (): Promise<NetworkTopology> => {
|
||||
const response = await api.get('/api/topology')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getHints = async (id: number): Promise<string[]> => {
|
||||
const response = await api.get(`/api/hints/${id}`)
|
||||
return response.data.hints
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
export const translations = {
|
||||
zh: {
|
||||
// Navigation
|
||||
'nav.title': 'fscan Lab',
|
||||
'nav.subtitle': '内网渗透训练平台',
|
||||
'nav.dashboard': '控制面板',
|
||||
'nav.network': '网络拓扑',
|
||||
'nav.challenges': '挑战列表',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': '控制面板',
|
||||
'dashboard.welcome': '欢迎来到 fscan 内网渗透训练平台',
|
||||
'dashboard.resetProgress': '重置进度',
|
||||
'dashboard.resetConfirm': '确定要重置进度吗?这将清除所有已完成的挑战记录。',
|
||||
'dashboard.totalScore': '总分',
|
||||
'dashboard.maxScore': '满分',
|
||||
'dashboard.completedChallenges': '已完成挑战',
|
||||
'dashboard.completionRate': '完成率',
|
||||
'dashboard.startTime': '开始时间',
|
||||
'dashboard.submissions': '提交次数',
|
||||
'dashboard.successful': '成功',
|
||||
'dashboard.progress': '完成进度',
|
||||
'dashboard.progressDesc': '已完成',
|
||||
'dashboard.progressDesc2': '个挑战',
|
||||
'dashboard.recentSubmissions': '最近提交',
|
||||
'dashboard.recentSubmissionsDesc': '最新的 5 次 flag 提交记录',
|
||||
'dashboard.noSubmissions': '暂无提交记录',
|
||||
'dashboard.correct': '正确',
|
||||
'dashboard.incorrect': '错误',
|
||||
'dashboard.challenge': '挑战',
|
||||
'dashboard.overview': '挑战概览',
|
||||
'dashboard.overviewDesc': '按难度分类的挑战统计',
|
||||
'dashboard.quickStart': '快速开始',
|
||||
'dashboard.quickStart1': '进入攻击者容器:',
|
||||
'dashboard.quickStart2': '开始扫描 DMZ 区:',
|
||||
'dashboard.quickStart3': '在"挑战列表"页面查看所有挑战并提交 flag',
|
||||
'dashboard.quickStart4': '在"网络拓扑"页面查看网络拓扑和攻击路径',
|
||||
'dashboard.loading': '加载中...',
|
||||
|
||||
// Challenges
|
||||
'challenges.title': '挑战列表',
|
||||
'challenges.desc': '完成所有挑战,攻陷整个网络',
|
||||
'challenges.all': '全部',
|
||||
'challenges.search': '搜索挑战...',
|
||||
'challenges.difficulty': '难度',
|
||||
'challenges.points': '分',
|
||||
'challenges.network': '网络',
|
||||
'challenges.targets': '目标',
|
||||
'challenges.status': '状态',
|
||||
'challenges.completed': '已完成',
|
||||
'challenges.locked': '未完成',
|
||||
'challenges.submitFlag': '提交 Flag',
|
||||
'challenges.viewHints': '查看提示',
|
||||
'challenges.hideHints': '隐藏提示',
|
||||
'challenges.hints': '提示',
|
||||
'challenges.enterFlag': '输入 flag...',
|
||||
'challenges.submit': '提交',
|
||||
'challenges.submitting': '提交中...',
|
||||
'challenges.noChallenges': '未找到匹配的挑战',
|
||||
|
||||
// Topology
|
||||
'topology.title': '网络拓扑',
|
||||
'topology.desc': '实时网络拓扑和攻击路径',
|
||||
'topology.legend': '图例',
|
||||
'topology.compromised': '已攻陷',
|
||||
'topology.discovered': '已发现',
|
||||
'topology.unknown': '未知',
|
||||
'topology.nodeInfo': '节点信息',
|
||||
'topology.selectNode': '点击节点查看详细信息',
|
||||
'topology.name': '名称',
|
||||
'topology.ip': 'IP 地址',
|
||||
'topology.services': '服务',
|
||||
'topology.status': '状态',
|
||||
|
||||
// Network Labels
|
||||
'network.internet': '外网',
|
||||
'network.dmz': 'DMZ',
|
||||
'network.office': '办公网',
|
||||
'network.production': '生产网',
|
||||
'network.core': '核心网',
|
||||
|
||||
// Difficulty
|
||||
'difficulty.Easy': 'Easy',
|
||||
'difficulty.Medium': 'Medium',
|
||||
'difficulty.Hard': 'Hard',
|
||||
'difficulty.Expert': 'Expert',
|
||||
|
||||
// Common
|
||||
'common.points': '分',
|
||||
'common.score': '分数',
|
||||
|
||||
// Challenge Content
|
||||
'challenge.1.name': 'DMZ 侦察',
|
||||
'challenge.1.desc': '扫描 DMZ 区,发现 Web 服务器并获取第一个 flag',
|
||||
'challenge.2.name': 'FTP 弱密码',
|
||||
'challenge.2.desc': '通过 FTP 弱密码进入 DMZ 区并获取 SSH 密钥',
|
||||
'challenge.3.name': 'VPN 网关突破',
|
||||
'challenge.3.desc': '使用获取的 SSH 密钥连接 VPN 网关进入办公网',
|
||||
'challenge.4.name': '办公网备份服务器',
|
||||
'challenge.4.desc': '发现 Rsync 备份服务器并获取敏感文件',
|
||||
'challenge.5.name': '生产网 Redis 渗透',
|
||||
'challenge.5.desc': '利用 Redis 弱密码获取 flag 并准备横向移动',
|
||||
'challenge.6.name': '核心网 MySQL 数据库',
|
||||
'challenge.6.desc': '爆破 MySQL 数据库获取敏感信息',
|
||||
'challenge.7.name': '最终目标 - MongoDB',
|
||||
'challenge.7.desc': '攻陷 MongoDB 获取最终 flag,完成整个网络渗透',
|
||||
'challenge.8.name': 'Elasticsearch 情报收集',
|
||||
'challenge.8.desc': '利用 Elasticsearch 未授权访问获取生产网敏感信息',
|
||||
'challenge.9.name': 'PostgreSQL 数据库渗透',
|
||||
'challenge.9.desc': '爆破 PostgreSQL 数据库获取业务数据',
|
||||
'challenge.10.name': 'MSSQL 数据库攻击',
|
||||
'challenge.10.desc': '攻破 MSSQL 数据库获取企业核心数据',
|
||||
'challenge.11.name': 'VNC 远程桌面入侵',
|
||||
'challenge.11.desc': '通过 VNC 弱密码获取办公网主机控制权',
|
||||
'challenge.12.name': '老旧 Telnet 服务',
|
||||
'challenge.12.desc': '利用古老的 Telnet 服务获取办公网老旧主机访问权',
|
||||
'challenge.13.name': '打印机 SMB 共享',
|
||||
'challenge.13.desc': '发现办公网打印机的 SMB 共享服务,通过弱密码访问共享文件',
|
||||
},
|
||||
en: {
|
||||
// Navigation
|
||||
'nav.title': 'fscan Lab',
|
||||
'nav.subtitle': 'Penetration Testing Platform',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.network': 'Network',
|
||||
'nav.challenges': 'Challenges',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Dashboard',
|
||||
'dashboard.welcome': 'Welcome to fscan Lab',
|
||||
'dashboard.resetProgress': 'Reset Progress',
|
||||
'dashboard.resetConfirm': 'Are you sure you want to reset progress? This will clear all completed challenges.',
|
||||
'dashboard.totalScore': 'Total Score',
|
||||
'dashboard.maxScore': 'Max',
|
||||
'dashboard.completedChallenges': 'Completed',
|
||||
'dashboard.completionRate': 'Completion',
|
||||
'dashboard.startTime': 'Started',
|
||||
'dashboard.submissions': 'Submissions',
|
||||
'dashboard.successful': 'successful',
|
||||
'dashboard.progress': 'Progress',
|
||||
'dashboard.progressDesc': 'Completed',
|
||||
'dashboard.progressDesc2': 'challenges',
|
||||
'dashboard.recentSubmissions': 'Recent Submissions',
|
||||
'dashboard.recentSubmissionsDesc': 'Last 5 flag submissions',
|
||||
'dashboard.noSubmissions': 'No submissions yet',
|
||||
'dashboard.correct': 'Correct',
|
||||
'dashboard.incorrect': 'Incorrect',
|
||||
'dashboard.challenge': 'Challenge',
|
||||
'dashboard.overview': 'Overview',
|
||||
'dashboard.overviewDesc': 'Challenges by difficulty',
|
||||
'dashboard.quickStart': 'Quick Start',
|
||||
'dashboard.quickStart1': 'Enter attacker container:',
|
||||
'dashboard.quickStart2': 'Start scanning DMZ:',
|
||||
'dashboard.quickStart3': 'View all challenges and submit flags in "Challenges" page',
|
||||
'dashboard.quickStart4': 'View network topology in "Network" page',
|
||||
'dashboard.loading': 'Loading...',
|
||||
|
||||
// Challenges
|
||||
'challenges.title': 'Challenges',
|
||||
'challenges.desc': 'Complete all challenges to pwn the network',
|
||||
'challenges.all': 'All',
|
||||
'challenges.search': 'Search challenges...',
|
||||
'challenges.difficulty': 'Difficulty',
|
||||
'challenges.points': 'pts',
|
||||
'challenges.network': 'Network',
|
||||
'challenges.targets': 'Targets',
|
||||
'challenges.status': 'Status',
|
||||
'challenges.completed': 'Completed',
|
||||
'challenges.locked': 'Locked',
|
||||
'challenges.submitFlag': 'Submit Flag',
|
||||
'challenges.viewHints': 'View Hints',
|
||||
'challenges.hideHints': 'Hide Hints',
|
||||
'challenges.hints': 'Hints',
|
||||
'challenges.enterFlag': 'Enter flag...',
|
||||
'challenges.submit': 'Submit',
|
||||
'challenges.submitting': 'Submitting...',
|
||||
'challenges.noChallenges': 'No challenges found',
|
||||
|
||||
// Topology
|
||||
'topology.title': 'Network Topology',
|
||||
'topology.desc': 'Real-time network topology and attack path',
|
||||
'topology.legend': 'Legend',
|
||||
'topology.compromised': 'Compromised',
|
||||
'topology.discovered': 'Discovered',
|
||||
'topology.unknown': 'Unknown',
|
||||
'topology.nodeInfo': 'Node Info',
|
||||
'topology.selectNode': 'Select a node to view details',
|
||||
'topology.name': 'Name',
|
||||
'topology.ip': 'IP Address',
|
||||
'topology.services': 'Services',
|
||||
'topology.status': 'Status',
|
||||
|
||||
// Network Labels
|
||||
'network.internet': 'Internet',
|
||||
'network.dmz': 'DMZ',
|
||||
'network.office': 'Office',
|
||||
'network.production': 'Production',
|
||||
'network.core': 'Core',
|
||||
|
||||
// Difficulty
|
||||
'difficulty.Easy': 'Easy',
|
||||
'difficulty.Medium': 'Medium',
|
||||
'difficulty.Hard': 'Hard',
|
||||
'difficulty.Expert': 'Expert',
|
||||
|
||||
// Common
|
||||
'common.points': 'pts',
|
||||
'common.score': 'score',
|
||||
|
||||
// Challenge Content
|
||||
'challenge.1.name': 'DMZ Reconnaissance',
|
||||
'challenge.1.desc': 'Scan DMZ network and discover the web server to get the first flag',
|
||||
'challenge.2.name': 'FTP Weak Password',
|
||||
'challenge.2.desc': 'Access DMZ through FTP weak password and obtain SSH key',
|
||||
'challenge.3.name': 'VPN Gateway Breach',
|
||||
'challenge.3.desc': 'Use SSH key to connect VPN gateway and enter office network',
|
||||
'challenge.4.name': 'Office Backup Server',
|
||||
'challenge.4.desc': 'Discover Rsync backup server and obtain sensitive files',
|
||||
'challenge.5.name': 'Production Redis Attack',
|
||||
'challenge.5.desc': 'Exploit Redis weak password to get flag and prepare lateral movement',
|
||||
'challenge.6.name': 'Core MySQL Database',
|
||||
'challenge.6.desc': 'Brute-force MySQL database to obtain sensitive information',
|
||||
'challenge.7.name': 'Final Target - MongoDB',
|
||||
'challenge.7.desc': 'Compromise MongoDB to get the final flag and pwn the entire network',
|
||||
'challenge.8.name': 'Elasticsearch Intelligence Gathering',
|
||||
'challenge.8.desc': 'Exploit Elasticsearch unauthorized access to obtain production network sensitive information',
|
||||
'challenge.9.name': 'PostgreSQL Database Penetration',
|
||||
'challenge.9.desc': 'Brute-force PostgreSQL database to obtain business data',
|
||||
'challenge.10.name': 'MSSQL Database Attack',
|
||||
'challenge.10.desc': 'Compromise MSSQL database to obtain enterprise core data',
|
||||
'challenge.11.name': 'VNC Remote Desktop Intrusion',
|
||||
'challenge.11.desc': 'Gain office network host control through VNC weak password',
|
||||
'challenge.12.name': 'Legacy Telnet Service',
|
||||
'challenge.12.desc': 'Exploit legacy Telnet service to gain access to old office host',
|
||||
'challenge.13.name': 'Printer SMB Share',
|
||||
'challenge.13.desc': 'Discover office printer SMB share service and access shared files via weak credentials',
|
||||
},
|
||||
}
|
||||
|
||||
export type Language = keyof typeof translations
|
||||
export type TranslationKey = keyof typeof translations.zh
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import { I18nProvider } from './contexts/I18nContext'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, HelpCircle, Target } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getChallenges, getProgress, submitFlag, getHints, type Challenge, type Progress } from '@/lib/api'
|
||||
import { useI18n } from '@/contexts/I18nContext'
|
||||
|
||||
export default function Challenges() {
|
||||
const { t } = useI18n()
|
||||
const [challenges, setChallenges] = useState<Challenge[]>([])
|
||||
const [progress, setProgress] = useState<Progress | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState<number | null>(null)
|
||||
const [flags, setFlags] = useState<Record<number, string>>({})
|
||||
const [hints, setHints] = useState<Record<number, string[]>>({})
|
||||
const [showHints, setShowHints] = useState<Record<number, boolean>>({})
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [challengesData, progressData] = await Promise.all([
|
||||
getChallenges(),
|
||||
getProgress(),
|
||||
])
|
||||
setChallenges(challengesData)
|
||||
setProgress(progressData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (challengeId: number) => {
|
||||
const flag = flags[challengeId]?.trim()
|
||||
if (!flag) {
|
||||
setMessage({ type: 'error', text: 'Please enter a flag' })
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(challengeId)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const result = await submitFlag(challengeId, flag)
|
||||
if (result.correct) {
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: result.already_solved
|
||||
? 'Already solved!'
|
||||
: `Correct! +${result.points_earned} points`,
|
||||
})
|
||||
setFlags({ ...flags, [challengeId]: '' })
|
||||
await loadData()
|
||||
} else {
|
||||
setMessage({ type: 'error', text: 'Incorrect flag. Try again!' })
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Submission failed' })
|
||||
} finally {
|
||||
setSubmitting(null)
|
||||
setTimeout(() => setMessage(null), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowHints = async (challengeId: number) => {
|
||||
if (!hints[challengeId]) {
|
||||
const challengeHints = await getHints(challengeId)
|
||||
setHints({ ...hints, [challengeId]: challengeHints })
|
||||
}
|
||||
setShowHints({ ...showHints, [challengeId]: !showHints[challengeId] })
|
||||
}
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'Easy':
|
||||
return 'bg-green-500'
|
||||
case 'Medium':
|
||||
return 'bg-yellow-500'
|
||||
case 'Hard':
|
||||
return 'bg-orange-500'
|
||||
case 'Expert':
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const getNetworkColor = (network: string) => {
|
||||
switch (network) {
|
||||
case 'dmz':
|
||||
return 'bg-blue-500/10 text-blue-500 border-blue-500/20'
|
||||
case 'office':
|
||||
return 'bg-purple-500/10 text-purple-500 border-purple-500/20'
|
||||
case 'production':
|
||||
return 'bg-orange-500/10 text-orange-500 border-orange-500/20'
|
||||
case 'core':
|
||||
return 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-500 border-gray-500/20'
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">{t('dashboard.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">{t('challenges.title')}</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('challenges.desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded-md ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-500/10 text-green-500 border border-green-500/20'
|
||||
: 'bg-red-500/10 text-red-500 border border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{challenges.sort((a, b) => (a.order || 0) - (b.order || 0)).map((challenge) => {
|
||||
const isCompleted = progress?.completed_challenges.includes(challenge.id) || false
|
||||
const flagValue = flags[challenge.id] || ''
|
||||
|
||||
return (
|
||||
<Card key={challenge.id} className={isCompleted ? 'border-green-500' : ''}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CardTitle className="text-xl">{t(`challenge.${challenge.id}.name` as any)}</CardTitle>
|
||||
{isCompleted && (
|
||||
<Check className="w-5 h-5 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>{t(`challenge.${challenge.id}.desc` as any)}</CardDescription>
|
||||
</div>
|
||||
<Badge className={getDifficultyColor(challenge.difficulty)}>
|
||||
{t(`difficulty.${challenge.difficulty}` as any)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('challenges.points')}</span>
|
||||
<span className="font-mono font-bold">{challenge.points}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('challenges.network')}</span>
|
||||
<Badge variant="outline" className={getNetworkColor(challenge.network)}>
|
||||
{t(`network.${challenge.network}` as any)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('challenges.targets')}</span>
|
||||
<span className="font-mono text-xs">{challenge.targets.join(', ')}</span>
|
||||
</div>
|
||||
|
||||
{!isCompleted && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('challenges.enterFlag')}
|
||||
value={flagValue}
|
||||
onChange={(e) =>
|
||||
setFlags({ ...flags, [challenge.id]: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSubmit(challenge.id)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleSubmit(challenge.id)}
|
||||
disabled={submitting === challenge.id}
|
||||
>
|
||||
{submitting === challenge.id ? t('challenges.submitting') : t('challenges.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleShowHints(challenge.id)}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4 mr-2" />
|
||||
{showHints[challenge.id] ? t('challenges.hideHints') : t('challenges.viewHints')}
|
||||
</Button>
|
||||
{showHints[challenge.id] && hints[challenge.id] && (
|
||||
<div className="bg-muted p-3 rounded-md space-y-1 text-sm">
|
||||
{hints[challenge.id].map((hint, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<Target className="w-4 h-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
<span>{hint}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
{isCompleted && (
|
||||
<CardFooter className="bg-green-500/10 border-t border-green-500/20">
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<Check className="w-4 h-4" />
|
||||
<span className="font-medium">{t('challenges.completed')}</span>
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Trophy, Target, Clock, Zap } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getProgress, getChallenges, resetProgress, type Progress as ProgressType, type Challenge } from '@/lib/api'
|
||||
import { useI18n } from '@/contexts/I18nContext'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useI18n()
|
||||
const [progress, setProgress] = useState<ProgressType | null>(null)
|
||||
const [challenges, setChallenges] = useState<Challenge[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [progressData, challengesData] = await Promise.all([
|
||||
getProgress(),
|
||||
getChallenges(),
|
||||
])
|
||||
setProgress(progressData)
|
||||
setChallenges(challengesData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
const interval = setInterval(loadData, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const handleReset = async () => {
|
||||
if (confirm(t('dashboard.resetConfirm'))) {
|
||||
await resetProgress()
|
||||
await loadData()
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">{t('dashboard.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalChallenges = challenges.length
|
||||
const completedChallenges = progress?.completed_challenges.length || 0
|
||||
const completionRate = totalChallenges > 0 ? (completedChallenges / totalChallenges) * 100 : 0
|
||||
const maxScore = challenges.reduce((sum, c) => sum + c.points, 0)
|
||||
|
||||
const recentSubmissions = progress?.submission_history.slice(-5).reverse() || []
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">{t('dashboard.title')}</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('dashboard.welcome')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
{t('dashboard.resetProgress')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.totalScore')}</CardTitle>
|
||||
<Trophy className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{progress?.total_score || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('dashboard.maxScore')} {maxScore} {t('common.points')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.completedChallenges')}</CardTitle>
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{completedChallenges} / {totalChallenges}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('dashboard.completionRate')} {completionRate.toFixed(0)}%
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.startTime')}</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{progress ? new Date(progress.start_time).toLocaleDateString() : '-'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress ? new Date(progress.start_time).toLocaleTimeString() : ''}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.submissions')}</CardTitle>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{progress?.submission_history.length || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('dashboard.successful')} {progress?.submission_history.filter(s => s.correct).length || 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('dashboard.progress')}</CardTitle>
|
||||
<CardDescription>{t('dashboard.progressDesc')} {completedChallenges} / {totalChallenges} {t('dashboard.progressDesc2')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress value={completionRate} className="h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('dashboard.recentSubmissions')}</CardTitle>
|
||||
<CardDescription>{t('dashboard.recentSubmissionsDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentSubmissions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('dashboard.noSubmissions')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentSubmissions.map((sub, idx) => {
|
||||
const challenge = challenges.find(c => c.id === sub.challenge_id)
|
||||
return (
|
||||
<div key={idx} className="flex items-center justify-between border-b pb-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{challenge?.name || `${t('dashboard.challenge')} ${sub.challenge_id}`}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(sub.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={sub.correct ? 'default' : 'destructive'}>
|
||||
{sub.correct ? t('dashboard.correct') : t('dashboard.incorrect')}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('dashboard.overview')}</CardTitle>
|
||||
<CardDescription>{t('dashboard.overviewDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{['Easy', 'Medium', 'Hard', 'Expert'].map(difficulty => {
|
||||
const diffChallenges = challenges.filter(c => c.difficulty === difficulty)
|
||||
const completed = diffChallenges.filter(c =>
|
||||
progress?.completed_challenges.includes(c.id)
|
||||
).length
|
||||
const total = diffChallenges.length
|
||||
|
||||
if (total === 0) return null
|
||||
|
||||
return (
|
||||
<div key={difficulty} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{t(`difficulty.${difficulty}` as any)}</span>
|
||||
<span className="text-muted-foreground">{completed} / {total}</span>
|
||||
</div>
|
||||
<Progress value={(completed / total) * 100} className="h-2" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('dashboard.quickStart')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<p>1. {t('dashboard.quickStart1')}<code className="bg-muted px-2 py-1 rounded">docker exec -it lab-attacker /bin/bash</code></p>
|
||||
<p>2. {t('dashboard.quickStart2')}<code className="bg-muted px-2 py-1 rounded">fscan -h 10.10.1.0/24</code></p>
|
||||
<p>3. {t('dashboard.quickStart3')}</p>
|
||||
<p>4. {t('dashboard.quickStart4')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { getTopology, getProgress } from '@/lib/api'
|
||||
import { useI18n } from '@/contexts/I18nContext'
|
||||
|
||||
export default function Topology() {
|
||||
const { t } = useI18n()
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
const [selectedNode, setSelectedNode] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadTopology = useCallback(async () => {
|
||||
try {
|
||||
const [topology] = await Promise.all([getTopology(), getProgress()])
|
||||
|
||||
const networkPositions: Record<string, { x: number; y: number }> = {
|
||||
internet: { x: 400, y: 50 },
|
||||
attacker: { x: 400, y: 150 },
|
||||
'web-dmz': { x: 200, y: 300 },
|
||||
'mail-dmz': { x: 350, y: 300 },
|
||||
'ftp-dmz': { x: 500, y: 300 },
|
||||
'vpn-gateway': { x: 650, y: 300 },
|
||||
'pc-vnc': { x: 100, y: 500 },
|
||||
'pc-ssh': { x: 250, y: 500 },
|
||||
'backup-server': { x: 400, y: 500 },
|
||||
'printer': { x: 550, y: 500 },
|
||||
'oldpc-telnet': { x: 700, y: 500 },
|
||||
'app-web': { x: 100, y: 700 },
|
||||
'cache-redis': { x: 250, y: 700 },
|
||||
'mq-rabbit': { x: 400, y: 700 },
|
||||
'mq-activemq': { x: 550, y: 700 },
|
||||
'search-es': { x: 700, y: 700 },
|
||||
'db-mysql': { x: 100, y: 900 },
|
||||
'db-mssql': { x: 250, y: 900 },
|
||||
'db-postgres': { x: 400, y: 900 },
|
||||
'db-mongo': { x: 550, y: 900 },
|
||||
'dc-ldap': { x: 700, y: 900 },
|
||||
}
|
||||
|
||||
const getNodeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'compromised':
|
||||
return '#ef4444'
|
||||
case 'discovered':
|
||||
return '#f59e0b'
|
||||
case 'unknown':
|
||||
return '#6b7280'
|
||||
default:
|
||||
return '#6b7280'
|
||||
}
|
||||
}
|
||||
|
||||
const getNetworkLabel = (network: string) => {
|
||||
return t(`network.${network}` as any) || network
|
||||
}
|
||||
|
||||
const flowNodes: Node[] = topology.nodes.map((node) => {
|
||||
const position = networkPositions[node.id] || { x: Math.random() * 800, y: Math.random() * 1000 }
|
||||
return {
|
||||
id: node.id,
|
||||
type: 'default',
|
||||
position,
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-sm">{node.name}</div>
|
||||
<div className="text-xs text-gray-500">{node.ip}</div>
|
||||
<div className="text-xs mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getNetworkLabel(node.network)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
...node,
|
||||
},
|
||||
style: {
|
||||
background: '#fff',
|
||||
border: `2px solid ${getNodeColor(node.status)}`,
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
width: 140,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const flowEdges: Edge[] = topology.edges.map((edge, idx) => ({
|
||||
id: `${edge.from}-${edge.to}-${idx}`,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
animated: edge.access === 'vpn',
|
||||
style: {
|
||||
stroke: edge.access === 'blocked' ? '#ef4444' : edge.access === 'vpn' ? '#3b82f6' : '#6b7280',
|
||||
strokeWidth: edge.access === 'vpn' ? 2 : 1,
|
||||
strokeDasharray: edge.access === 'blocked' ? '5,5' : undefined,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: edge.access === 'blocked' ? '#ef4444' : edge.access === 'vpn' ? '#3b82f6' : '#6b7280',
|
||||
},
|
||||
}))
|
||||
|
||||
setNodes(flowNodes)
|
||||
setEdges(flowEdges)
|
||||
} catch (error) {
|
||||
console.error('Failed to load topology:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [setNodes, setEdges])
|
||||
|
||||
useEffect(() => {
|
||||
loadTopology()
|
||||
const interval = setInterval(loadTopology, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [loadTopology])
|
||||
|
||||
const onNodeClick = useCallback((_: any, node: Node) => {
|
||||
setSelectedNode(node.data)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">{t('dashboard.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">{t('topology.title')}</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('topology.desc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div style={{ height: '700px' }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('topology.legend')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-red-500"></div>
|
||||
<span className="text-sm">{t('topology.compromised')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-orange-500"></div>
|
||||
<span className="text-sm">{t('topology.discovered')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-gray-500"></div>
|
||||
<span className="text-sm">{t('topology.unknown')}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedNode ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('topology.nodeInfo')}</CardTitle>
|
||||
<CardDescription>{selectedNode.name}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{t('topology.ip')}</div>
|
||||
<div className="font-mono text-sm">{selectedNode.ip}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{t('challenges.network')}</div>
|
||||
<Badge variant="outline">{t(`network.${selectedNode.network}` as any)}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{t('topology.status')}</div>
|
||||
<Badge variant={selectedNode.status === 'compromised' ? 'destructive' : 'default'}>
|
||||
{t(`topology.${selectedNode.status}` as any)}
|
||||
</Badge>
|
||||
</div>
|
||||
{selectedNode.services && selectedNode.services.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-2">{t('topology.services')}</div>
|
||||
<div className="space-y-1">
|
||||
{selectedNode.services.map((service: string, idx: number) => (
|
||||
<div key={idx} className="text-xs font-mono bg-muted px-2 py-1 rounded">
|
||||
{service}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('topology.nodeInfo')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t('topology.selectNode')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">攻击路径</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">1</div>
|
||||
<span>外网 → DMZ</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">2</div>
|
||||
<span>DMZ → 办公网(VPN)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">3</div>
|
||||
<span>办公网 → 生产网</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">4</div>
|
||||
<span>生产网 → 核心网</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user