mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-24 04:01:52 +08:00
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:
@@ -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