This commit is contained in:
iqubik
2026-03-25 21:40:56 +03:00
parent 241e9c5af7
commit ed1cd84aba
5 changed files with 474 additions and 8 deletions
@@ -0,0 +1,143 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common';
import { spawn, spawnSync } from 'child_process';
type StartScanPayload = {
addr: string;
scanSeconds?: number;
thread?: number;
timeout?: number;
};
@Injectable()
export class DomainScannerService {
private readonly logger = new Logger(DomainScannerService.name);
private readonly scannerBin = 'RealiTLScanner-linux-64';
getCapabilities() {
const scannerCheck = spawnSync('sh', ['-lc', `command -v ${this.scannerBin}`], { encoding: 'utf-8' });
const timeoutCheck = spawnSync('sh', ['-lc', 'command -v timeout'], { encoding: 'utf-8' });
return {
scannerAvailable: scannerCheck.status === 0,
scannerPath: scannerCheck.stdout.trim() || null,
timeoutAvailable: timeoutCheck.status === 0,
timeoutPath: timeoutCheck.stdout.trim() || null,
};
}
async startScan(payload: StartScanPayload) {
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 connectTimeout = this.clampNumber(payload.timeout, 5, 1, 20);
const capabilities = this.getCapabilities();
if (!capabilities.scannerAvailable) {
throw new ServiceUnavailableException(`Не найден ${this.scannerBin} в контейнере`);
}
if (!capabilities.timeoutAvailable) {
throw new ServiceUnavailableException('Не найдена утилита timeout в контейнере');
}
const args = [
'--signal=TERM',
`${scanSeconds}s`,
this.scannerBin,
'--addr',
addr,
'--thread',
String(thread),
'--timeout',
String(connectTimeout),
'--out',
'',
];
this.logger.log(`Starting scanner: addr=${addr}, seconds=${scanSeconds}, thread=${thread}, timeout=${connectTimeout}`);
const child = spawn('timeout', args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
const domains = new Set<string>();
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stdout += text;
this.extractDomainsFromLog(text, domains);
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
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}`);
});
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),
};
}
private extractDomainsFromLog(text: string, out: Set<string>) {
const regex = /cert-domain=([^\s]+)/g;
let match: RegExpExecArray | null = regex.exec(text);
while (match) {
const normalized = this.normalizeDomain(match[1]);
if (normalized) {
out.add(normalized);
}
match = regex.exec(text);
}
}
private normalizeDomain(input: string) {
const cleaned = input
.trim()
.replace(/^"+|"+$/g, '')
.replace(/^\*\./, '')
.toLowerCase();
if (!/^[a-z0-9.-]+$/.test(cleaned)) {
return null;
}
if (!cleaned.includes('.')) {
return null;
}
return cleaned;
}
private clampNumber(value: number | undefined, fallback: number, min: number, max: number) {
const num = Number.isFinite(value) ? Number(value) : fallback;
if (num < min) return min;
if (num > max) return max;
return Math.floor(num);
}
}
+16 -2
View File
@@ -1,9 +1,13 @@
import { Controller, Get, Post, Body, Param, Delete, Query } from '@nestjs/common';
import { DomainsService } from './domains.service';
import { DomainScannerService } from './domain-scanner.service';
@Controller('domains')
export class DomainsController {
constructor(private readonly domainsService: DomainsService) { }
constructor(
private readonly domainsService: DomainsService,
private readonly domainScannerService: DomainScannerService,
) { }
@Post()
create(@Body() body: { name: string }) {
@@ -15,6 +19,16 @@ export class DomainsController {
return this.domainsService.createMany(body.domains);
}
@Get('scan/capabilities')
scanCapabilities() {
return this.domainScannerService.getCapabilities();
}
@Post('scan/start')
startScan(@Body() body: { addr: string; scanSeconds?: number; thread?: number; timeout?: number }) {
return this.domainScannerService.startScan(body);
}
@Get('all')
findAllWithoutPagination() {
return this.domainsService.findAllUnpaginated();
@@ -45,4 +59,4 @@ export class DomainsController {
remove(@Param('id') id: string) {
return this.domainsService.remove(+id);
}
}
}
+3 -2
View File
@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { DomainsService } from './domains.service';
import { DomainsController } from './domains.controller';
import { Domain } from './entities/domain.entity';
import { DomainScannerService } from './domain-scanner.service';
@Module({
imports: [TypeOrmModule.forFeature([Domain])],
controllers: [DomainsController],
providers: [DomainsService],
providers: [DomainsService, DomainScannerService],
exports: [DomainsService],
})
export class DomainsModule {}
export class DomainsModule {}