From 1b9b9b24313a17c066f2a3993eef82bb658b6a0b Mon Sep 17 00:00:00 2001 From: iqubik Date: Wed, 25 Mar 2026 22:28:53 +0300 Subject: [PATCH] 1 --- client/src/pages/DomainsPage.tsx | 360 +++++++++++++------ server/Dockerfile | 8 +- server/src/domains/domain-scanner.service.ts | 116 +++--- server/src/domains/domains.service.ts | 72 +++- 4 files changed, 403 insertions(+), 153 deletions(-) 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 && ( + + )} - - - )} - - - - {domains.length > 0 && ( - - - + + )} + + + )} - 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()}> + + + ) : ( + <> + + + + )} + + + + {domains.length > 0 && ( + + + + + )} + + + + {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('-'), + ); + } +}