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)
This commit is contained in:
ZacharyZcR
2026-01-11 20:16:23 +08:00
parent 6b13b2e84f
commit 71b92d4408
948 changed files with 92335 additions and 24630 deletions
+76
View File
@@ -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
}
+59
View File
@@ -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;
}
}
+105
View File
@@ -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
}
+241
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+13
View File
@@ -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>,
)
+234
View File
@@ -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>
)
}
+219
View File
@@ -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>
)
}
+268
View File
@@ -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>
)
}