1
This commit is contained in:
@@ -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