diff --git a/Apps/BlockListGroupsApp/App.cs b/Apps/BlockListGroupsApp/App.cs
new file mode 100644
index 00000000..de8fe980
--- /dev/null
+++ b/Apps/BlockListGroupsApp/App.cs
@@ -0,0 +1,626 @@
+/*
+Technitium DNS Server
+Copyright (C) 2021 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 DnsApplicationCommon;
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using TechnitiumLibrary.Net;
+using TechnitiumLibrary.Net.Dns;
+using TechnitiumLibrary.Net.Dns.ResourceRecords;
+
+namespace BlockListGroups
+{
+ public class App : IDnsAuthoritativeRequestHandler
+ {
+ #region variables
+
+ IDnsServer _dnsServer;
+ string _localCacheFolder;
+
+ bool _enableBlocking;
+ bool _blockAsNxDomain;
+ int _blockListUrlUpdateIntervalHours;
+
+ IReadOnlyCollection _aRecords;
+ IReadOnlyCollection _aaaaRecords;
+ DnsSOARecord _soaRecord;
+ DnsNSRecord _nsRecord;
+
+ IReadOnlyDictionary> _blockListUrlGroups;
+ IReadOnlyDictionary _networkGroupMap;
+
+ IReadOnlyDictionary>> _blockListZones;
+
+ Timer _blockListUrlUpdateTimer;
+ DateTime _blockListUrlLastUpdatedOn;
+ const int BLOCK_LIST_UPDATE_TIMER_INITIAL_INTERVAL = 5000;
+ const int BLOCK_LIST_UPDATE_TIMER_INTERVAL = 900000;
+
+ #endregion
+
+ #region IDisposable
+
+ public void Dispose()
+ {
+ if (_blockListUrlUpdateTimer is not null)
+ {
+ _blockListUrlUpdateTimer.Dispose();
+ _blockListUrlUpdateTimer = null;
+ }
+ }
+
+ #endregion
+
+ #region private
+
+ private async void BlockListUrlUpdateTimerCallbackAsync(object state)
+ {
+ try
+ {
+ if (DateTime.UtcNow > _blockListUrlLastUpdatedOn.AddHours(_blockListUrlUpdateIntervalHours))
+ {
+ if (await UpdateBlockListsAsync())
+ {
+ //block lists were updated
+ //save last updated on time
+ _blockListUrlLastUpdatedOn = DateTime.UtcNow;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _dnsServer.WriteLog(ex);
+ }
+ }
+
+ private string GetBlockListFilePath(Uri blockListUrl)
+ {
+ using (HashAlgorithm hash = SHA256.Create())
+ {
+ return Path.Combine(_localCacheFolder, BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(blockListUrl.AbsoluteUri))).Replace("-", "").ToLower());
+ }
+ }
+
+ private async Task UpdateBlockListsAsync()
+ {
+ bool downloaded = false;
+ bool notModified = false;
+
+ async Task DownloadListUrlAsync(Uri listUrl, bool isAllowList)
+ {
+ string listFilePath = GetBlockListFilePath(listUrl);
+ string listDownloadFilePath = listFilePath + ".downloading";
+
+ try
+ {
+ if (File.Exists(listDownloadFilePath))
+ File.Delete(listDownloadFilePath);
+
+ SocketsHttpHandler handler = new SocketsHttpHandler();
+ handler.Proxy = _dnsServer.Proxy;
+ handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
+
+ using (HttpClient http = new HttpClient(handler))
+ {
+ if (File.Exists(listFilePath))
+ http.DefaultRequestHeaders.IfModifiedSince = File.GetLastWriteTimeUtc(listFilePath);
+
+ HttpResponseMessage httpResponse = await http.GetAsync(listUrl);
+ switch (httpResponse.StatusCode)
+ {
+ case HttpStatusCode.OK:
+ {
+ using (FileStream fS = new FileStream(listDownloadFilePath, FileMode.Create, FileAccess.Write))
+ {
+ using (Stream httpStream = await httpResponse.Content.ReadAsStreamAsync())
+ {
+ await httpStream.CopyToAsync(fS);
+ }
+ }
+
+ if (File.Exists(listFilePath))
+ File.Delete(listFilePath);
+
+ File.Move(listDownloadFilePath, listFilePath);
+
+ if (httpResponse.Content.Headers.LastModified != null)
+ File.SetLastWriteTimeUtc(listFilePath, httpResponse.Content.Headers.LastModified.Value.UtcDateTime);
+
+ downloaded = true;
+
+ _dnsServer.WriteLog("Block List Groups app successfully downloaded " + (isAllowList ? "allow" : "block") + " list (" + WebUtilities.GetFormattedSize(new FileInfo(listFilePath).Length) + "): " + listUrl.AbsoluteUri);
+ }
+ break;
+
+ case HttpStatusCode.NotModified:
+ {
+ notModified = true;
+
+ _dnsServer.WriteLog("Block List Groups app successfully checked for a new update of the " + (isAllowList ? "allow" : "block") + " list: " + listUrl.AbsoluteUri);
+ }
+ break;
+
+ default:
+ throw new HttpRequestException((int)httpResponse.StatusCode + " " + httpResponse.ReasonPhrase);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _dnsServer.WriteLog("Block List Groups app failed to download " + (isAllowList ? "allow" : "block") + " list and will use previously downloaded file (if available): " + listUrl.AbsoluteUri + "\r\n" + ex.ToString());
+ }
+ }
+
+ List tasks = new List();
+ IReadOnlyList uniqueBlockListUrls = GetUniqueBlockListUrls();
+
+ foreach (Uri blockListUrl in uniqueBlockListUrls)
+ tasks.Add(DownloadListUrlAsync(blockListUrl, false));
+
+ await Task.WhenAll(tasks);
+
+ if (downloaded)
+ LoadBlockListUrls();
+
+ return downloaded || notModified;
+ }
+
+ private static string PopWord(ref string line)
+ {
+ if (line.Length == 0)
+ return line;
+
+ line = line.TrimStart(' ', '\t');
+
+ int i = line.IndexOfAny(new char[] { ' ', '\t' });
+ string word;
+
+ if (i < 0)
+ {
+ word = line;
+ line = "";
+ }
+ else
+ {
+ word = line.Substring(0, i);
+ line = line.Substring(i + 1);
+ }
+
+ return word;
+ }
+
+ private Queue ReadListFile(Uri listUrl, bool isAllow)
+ {
+ Queue domains = new Queue();
+
+ try
+ {
+ _dnsServer.WriteLog("Block List Groups app is reading " + (isAllow ? "allow" : "block") + " list from: " + listUrl.AbsoluteUri);
+
+ using (FileStream fS = new FileStream(GetBlockListFilePath(listUrl), FileMode.Open, FileAccess.Read))
+ {
+ //parse hosts file and populate block zone
+ StreamReader sR = new StreamReader(fS, true);
+ string line;
+ string firstWord;
+ string secondWord;
+ string hostname;
+
+ while (true)
+ {
+ line = sR.ReadLine();
+ if (line == null)
+ break; //eof
+
+ line = line.TrimStart(' ', '\t');
+
+ if (line.Length == 0)
+ continue; //skip empty line
+
+ if (line.StartsWith("#"))
+ continue; //skip comment line
+
+ firstWord = PopWord(ref line);
+
+ if (line.Length == 0)
+ {
+ hostname = firstWord;
+ }
+ else
+ {
+ secondWord = PopWord(ref line);
+
+ if (secondWord.Length == 0)
+ hostname = firstWord;
+ else
+ hostname = secondWord;
+ }
+
+ hostname = hostname.Trim('.').ToLower();
+
+ switch (hostname)
+ {
+ case "":
+ case "localhost":
+ case "localhost.localdomain":
+ case "local":
+ case "broadcasthost":
+ case "ip6-localhost":
+ case "ip6-loopback":
+ case "ip6-localnet":
+ case "ip6-mcastprefix":
+ case "ip6-allnodes":
+ case "ip6-allrouters":
+ case "ip6-allhosts":
+ continue; //skip these hostnames
+ }
+
+ if (!DnsClient.IsDomainNameValid(hostname))
+ continue;
+
+ if (IPAddress.TryParse(hostname, out _))
+ continue; //skip line when hostname is IP address
+
+ domains.Enqueue(hostname);
+ }
+ }
+
+ _dnsServer.WriteLog("Block List Groups app " + (isAllow ? "allow" : "block") + " list file was read (" + domains.Count + " domains) from: " + listUrl.AbsoluteUri);
+ }
+ catch (Exception ex)
+ {
+ _dnsServer.WriteLog("Block List Groups app failed to read " + (isAllow ? "allow" : "block") + " list from: " + listUrl.AbsoluteUri + "\r\n" + ex.ToString());
+ }
+
+ return domains;
+ }
+
+ private IReadOnlyList GetUniqueBlockListUrls()
+ {
+ List blockListUrls = new List();
+
+ foreach (KeyValuePair> blockListUrlGroup in _blockListUrlGroups)
+ {
+ foreach (Uri blockListUrl in blockListUrlGroup.Value)
+ {
+ if (!blockListUrls.Contains(blockListUrl))
+ blockListUrls.Add(blockListUrl);
+ }
+ }
+
+ return blockListUrls;
+ }
+
+ private void LoadBlockListUrls()
+ {
+ IReadOnlyList uniqueBlockListUrls = GetUniqueBlockListUrls();
+
+ //read all block lists in a queue
+ Dictionary> uniqueBlockListQueues = new Dictionary>(uniqueBlockListUrls.Count);
+
+ foreach (Uri blockListUrl in uniqueBlockListUrls)
+ {
+ if (!uniqueBlockListQueues.ContainsKey(blockListUrl))
+ {
+ Queue blockListQueue = ReadListFile(blockListUrl, false);
+ uniqueBlockListQueues.Add(blockListUrl, blockListQueue);
+ }
+ }
+
+ //load block list zone per group
+ Dictionary>> blockListZones = new Dictionary>>();
+
+ foreach (KeyValuePair> blockListUrlGroup in _blockListUrlGroups)
+ {
+ string group = blockListUrlGroup.Key;
+ IReadOnlyList blockListUrls = blockListUrlGroup.Value;
+
+ //prepare group wise block list queue
+ Dictionary> blockListQueues = new Dictionary>(uniqueBlockListUrls.Count);
+ int totalDomains = 0;
+
+ foreach (Uri blockListUrl in blockListUrls)
+ {
+ if (uniqueBlockListQueues.TryGetValue(blockListUrl, out Queue blockListQueue))
+ {
+ totalDomains += blockListQueue.Count;
+ blockListQueues.Add(blockListUrl, blockListQueue);
+ }
+ }
+
+ //load block list zone
+ Dictionary> blockListZone = new Dictionary>(totalDomains);
+
+ foreach (KeyValuePair> blockListQueue in blockListQueues)
+ {
+ Queue queue = blockListQueue.Value;
+
+ while (queue.Count > 0)
+ {
+ string domain = queue.Dequeue();
+
+ if (!blockListZone.TryGetValue(domain, out List blockLists))
+ {
+ blockLists = new List(2);
+ blockListZone.Add(domain, blockLists);
+ }
+
+ blockLists.Add(blockListQueue.Key);
+ }
+ }
+
+ blockListZones.Add(group, blockListZone);
+ }
+
+ //set new blocked zone
+ _blockListZones = blockListZones;
+
+ _dnsServer.WriteLog("Block List Groups app loaded all block list zones successfully.");
+
+ //force GC collection to remove old zone data from memory quickly
+ GC.Collect();
+ }
+
+ private static string GetParentZone(string domain)
+ {
+ int i = domain.IndexOf('.');
+ if (i > -1)
+ return domain.Substring(i + 1);
+
+ //dont return root zone
+ return null;
+ }
+
+ private IReadOnlyList IsZoneBlocked(string group, string domain, out string blockedDomain)
+ {
+ if (!_blockListZones.TryGetValue(group, out IReadOnlyDictionary> blockListZone))
+ {
+ blockedDomain = null;
+ return null;
+ }
+
+ domain = domain.ToLower();
+
+ do
+ {
+ if (blockListZone.TryGetValue(domain, out List blockLists))
+ {
+ //found zone blocked
+ blockedDomain = domain;
+ return blockLists;
+ }
+
+ domain = GetParentZone(domain);
+ }
+ while (domain is not null);
+
+ blockedDomain = null;
+ return null;
+ }
+
+ #endregion
+
+ #region public
+
+ public Task InitializeAsync(IDnsServer dnsServer, string config)
+ {
+ _dnsServer = dnsServer;
+ _localCacheFolder = Path.Combine(_dnsServer.ApplicationFolder, "blocklists");
+
+ Directory.CreateDirectory(_localCacheFolder);
+
+ dynamic jsonConfig = JsonConvert.DeserializeObject(config);
+
+ _enableBlocking = jsonConfig.enableBlocking.Value;
+ _blockAsNxDomain = jsonConfig.blockAsNxDomain.Value;
+ _blockListUrlUpdateIntervalHours = Convert.ToInt32(jsonConfig.blockListUrlUpdateIntervalHours.Value);
+
+ {
+ List aRecords = new List();
+ List aaaaRecords = new List();
+
+ foreach (dynamic jsonBlockingAddress in jsonConfig.blockingAddresses)
+ {
+ string strAddress = jsonBlockingAddress.Value;
+
+ if (IPAddress.TryParse(strAddress, out IPAddress address))
+ {
+ switch (address.AddressFamily)
+ {
+ case AddressFamily.InterNetwork:
+ aRecords.Add(new DnsARecord(address));
+ break;
+
+ case AddressFamily.InterNetworkV6:
+ aaaaRecords.Add(new DnsAAAARecord(address));
+ break;
+ }
+ }
+ }
+
+ _aRecords = aRecords;
+ _aaaaRecords = aaaaRecords;
+ _soaRecord = new DnsSOARecord(dnsServer.ServerDomain, "hostadmin." + dnsServer.ServerDomain, 1, 14400, 3600, 604800, 60);
+ _nsRecord = new DnsNSRecord(dnsServer.ServerDomain);
+ }
+
+ {
+ Dictionary> blockListUrlGroups = new Dictionary>();
+
+ foreach (dynamic jsonProperty in jsonConfig.blockListUrlGroups)
+ {
+ string group = jsonProperty.Name;
+
+ List blockListUrls = new List();
+
+ foreach (dynamic jsonUrl in jsonProperty.Value)
+ blockListUrls.Add(new Uri(jsonUrl.Value));
+
+ blockListUrlGroups.Add(group, blockListUrls);
+ }
+
+ _blockListUrlGroups = blockListUrlGroups;
+ }
+
+ {
+ Dictionary networkGroupMap = new Dictionary();
+
+ foreach (dynamic jsonProperty in jsonConfig.networkGroupMap)
+ {
+ string network = jsonProperty.Name;
+ string group = jsonProperty.Value;
+
+ if (NetworkAddress.TryParse(network, out NetworkAddress networkAddress))
+ networkGroupMap.Add(networkAddress, group);
+ }
+
+ _networkGroupMap = networkGroupMap;
+ }
+
+ if (_blockListUrlUpdateTimer is null)
+ {
+ _blockListUrlUpdateTimer = new Timer(BlockListUrlUpdateTimerCallbackAsync, null, Timeout.Infinite, Timeout.Infinite);
+ _blockListUrlUpdateTimer.Change(BLOCK_LIST_UPDATE_TIMER_INITIAL_INTERVAL, BLOCK_LIST_UPDATE_TIMER_INTERVAL);
+ }
+
+ Task.Run(delegate ()
+ {
+ LoadBlockListUrls();
+ });
+
+ return Task.CompletedTask;
+ }
+
+ public Task ProcessRequestAsync(DnsDatagram request, IPEndPoint remoteEP, DnsTransportProtocol protocol, bool isRecursionAllowed)
+ {
+ if (!_enableBlocking)
+ return Task.FromResult(null);
+
+ IPAddress remoteIP = remoteEP.Address;
+ string group = null;
+
+ foreach (KeyValuePair entry in _networkGroupMap)
+ {
+ if (entry.Key.Contains(remoteIP))
+ {
+ group = entry.Value;
+ break;
+ }
+ }
+
+ if (group is null)
+ return Task.FromResult(null);
+
+ DnsQuestionRecord question = request.Question[0];
+
+ IReadOnlyList blockLists = IsZoneBlocked(group, question.Name, out string blockedDomain);
+ if (blockLists is null)
+ return Task.FromResult(null);
+
+ if (question.Type == DnsResourceRecordType.TXT)
+ {
+ //return meta data
+ DnsResourceRecord[] answer = new DnsResourceRecord[blockLists.Count];
+
+ for (int i = 0; i < answer.Length; i++)
+ answer[i] = new DnsResourceRecord(question.Name, DnsResourceRecordType.TXT, question.Class, 60, new DnsTXTRecord("blockList=block-list-groups-app; blockListUrl=" + blockLists[i].AbsoluteUri + "; domain=" + blockedDomain));
+
+ return Task.FromResult(new DnsDatagram(request.Identifier, true, DnsOpcode.StandardQuery, false, false, request.RecursionDesired, isRecursionAllowed, false, false, DnsResponseCode.NoError, request.Question, answer) { Tag = DnsServerResponseType.Blocked });
+ }
+ else
+ {
+ DnsResponseCode rcode;
+ IReadOnlyList answer = null;
+ IReadOnlyList authority = null;
+
+ if (_blockAsNxDomain)
+ {
+ rcode = DnsResponseCode.NxDomain;
+
+ string parentDomain = GetParentZone(blockedDomain);
+ if (parentDomain is null)
+ parentDomain = string.Empty;
+
+ authority = new DnsResourceRecord[] { new DnsResourceRecord(parentDomain, DnsResourceRecordType.SOA, question.Class, 60, _soaRecord) };
+ }
+ else
+ {
+ rcode = DnsResponseCode.NoError;
+
+ switch (question.Type)
+ {
+ case DnsResourceRecordType.A:
+ {
+ List rrList = new List(_aRecords.Count);
+
+ foreach (DnsARecord record in _aRecords)
+ rrList.Add(new DnsResourceRecord(question.Name, DnsResourceRecordType.A, question.Class, 60, record));
+
+ answer = rrList;
+ }
+ break;
+
+ case DnsResourceRecordType.AAAA:
+ {
+ List rrList = new List(_aaaaRecords.Count);
+
+ foreach (DnsAAAARecord record in _aaaaRecords)
+ rrList.Add(new DnsResourceRecord(question.Name, DnsResourceRecordType.AAAA, question.Class, 60, record));
+
+ answer = rrList;
+ }
+ break;
+
+ case DnsResourceRecordType.NS:
+ if (question.Name.Equals(blockedDomain, StringComparison.OrdinalIgnoreCase))
+ answer = new DnsResourceRecord[] { new DnsResourceRecord(blockedDomain, DnsResourceRecordType.NS, question.Class, 60, _nsRecord) };
+ else
+ authority = new DnsResourceRecord[] { new DnsResourceRecord(blockedDomain, DnsResourceRecordType.SOA, question.Class, 60, _soaRecord) };
+
+ break;
+
+ default:
+ authority = new DnsResourceRecord[] { new DnsResourceRecord(blockedDomain, DnsResourceRecordType.SOA, question.Class, 60, _soaRecord) };
+ break;
+ }
+ }
+
+ return Task.FromResult(new DnsDatagram(request.Identifier, true, DnsOpcode.StandardQuery, false, false, request.RecursionDesired, isRecursionAllowed, false, false, rcode, request.Question, answer, authority) { Tag = DnsServerResponseType.Blocked });
+ }
+ }
+
+ #endregion
+
+ #region properties
+
+ public string Description
+ { get { return "Blocks domain names using client's IP address or subnet specific block list URLs."; } }
+
+ #endregion
+ }
+}
diff --git a/Apps/BlockListGroupsApp/BlockListGroupsApp.csproj b/Apps/BlockListGroupsApp/BlockListGroupsApp.csproj
new file mode 100644
index 00000000..3d4afc9b
--- /dev/null
+++ b/Apps/BlockListGroupsApp/BlockListGroupsApp.csproj
@@ -0,0 +1,41 @@
+
+
+
+ net5.0
+ false
+ 1.0
+ Technitium
+ Technitium DNS Server
+ Shreyas Zare
+ BlockListGroupsApp
+ BlockListGroups
+ https://technitium.com/dns/
+ https://github.com/TechnitiumSoftware/DnsServer
+ false
+ Library
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
+ ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.Net.dll
+ false
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/Apps/BlockListGroupsApp/dnsApp.config b/Apps/BlockListGroupsApp/dnsApp.config
new file mode 100644
index 00000000..fde9d323
--- /dev/null
+++ b/Apps/BlockListGroupsApp/dnsApp.config
@@ -0,0 +1,24 @@
+{
+ "enableBlocking": true,
+ "blockAsNxDomain": false,
+ "blockListUrlUpdateIntervalHours": 24,
+ "blockingAddresses": [
+ "0.0.0.0",
+ "::"
+ ],
+ "blockListUrlGroups": {
+ "default": [
+ ],
+ "everyone": [
+ "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
+ ],
+ "kids": [
+ "https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/social/hosts",
+ "https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/porn/hosts"
+ ]
+ },
+ "networkGroupMap": {
+ "192.168.1.20": "kids",
+ "192.168.1.0/24": "everyone"
+ }
+}
\ No newline at end of file