This commit is contained in:
iqubik
2026-03-25 22:28:53 +03:00
parent ed1cd84aba
commit 1b9b9b2431
4 changed files with 403 additions and 153 deletions
+75 -41
View File
@@ -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);
}
}
+64 -8
View File
@@ -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('-'),
);
}
}