diff --git a/client/src/pages/DomainsPage.tsx b/client/src/pages/DomainsPage.tsx
index a150008..9b5efb4 100644
--- a/client/src/pages/DomainsPage.tsx
+++ b/client/src/pages/DomainsPage.tsx
@@ -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 (
Белый список доменов (SNI)
-
- setScanPanelExpanded(expanded)}
- disableGutters
- sx={{
- boxShadow: 'none',
- '&:before': { display: 'none' },
- }}
- >
- }>
- Автопоиск SNI (backend scanner)
-
-
+ {scanCapabilities?.scannerAvailable && (
+
+ 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)}
-
- )}
+ {scanCapabilities && (!scanCapabilities.scannerAvailable || !scanCapabilities.timeoutAvailable) && (
+
+ Сканер в контейнере недоступен. scanner: {String(scanCapabilities.scannerAvailable)}, timeout: {String(scanCapabilities.timeoutAvailable)}
+
+ )}
setScanTimeout(Number(e.target.value))}
@@ -309,6 +412,16 @@ export default function DomainsPage() {
>
Добавить найденные в список
+ {scanCandidates.length > 0 && (
+ }
+ onClick={handleExportScannedDomains}
+ disabled={isScanning}
+ >
+ Экспорт найденных
+
+ )}
-
-
-
-
- setNewDomain(e.target.value)}
- />
- {isMobile ? (
- <>
- fileInputRef.current?.click()}>
-
- >
- ) : (
- <>
- }
- sx={{ width: isMobile ? 'auto' : '170px' }}
- onClick={() => fileInputRef.current?.click()}
- >
- {isMobile ? '' : 'Из файла'}
-
- } onClick={handleAdd}>Добавить
- >
- )}
-
-
-
- {domains.length > 0 && (
-
- }
- onClick={handleDeleteAll}
- >
- Удалить все
-
-
+
+ )}
+
+
+
)}
- 0 ? 0 : 3 }}>
-
- {domains.map((d) => (
- handleDelete(d.id)}>
- }>
-
-
- ))}
- {domains.length === 0 && Нет доменов}
-
- `${from}–${to} из ${count !== -1 ? count : `более ${to}`}`}
- />
+
+
+ Управление белым списком (SNI)
+
+
+ setNewDomain(e.target.value)}
+ sx={{ flex: '1 1 280px' }}
+ />
+ {isMobile ? (
+ <>
+ fileInputRef.current?.click()}>
+
+ >
+ ) : (
+ <>
+ }
+ sx={{ width: '170px' }}
+ onClick={() => fileInputRef.current?.click()}
+ >
+ Из файла
+
+ } onClick={handleAdd}>Добавить
+ >
+ )}
+
+
+
+ {domains.length > 0 && (
+
+ }
+ onClick={handleExportMainDomains}
+ >
+ Экспорт списка
+
+ }
+ onClick={handleDeleteAll}
+ >
+ Удалить все
+
+
+ )}
+
+
+
+ {domains.map((d) => (
+ handleDelete(d.id)}>
+ }
+ >
+
+ {d.name}
+
+ }
+ />
+
+ ))}
+ {domains.length === 0 && Нет доменов}
+
+ `${from}–${to} из ${count !== -1 ? count : `более ${to}`}`}
+ />
+
);
diff --git a/server/Dockerfile b/server/Dockerfile
index c2db961..651c986 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -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
diff --git a/server/src/domains/domain-scanner.service.ts b/server/src/domains/domain-scanner.service.ts
index 5bf9afb..cab15fc 100644
--- a/server/src/domains/domain-scanner.service.ts
+++ b/server/src/domains/domain-scanner.service.ts
@@ -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();
- let stdout = '';
- let stderr = '';
+ const domains = new Set();
+ 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((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((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) {
@@ -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);
+ }
}
diff --git a/server/src/domains/domains.service.ts b/server/src/domains/domains.service.ts
index a8cfc88..ce4bbeb 100644
--- a/server/src/domains/domains.service.ts
+++ b/server/src/domains/domains.service.ts
@@ -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 };
}
-}
\ No newline at end of file
+
+ 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('-'),
+ );
+ }
+}