0
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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 {}
|
||||
|
||||
Reference in New Issue
Block a user