diff --git a/client/nginx.conf b/client/nginx.conf index c6c24d4..e65f6a3 100644 --- a/client/nginx.conf +++ b/client/nginx.conf @@ -20,6 +20,9 @@ server { proxy_set_header Connection 'upgrade'; proxy_set_header Host $http_host; proxy_cache_bypass $http_upgrade; + proxy_connect_timeout 10s; + proxy_send_timeout 650s; + proxy_read_timeout 650s; } } server { @@ -29,4 +32,4 @@ server { proxy_pass http://backend:3000/; proxy_set_header Host \$http_host; } -} \ No newline at end of file +} diff --git a/client/src/pages/DomainsPage.tsx b/client/src/pages/DomainsPage.tsx index 9b5efb4..7a7689a 100644 --- a/client/src/pages/DomainsPage.tsx +++ b/client/src/pages/DomainsPage.tsx @@ -11,10 +11,14 @@ interface ScanCapabilities { timeoutPath: string | null; } interface ScanResponse { + runId: string; addr: string; scanSeconds: number; thread: number; timeout: number; + startedAt: string; + endsAt: string; + finishedAt: string; timedOut: boolean; exitCode: number; foundCount: number; @@ -22,6 +26,21 @@ interface ScanResponse { stderrTail: string; stdoutTail: string; } +interface ScanStatusResponse { + running: boolean; + runId: string | null; + addr: string | null; + scanSeconds: number | null; + thread: number | null; + timeout: number | null; + startedAt: string | null; + endsAt: string | null; + now: string; + remainingSeconds: number; + foundCount: number; + lastRunId: string | null; + lastFinishedAt: string | null; +} const SCAN_STORAGE_KEY = 'domains_scan_state_v1'; @@ -46,6 +65,15 @@ export default function DomainsPage() { const [scanCandidates, setScanCandidates] = useState([]); const [scanPanelExpanded, setScanPanelExpanded] = useState(false); const [scanStateHydrated, setScanStateHydrated] = useState(false); + const [scanStatus, setScanStatus] = useState(null); + const [activeScanRunId, setActiveScanRunId] = useState(null); + + const clampInteger = (value: number, fallback: number, min: number, max: number) => { + const num = Number.isFinite(value) ? Math.floor(value) : fallback; + if (num < min) return min; + if (num > max) return max; + return num; + }; const isLoopbackHost = (value: string) => { const host = value.trim().toLowerCase(); @@ -107,6 +135,22 @@ export default function DomainsPage() { return ''; }; + const fetchScanStatus = async () => { + const { data } = await api.get('/domains/scan/status'); + setScanStatus(data); + return data as ScanStatusResponse; + }; + + const fetchLastScanResult = async (expectedRunId?: string | null) => { + const { data } = await api.get('/domains/scan/last-result'); + if (!data) return null; + if (expectedRunId && data.runId !== expectedRunId) return null; + + setScanResult(data); + setScanCandidates(data.domains || []); + return data as ScanResponse; + }; + const loadDomains = async () => { try { const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`); @@ -127,6 +171,16 @@ export default function DomainsPage() { try { const capRes = await api.get('/domains/scan/capabilities'); setScanCapabilities(capRes.data); + if (capRes.data?.scannerAvailable) { + const status = await fetchScanStatus(); + if (status.running) { + setIsScanning(true); + setActiveScanRunId(status.runId); + setScanResult(null); + setScanCandidates([]); + setScanError(''); + } + } } catch (e) { console.error(e); } @@ -197,6 +251,42 @@ export default function DomainsPage() { } }, [scanAddr, scanSeconds, scanThread, scanTimeout, scanResult, scanCandidates, scanPanelExpanded, scanStateHydrated]); + useEffect(() => { + if (!isScanning) return; + + let cancelled = false; + + const tick = async () => { + try { + const status = await fetchScanStatus(); + if (cancelled) return; + + if (status.running) { + if (status.runId) { + setActiveScanRunId((prev) => prev ?? status.runId); + } + return; + } + + setIsScanning(false); + const runIdToLoad = activeScanRunId || status.lastRunId; + await fetchLastScanResult(runIdToLoad); + setActiveScanRunId(null); + } catch (e) { + if (!cancelled) { + console.error(e); + } + } + }; + + tick(); + const timer = window.setInterval(tick, 1000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [isScanning, activeScanRunId]); + const handleChangePage = (_event: unknown, newPage: number) => { setPage(newPage); }; @@ -257,25 +347,51 @@ export default function DomainsPage() { return; } + const effectiveScanSeconds = clampInteger(scanSeconds, 120, 10, 600); + const effectiveThread = clampInteger(scanThread, 2, 1, 20); + const effectiveTimeout = clampInteger(scanTimeout, 5, 1, 20); + let keepScanning = false; + try { setIsScanning(true); setScanError(''); setScanResult(null); + setScanStatus(null); + setActiveScanRunId(null); const { data } = await api.post('/domains/scan/start', { addr: scanAddr.trim(), - scanSeconds, - thread: scanThread, - timeout: scanTimeout, + scanSeconds: effectiveScanSeconds, + thread: effectiveThread, + timeout: effectiveTimeout, }); setScanResult(data); setScanCandidates(data.domains || []); + setActiveScanRunId(data.runId || null); + await fetchScanStatus(); } catch (e: any) { const message = e?.response?.data?.message || e?.message || 'Ошибка запуска сканера'; setScanError(Array.isArray(message) ? message.join('; ') : message); + + if (e?.response?.status === 429) { + try { + const status = await fetchScanStatus(); + if (status.running) { + keepScanning = true; + setIsScanning(true); + setActiveScanRunId(status.runId); + setScanError('Скан уже выполняется. Подключились к текущему запуску.'); + } + } catch (statusErr) { + console.error(statusErr); + } + } } finally { - setIsScanning(false); + if (!keepScanning) { + setIsScanning(false); + setActiveScanRunId(null); + } } }; @@ -299,6 +415,8 @@ export default function DomainsPage() { const handleClearScannedDomains = async () => { setScanCandidates([]); setScanResult(null); + setScanStatus(null); + setActiveScanRunId(null); setScanAddr(''); const suggestedAddr = await resolveSuggestedScanAddr({ allowLoopbackFallback: true }); @@ -432,6 +550,15 @@ export default function DomainsPage() { {isScanning && } + {isScanning && ( + + {scanStatus?.running + ? scanStatus.remainingSeconds > 0 + ? `Сканирование выполняется. Осталось ${scanStatus.remainingSeconds} сек (по данным сервера). Найдено сейчас: ${scanStatus.foundCount}.` + : 'Сканирование завершается, ожидайте...' + : 'Сканирование запущено, получаем статус от сервера...'} + + )} {scanError && {scanError}} diff --git a/debug.md b/debug.md index 378e86e..6f121d0 100644 --- a/debug.md +++ b/debug.md @@ -23,6 +23,9 @@ docker compose -f docker-compose.local.yml ps ## Что уже встроено - Backend scanner (RealiTLScanner) собирается в `server/Dockerfile` и кладется в контейнер backend. - Блок `Автопоиск SNI (backend scanner)` показывается только если бинарник сканера доступен в контейнере. +- Во время сканирования UI получает статус с backend (`/api/domains/scan/status`) и показывает реальный countdown. +- Если обновить страницу во время скана, UI подхватывает активный запуск по backend-статусу и продолжает отсчет. +- После завершения скана результат можно получить из backend (`/api/domains/scan/last-result`) даже если исходный запрос был прерван (например, после F5). - Предварительный список сканера: - ручная проверка ссылками, - удаление позиций, diff --git a/install.sh b/install.sh index 8055114..59b45d0 100644 --- a/install.sh +++ b/install.sh @@ -277,6 +277,9 @@ server { proxy_set_header Host \$http_host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_connect_timeout 10s; + proxy_send_timeout 650s; + proxy_read_timeout 650s; } } server { @@ -388,6 +391,9 @@ server { proxy_cache_bypass \$http_upgrade; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_connect_timeout 10s; + proxy_send_timeout 650s; + proxy_read_timeout 650s; } } server { @@ -501,4 +507,4 @@ echo -e "${GREEN}Логин: ${ADMIN_USER}${NC}" echo -e "${GREEN}Пароль: ${ADMIN_PASS}${NC}" echo "" echo "Немедленно измените пароль в Настройках утилиты!" -echo "===================================================" \ No newline at end of file +echo "===================================================" diff --git a/server/src/domains/domain-scanner.service.ts b/server/src/domains/domain-scanner.service.ts index cab15fc..a0ce630 100644 --- a/server/src/domains/domain-scanner.service.ts +++ b/server/src/domains/domain-scanner.service.ts @@ -8,12 +8,42 @@ type StartScanPayload = { timeout?: number; }; +type ActiveScanState = { + runId: string; + addr: string; + scanSeconds: number; + thread: number; + timeout: number; + startedAtMs: number; + endsAtMs: number; + foundCount: number; +}; + +type ScanResult = { + runId: string; + addr: string; + scanSeconds: number; + thread: number; + timeout: number; + startedAt: string; + endsAt: string; + finishedAt: string; + timedOut: boolean; + exitCode: number; + foundCount: number; + domains: string[]; + stderrTail: string; + stdoutTail: string; +}; + @Injectable() export class DomainScannerService { private readonly logger = new Logger(DomainScannerService.name); private readonly scannerBin = 'RealiTLScanner-linux-64'; private isScanRunning = false; private readonly logTailLimit = 8000; + private activeScan: ActiveScanState | null = null; + private lastScanResult: ScanResult | null = null; getCapabilities() { const scannerCheck = spawnSync('sh', ['-lc', `command -v ${this.scannerBin}`], { encoding: 'utf-8' }); @@ -27,11 +57,42 @@ export class DomainScannerService { }; } + getScanStatus() { + const nowMs = Date.now(); + const active = this.activeScan; + + return { + running: Boolean(active), + runId: active?.runId ?? null, + addr: active?.addr ?? null, + scanSeconds: active?.scanSeconds ?? null, + thread: active?.thread ?? null, + timeout: active?.timeout ?? null, + startedAt: active ? new Date(active.startedAtMs).toISOString() : null, + endsAt: active ? new Date(active.endsAtMs).toISOString() : null, + now: new Date(nowMs).toISOString(), + remainingSeconds: active ? Math.max(0, Math.ceil((active.endsAtMs - nowMs) / 1000)) : 0, + foundCount: active?.foundCount ?? 0, + lastRunId: this.lastScanResult?.runId ?? null, + lastFinishedAt: this.lastScanResult?.finishedAt ?? null, + }; + } + + getLastScanResult() { + return this.lastScanResult; + } + 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); + throw new HttpException( + { + message: 'Сканер уже запущен, дождитесь завершения текущего запуска', + scanStatus: this.getScanStatus(), + }, + HttpStatus.TOO_MANY_REQUESTS, + ); } const addr = (payload.addr || '').trim(); @@ -65,9 +126,24 @@ export class DomainScannerService { '', ]; + const runId = this.createRunId(); + const startedAtMs = Date.now(); + const endsAtMs = startedAtMs + scanSeconds * 1000; + this.logger.log(`Starting scanner: addr=${addr}, seconds=${scanSeconds}, thread=${thread}, timeout=${connectTimeout}`); this.isScanRunning = true; + this.activeScan = { + runId, + addr, + scanSeconds, + thread, + timeout: connectTimeout, + startedAtMs, + endsAtMs, + foundCount: 0, + }; + try { const child = spawn('timeout', args, { stdio: ['ignore', 'pipe', 'pipe'], @@ -90,6 +166,9 @@ export class DomainScannerService { for (const line of parts) { this.extractDomainsFromLog(line, domains); } + if (this.activeScan?.runId === runId) { + this.activeScan.foundCount = domains.size; + } }); child.stderr.on('data', (chunk: Buffer) => { @@ -106,6 +185,9 @@ export class DomainScannerService { if (stdoutRemainder) { this.extractDomainsFromLog(stdoutRemainder, domains); + if (this.activeScan?.runId === runId) { + this.activeScan.foundCount = domains.size; + } } const timedOut = exitCode === 124 || exitCode === 137 || exitCode === 143; @@ -115,11 +197,15 @@ export class DomainScannerService { } const sortedDomains = [...domains].sort(); - return { + const result: ScanResult = { + runId, addr, scanSeconds, thread, timeout: connectTimeout, + startedAt: new Date(startedAtMs).toISOString(), + endsAt: new Date(endsAtMs).toISOString(), + finishedAt: new Date().toISOString(), timedOut, exitCode, foundCount: sortedDomains.length, @@ -127,8 +213,12 @@ export class DomainScannerService { stderrTail: stderr.slice(-800), stdoutTail: stdout.slice(-800), }; + + this.lastScanResult = result; + return result; } finally { this.isScanRunning = false; + this.activeScan = null; } } @@ -174,4 +264,8 @@ export class DomainScannerService { } return merged.slice(-this.logTailLimit); } + + private createRunId() { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + } } diff --git a/server/src/domains/domains.controller.ts b/server/src/domains/domains.controller.ts index 0765ce4..fd56fe7 100644 --- a/server/src/domains/domains.controller.ts +++ b/server/src/domains/domains.controller.ts @@ -24,6 +24,16 @@ export class DomainsController { return this.domainScannerService.getCapabilities(); } + @Get('scan/status') + scanStatus() { + return this.domainScannerService.getScanStatus(); + } + + @Get('scan/last-result') + lastScanResult() { + return this.domainScannerService.getLastScanResult(); + } + @Post('scan/start') startScan(@Body() body: { addr: string; scanSeconds?: number; thread?: number; timeout?: number }) { return this.domainScannerService.startScan(body); diff --git a/update-custom.sh b/update-custom.sh index 456c958..979e475 100644 --- a/update-custom.sh +++ b/update-custom.sh @@ -30,6 +30,46 @@ need_root() { [[ $EUID -eq 0 ]] || die "Запускать только от root" } +ensure_nginx_api_timeouts() { + local nginx_conf="$1" + [[ -f "$nginx_conf" ]] || return 0 + + local tmp_file + tmp_file="$(mktemp)" + + awk ' + BEGIN { in_api = 0; injected = 0 } + { + line = $0 + + if (line ~ /^[[:space:]]*location[[:space:]]+\/api\/[[:space:]]*\{/) { + in_api = 1 + injected = 0 + } + + if (in_api && line ~ /proxy_(connect|send|read)_timeout[[:space:]]+[0-9]+s;/) { + next + } + + print line + + if (in_api && line ~ /proxy_set_header[[:space:]]+X-Forwarded-For[[:space:]]+/ && injected == 0) { + print " proxy_connect_timeout 10s;" + print " proxy_send_timeout 650s;" + print " proxy_read_timeout 650s;" + injected = 1 + } + + if (in_api && line ~ /^[[:space:]]*}/) { + in_api = 0 + injected = 0 + } + } + ' "$nginx_conf" > "$tmp_file" + + mv "$tmp_file" "$nginx_conf" +} + REPO_URL="" BRANCH="" PROJECT_DIR="/opt/3dp-manager" @@ -98,6 +138,8 @@ services: image: 3dp-manager-client:custom EOF +ensure_nginx_api_timeouts "$PROJECT_DIR/client/nginx-client.conf" + log "Сборка custom-образов backend/frontend" cd "$PROJECT_DIR" docker compose -f docker-compose.yml -f docker-compose.custom.yml build backend frontend