/* 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 } }