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