import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { PrismaService } from '../infra/prisma.service'; import { collectOAuthSslHostnames, parseHostnameFromPublicUrl, resolveRegistrableDomain } from '../infra/oauth-ssl-sans.util'; const OAUTH_SSL_SANS_SETTING_KEY = 'OAUTH_SSL_SANS'; @Injectable() export class OAuthSslSansService implements OnModuleInit { private readonly logger = new Logger(OAuthSslSansService.name); constructor( private readonly prisma: PrismaService, private readonly config: ConfigService ) {} async onModuleInit() { try { await this.syncFromDatabase(); } catch (error) { this.logger.warn( `Не удалось синхронизировать SAN из OAuth redirect URI: ${error instanceof Error ? error.message : 'ошибка'}` ); } } async syncFromDatabase(): Promise { const clients = await this.prisma.oAuthClient.findMany({ where: { isActive: true }, select: { redirectUris: true } }); const redirectUris = clients.flatMap((client) => client.redirectUris); const hostnames = collectOAuthSslHostnames(redirectUris, this.buildExcludedHostnames()); await this.persistHostnames(hostnames); return hostnames; } private buildExcludedHostnames(): Set { const excluded = new Set(); for (const envKey of ['DOMAIN_API', 'DOMAIN_FRONTEND', 'DOMAIN_DOCS', 'DOMAIN_WS', 'DOMAIN_MINIO', 'DOMAIN_MINIO_CONSOLE']) { const value = this.config.get(envKey)?.trim().toLowerCase(); if (value) excluded.add(value); } for (const url of [ this.config.get('PUBLIC_API_URL'), this.config.get('PUBLIC_FRONTEND_URL'), this.config.get('PUBLIC_DOCS_URL'), this.config.get('PUBLIC_WS_URL') ]) { const host = parseHostnameFromPublicUrl(url); if (host) excluded.add(host); } const idpHosts = [...excluded]; for (const host of idpHosts) { excluded.add(resolveRegistrableDomain(host)); const parts = host.split('.'); if (parts.length >= 3) { excluded.add(parts.slice(-2).join('.')); } } return excluded; } private async persistHostnames(hostnames: string[]) { const serialized = hostnames.join(','); await this.prisma.systemSetting.upsert({ where: { key: OAUTH_SSL_SANS_SETTING_KEY }, create: { key: OAUTH_SSL_SANS_SETTING_KEY, value: serialized, description: 'DNS-имена из redirect_uri OAuth-приложений для SAN сертификата (авто)', isSecret: false }, update: { value: serialized, description: 'DNS-имена из redirect_uri OAuth-приложений для SAN сертификата (авто)' } }); const targetFile = this.config.get('OAUTH_SSL_SANS_FILE')?.trim() || '/data/nginx-certs/oauth-redirect-sans.txt'; const body = `${hostnames.join('\n')}\n`; await mkdir(dirname(targetFile), { recursive: true }); await writeFile(targetFile, body, 'utf8'); this.logger.log(`OAuth SSL SAN обновлён (${hostnames.length}): ${hostnames.join(', ') || '—'}`); } }