using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Tenancy; public sealed class DnsDomainOwnershipVerifier( HttpClient httpClient, IOptions options) : IDomainOwnershipVerifier { private readonly DomainLifecycleOptions options = options.Value; public async Task VerifyAsync( string host, string verificationToken, CancellationToken cancellationToken = default) { if (options.AllowedCnameTargets.Length == 0 || string.IsNullOrWhiteSpace(options.DnsJsonEndpoint)) { return new(false, false, "DNS verification is not configured."); } try { var cnameAnswers = await QueryAsync(host, "CNAME", cancellationToken); var cnameMatches = cnameAnswers.Any(answer => options.AllowedCnameTargets.Any(target => NormalizeDnsName(answer).Equals(NormalizeDnsName(target), StringComparison.OrdinalIgnoreCase))); if (!cnameMatches) { return new(false, true, "CNAME does not point to an allowed gateway target."); } var verificationName = $"{options.VerificationRecordPrefix.Trim().TrimEnd('.')}.{host}"; var txtAnswers = await QueryAsync(verificationName, "TXT", cancellationToken); var txtMatches = txtAnswers.Any(answer => answer.Trim().Trim('"').Equals(verificationToken, StringComparison.Ordinal)); return txtMatches ? new(true, true, null) : new(false, true, "TXT ownership token was not found."); } catch (Exception exception) when (exception is HttpRequestException or JsonException or TaskCanceledException) { return new(false, true, $"DNS verification failed: {exception.Message}"); } } private async Task QueryAsync(string name, string type, CancellationToken cancellationToken) { var endpoint = options.DnsJsonEndpoint.TrimEnd('/'); using var request = new HttpRequestMessage( HttpMethod.Get, $"{endpoint}?name={Uri.EscapeDataString(name)}&type={type}"); request.Headers.Accept.ParseAdd("application/dns-json"); using var response = await httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); if (!document.RootElement.TryGetProperty("Answer", out var answers) || answers.ValueKind != JsonValueKind.Array) { return []; } return answers.EnumerateArray() .Where(answer => answer.TryGetProperty("data", out _)) .Select(answer => answer.GetProperty("data").GetString() ?? string.Empty) .Where(answer => answer.Length > 0) .ToArray(); } private static string NormalizeDnsName(string value) => value.Trim().Trim('"').TrimEnd('.'); } public sealed class HttpDomainGatewayProvisioner( HttpClient httpClient, IOptions options) : IDomainGatewayProvisioner { private readonly DomainLifecycleOptions options = options.Value; public async Task EnsureTlsAsync( string host, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(options.GatewayBaseUrl) || string.IsNullOrWhiteSpace(options.GatewayApiKey)) { return new(false, false, "Gateway TLS provisioning is not configured."); } try { using var request = new HttpRequestMessage( HttpMethod.Post, $"{options.GatewayBaseUrl.TrimEnd('/')}/domains/ensure"); request.Headers.Authorization = new("Bearer", options.GatewayApiKey); request.Content = JsonContent.Create(new { host }); using var response = await httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { return new(false, true, $"Gateway returned HTTP {(int)response.StatusCode}."); } using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); var tlsReady = document.RootElement.TryGetProperty("tlsReady", out var value) && value.GetBoolean(); return tlsReady ? new(true, true, null) : new(false, true, "Gateway route exists but TLS is not ready."); } catch (Exception exception) when (exception is HttpRequestException or JsonException or TaskCanceledException) { return new(false, true, $"Gateway provisioning failed: {exception.Message}"); } } } public sealed class TenantRuntimeCacheInvalidator( IMemoryCache cache, ITenantPublicCacheInvalidator publicCacheInvalidator) : ITenantRuntimeCacheInvalidator { public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default) { cache.Remove($"tenant-runtime:{tenantId:N}"); await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken); } } public sealed class TenantDomainLifecycleService( TikuDbContext dbContext, IDomainOwnershipVerifier ownershipVerifier, IDomainGatewayProvisioner gatewayProvisioner, ITenantRuntimeCacheInvalidator cacheInvalidator, IOptions options) : ITenantDomainLifecycleService { private readonly DomainLifecycleOptions options = options.Value; public async Task ProcessPendingAsync(CancellationToken cancellationToken = default) { if (!options.Enabled) { return 0; } var domains = await dbContext.TenantDomains .Where(domain => domain.DomainType == TenantDomainType.Custom && (domain.Status == TenantDomainStatus.Pending || domain.Status == TenantDomainStatus.Failed)) .OrderBy(domain => domain.LastCheckedAt) .Take(Math.Clamp(options.BatchSize, 1, 500)) .ToArrayAsync(cancellationToken); foreach (var domain in domains) { await ProcessAsync(domain, cancellationToken); } if (domains.Length > 0) { await dbContext.SaveChangesAsync(cancellationToken); } return domains.Length; } private async Task ProcessAsync(TenantDomain domain, CancellationToken cancellationToken) { domain.LastCheckedAt = DateTimeOffset.UtcNow; if (string.IsNullOrWhiteSpace(domain.VerificationToken)) { Fail(domain, true, "Domain verification token is missing."); return; } var ownership = await ownershipVerifier.VerifyAsync(domain.Host, domain.VerificationToken, cancellationToken); if (!ownership.Verified) { Fail(domain, ownership.Configured, ownership.FailureReason); return; } domain.DnsVerifiedAt ??= DateTimeOffset.UtcNow; domain.VerifiedAt ??= domain.DnsVerifiedAt; var gateway = await gatewayProvisioner.EnsureTlsAsync(domain.Host, cancellationToken); if (!gateway.TlsReady) { Fail(domain, gateway.Configured, gateway.FailureReason); return; } domain.TlsReadyAt ??= DateTimeOffset.UtcNow; domain.Status = TenantDomainStatus.Active; domain.LastFailureReason = null; await cacheInvalidator.InvalidateAsync(domain.TenantId, cancellationToken); } private static void Fail(TenantDomain domain, bool configured, string? reason) { domain.Status = configured ? TenantDomainStatus.Failed : TenantDomainStatus.Pending; domain.LastFailureReason = reason; } }