1
This commit is contained in:
+258
-102
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
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 { Delete, Add, UploadFile, Remove, ExpandMore, Download } from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
|
||||
interface Domain { id: number; name: string; }
|
||||
@@ -47,6 +47,66 @@ export default function DomainsPage() {
|
||||
const [scanPanelExpanded, setScanPanelExpanded] = useState(false);
|
||||
const [scanStateHydrated, setScanStateHydrated] = useState(false);
|
||||
|
||||
const isLoopbackHost = (value: string) => {
|
||||
const host = value.trim().toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
};
|
||||
|
||||
const collectAddrCandidatesFromSettings = (settings: any) => {
|
||||
const candidates: string[] = [];
|
||||
const xuiIp = String(settings?.xui_ip || '').trim();
|
||||
const xuiHost = String(settings?.xui_host || '').trim();
|
||||
const xuiUrl = String(settings?.xui_url || '').trim();
|
||||
|
||||
if (xuiIp) candidates.push(xuiIp);
|
||||
if (xuiHost) candidates.push(xuiHost);
|
||||
|
||||
if (xuiUrl) {
|
||||
try {
|
||||
const parsed = new URL(xuiUrl);
|
||||
if (parsed.hostname) {
|
||||
candidates.push(parsed.hostname.trim());
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore malformed URL from settings and fall back to runtime hostname.
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.filter(Boolean);
|
||||
};
|
||||
|
||||
const resolveSuggestedScanAddr = async (opts?: { allowLoopbackFallback?: boolean }) => {
|
||||
const allowLoopbackFallback = Boolean(opts?.allowLoopbackFallback);
|
||||
let settingsCandidates: string[] = [];
|
||||
|
||||
try {
|
||||
const settingsRes = await api.get('/settings');
|
||||
settingsCandidates = collectAddrCandidatesFromSettings(settingsRes.data);
|
||||
const publicFromSettings = settingsCandidates.find((c) => !isLoopbackHost(c));
|
||||
if (publicFromSettings) {
|
||||
return publicFromSettings;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
// Fallback: panel host where user opened 3dp (often the target VPS in real usage).
|
||||
const runtimeHost = window.location.hostname;
|
||||
if (runtimeHost && !isLoopbackHost(runtimeHost)) {
|
||||
return runtimeHost;
|
||||
}
|
||||
|
||||
// Optional fallback for explicit reset action: prefer some known address
|
||||
// over keeping stale user input in the field.
|
||||
if (allowLoopbackFallback) {
|
||||
const anyFromSettings = settingsCandidates[0];
|
||||
if (anyFromSettings) return anyFromSettings;
|
||||
if (runtimeHost) return runtimeHost;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const loadDomains = async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`);
|
||||
@@ -72,9 +132,9 @@ export default function DomainsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const settingsRes = await api.get('/settings');
|
||||
const defaultAddr = settingsRes.data?.xui_ip || settingsRes.data?.xui_host || '';
|
||||
const defaultAddr = await resolveSuggestedScanAddr();
|
||||
if (defaultAddr) {
|
||||
// Do not overwrite manually saved value from localStorage.
|
||||
setScanAddr((prev) => (prev.trim() ? prev : defaultAddr));
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -86,6 +146,7 @@ export default function DomainsPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Hydrate scanner UI state once so users do not lose pre-import review list after reload.
|
||||
try {
|
||||
const raw = localStorage.getItem(SCAN_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
@@ -118,6 +179,7 @@ export default function DomainsPage() {
|
||||
if (!scanStateHydrated) return;
|
||||
|
||||
try {
|
||||
// Persist scanner input + results + accordion state for continuation after F5.
|
||||
localStorage.setItem(
|
||||
SCAN_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
@@ -234,35 +296,76 @@ export default function DomainsPage() {
|
||||
setScanCandidates((prev) => prev.filter((d) => d !== domain));
|
||||
};
|
||||
|
||||
const handleClearScannedDomains = () => {
|
||||
const handleClearScannedDomains = async () => {
|
||||
setScanCandidates([]);
|
||||
setScanResult(null);
|
||||
setScanAddr('');
|
||||
|
||||
const suggestedAddr = await resolveSuggestedScanAddr({ allowLoopbackFallback: true });
|
||||
setScanAddr(suggestedAddr);
|
||||
};
|
||||
|
||||
const downloadDomainsAsTxt = (filename: string, domainNames: string[]) => {
|
||||
const content = `${domainNames.join('\n')}\n`;
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getExportTimestamp = () => new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||||
|
||||
const handleExportScannedDomains = () => {
|
||||
if (scanCandidates.length === 0) return;
|
||||
downloadDomainsAsTxt(`sni-scanned-${getExportTimestamp()}.txt`, scanCandidates);
|
||||
};
|
||||
|
||||
const handleExportMainDomains = async () => {
|
||||
if (domains.length === 0) return;
|
||||
|
||||
try {
|
||||
const { data } = await api.get('/domains/all');
|
||||
const names = (Array.isArray(data) ? data : [])
|
||||
.map((d: Domain) => d.name)
|
||||
.filter(Boolean);
|
||||
|
||||
if (names.length === 0) return;
|
||||
downloadDomainsAsTxt(`sni-whitelist-${getExportTimestamp()}.txt`, names);
|
||||
} catch (_e) {
|
||||
alert('Ошибка экспорта списка');
|
||||
}
|
||||
};
|
||||
|
||||
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?.scannerAvailable && (
|
||||
<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>
|
||||
)}
|
||||
{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
|
||||
@@ -289,7 +392,7 @@ export default function DomainsPage() {
|
||||
sx={{ minWidth: 120 }}
|
||||
/>
|
||||
<TextField
|
||||
label='Таймаут'
|
||||
label='Таймаут, сек'
|
||||
type='number'
|
||||
value={scanTimeout}
|
||||
onChange={(e) => setScanTimeout(Number(e.target.value))}
|
||||
@@ -309,6 +412,16 @@ export default function DomainsPage() {
|
||||
>
|
||||
Добавить найденные в список
|
||||
</Button>
|
||||
{scanCandidates.length > 0 && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
startIcon={<Download />}
|
||||
onClick={handleExportScannedDomains}
|
||||
disabled={isScanning}
|
||||
>
|
||||
Экспорт найденных
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='text'
|
||||
color='error'
|
||||
@@ -322,15 +435,25 @@ export default function DomainsPage() {
|
||||
|
||||
{scanError && <Alert severity='error' sx={{ mt: 2 }}>{scanError}</Alert>}
|
||||
|
||||
{scanResult && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{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>
|
||||
Найдено доменов: <b>{scanResult.foundCount}</b>. В предварительном списке: <b>{scanCandidates.length}</b>.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant='body2'
|
||||
color={scanResult.timedOut ? 'info.main' : 'success.main'}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{scanResult.timedOut
|
||||
? `Скан остановлен по лимиту времени (${scanResult.scanSeconds} сек) - это нормальный режим поиска.`
|
||||
: 'Скан завершен успешно.'}
|
||||
{' '}<Box component='span' sx={{ color: 'text.secondary' }}>(код: {scanResult.exitCode})</Box>
|
||||
</Typography>
|
||||
<Typography variant='body2' color='text.secondary' sx={{ mb: 1 }}>
|
||||
Проверяйте домены кликом и удаляйте лишние перед импортом.
|
||||
</Typography>
|
||||
<Alert severity='info' sx={{ mb: 1 }}>
|
||||
Можно открыть домен в новом окне, проверить вручную и удалить из предварительного списка перед импортом.
|
||||
</Alert>
|
||||
<Paper variant='outlined' sx={{ maxHeight: 220, overflow: 'auto' }}>
|
||||
<List dense>
|
||||
{scanCandidates.map((d) => (
|
||||
@@ -365,80 +488,113 @@ export default function DomainsPage() {
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Доменное имя" size="small" fullWidth
|
||||
value={newDomain} onChange={(e) => setNewDomain(e.target.value)}
|
||||
/>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<IconButton edge="end" onClick={() => fileInputRef.current?.click()}><UploadFile /></IconButton>
|
||||
<IconButton edge="end" onClick={handleAdd}><Add /></IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<UploadFile />}
|
||||
sx={{ width: isMobile ? 'auto' : '170px' }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{isMobile ? '' : 'Из файла'}
|
||||
</Button>
|
||||
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{domains.length > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'end', width: '100%' }}>
|
||||
<Button
|
||||
variant="text"
|
||||
color="error"
|
||||
size='small'
|
||||
startIcon={<Remove />}
|
||||
onClick={handleDeleteAll}
|
||||
>
|
||||
Удалить все
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper sx={{ mt: domains.length > 0 ? 0 : 3 }}>
|
||||
<List>
|
||||
{domains.map((d) => (
|
||||
<ListItem key={d.id} secondaryAction={
|
||||
<IconButton edge="end" onClick={() => handleDelete(d.id)}><Delete /></IconButton>
|
||||
}>
|
||||
<ListItemText primary={d.name} />
|
||||
</ListItem>
|
||||
))}
|
||||
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Нет доменов</Typography>}
|
||||
</List>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
labelRowsPerPage="Доменов на странице:"
|
||||
labelDisplayedRows={({ from, to, count }) => `${from}–${to} из ${count !== -1 ? count : `более ${to}`}`}
|
||||
/>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant='h6' gutterBottom>
|
||||
Управление белым списком (SNI)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
label="Доменное имя"
|
||||
size="small"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
sx={{ flex: '1 1 280px' }}
|
||||
/>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<IconButton edge="end" onClick={() => fileInputRef.current?.click()}><UploadFile /></IconButton>
|
||||
<IconButton edge="end" onClick={handleAdd}><Add /></IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<UploadFile />}
|
||||
sx={{ width: '170px' }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Из файла
|
||||
</Button>
|
||||
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{domains.length > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'end', width: '100%', mt: 1, gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size='small'
|
||||
startIcon={<Download />}
|
||||
onClick={handleExportMainDomains}
|
||||
>
|
||||
Экспорт списка
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
color="error"
|
||||
size='small'
|
||||
startIcon={<Remove />}
|
||||
onClick={handleDeleteAll}
|
||||
>
|
||||
Удалить все
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Paper variant='outlined' sx={{ mt: 1 }}>
|
||||
<List>
|
||||
{domains.map((d) => (
|
||||
<ListItem
|
||||
key={d.id}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
transition: 'background-color 120ms ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
secondaryAction={
|
||||
<IconButton edge="end" onClick={() => handleDelete(d.id)}><Delete /></IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<MuiLink href={`https://${d.name}`} target='_blank' rel='noopener noreferrer' underline='hover'>
|
||||
{d.name}
|
||||
</MuiLink>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Нет доменов</Typography>}
|
||||
</List>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
labelRowsPerPage="Доменов на странице:"
|
||||
labelDisplayedRows={({ from, to, count }) => `${from}–${to} из ${count !== -1 ? count : `более ${to}`}`}
|
||||
/>
|
||||
</Paper>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
|
||||
+6
-2
@@ -13,14 +13,18 @@ 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
|
||||
# Pin to an immutable commit for reproducible builds (main as of 2026-03-25).
|
||||
ARG REALITLSCANNER_REF=4dbba8cb1d7c6be86b260dd45db7fd2a84d3293b
|
||||
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 git init . \
|
||||
&& git remote add origin ${REALITLSCANNER_REPO} \
|
||||
&& git fetch --depth 1 origin ${REALITLSCANNER_REF} \
|
||||
&& git checkout --detach FETCH_HEAD
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/RealiTLScanner-linux-64 .
|
||||
|
||||
FROM node:24-alpine
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { BadRequestException, HttpException, HttpStatus, Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
|
||||
type StartScanPayload = {
|
||||
@@ -12,6 +12,8 @@ type StartScanPayload = {
|
||||
export class DomainScannerService {
|
||||
private readonly logger = new Logger(DomainScannerService.name);
|
||||
private readonly scannerBin = 'RealiTLScanner-linux-64';
|
||||
private isScanRunning = false;
|
||||
private readonly logTailLimit = 8000;
|
||||
|
||||
getCapabilities() {
|
||||
const scannerCheck = spawnSync('sh', ['-lc', `command -v ${this.scannerBin}`], { encoding: 'utf-8' });
|
||||
@@ -26,13 +28,19 @@ export class DomainScannerService {
|
||||
}
|
||||
|
||||
async startScan(payload: StartScanPayload) {
|
||||
// Scanner is CPU/network heavy; keep exactly one active run per backend instance
|
||||
// to avoid accidental DoS from repeated button clicks.
|
||||
if (this.isScanRunning) {
|
||||
throw new HttpException('Сканер уже запущен, дождитесь завершения текущего запуска', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
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 thread = this.clampNumber(payload.thread, 2, 1, 20);
|
||||
const connectTimeout = this.clampNumber(payload.timeout, 5, 1, 20);
|
||||
|
||||
const capabilities = this.getCapabilities();
|
||||
@@ -59,51 +67,69 @@ export class DomainScannerService {
|
||||
|
||||
this.logger.log(`Starting scanner: addr=${addr}, seconds=${scanSeconds}, thread=${thread}, timeout=${connectTimeout}`);
|
||||
|
||||
const child = spawn('timeout', args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
this.isScanRunning = true;
|
||||
try {
|
||||
const child = spawn('timeout', args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const domains = new Set<string>();
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const domains = new Set<string>();
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutRemainder = '';
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
stdout += text;
|
||||
this.extractDomainsFromLog(text, domains);
|
||||
});
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
stdout = this.appendTail(stdout, text);
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
// Keep unfinished line tail between chunks; this prevents losing domains
|
||||
// when "cert-domain=..." is split by stream chunk boundaries.
|
||||
const combined = stdoutRemainder + text;
|
||||
const parts = combined.split(/\r?\n/);
|
||||
stdoutRemainder = parts.pop() ?? '';
|
||||
for (const line of parts) {
|
||||
this.extractDomainsFromLog(line, domains);
|
||||
}
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr = this.appendTail(stderr, chunk.toString());
|
||||
});
|
||||
|
||||
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 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}`);
|
||||
});
|
||||
|
||||
if (stdoutRemainder) {
|
||||
this.extractDomainsFromLog(stdoutRemainder, domains);
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
} finally {
|
||||
this.isScanRunning = false;
|
||||
}
|
||||
|
||||
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>) {
|
||||
@@ -140,4 +166,12 @@ export class DomainScannerService {
|
||||
if (num > max) return max;
|
||||
return Math.floor(num);
|
||||
}
|
||||
|
||||
private appendTail(current: string, incoming: string) {
|
||||
const merged = current + incoming;
|
||||
if (merged.length <= this.logTailLimit) {
|
||||
return merged;
|
||||
}
|
||||
return merged.slice(-this.logTailLimit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Domain } from './entities/domain.entity';
|
||||
@@ -38,10 +38,18 @@ export class DomainsService implements OnModuleInit {
|
||||
}
|
||||
|
||||
async create(createDomainDto: { name: string }) {
|
||||
const exists = await this.repo.findOne({ where: { name: createDomainDto.name } });
|
||||
const normalized = this.normalizeImportedDomain(createDomainDto.name);
|
||||
if (!normalized) {
|
||||
throw new BadRequestException('Некорректное доменное имя');
|
||||
}
|
||||
|
||||
const exists = await this.repo
|
||||
.createQueryBuilder('domain')
|
||||
.where('LOWER(domain.name) = LOWER(:name)', { name: normalized })
|
||||
.getOne();
|
||||
if (exists) return exists;
|
||||
|
||||
const domain = this.repo.create(createDomainDto);
|
||||
const domain = this.repo.create({ name: normalized });
|
||||
return this.repo.save(domain);
|
||||
}
|
||||
|
||||
@@ -81,14 +89,14 @@ export class DomainsService implements OnModuleInit {
|
||||
if (!names || names.length === 0) return { count: 0 };
|
||||
|
||||
const cleanNames = names
|
||||
.map(n => n.trim())
|
||||
.filter(n => n.length > 0);
|
||||
.map((name) => this.normalizeImportedDomain(name))
|
||||
.filter((name): name is string => Boolean(name));
|
||||
|
||||
const existing = await this.repo.find();
|
||||
const existingSet = new Set(existing.map(d => d.name));
|
||||
const existingSet = new Set(existing.map(d => d.name.toLowerCase()));
|
||||
|
||||
const uniqueNewNames = [...new Set(cleanNames)]
|
||||
.filter(name => !existingSet.has(name));
|
||||
.filter(name => !existingSet.has(name.toLowerCase()));
|
||||
|
||||
if (uniqueNewNames.length === 0) return { count: 0 };
|
||||
|
||||
@@ -97,4 +105,52 @@ export class DomainsService implements OnModuleInit {
|
||||
|
||||
return { count: entities.length };
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeImportedDomain(input: string) {
|
||||
let value = (input || '').replace(/^\uFEFF/, '').trim();
|
||||
if (!value) return null;
|
||||
|
||||
// Skip full-line comments often used in shared lists.
|
||||
if (/^(#|;|\/\/)/.test(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove inline comments while keeping the domain token itself.
|
||||
value = value.replace(/\s+(#|;|\/\/).*$/, '').trim();
|
||||
if (!value) return null;
|
||||
|
||||
value = value
|
||||
.replace(/^['"`]+|['"`]+$/g, '')
|
||||
.replace(/^[a-z]+:\/\//i, '')
|
||||
.split('/')[0]
|
||||
.split('?')[0]
|
||||
.split('#')[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const hostPortMatch = value.match(/^(.+):(\d{1,5})$/);
|
||||
if (hostPortMatch) {
|
||||
value = hostPortMatch[1];
|
||||
}
|
||||
|
||||
// Wildcard entries are valid for input UX, but in whitelist storage we keep root form.
|
||||
value = value.replace(/^\*+\./, '').replace(/^\.+/, '').replace(/\.+$/, '');
|
||||
if (!value) return null;
|
||||
|
||||
return this.isValidDomain(value) ? value : null;
|
||||
}
|
||||
|
||||
private isValidDomain(domain: string) {
|
||||
if (domain.length > 253) return false;
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(domain)) return false;
|
||||
|
||||
const parts = domain.split('.');
|
||||
if (parts.length < 2) return false;
|
||||
|
||||
return parts.every((part) =>
|
||||
/^[a-z0-9-]{1,63}$/.test(part)
|
||||
&& !part.startsWith('-')
|
||||
&& !part.endsWith('-'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user