diff --git a/client/src/pages/DomainsPage.tsx b/client/src/pages/DomainsPage.tsx index 30cd5bf..a150008 100644 --- a/client/src/pages/DomainsPage.tsx +++ b/client/src/pages/DomainsPage.tsx @@ -1,9 +1,29 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination, useTheme, useMediaQuery } from '@mui/material'; -import { Delete, Add, UploadFile, Remove } from '@mui/icons-material'; +import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination, useTheme, useMediaQuery, Alert, Stack, CircularProgress, Divider, Link as MuiLink, Accordion, AccordionSummary, AccordionDetails } from '@mui/material'; +import { Delete, Add, UploadFile, Remove, ExpandMore } from '@mui/icons-material'; import api from '../api'; interface Domain { id: number; name: string; } +interface ScanCapabilities { + scannerAvailable: boolean; + scannerPath: string | null; + timeoutAvailable: boolean; + timeoutPath: string | null; +} +interface ScanResponse { + addr: string; + scanSeconds: number; + thread: number; + timeout: number; + timedOut: boolean; + exitCode: number; + foundCount: number; + domains: string[]; + stderrTail: string; + stdoutTail: string; +} + +const SCAN_STORAGE_KEY = 'domains_scan_state_v1'; export default function DomainsPage() { const [domains, setDomains] = useState([]); @@ -15,6 +35,17 @@ export default function DomainsPage() { const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); + const [scanCapabilities, setScanCapabilities] = useState(null); + const [scanAddr, setScanAddr] = useState(''); + const [scanSeconds, setScanSeconds] = useState(30); + const [scanThread, setScanThread] = useState(2); + const [scanTimeout, setScanTimeout] = useState(5); + const [isScanning, setIsScanning] = useState(false); + const [scanError, setScanError] = useState(''); + const [scanResult, setScanResult] = useState(null); + const [scanCandidates, setScanCandidates] = useState([]); + const [scanPanelExpanded, setScanPanelExpanded] = useState(false); + const [scanStateHydrated, setScanStateHydrated] = useState(false); const loadDomains = async () => { try { @@ -31,6 +62,79 @@ export default function DomainsPage() { loadDomains(); }, [page, rowsPerPage]); + useEffect(() => { + const loadScannerContext = async () => { + try { + const capRes = await api.get('/domains/scan/capabilities'); + setScanCapabilities(capRes.data); + } catch (e) { + console.error(e); + } + + try { + const settingsRes = await api.get('/settings'); + const defaultAddr = settingsRes.data?.xui_ip || settingsRes.data?.xui_host || ''; + if (defaultAddr) { + setScanAddr((prev) => (prev.trim() ? prev : defaultAddr)); + } + } catch (e) { + console.error(e); + } + }; + + loadScannerContext(); + }, []); + + useEffect(() => { + try { + const raw = localStorage.getItem(SCAN_STORAGE_KEY); + if (!raw) return; + + const parsed = JSON.parse(raw) as { + scanAddr?: string; + scanSeconds?: number; + scanThread?: number; + scanTimeout?: number; + scanResult?: ScanResponse | null; + scanCandidates?: string[]; + scanPanelExpanded?: boolean; + }; + + if (typeof parsed.scanAddr === 'string' && parsed.scanAddr.trim()) setScanAddr(parsed.scanAddr); + if (typeof parsed.scanSeconds === 'number') setScanSeconds(parsed.scanSeconds); + if (typeof parsed.scanThread === 'number') setScanThread(parsed.scanThread); + if (typeof parsed.scanTimeout === 'number') setScanTimeout(parsed.scanTimeout); + if (parsed.scanResult) setScanResult(parsed.scanResult); + if (Array.isArray(parsed.scanCandidates)) setScanCandidates(parsed.scanCandidates); + if (typeof parsed.scanPanelExpanded === 'boolean') setScanPanelExpanded(parsed.scanPanelExpanded); + } catch (e) { + console.error(e); + } finally { + setScanStateHydrated(true); + } + }, []); + + useEffect(() => { + if (!scanStateHydrated) return; + + try { + localStorage.setItem( + SCAN_STORAGE_KEY, + JSON.stringify({ + scanAddr, + scanSeconds, + scanThread, + scanTimeout, + scanResult, + scanCandidates, + scanPanelExpanded, + }), + ); + } catch (e) { + console.error(e); + } + }, [scanAddr, scanSeconds, scanThread, scanTimeout, scanResult, scanCandidates, scanPanelExpanded, scanStateHydrated]); + const handleChangePage = (_event: unknown, newPage: number) => { setPage(newPage); }; @@ -85,10 +189,188 @@ export default function DomainsPage() { reader.readAsText(file); }; + const handleStartScan = async () => { + if (!scanAddr.trim()) { + alert('Укажите IP/домен для сканирования'); + return; + } + + try { + setIsScanning(true); + setScanError(''); + setScanResult(null); + + const { data } = await api.post('/domains/scan/start', { + addr: scanAddr.trim(), + scanSeconds, + thread: scanThread, + timeout: scanTimeout, + }); + + setScanResult(data); + setScanCandidates(data.domains || []); + } catch (e: any) { + const message = e?.response?.data?.message || e?.message || 'Ошибка запуска сканера'; + setScanError(Array.isArray(message) ? message.join('; ') : message); + } finally { + setIsScanning(false); + } + }; + + const handleImportScannedDomains = async () => { + const found = scanCandidates; + if (found.length === 0) return; + + try { + const { data } = await api.post('/domains/upload', { domains: found }); + alert(`Скан завершен. Добавлено новых доменов: ${data.count}`); + loadDomains(); + } catch (_e) { + alert('Ошибка импорта найденных доменов'); + } + }; + + const handleRemoveScannedDomain = (domain: string) => { + setScanCandidates((prev) => prev.filter((d) => d !== domain)); + }; + + const handleClearScannedDomains = () => { + setScanCandidates([]); + setScanResult(null); + }; + return ( Белый список доменов (SNI) + + setScanPanelExpanded(expanded)} + disableGutters + sx={{ + boxShadow: 'none', + '&:before': { display: 'none' }, + }} + > + }> + Автопоиск SNI (backend scanner) + + + + {scanCapabilities && (!scanCapabilities.scannerAvailable || !scanCapabilities.timeoutAvailable) && ( + + Сканер в контейнере недоступен. scanner: {String(scanCapabilities.scannerAvailable)}, timeout: {String(scanCapabilities.timeoutAvailable)} + + )} + + + setScanAddr(e.target.value)} + fullWidth + size='small' + /> + setScanSeconds(Number(e.target.value))} + size='small' + sx={{ minWidth: 140 }} + /> + setScanThread(Number(e.target.value))} + size='small' + sx={{ minWidth: 120 }} + /> + setScanTimeout(Number(e.target.value))} + size='small' + sx={{ minWidth: 120 }} + /> + + + + + + + {isScanning && } + + + {scanError && {scanError}} + + {scanResult && ( + + + + Найдено: {scanResult.foundCount}. К отбору: {scanCandidates.length}. Код выхода: {scanResult.exitCode}. Таймаут: {String(scanResult.timedOut)} + + + Можно открыть домен в новом окне, проверить вручную и удалить из предварительного списка перед импортом. + + + + {scanCandidates.map((d) => ( + handleRemoveScannedDomain(d)}> + + + } + > + + {d} + + } + /> + + ))} + {scanCandidates.length === 0 && ( + + + + )} + + + + )} + + + + ); -} \ No newline at end of file +} diff --git a/server/Dockerfile b/server/Dockerfile index 24c295b..c2db961 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -10,6 +10,19 @@ COPY . . RUN npm run build +FROM golang:1.23-alpine AS scanner-builder + +ARG REALITLSCANNER_REPO=https://github.com/XTLS/RealiTLScanner.git +ARG REALITLSCANNER_REF=main +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +WORKDIR /src + +RUN apk add --no-cache git +RUN git clone --depth 1 --branch ${REALITLSCANNER_REF} ${REALITLSCANNER_REPO} . +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/RealiTLScanner-linux-64 . + FROM node:24-alpine WORKDIR /app @@ -17,11 +30,24 @@ WORKDIR /app COPY package*.json ./ RUN npm ci --only=production +# Runtime tools required for checker/scanner integration scripts. +RUN apk add --no-cache \ + bash \ + curl \ + python3 \ + grep \ + gawk \ + coreutils \ + procps \ + ca-certificates + COPY --from=builder /app/dist ./dist +COPY --from=scanner-builder /out/RealiTLScanner-linux-64 /usr/local/bin/RealiTLScanner-linux-64 +RUN chmod +x /usr/local/bin/RealiTLScanner-linux-64 ENV NODE_ENV=production ENV PORT=3000 EXPOSE 3000 -CMD ["node", "dist/main"] \ No newline at end of file +CMD ["node", "dist/main"] diff --git a/server/src/domains/domain-scanner.service.ts b/server/src/domains/domain-scanner.service.ts new file mode 100644 index 0000000..5bf9afb --- /dev/null +++ b/server/src/domains/domain-scanner.service.ts @@ -0,0 +1,143 @@ +import { BadRequestException, Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { spawn, spawnSync } from 'child_process'; + +type StartScanPayload = { + addr: string; + scanSeconds?: number; + thread?: number; + timeout?: number; +}; + +@Injectable() +export class DomainScannerService { + private readonly logger = new Logger(DomainScannerService.name); + private readonly scannerBin = 'RealiTLScanner-linux-64'; + + getCapabilities() { + const scannerCheck = spawnSync('sh', ['-lc', `command -v ${this.scannerBin}`], { encoding: 'utf-8' }); + const timeoutCheck = spawnSync('sh', ['-lc', 'command -v timeout'], { encoding: 'utf-8' }); + + return { + scannerAvailable: scannerCheck.status === 0, + scannerPath: scannerCheck.stdout.trim() || null, + timeoutAvailable: timeoutCheck.status === 0, + timeoutPath: timeoutCheck.stdout.trim() || null, + }; + } + + async startScan(payload: StartScanPayload) { + const addr = (payload.addr || '').trim(); + if (!addr) { + throw new BadRequestException('Поле addr обязательно'); + } + + const scanSeconds = this.clampNumber(payload.scanSeconds, 120, 10, 600); + const thread = this.clampNumber(payload.thread, 2, 1, 50); + const connectTimeout = this.clampNumber(payload.timeout, 5, 1, 20); + + const capabilities = this.getCapabilities(); + if (!capabilities.scannerAvailable) { + throw new ServiceUnavailableException(`Не найден ${this.scannerBin} в контейнере`); + } + if (!capabilities.timeoutAvailable) { + throw new ServiceUnavailableException('Не найдена утилита timeout в контейнере'); + } + + const args = [ + '--signal=TERM', + `${scanSeconds}s`, + this.scannerBin, + '--addr', + addr, + '--thread', + String(thread), + '--timeout', + String(connectTimeout), + '--out', + '', + ]; + + this.logger.log(`Starting scanner: addr=${addr}, seconds=${scanSeconds}, thread=${thread}, timeout=${connectTimeout}`); + + const child = spawn('timeout', args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const domains = new Set(); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + stdout += text; + this.extractDomainsFromLog(text, domains); + }); + + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + const exitCode = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => resolve(code ?? -1)); + }).catch((error: NodeJS.ErrnoException) => { + this.logger.error(`Scanner process failed to start: ${error.message}`); + throw new ServiceUnavailableException(`Не удалось запустить сканер: ${error.message}`); + }); + + const timedOut = exitCode === 124 || exitCode === 137 || exitCode === 143; + if (exitCode !== 0 && !timedOut) { + this.logger.error(`Scanner failed, code=${exitCode}, stderr=${stderr.slice(-1200)}`); + throw new InternalServerErrorException(`Сканер завершился с ошибкой (code=${exitCode})`); + } + + const sortedDomains = [...domains].sort(); + return { + addr, + scanSeconds, + thread, + timeout: connectTimeout, + timedOut, + exitCode, + foundCount: sortedDomains.length, + domains: sortedDomains, + stderrTail: stderr.slice(-800), + stdoutTail: stdout.slice(-800), + }; + } + + private extractDomainsFromLog(text: string, out: Set) { + const regex = /cert-domain=([^\s]+)/g; + let match: RegExpExecArray | null = regex.exec(text); + while (match) { + const normalized = this.normalizeDomain(match[1]); + if (normalized) { + out.add(normalized); + } + match = regex.exec(text); + } + } + + private normalizeDomain(input: string) { + const cleaned = input + .trim() + .replace(/^"+|"+$/g, '') + .replace(/^\*\./, '') + .toLowerCase(); + + if (!/^[a-z0-9.-]+$/.test(cleaned)) { + return null; + } + if (!cleaned.includes('.')) { + return null; + } + return cleaned; + } + + private clampNumber(value: number | undefined, fallback: number, min: number, max: number) { + const num = Number.isFinite(value) ? Number(value) : fallback; + if (num < min) return min; + if (num > max) return max; + return Math.floor(num); + } +} diff --git a/server/src/domains/domains.controller.ts b/server/src/domains/domains.controller.ts index b5ce985..0765ce4 100644 --- a/server/src/domains/domains.controller.ts +++ b/server/src/domains/domains.controller.ts @@ -1,9 +1,13 @@ import { Controller, Get, Post, Body, Param, Delete, Query } from '@nestjs/common'; import { DomainsService } from './domains.service'; +import { DomainScannerService } from './domain-scanner.service'; @Controller('domains') export class DomainsController { - constructor(private readonly domainsService: DomainsService) { } + constructor( + private readonly domainsService: DomainsService, + private readonly domainScannerService: DomainScannerService, + ) { } @Post() create(@Body() body: { name: string }) { @@ -15,6 +19,16 @@ export class DomainsController { return this.domainsService.createMany(body.domains); } + @Get('scan/capabilities') + scanCapabilities() { + return this.domainScannerService.getCapabilities(); + } + + @Post('scan/start') + startScan(@Body() body: { addr: string; scanSeconds?: number; thread?: number; timeout?: number }) { + return this.domainScannerService.startScan(body); + } + @Get('all') findAllWithoutPagination() { return this.domainsService.findAllUnpaginated(); @@ -45,4 +59,4 @@ export class DomainsController { remove(@Param('id') id: string) { return this.domainsService.remove(+id); } -} \ No newline at end of file +} diff --git a/server/src/domains/domains.module.ts b/server/src/domains/domains.module.ts index b241a33..ec5e369 100644 --- a/server/src/domains/domains.module.ts +++ b/server/src/domains/domains.module.ts @@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { DomainsService } from './domains.service'; import { DomainsController } from './domains.controller'; import { Domain } from './entities/domain.entity'; +import { DomainScannerService } from './domain-scanner.service'; @Module({ imports: [TypeOrmModule.forFeature([Domain])], controllers: [DomainsController], - providers: [DomainsService], + providers: [DomainsService, DomainScannerService], exports: [DomainsService], }) -export class DomainsModule {} \ No newline at end of file +export class DomainsModule {}