Files
tiku-backend.net/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs
xiong 603bc24c26
Some checks are pending
ci / release-gate (push) Waiting to run
feat(cache): adopt FusionCache for business caching
2026-08-05 09:30:48 +08:00

221 lines
9.5 KiB
C#

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tiku.Application.Tenancy;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Caching;
using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Tenancy;
public sealed class DnsDomainOwnershipVerifier(
HttpClient httpClient,
IOptions<DomainLifecycleOptions> options) : IDomainOwnershipVerifier
{
private readonly DomainLifecycleOptions options = options.Value;
public async Task<DomainOwnershipResult> VerifyAsync(
string host,
string verificationToken,
CancellationToken cancellationToken = default)
{
if (options.EnableDevelopmentLocalhostBypass && IsLocalhostHost(host))
return new DomainOwnershipResult(true, true, null);
if (options.AllowedCnameTargets.Length == 0 || string.IsNullOrWhiteSpace(options.DnsJsonEndpoint))
return new DomainOwnershipResult(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 DomainOwnershipResult(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 DomainOwnershipResult(true, true, null)
: new DomainOwnershipResult(false, true, "TXT ownership token was not found.");
}
catch (Exception exception) when (exception is HttpRequestException or JsonException or TaskCanceledException)
{
return new DomainOwnershipResult(false, true, $"DNS verification failed: {exception.Message}");
}
}
private async Task<string[]> 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)
{
return value.Trim().Trim('"').TrimEnd('.');
}
private static bool IsLocalhostHost(string host)
{
var normalized = host.Trim().TrimEnd('.');
return normalized.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
normalized.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase);
}
}
public sealed class HttpDomainGatewayProvisioner(
HttpClient httpClient,
IOptions<DomainLifecycleOptions> options) : IDomainGatewayProvisioner
{
private readonly DomainLifecycleOptions options = options.Value;
public async Task<DomainGatewayResult> EnsureTlsAsync(
string host,
CancellationToken cancellationToken = default)
{
if (options.EnableDevelopmentLocalhostBypass && IsLocalhostHost(host))
return new DomainGatewayResult(true, true, null);
if (string.IsNullOrWhiteSpace(options.GatewayBaseUrl) || string.IsNullOrWhiteSpace(options.GatewayApiKey))
return new DomainGatewayResult(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 AuthenticationHeaderValue("Bearer", options.GatewayApiKey);
request.Content = JsonContent.Create(new { host });
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
return new DomainGatewayResult(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 DomainGatewayResult(true, true, null)
: new DomainGatewayResult(false, true, "Gateway route exists but TLS is not ready.");
}
catch (Exception exception) when (exception is HttpRequestException or JsonException or TaskCanceledException)
{
return new DomainGatewayResult(false, true, $"Gateway provisioning failed: {exception.Message}");
}
}
private static bool IsLocalhostHost(string host)
{
var normalized = host.Trim().TrimEnd('.');
return normalized.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
normalized.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase);
}
}
public sealed class TenantRuntimeCacheInvalidator(
[FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
IFusionCache cache,
ITenantPublicCacheInvalidator publicCacheInvalidator) : ITenantRuntimeCacheInvalidator
{
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default)
{
await cache.RemoveAsync(TenantFrontendConfigService.CacheKey(tenantId), token: cancellationToken);
await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
}
}
public sealed class TenantDomainLifecycleService(
ITenancyPersistence tenancyPersistence,
IJobsOperationsPersistence jobsOperationsPersistence,
IDomainOwnershipVerifier ownershipVerifier,
IDomainGatewayProvisioner gatewayProvisioner,
ITenantRuntimeCacheInvalidator cacheInvalidator,
IOptions<DomainLifecycleOptions> options) : ITenantDomainLifecycleService
{
private readonly DomainLifecycleOptions options = options.Value;
public async Task<int> ProcessPendingAsync(CancellationToken cancellationToken = default)
{
if (!options.Enabled) return 0;
var domains = await tenancyPersistence.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 tenancyPersistence.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;
jobsOperationsPersistence.AuditLogs.Add(new AuditLog
{
TenantId = domain.TenantId,
Action = "tenant.domain.activated",
TargetType = "tenant_domains",
TargetId = domain.Id.ToString("N"),
Details = JsonSerializer.SerializeToElement(new { domain.Host, domain.IsPrimary })
});
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;
}
}