diff --git a/DnsServerCore/Dns/Zones/CatalogZone.cs b/DnsServerCore/Dns/Zones/CatalogZone.cs new file mode 100644 index 00000000..269e0d6e --- /dev/null +++ b/DnsServerCore/Dns/Zones/CatalogZone.cs @@ -0,0 +1,421 @@ +/* +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.Security.Cryptography; +using System.Threading; +using TechnitiumLibrary; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace DnsServerCore.Dns.Zones +{ + class CatalogZone : ForwarderZone + { + #region variables + + readonly Dictionary _membersIndex = new Dictionary(); + readonly ReaderWriterLockSlim _membersIndexLock = new ReaderWriterLockSlim(); + + #endregion + + #region constructor + + public CatalogZone(DnsServer dnsServer, AuthZoneInfo zoneInfo) + : base(dnsServer, zoneInfo) + { } + + public CatalogZone(DnsServer dnsServer, string name) + : base(dnsServer, name) + { } + + #endregion + + #region IDisposable + + protected override void Dispose(bool disposing) + { + try + { + _membersIndexLock.Dispose(); + } + finally + { + base.Dispose(disposing); + } + } + + #endregion + + #region internal + + internal override void InitZone() + { + //init catalog zone with dummy SOA and NS records + DnsSOARecordData soa = new DnsSOARecordData("invalid", "invalid", 1, 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"))]; + } + + internal void InitZoneProperties() + { + //set catalog zone version record + _dnsServer.AuthZoneManager.SetRecord(_name, new DnsResourceRecord("version." + _name, DnsResourceRecordType.TXT, DnsClass.IN, 0, new DnsTXTRecordData("2"))); + + //init catalog global properties + QueryAccess = AuthZoneQueryAccess.Allow; + ZoneTransfer = AuthZoneTransfer.Deny; + } + + internal void BuildMembersIndex() + { + foreach (KeyValuePair memberEntry in EnumerateCatalogMemberZones(_dnsServer)) + _membersIndex.TryAdd(memberEntry.Key.ToLowerInvariant(), memberEntry.Value); + } + + #endregion + + #region catalog + + public void AddMemberZone(string memberZoneName, AuthZoneType zoneType) + { + memberZoneName = memberZoneName.ToLowerInvariant(); + + _membersIndexLock.EnterWriteLock(); + try + { + if (_membersIndex.TryGetValue(memberZoneName, out _)) + { + if (_membersIndex.Remove(memberZoneName, out string removedMemberZoneDomain)) + { + foreach (DnsResourceRecord record in _dnsServer.AuthZoneManager.EnumerateAllRecords(_name, removedMemberZoneDomain, true)) + _dnsServer.AuthZoneManager.DeleteRecord(_name, record); + } + } + + string memberZoneDomain = GetDomainWithLabel("zones." + _name); + DateTime utcNow = DateTime.UtcNow; + + DnsResourceRecord ptrRecord = new DnsResourceRecord(memberZoneDomain, DnsResourceRecordType.PTR, DnsClass.IN, 0, new DnsPTRRecordData(memberZoneName)); + ptrRecord.GetAuthGenericRecordInfo().LastModified = utcNow; + + DnsResourceRecord txtRecord = new DnsResourceRecord("zone-type.ext." + memberZoneDomain, DnsResourceRecordType.TXT, DnsClass.IN, 0, new DnsTXTRecordData(zoneType.ToString().ToLowerInvariant())); + txtRecord.GetAuthGenericRecordInfo().LastModified = utcNow; + + _dnsServer.AuthZoneManager.AddRecord(_name, ptrRecord); + _dnsServer.AuthZoneManager.AddRecord(_name, txtRecord); + + _membersIndex[memberZoneName] = memberZoneDomain; + } + finally + { + _membersIndexLock.ExitWriteLock(); + } + } + + public bool RemoveMemberZone(string memberZoneName) + { + memberZoneName = memberZoneName.ToLowerInvariant(); + + _membersIndexLock.EnterWriteLock(); + try + { + if (_membersIndex.Remove(memberZoneName, out string removedMemberZoneDomain)) + { + foreach (DnsResourceRecord record in _dnsServer.AuthZoneManager.EnumerateAllRecords(_name, removedMemberZoneDomain, true)) + _dnsServer.AuthZoneManager.DeleteRecord(_name, record); + + return true; + } + + return false; + } + finally + { + _membersIndexLock.ExitWriteLock(); + } + } + + public void ChangeMemberZoneOwnership(string memberZoneName, string newCatalogZoneName) + { + string memberZoneDomain = GetMemberZoneDomain(memberZoneName); + string domain = "coo." + memberZoneDomain; + + DateTime utcNow = DateTime.UtcNow; + uint soaExpiry = GetZoneSoaExpire(); + + //add COO record with expiry + DnsResourceRecord cooRecord = new DnsResourceRecord(domain, DnsResourceRecordType.PTR, DnsClass.IN, 0, new DnsPTRRecordData(newCatalogZoneName)); + GenericRecordInfo cooRecordInfo = cooRecord.GetAuthGenericRecordInfo(); + cooRecordInfo.LastModified = utcNow; + cooRecordInfo.ExpiryTtl = soaExpiry; + + _dnsServer.AuthZoneManager.SetRecord(_name, cooRecord); + + //set expiry for other member zone records + foreach (DnsResourceRecord record in _dnsServer.AuthZoneManager.EnumerateAllRecords(_name, memberZoneDomain, true)) + { + GenericRecordInfo recordInfo = record.GetAuthGenericRecordInfo(); + recordInfo.LastModified = utcNow; + recordInfo.ExpiryTtl = soaExpiry; + } + } + + public IReadOnlyCollection GetAllMemberZoneNames() + { + _membersIndexLock.EnterReadLock(); + try + { + return _membersIndex.Keys.ToArray(); + } + finally + { + _membersIndexLock.ExitReadLock(); + } + } + + public void SetAllowQueryProperty(IReadOnlyCollection acl = null, string memberZoneName = null) + { + string domain = "allow-query.ext." + GetMemberZoneDomain(memberZoneName); + + if (acl is null) + { + _dnsServer.AuthZoneManager.DeleteRecords(_name, domain, DnsResourceRecordType.APL); + } + else + { + DnsResourceRecord record = new DnsResourceRecord(domain, DnsResourceRecordType.APL, DnsClass.IN, 0, NetworkAccessControl.ConvertToAPLRecordData(acl)); + record.GetAuthGenericRecordInfo().LastModified = DateTime.UtcNow; + + _dnsServer.AuthZoneManager.SetRecord(_name, record); + } + } + + public void SetAllowTransferProperty(IReadOnlyCollection acl = null, string memberZoneName = null) + { + string domain = "allow-transfer.ext." + GetMemberZoneDomain(memberZoneName); + + if (acl is null) + { + _dnsServer.AuthZoneManager.DeleteRecords(_name, domain, DnsResourceRecordType.APL); + } + else + { + DnsResourceRecord record = new DnsResourceRecord(domain, DnsResourceRecordType.APL, DnsClass.IN, 0, NetworkAccessControl.ConvertToAPLRecordData(acl)); + record.GetAuthGenericRecordInfo().LastModified = DateTime.UtcNow; + + _dnsServer.AuthZoneManager.SetRecord(_name, record); + } + } + + public void SetZoneTransferTsigKeyNamesProperty(IReadOnlyDictionary tsigKeyNames = null, string memberZoneName = null) + { + string domain = "transfer-tsig-key-names.ext." + GetMemberZoneDomain(memberZoneName); + + if (tsigKeyNames is null) + { + _dnsServer.AuthZoneManager.DeleteRecords(_name, domain, DnsResourceRecordType.PTR); + } + else + { + DnsResourceRecord[] records = new DnsResourceRecord[tsigKeyNames.Count]; + int i = 0; + + foreach (KeyValuePair entry in tsigKeyNames) + { + DnsResourceRecord record = new DnsResourceRecord(domain, DnsResourceRecordType.PTR, DnsClass.IN, 0, new DnsPTRRecordData(entry.Key)); + record.GetAuthGenericRecordInfo().LastModified = DateTime.UtcNow; + + records[i++] = record; + } + + _dnsServer.AuthZoneManager.SetRecords(_name, records); + } + } + + public void SetPrimaryAddressesProperty(IReadOnlyList primaryServerAddresses = null, string memberZoneName = null) + { + string domain = "primary-addresses.ext." + GetMemberZoneDomain(memberZoneName); + + if (primaryServerAddresses is null) + { + _dnsServer.AuthZoneManager.DeleteRecords(_name, domain, DnsResourceRecordType.TXT); + } + else + { + IReadOnlyList charStrings = primaryServerAddresses.Convert(delegate (NameServerAddress nameServer) + { + return nameServer.ToString(); + }); + + DnsResourceRecord record = new DnsResourceRecord(domain, DnsResourceRecordType.TXT, DnsClass.IN, 0, new DnsTXTRecordData(charStrings)); + record.GetAuthGenericRecordInfo().LastModified = DateTime.UtcNow; + + _dnsServer.AuthZoneManager.SetRecord(_name, record); + } + } + + private string GetMemberZoneDomain(string memberZoneName = null) + { + if (memberZoneName is null) + { + return _name; + } + else + { + memberZoneName = memberZoneName.ToLowerInvariant(); + + _membersIndexLock.EnterReadLock(); + try + { + if (!_membersIndex.TryGetValue(memberZoneName, out string memberZoneDomain)) + throw new DnsServerException("Failed to find '" + memberZoneName + "' member zone entry in '" + ToString() + "' Catalog zone: member zone does not exists."); + + return memberZoneDomain; + } + finally + { + _membersIndexLock.ExitReadLock(); + } + } + } + + private string GetDomainWithLabel(string domain) + { + Span buffer = stackalloc byte[8]; + int i = 0; + + do + { + RandomNumberGenerator.Fill(buffer); + string label = Base32.ToBase32HexString(buffer, true).ToLowerInvariant(); + string domainWithLabel = label + "." + domain; + + if (_dnsServer.AuthZoneManager.NameExists(_name, domainWithLabel)) + continue; + + return domainWithLabel; + } + while (++i < 10); + + throw new DnsServerException("Failed to generate unique label for the given domain name '" + domain + "'. Please try again."); + } + + #endregion + + #region public + + public override string GetZoneTypeName() + { + return "Catalog"; + } + + public override void SetRecords(DnsResourceRecordType type, IReadOnlyList records) + { + switch (type) + { + case DnsResourceRecordType.SOA: + if ((records.Count != 1) || !records[0].Name.Equals(_name, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Invalid SOA record."); + + DnsResourceRecord newSoaRecord = records[0]; + DnsSOARecordData newSoa = newSoaRecord.RDATA as DnsSOARecordData; + + //reset fixed record values + DnsSOARecordData modifiedSoa = new DnsSOARecordData("invalid", "invalid", newSoa.Serial, newSoa.Refresh, newSoa.Retry, newSoa.Expire, newSoa.Minimum); + DnsResourceRecord modifiedSoaRecord = new DnsResourceRecord(_name, DnsResourceRecordType.SOA, DnsClass.IN, 0, modifiedSoa) { Tag = newSoaRecord.Tag }; + + base.SetRecords(type, [modifiedSoaRecord]); + break; + + default: + throw new InvalidOperationException("Cannot set records in Catalog zone."); + } + + } + + public override void AddRecord(DnsResourceRecord record) + { + throw new InvalidOperationException("Cannot add record in Catalog zone."); + } + + public override bool DeleteRecords(DnsResourceRecordType type) + { + throw new InvalidOperationException("Cannot delete record in Catalog zone."); + } + + public override bool DeleteRecord(DnsResourceRecordType type, DnsResourceRecordData record) + { + throw new InvalidOperationException("Cannot delete records in Catalog zone."); + } + + public override void UpdateRecord(DnsResourceRecord oldRecord, DnsResourceRecord newRecord) + { + throw new InvalidOperationException("Cannot update record in Catalog zone."); + } + + public override IReadOnlyList QueryRecords(DnsResourceRecordType type, bool dnssecOk) + { + return []; //catalog zone is not queriable + } + + #endregion + + #region properties + + public override string CatalogZoneName + { + get { return base.CatalogZoneName; } + set { throw new InvalidOperationException(); } + } + + public override bool OverrideCatalogQueryAccess + { + get { return base.OverrideCatalogQueryAccess; } + set { throw new InvalidOperationException(); } + } + + public override bool OverrideCatalogZoneTransfer + { + get { return base.OverrideCatalogZoneTransfer; } + set { throw new InvalidOperationException(); } + } + + public override bool OverrideCatalogNotify + { + get { return base.OverrideCatalogNotify; } + set { throw new InvalidOperationException(); } + } + + public override AuthZoneUpdate Update + { + get { return base.Update; } + set { throw new InvalidOperationException(); } + } + + #endregion + } +}