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
+ }
+}