From 6cca886d2b67f0d7f6f858fc2c8f7aadc014e851 Mon Sep 17 00:00:00 2001 From: Shreyas Zare Date: Sat, 14 Sep 2024 18:02:35 +0530 Subject: [PATCH] added SecondaryCatalogZone --- .../Dns/Zones/SecondaryCatalogZone.cs | 652 ++++++++++++++++++ 1 file changed, 652 insertions(+) create mode 100644 DnsServerCore/Dns/Zones/SecondaryCatalogZone.cs diff --git a/DnsServerCore/Dns/Zones/SecondaryCatalogZone.cs b/DnsServerCore/Dns/Zones/SecondaryCatalogZone.cs new file mode 100644 index 00000000..e8b2968f --- /dev/null +++ b/DnsServerCore/Dns/Zones/SecondaryCatalogZone.cs @@ -0,0 +1,652 @@ +/* +Technitium DNS Server +Copyright (C) 2024 Shreyas Zare (shreyas@technitium.com) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +*/ + +using DnsServerCore.Dns.ResourceRecords; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using TechnitiumLibrary; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace DnsServerCore.Dns.Zones +{ + class SecondaryCatalogZone : SecondaryForwarderZone + { + #region events + + public event EventHandler ZoneAdded; + public event EventHandler ZoneRemoved; + + #endregion + + #region variables + + readonly static IReadOnlyCollection _allowACL = + [ + new NetworkAccessControl(IPAddress.Any, 0), + new NetworkAccessControl(IPAddress.IPv6Any, 0) + ]; + + readonly static IReadOnlyCollection _queryAccessAllowOnlyPrivateNetworksACL = + [ + new NetworkAccessControl(IPAddress.Parse("127.0.0.0"), 8), + new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8), + new NetworkAccessControl(IPAddress.Parse("100.64.0.0"), 10), + new NetworkAccessControl(IPAddress.Parse("169.254.0.0"), 16), + new NetworkAccessControl(IPAddress.Parse("172.16.0.0"), 12), + new NetworkAccessControl(IPAddress.Parse("192.168.0.0"), 16), + new NetworkAccessControl(IPAddress.Parse("2000::"), 3, true), + new NetworkAccessControl(IPAddress.IPv6Any, 0) + ]; + + readonly static IReadOnlyCollection _allowOnlyZoneNameServersACL = + [ + new NetworkAccessControl(IPAddress.Parse("224.0.0.0"), 32) + ]; + + readonly static IReadOnlyCollection _denyACL = + [ + new NetworkAccessControl(IPAddress.Parse("127.0.0.0"), 8), + new NetworkAccessControl(IPAddress.Parse("::1"), 128) + ]; + + readonly static NetworkAccessControl _allowZoneNameServersAndUseSpecifiedNetworkACL = new NetworkAccessControl(IPAddress.Parse("224.0.0.0"), 32); + + Dictionary _membersIndex = new Dictionary(); + + #endregion + + #region constructor + + public SecondaryCatalogZone(DnsServer dnsServer, AuthZoneInfo zoneInfo) + : base(dnsServer, zoneInfo) + { } + + public SecondaryCatalogZone(DnsServer dnsServer, string name, IReadOnlyList primaryNameServerAddresses, DnsTransportProtocol primaryZoneTransferProtocol = DnsTransportProtocol.Tcp, string primaryZoneTransferTsigKeyName = null) + : base(dnsServer, name, primaryNameServerAddresses, primaryZoneTransferProtocol, primaryZoneTransferTsigKeyName) + { } + + #endregion + + #region protected + + protected override void InitZone() + { + //init secondary catalog zone with dummy SOA and NS records + DnsSOARecordData soa = new DnsSOARecordData("invalid", "invalid", 0, 300, 60, 604800, 900); + DnsResourceRecord soaRecord = new DnsResourceRecord(_name, DnsResourceRecordType.SOA, DnsClass.IN, 0, soa); + soaRecord.GetAuthGenericRecordInfo().LastModified = DateTime.UtcNow; + + _entries[DnsResourceRecordType.SOA] = [soaRecord]; + _entries[DnsResourceRecordType.NS] = [new DnsResourceRecord(_name, DnsResourceRecordType.NS, DnsClass.IN, 0, new DnsNSRecordData("invalid"))]; + } + + #endregion + + #region internal + + internal void BuildMembersIndex() + { + Dictionary membersIndex = new Dictionary(); + + foreach (KeyValuePair memberEntry in EnumerateCatalogMemberZones(_dnsServer)) + membersIndex.TryAdd(memberEntry.Key.ToLowerInvariant(), memberEntry.Value); + + _membersIndex = membersIndex; + } + + #endregion + + #region secondary catalog + + public IReadOnlyCollection GetAllMemberZoneNames() + { + return _membersIndex.Keys; + } + + protected override async Task FinalizeZoneTransferAsync() + { + //secondary catalog does not maintain zone history + await ReProvisionZonesAsync(); + } + + protected override async Task FinalizeIncrementalZoneTransferAsync(IReadOnlyList historyRecords) + { + //secondary catalog does not maintain zone history + await ReProvisionZonesAsync(); + } + + private async Task ReProvisionZonesAsync() + { + string version = GetVersion(); + if ((version is null) || !version.Equals("2", StringComparison.OrdinalIgnoreCase)) + { + _dnsServer.LogManager?.Write("Failed to provision Secondary Catalog zone '" + ToString() + "': catalog version not supported."); + return; + } + + Dictionary updatedMembersIndex = new Dictionary(); + + foreach (KeyValuePair memberEntry in EnumerateCatalogMemberZones(_dnsServer)) + updatedMembersIndex.TryAdd(memberEntry.Key, memberEntry.Value); + + Dictionary membersToRemove = new Dictionary(); + Dictionary membersToAdd = new Dictionary(); + + foreach (KeyValuePair memberEntry in _membersIndex) + { + if (!updatedMembersIndex.TryGetValue(memberEntry.Key, out string updatedMembersZoneDomain)) + { + //member was removed from catalog zone; remove local zone + membersToRemove.Add(memberEntry.Key, null); + } + else if (!memberEntry.Value.Equals(updatedMembersZoneDomain, StringComparison.OrdinalIgnoreCase)) + { + //member exists but label does not match; reprovision zone + membersToRemove.Add(memberEntry.Key, null); + membersToAdd.Add(memberEntry.Key, updatedMembersZoneDomain); + } + } + + foreach (KeyValuePair updatedMemberEntry in updatedMembersIndex) + { + if (_membersIndex.TryGetValue(updatedMemberEntry.Key, out _)) + { + AuthZone authZone = _dnsServer.AuthZoneManager.GetAuthZone(updatedMemberEntry.Key, updatedMemberEntry.Key); + if (authZone is ApexZone) + continue; //zone already exists; do nothing + } + + //member was added to catalog zone; provision zone + membersToAdd.TryAdd(updatedMemberEntry.Key, updatedMemberEntry.Value); + } + + //remove zones + foreach (KeyValuePair removeMember in membersToRemove) + { + AuthZone authZone = _dnsServer.AuthZoneManager.GetAuthZone(removeMember.Key, removeMember.Key); + if ((authZone is ApexZone apexZone) && _name.Equals(apexZone.CatalogZoneName, StringComparison.OrdinalIgnoreCase)) + DeleteMemberZone(apexZone); + } + + //add zones + List> addZoneTasks = new List>(); + + foreach (KeyValuePair addMember in membersToAdd) + { + AuthZone authZone = _dnsServer.AuthZoneManager.GetAuthZone(addMember.Key, addMember.Key); + if (authZone is not ApexZone) + { + //create zone + AuthZoneType zoneType = GetZoneTypeProperty(addMember.Value); + switch (zoneType) + { + case AuthZoneType.Primary: + { + //create secondary zone + IReadOnlyList primaryNameServerAddresses; + DnsTransportProtocol primaryZoneTransferProtocol; + string primaryZoneTransferTsigKeyName; + + IReadOnlyList> primaries = GetPrimariesProperty(addMember.Value); + if (primaries.Count == 0) + primaries = GetPrimariesProperty(_name); + + if (primaries.Count == 0) + { + primaryNameServerAddresses = PrimaryNameServerAddresses; + primaryZoneTransferProtocol = PrimaryZoneTransferProtocol; + primaryZoneTransferTsigKeyName = PrimaryZoneTransferTsigKeyName; + } + else + { + Tuple primary = primaries[0]; + + primaryNameServerAddresses = [new NameServerAddress(primary.Item1, DnsTransportProtocol.Tcp)]; + primaryZoneTransferProtocol = DnsTransportProtocol.Tcp; + primaryZoneTransferTsigKeyName = primary.Item2; + } + + addZoneTasks.Add(_dnsServer.AuthZoneManager.CreateSecondaryZoneAsync(addMember.Key, primaryNameServerAddresses, primaryZoneTransferProtocol, primaryZoneTransferTsigKeyName, false, true)); + } + break; + + case AuthZoneType.Stub: + { + //create stub zone + IReadOnlyList primaryNameServerAddresses = GetPrimaryAddressesProperty(addMember.Value); + + addZoneTasks.Add(_dnsServer.AuthZoneManager.CreateStubZoneAsync(addMember.Key, primaryNameServerAddresses)); + } + break; + + case AuthZoneType.Forwarder: + { + //create secondary forwarder zone + addZoneTasks.Add(Task.FromResult(_dnsServer.AuthZoneManager.CreateSecondaryForwarderZone(addMember.Key, PrimaryNameServerAddresses, PrimaryZoneTransferProtocol, PrimaryZoneTransferTsigKeyName))); + } + break; + } + } + } + + await Task.WhenAll(addZoneTasks); + + //finalize add zone tasks + foreach (Task task in addZoneTasks) + { + try + { + AuthZoneInfo zoneInfo = await task; + + //set as catalog zone member + zoneInfo.ApexZone.CatalogZoneName = _name; + + //raise event + ZoneAdded?.Invoke(this, new SecondaryCatalogEventArgs(zoneInfo)); + + //write log + _dnsServer.LogManager?.Write(zoneInfo.TypeName + " zone '" + zoneInfo.DisplayName + "' was added via Secondary Catalog zone '" + ToString() + "' sucessfully."); + } + catch (Exception ex) + { + _dnsServer.LogManager?.Write(ex); + } + } + + //set properties for all members + foreach (KeyValuePair updatedMemberEntry in updatedMembersIndex) + { + AuthZone authZone = _dnsServer.AuthZoneManager.GetAuthZone(updatedMemberEntry.Key, updatedMemberEntry.Key); + if (authZone is ApexZone apexZone && _name.Equals(apexZone.CatalogZoneName, StringComparison.OrdinalIgnoreCase)) + { + //change of ownership property + { + string newCatalogZoneName = GetChangeOfOwnershipProperty(updatedMemberEntry.Value); + if (newCatalogZoneName is not null) + { + AuthZone catalogAuthZone = _dnsServer.AuthZoneManager.GetAuthZone(newCatalogZoneName, newCatalogZoneName); + if (catalogAuthZone is SecondaryCatalogZone secondaryCatalogZone) + { + //found secondary catalog zone; transfer ownership to it + apexZone.CatalogZoneName = secondaryCatalogZone._name; + } + else + { + //no such secondary catalog zone exists; delete member zone + DeleteMemberZone(apexZone); + continue; + } + } + } + + //allow query property + { + IReadOnlyCollection allowQueryACL = GetAllowQueryProperty(updatedMemberEntry.Value); + if (allowQueryACL.Count == 0) + allowQueryACL = GetAllowQueryProperty(_name); + + apexZone.QueryAccess = GetQueryAccessType(allowQueryACL); + + switch (apexZone.QueryAccess) + { + case AuthZoneQueryAccess.UseSpecifiedNetworkACL: + apexZone.QueryAccessNetworkACL = allowQueryACL; + break; + + case AuthZoneQueryAccess.AllowZoneNameServersAndUseSpecifiedNetworkACL: + apexZone.QueryAccessNetworkACL = GetFilteredACL(allowQueryACL); + break; + + default: + apexZone.QueryAccessNetworkACL = null; + break; + } + } + + if (apexZone is StubZone stubZone) + { + //primary addresses property + IReadOnlyList primaryNameServerAddresses = GetPrimaryAddressesProperty(updatedMemberEntry.Value); + + stubZone.PrimaryNameServerAddresses = primaryNameServerAddresses; + } + else if (apexZone is SecondaryForwarderZone) + { + //do nothing + } + else if (apexZone is SecondaryZone secondaryZone) + { + //primaries property + { + IReadOnlyList> primaries = GetPrimariesProperty(updatedMemberEntry.Value); + if (primaries.Count == 0) + primaries = GetPrimariesProperty(_name); + + if (primaries.Count > 0) + { + Tuple primary = primaries[0]; + + secondaryZone.PrimaryNameServerAddresses = [new NameServerAddress(primary.Item1, DnsTransportProtocol.Tcp)]; + secondaryZone.PrimaryZoneTransferProtocol = DnsTransportProtocol.Tcp; + secondaryZone.PrimaryZoneTransferTsigKeyName = primary.Item2; + secondaryZone.OverrideCatalogPrimaryNameServers = true; + } + else + { + secondaryZone.OverrideCatalogPrimaryNameServers = false; + secondaryZone.PrimaryNameServerAddresses = null; + secondaryZone.PrimaryZoneTransferProtocol = DnsTransportProtocol.Tcp; + secondaryZone.PrimaryZoneTransferTsigKeyName = null; + } + } + + //allow transfer property + { + IReadOnlyCollection allowTransferACL = GetAllowTransferProperty(updatedMemberEntry.Value); + if (allowTransferACL.Count == 0) + allowTransferACL = GetAllowTransferProperty(_name); + + apexZone.ZoneTransfer = GetZoneTransferType(allowTransferACL); + + switch (apexZone.ZoneTransfer) + { + case AuthZoneTransfer.UseSpecifiedNetworkACL: + apexZone.ZoneTransferNetworkACL = allowTransferACL; + break; + + case AuthZoneTransfer.AllowZoneNameServersAndUseSpecifiedNetworkACL: + apexZone.ZoneTransferNetworkACL = GetFilteredACL(allowTransferACL); + break; + + default: + apexZone.ZoneTransferNetworkACL = null; + break; + } + } + + //zone tranfer tsig key names property + { + IReadOnlyDictionary tsigKeyNames = GetZoneTransferTsigKeyNamesProperty(updatedMemberEntry.Value); + if (tsigKeyNames.Count == 0) + tsigKeyNames = GetZoneTransferTsigKeyNamesProperty(_name); + + apexZone.ZoneTransferTsigKeyNames = tsigKeyNames; + } + } + + _dnsServer.AuthZoneManager.SaveZoneFile(apexZone.Name); + } + } + + _membersIndex = updatedMembersIndex; + } + + private void DeleteMemberZone(ApexZone apexZone) + { + AuthZoneInfo zoneInfo = new AuthZoneInfo(apexZone); + + if (_dnsServer.AuthZoneManager.DeleteZone(zoneInfo, true)) + { + ZoneRemoved?.Invoke(this, new SecondaryCatalogEventArgs(zoneInfo)); + + _dnsServer.LogManager?.Write(apexZone.GetZoneTypeName() + " zone '" + apexZone.ToString() + "' was removed via Secondary Catalog zone '" + ToString() + "' sucessfully."); + } + } + + private string GetVersion() + { + string domain = "version." + _name; + + IReadOnlyList records = _dnsServer.AuthZoneManager.GetRecords(_name, domain, DnsResourceRecordType.TXT); + if (records.Count > 0) + return (records[0].RDATA as DnsTXTRecordData).GetText(); + + return null; + + } + + private string GetChangeOfOwnershipProperty(string memberZoneDomain) + { + string domain = "coo." + memberZoneDomain; + + IReadOnlyList records = _dnsServer.AuthZoneManager.GetRecords(_name, domain, DnsResourceRecordType.PTR); + if (records.Count > 0) + return (records[0].RDATA as DnsPTRRecordData).Domain; + + return null; + } + + private AuthZoneType GetZoneTypeProperty(string memberZoneDomain) + { + string domain = "zone-type.ext." + memberZoneDomain; + + IReadOnlyList records = _dnsServer.AuthZoneManager.GetRecords(_name, domain, DnsResourceRecordType.TXT); + if (records.Count > 0) + return Enum.Parse((records[0].RDATA as DnsTXTRecordData).GetText(), true); + + return AuthZoneType.Primary; + } + + private List> GetPrimariesProperty(string memberZoneDomain) + { + string domain = "primaries.ext." + memberZoneDomain; + + List> primaries = new List>(2); + + AuthZone authZone = _dnsServer.AuthZoneManager.GetAuthZone(_name, domain); + if (authZone is not null) + { + foreach (DnsResourceRecord record in authZone.GetRecords(DnsResourceRecordType.A)) + primaries.Add(new Tuple((record.RDATA as DnsARecordData).Address, null)); + + foreach (DnsResourceRecord record in authZone.GetRecords(DnsResourceRecordType.AAAA)) + primaries.Add(new Tuple((record.RDATA as DnsAAAARecordData).Address, null)); + } + + List subdomains = new List(); + _dnsServer.AuthZoneManager.ListSubDomains(domain, subdomains); + + foreach (string subdomain in subdomains) + { + AuthZone subZone = _dnsServer.AuthZoneManager.GetAuthZone(_name, subdomain + "." + domain); + if (subZone is null) + continue; + + string tsigKeyName = null; + IReadOnlyList szTXTRecords = subZone.GetRecords(DnsResourceRecordType.TXT); + if (szTXTRecords.Count > 0) + tsigKeyName = (szTXTRecords[0].RDATA as DnsTXTRecordData).GetText(); + + foreach (DnsResourceRecord record in subZone.GetRecords(DnsResourceRecordType.A)) + primaries.Add(new Tuple((record.RDATA as DnsARecordData).Address, tsigKeyName)); + + foreach (DnsResourceRecord record in subZone.GetRecords(DnsResourceRecordType.AAAA)) + primaries.Add(new Tuple((record.RDATA as DnsAAAARecordData).Address, tsigKeyName)); + } + + return primaries; + } + + private IReadOnlyList GetPrimaryAddressesProperty(string memberZoneDomain) + { + string domain = "primary-addresses.ext." + memberZoneDomain; + + IReadOnlyList records = _dnsServer.AuthZoneManager.GetRecords(_name, domain, DnsResourceRecordType.TXT); + if (records.Count > 0) + return (records[0].RDATA as DnsTXTRecordData).CharacterStrings.Convert(NameServerAddress.Parse); + + return []; + } + + private IReadOnlyCollection GetAllowQueryProperty(string memberZoneDomain) + { + string domain = "allow-query.ext." + memberZoneDomain; + + IReadOnlyList records = _dnsServer.AuthZoneManager.GetRecords(_name, domain, DnsResourceRecordType.APL); + if (records.Count > 0) + return NetworkAccessControl.ConvertFromAPLRecordData(records[0].RDATA as DnsAPLRecordData); + + return []; + } + + private IReadOnlyCollection GetAllowTransferProperty(string memberZoneDomain) + { + string domain = "allow-transfer.ext." + memberZoneDomain; + + IReadOnlyList records = _dnsServer.AuthZoneManager.GetRecords(_name, domain, DnsResourceRecordType.APL); + if (records.Count > 0) + return NetworkAccessControl.ConvertFromAPLRecordData(records[0].RDATA as DnsAPLRecordData); + + return []; + } + + private Dictionary GetZoneTransferTsigKeyNamesProperty(string memberZoneDomain) + { + string domain = "transfer-tsig-key-names.ext." + memberZoneDomain; + + IReadOnlyList records = _dnsServer.AuthZoneManager.GetRecords(_name, domain, DnsResourceRecordType.PTR); + Dictionary keyNames = new Dictionary(records.Count); + + foreach (DnsResourceRecord record in records) + keyNames.TryAdd((record.RDATA as DnsPTRRecordData).Domain.ToLowerInvariant(), null); + + return keyNames; + } + + private static AuthZoneQueryAccess GetQueryAccessType(IReadOnlyCollection acl) + { + if (acl.HasSameItems(_allowACL)) + return AuthZoneQueryAccess.Allow; + + if (acl.HasSameItems(_queryAccessAllowOnlyPrivateNetworksACL)) + return AuthZoneQueryAccess.AllowOnlyPrivateNetworks; + + if (acl.HasSameItems(_allowOnlyZoneNameServersACL)) + return AuthZoneQueryAccess.AllowOnlyZoneNameServers; + + if ((acl.Count > 1) && acl.Contains(_allowZoneNameServersAndUseSpecifiedNetworkACL)) + return AuthZoneQueryAccess.AllowZoneNameServersAndUseSpecifiedNetworkACL; + + if (acl.HasSameItems(_denyACL)) + return AuthZoneQueryAccess.Deny; + + return AuthZoneQueryAccess.UseSpecifiedNetworkACL; + } + + private static AuthZoneTransfer GetZoneTransferType(IReadOnlyCollection acl) + { + if (acl.HasSameItems(_allowACL)) + return AuthZoneTransfer.Allow; + + if (acl.HasSameItems(_allowOnlyZoneNameServersACL)) + return AuthZoneTransfer.AllowOnlyZoneNameServers; + + if ((acl.Count > 1) && acl.Contains(_allowZoneNameServersAndUseSpecifiedNetworkACL)) + return AuthZoneTransfer.AllowZoneNameServersAndUseSpecifiedNetworkACL; + + if (acl.HasSameItems(_denyACL)) + return AuthZoneTransfer.Deny; + + return AuthZoneTransfer.UseSpecifiedNetworkACL; + } + + private static List GetFilteredACL(IReadOnlyCollection acl) + { + List filteredACL = new List(acl.Count); + + foreach (NetworkAccessControl ac in acl) + { + if (ac.Equals(_allowZoneNameServersAndUseSpecifiedNetworkACL)) + continue; + + filteredACL.Add(ac); + } + + return filteredACL; + } + + #endregion + + #region public + + public override string GetZoneTypeName() + { + return "Secondary Catalog"; + } + + public override IReadOnlyList QueryRecords(DnsResourceRecordType type, bool dnssecOk) + { + return []; //secondary catalog zone is not queriable + } + + #endregion + + #region properties + + public override string CatalogZoneName + { + get { return base.CatalogZoneName; } + set { throw new InvalidOperationException(); } + } + + public override AuthZoneQueryAccess QueryAccess + { + get { return base.QueryAccess; } + set { throw new InvalidOperationException(); } + } + + public override AuthZoneUpdate Update + { + get { return base.Update; } + set { throw new InvalidOperationException(); } + } + + #endregion + } + + public class SecondaryCatalogEventArgs : EventArgs + { + #region variables + + readonly AuthZoneInfo _zoneInfo; + + #endregion + + #region constructor + + public SecondaryCatalogEventArgs(AuthZoneInfo zoneInfo) + { + _zoneInfo = zoneInfo; + } + + #endregion + + #region properties + + public AuthZoneInfo ZoneInfo + { get { return _zoneInfo; } } + + #endregion + } +}