using NewLife;
using NewLife.Log;
using NewLife.Threading;
using Stardust.Data.Nodes;
using Stardust.Services;
namespace Stardust.Server.Services;
/// <summary>DDNS核心服务。检测节点IP变化并自动更新DNS记录</summary>
public class DnsService : IDisposable
{
#region 属性
private readonly DnsProviderFactory _factory;
private readonly ITracer _tracer;
private TimerX? _timer;
/// <summary>日志</summary>
public ILog Log { get; set; } = XTrace.Log;
#endregion
#region 构造
/// <summary>实例化DDNS核心服务</summary>
/// <param name="factory"></param>
/// <param name="tracer"></param>
public DnsService(DnsProviderFactory factory, ITracer tracer)
{
_factory = factory;
_tracer = tracer;
// 定时全量刷新,每10分钟检查一次
_timer = new TimerX(DoRefreshAll, null, 60_000, 600_000) { Async = true };
}
/// <summary>释放</summary>
public void Dispose()
{
_timer.TryDispose();
_timer = null;
}
#endregion
#region 方法
/// <summary>检测节点IP变化并触发DNS更新。由NodeService在登录/心跳时调用</summary>
/// <param name="node">节点</param>
/// <param name="ip">当前公网IP</param>
/// <param name="oldIp">旧IP。登录时因Login已更新LastLoginIP,需传入登录前旧值</param>
public async Task CheckNodeIPChange(Node node, String ip, String? oldIp = null)
{
if (node == null || ip.IsNullOrEmpty()) return;
if (node.Domains.IsNullOrEmpty()) return;
// 使用传入的旧IP对比,否则用数据库中的LastLoginIP
oldIp ??= node.LastLoginIP;
// IP未变化,无需更新
if (ip == oldIp) return;
Log.Info("Node [{0}] IP changed: {1} -> {2}; updating DNS", node.Name, oldIp, ip);
await RefreshNodeDomainsAsync(node, ip);
}
/// <summary>刷新指定节点的所有域名</summary>
/// <param name="nodeId">节点ID</param>
public async Task RefreshNodeDomainsAsync(Int32 nodeId)
{
var node = Node.FindByID(nodeId);
if (node == null) return;
await RefreshNodeDomainsAsync(node, null);
}
/// <summary>刷新指定节点的所有域名</summary>
/// <param name="node">节点</param>
/// <param name="ip">IP地址,为空时使用节点最后登录IP</param>
public async Task RefreshNodeDomainsAsync(Node node, String? ip = null)
{
if (node == null || node.Domains.IsNullOrEmpty()) return;
ip ??= node.LastLoginIP;
if (ip.IsNullOrEmpty())
{
Log.Warn("Node [{0}] has no IP address; DNS cannot be updated", node.Name);
return;
}
var domains = node.Domains.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (domains.Length == 0) return;
using var span = _tracer?.NewSpan("DnsRefreshNode", new { node.Name, node.Domains, ip });
foreach (var domain in domains)
{
await RefreshDomainAsync(node, domain, ip);
}
}
/// <summary>刷新单个域名记录</summary>
/// <param name="node">节点</param>
/// <param name="domainName">完整域名</param>
/// <param name="ip">IP地址</param>
public async Task<Boolean> RefreshDomainAsync(Node node, String domainName, String ip)
{
if (node == null || domainName.IsNullOrEmpty() || ip.IsNullOrEmpty()) return false;
using var span = _tracer?.NewSpan("DnsRefreshDomain", new { node.Name, domainName, ip });
// 查找匹配的凭据。根据域名后缀匹配 DomainProvider.Domain
var rootDomain = GetRootDomain(domainName);
var credential = DomainProvider.FindAllWithCache()
.FirstOrDefault(e => e.Enable && !e.Domain.IsNullOrEmpty() && domainName.EndsWithIgnoreCase(e.Domain));
if (credential == null)
{
Log.Warn("No matching domain provider configuration found for domain [{0}] (root domain: {1}); skipping update", domainName, rootDomain);
return false;
}
var provider = _factory.GetProvider(credential.Provider);
if (provider == null)
{
Log.Warn("Domain [{0}] uses an unsupported provider type: {1}", domainName, credential.Provider);
return false;
}
Log.Info("Updating DNS record: {0} -> {1} (provider: {2}, credential: {3})", domainName, ip, credential.Provider, credential.Name);
// 直接传递凭据实体(实现IDnsConfig),供应商内部按需读取配置
var result = await provider.UpdateRecordAsync(credential, domainName, ip);
if (result)
{
Log.Info("DNS updated successfully: {0} -> {1}", domainName, ip);
}
else
{
Log.Error("DNS update failed: {0} -> {1}", domainName, ip);
}
return result;
}
#endregion
#region 定时刷新
private async Task DoRefreshAll(Object? state)
{
using var span = _tracer?.NewSpan("DnsRefreshAll");
// 查找所有有域名配置的已启用节点
var nodes = Node.FindAllWithCache()
.Where(e => e.Enable && !e.Domains.IsNullOrEmpty() && !e.LastLoginIP.IsNullOrEmpty())
.ToList();
Log.Debug("Scheduled DNS refresh: {0} node(s) have domain configuration", nodes.Count);
foreach (var node in nodes)
{
try
{
await RefreshNodeDomainsAsync(node, null);
}
catch (Exception ex)
{
Log.Error("Error refreshing DNS for node [{0}]: {1}", node.Name, ex.Message);
}
}
}
#endregion
#region 辅助
/// <summary>从完整域名中提取根域名</summary>
/// <param name="domainName">完整域名,如 sh05.newlifex.com</param>
/// <returns>根域名,如 newlifex.com</returns>
private static String GetRootDomain(String domainName)
{
if (domainName.IsNullOrEmpty()) return domainName;
var p = domainName.IndexOf('.');
if (p < 0) return domainName;
return domainName[(p + 1)..];
}
#endregion
}
|