203 lines
8.0 KiB
C#
203 lines
8.0 KiB
C#
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<DomainLifecycleOptions> options) : IDomainOwnershipVerifier
|
|
{
|
|
private readonly DomainLifecycleOptions options = options.Value;
|
|
|
|
public async Task<DomainOwnershipResult> 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<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) => value.Trim().Trim('"').TrimEnd('.');
|
|
}
|
|
|
|
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 (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<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 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;
|
|
}
|
|
}
|