This commit is contained in:
iqubik
2026-03-25 21:40:56 +03:00
parent 241e9c5af7
commit ed1cd84aba
5 changed files with 474 additions and 8 deletions
+285 -3
View File
@@ -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<Domain[]>([]);
@@ -15,6 +35,17 @@ export default function DomainsPage() {
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [scanCapabilities, setScanCapabilities] = useState<ScanCapabilities | null>(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<ScanResponse | null>(null);
const [scanCandidates, setScanCandidates] = useState<string[]>([]);
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 (
<Box>
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom>Белый список доменов (SNI)</Typography>
<Paper sx={{ mb: 2 }}>
<Accordion
expanded={scanPanelExpanded}
onChange={(_event, expanded) => setScanPanelExpanded(expanded)}
disableGutters
sx={{
boxShadow: 'none',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography variant='h6'>Автопоиск SNI (backend scanner)</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pb: 2 }}>
{scanCapabilities && (!scanCapabilities.scannerAvailable || !scanCapabilities.timeoutAvailable) && (
<Alert severity='warning' sx={{ mb: 2 }}>
Сканер в контейнере недоступен. scanner: {String(scanCapabilities.scannerAvailable)}, timeout: {String(scanCapabilities.timeoutAvailable)}
</Alert>
)}
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
<TextField
label='IP/домен VPS'
value={scanAddr}
onChange={(e) => setScanAddr(e.target.value)}
fullWidth
size='small'
/>
<TextField
label='Секунд скана'
type='number'
value={scanSeconds}
onChange={(e) => setScanSeconds(Number(e.target.value))}
size='small'
sx={{ minWidth: 140 }}
/>
<TextField
label='Потоков'
type='number'
value={scanThread}
onChange={(e) => setScanThread(Number(e.target.value))}
size='small'
sx={{ minWidth: 120 }}
/>
<TextField
label='Таймаут'
type='number'
value={scanTimeout}
onChange={(e) => setScanTimeout(Number(e.target.value))}
size='small'
sx={{ minWidth: 120 }}
/>
</Stack>
<Stack direction='row' spacing={1} sx={{ mt: 2, flexWrap: 'wrap' }}>
<Button variant='contained' onClick={handleStartScan} disabled={isScanning}>
{isScanning ? 'Сканирование...' : 'Сканировать'}
</Button>
<Button
variant='outlined'
onClick={handleImportScannedDomains}
disabled={!scanResult || scanCandidates.length === 0 || isScanning}
>
Добавить найденные в список
</Button>
<Button
variant='text'
color='error'
onClick={handleClearScannedDomains}
disabled={scanCandidates.length === 0 && !scanResult}
>
Очистить предварительный
</Button>
{isScanning && <CircularProgress size={24} />}
</Stack>
{scanError && <Alert severity='error' sx={{ mt: 2 }}>{scanError}</Alert>}
{scanResult && (
<Box sx={{ mt: 2 }}>
<Divider sx={{ mb: 2 }} />
<Typography variant='body2' sx={{ mb: 1 }}>
Найдено: <b>{scanResult.foundCount}</b>. К отбору: <b>{scanCandidates.length}</b>. Код выхода: <b>{scanResult.exitCode}</b>. Таймаут: <b>{String(scanResult.timedOut)}</b>
</Typography>
<Alert severity='info' sx={{ mb: 1 }}>
Можно открыть домен в новом окне, проверить вручную и удалить из предварительного списка перед импортом.
</Alert>
<Paper variant='outlined' sx={{ maxHeight: 220, overflow: 'auto' }}>
<List dense>
{scanCandidates.map((d) => (
<ListItem
key={d}
sx={{
borderRadius: 1,
transition: 'background-color 120ms ease',
'&:hover': {
backgroundColor: 'action.hover',
},
}}
secondaryAction={
<IconButton edge='end' onClick={() => handleRemoveScannedDomain(d)}>
<Delete />
</IconButton>
}
>
<ListItemText
primary={
<MuiLink href={`https://${d}`} target='_blank' rel='noopener noreferrer' underline='hover'>
{d}
</MuiLink>
}
/>
</ListItem>
))}
{scanCandidates.length === 0 && (
<ListItem>
<ListItemText primary='Домены не найдены' />
</ListItem>
)}
</List>
</Paper>
</Box>
)}
</AccordionDetails>
</Accordion>
</Paper>
<Paper sx={{ p: 2, display: 'flex', gap: 2 }}>
<TextField
label="Доменное имя" size="small" fullWidth
@@ -160,4 +442,4 @@ export default function DomainsPage() {
</Paper>
</Box>
);
}
}
+27 -1
View File
@@ -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"]
CMD ["node", "dist/main"]
@@ -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<string>();
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<number>((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<string>) {
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);
}
}
+16 -2
View File
@@ -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);
}
}
}
+3 -2
View File
@@ -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 {}
export class DomainsModule {}