using DnsClient; using GB5Shared.Cache; using GB5Shared.DTO.Framework.Login; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using PartnerDAL.CustomCode.ClientDomain; using PartnerDAL.DTO.ClientDomain; using System.ComponentModel.DataAnnotations; using System.Net; namespace PartnerBLL.ClientDomain { public class ClientDomainBLL : IClientDomainBLL { private readonly IClientDomainDAL _dal; private readonly IValidation _validation; private readonly ILogger _logger; private readonly IConfiguration _config; private readonly IDomainCacheInvalidator _domainCacheInvalidator; private const string DEFAULT_CNAME_TARGET_KEY = "Domains.DefaultCnameTarget"; public ClientDomainBLL( IClientDomainDAL dal, IValidation validation, ILogger logger, IConfiguration config, IDomainCacheInvalidator domainCacheInvalidator) { _dal = dal; _validation = validation; _logger = logger; _config = config; _domainCacheInvalidator = domainCacheInvalidator; } public async Task GetClientDomains(int clientId, LoginDTO loginDTO) { try { return await _dal.GetClientDomains(clientId, loginDTO).ConfigureAwait(false); } catch (Exception) { throw; } } public async Task SaveClientDomain(ClientDomainDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); try { GB5Trace.Step("validate-client-domain", new { dto.DomainName }); if (string.IsNullOrWhiteSpace(dto.DomainName)) throw new ValidationException("DomainName is required."); if (dto.ClientId == 0) throw new ValidationException("ClientId is required."); dto.DomainName = dto.DomainName.ToLowerInvariant().Trim(); // Normalize optional per-domain CNAME override if (!string.IsNullOrWhiteSpace(dto.ExpectedCnameTarget)) dto.ExpectedCnameTarget = dto.ExpectedCnameTarget.ToLowerInvariant().Trim().TrimEnd('.'); bool isNew = dto.DomainId == 0; dto.ModifiedById = loginDTO.UserId; dto.ModifiedOn = DateTime.UtcNow; GB5Trace.Step("save-client-domain", new { dto.DomainId, isNew, dto.DomainName }); if (isNew) { dto.CreatedById = loginDTO.UserId; dto.CreatedOn = DateTime.UtcNow; int newId = await _dal.SaveClientDomain(dto, loginDTO).ConfigureAwait(false); _domainCacheInvalidator.Invalidate(dto.DomainName); return $"{SuccessResponse.SaveSuccessMessage} {newId}"; } else { // Fetch the pre-update row so a renamed DomainName also drops its OLD // cache entry — otherwise the stale hostname keeps resolving to this // tenant for up to 15 more minutes (DomainCacheService's sliding TTL). var existing = await _dal.GetClientDomainById(dto.DomainId, loginDTO).ConfigureAwait(false); await _dal.UpdateClientDomain(dto, loginDTO).ConfigureAwait(false); if (existing != null && !string.Equals(existing.DomainName, dto.DomainName, StringComparison.OrdinalIgnoreCase)) _domainCacheInvalidator.Invalidate(existing.DomainName); _domainCacheInvalidator.Invalidate(dto.DomainName); return $"{SuccessResponse.UpdateSuccessMessage} {dto.DomainId}"; } } catch (ValidationException vex) { throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-client-domain-failed", ex); _logger.LogError(ex, "SaveClientDomain failed for DomainName {DomainName}", dto.DomainName); throw; } } public async Task DeleteClientDomain(int domainId, LoginDTO loginDTO) { try { GB5Trace.Step("delete-client-domain", new { domainId }); var existing = await _dal.GetClientDomainById(domainId, loginDTO).ConfigureAwait(false); int rows = await _dal.DeleteClientDomain(domainId, loginDTO.UserId, loginDTO).ConfigureAwait(false); if (rows > 0 && existing != null) _domainCacheInvalidator.Invalidate(existing.DomainName); return rows > 0 ? SuccessResponse.DeleteSuccessMessage : ErrorResponse.DeleteNotFoundMessage; } catch (Exception ex) { GB5Trace.MarkFailed("delete-client-domain-failed", ex); _logger.LogError(ex, "DeleteClientDomain failed for DomainId {DomainId}", domainId); throw; } } public async Task VerifyClientDomain(int domainId, LoginDTO loginDTO) { try { GB5Trace.Step("verify-client-domain", new { domainId }); var dto = await _dal.GetClientDomainById(domainId, loginDTO).ConfigureAwait(false); if (dto == null) return ErrorResponse.DeleteNotFoundMessage; // Dynamic resolution order: per-domain DB override -> platform-wide default (DB) var expectedTarget = dto.ExpectedCnameTarget ?? await _dal.GetPlatformSetting(DEFAULT_CNAME_TARGET_KEY, loginDTO).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(expectedTarget)) { _logger.LogError( "No CNAME target resolved for DomainId {DomainId} (DomainName={DomainName}, ClientId={ClientId}). " + "Neither dto.ExpectedCnameTarget nor platform setting '{SettingKey}' is set.", dto.DomainId, dto.DomainName, dto.ClientId, DEFAULT_CNAME_TARGET_KEY); return "Server configuration error. Please contact support."; } try { // Use explicit public DNS servers — never rely on system/container default resolver var configuredServers = _config.GetSection("Domains:DnsServers").Get(); var nameServers = (configuredServers != null && configuredServers.Length > 0) ? configuredServers .Select(ip => IPAddress.TryParse(ip, out var parsed) ? parsed : null) .Where(ip => ip != null) .Select(ip => new NameServer(ip!)) .ToArray() : Array.Empty(); if (nameServers.Length == 0) { nameServers = new[] { new NameServer(IPAddress.Parse("8.8.8.8")), new NameServer(IPAddress.Parse("1.1.1.1")) }; } var lookup = new LookupClient(new LookupClientOptions(nameServers) { Timeout = TimeSpan.FromSeconds(5), Retries = 1, UseCache = false // avoid stale negative cache on repeated "verify" clicks }); var result = await lookup.QueryAsync(dto.DomainName, QueryType.CNAME) .ConfigureAwait(false); if (result.HasError) { // NXDOMAIN means the domain simply has no DNS record yet — tell the user // to add a CNAME rather than reporting a server-side failure. if (result.Header?.ResponseCode == DnsHeaderResponseCode.NotExistentDomain) { return "No CNAME record found. Please add a CNAME record pointing your domain to " + expectedTarget + "."; } _logger.LogWarning( "DNS query returned error for domain {DomainName}. ResponseCode={ResponseCode}, ErrorMessage={ErrorMessage}", dto.DomainName, result.Header?.ResponseCode, result.ErrorMessage); return "Domain DNS lookup failed. Please verify your CNAME record points to the platform."; } var cname = result.Answers.CnameRecords().FirstOrDefault()?.CanonicalName?.Value ?.TrimEnd('.'); if (cname == null) { _logger.LogInformation( "No CNAME record found for domain {DomainName}. Raw answers: {Answers}", dto.DomainName, string.Join(", ", result.Answers.Select(a => a.ToString()))); return "No CNAME record found. Please add a CNAME record pointing your domain to " + expectedTarget + "."; } if (!cname.Equals(expectedTarget, StringComparison.OrdinalIgnoreCase)) { return $"CNAME record found but points to '{cname}', expected '{expectedTarget}'. " + "Please update your DNS record."; } } catch (DnsResponseException dnsEx) when (dnsEx.Code == DnsResponseCode.NotExistentDomain) { _logger.LogWarning(dnsEx, "Domain does not exist: {DomainName}", dto.DomainName); return "Domain does not exist. Please check the domain name and DNS configuration."; } catch (DnsResponseException dnsEx) { _logger.LogWarning(dnsEx, "DNS lookup failed for domain {DomainName}. Code={Code}, Message={Message}", dto.DomainName, dnsEx.Code, dnsEx.Message); return "Domain DNS lookup failed. Please verify your CNAME record points to the platform."; } catch (OperationCanceledException) { _logger.LogWarning("DNS lookup timed out for domain {DomainName}", dto.DomainName); return "DNS lookup timed out. Please try again in a few minutes."; } await _dal.VerifyClientDomain(domainId, loginDTO.UserId, loginDTO).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } catch (Exception ex) { GB5Trace.MarkFailed("verify-client-domain-failed", ex); _logger.LogError(ex, "VerifyClientDomain failed for DomainId {DomainId}", domainId); throw; } } // --------------------------------------------------------------- // Platform-wide settings management (used by an admin settings screen) // --------------------------------------------------------------- public async Task GetPlatformCnameTarget(LoginDTO loginDTO) { try { var value = await _dal.GetPlatformSetting(DEFAULT_CNAME_TARGET_KEY, loginDTO).ConfigureAwait(false); return value ?? string.Empty; } catch (Exception ex) { _logger.LogError(ex, "GetPlatformCnameTarget failed"); throw; } } public async Task SavePlatformCnameTarget(string cnameTarget, LoginDTO loginDTO) { try { if (string.IsNullOrWhiteSpace(cnameTarget)) throw new ValidationException("CNAME target value is required."); cnameTarget = cnameTarget.ToLowerInvariant().Trim().TrimEnd('.'); await _dal.SavePlatformSetting(DEFAULT_CNAME_TARGET_KEY, cnameTarget, loginDTO.UserId, loginDTO) .ConfigureAwait(false); return SuccessResponse.UpdateSuccessMessage; } catch (ValidationException vex) { throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-platform-cname-target-failed", ex); _logger.LogError(ex, "SavePlatformCnameTarget failed for value {CnameTarget}", cnameTarget); throw; } } } }