Skip to content

Commit 20ae268

Browse files
committed
Updated copyright year, added README and an error handler for update task
Signed-off-by: Zafer Balkan <[email protected]>
1 parent 0076019 commit 20ae268

File tree

3 files changed

+144
-76
lines changed

3 files changed

+144
-76
lines changed

Apps/MispConnectorApp/App.cs

Lines changed: 100 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
Technitium DNS Server
3-
Copyright (C) 2024 Shreyas Zare ([email protected])
3+
Copyright (C) 2025 Shreyas Zare ([email protected])
44
Copyright (C) 2025 Zafer Balkan ([email protected])
55
66
This program is free software: you can redistribute it and/or modify
@@ -45,28 +45,41 @@ public sealed class App : IDnsApplication, IDnsRequestBlockingHandler
4545
{
4646
#region variables
4747

48-
string _cacheFilePath;
48+
string _domainCacheFilePath;
4949
Config _config;
5050
IDnsServer _dnsServer;
51-
private FrozenSet<string> _globalBlocklist = FrozenSet<string>.Empty;
51+
FrozenSet<string> _domainBlocklist = FrozenSet<string>.Empty;
5252
HttpClient _httpClient;
5353

5454
Uri _mispApiUrl;
5555

5656
DnsSOARecordData _soaRecord;
5757
TimeSpan _updateInterval;
58+
Task _updateLoopTask;
5859

5960
CancellationTokenSource _appShutdownCts;
60-
6161
#endregion variables
6262

6363
#region IDisposable
6464

6565
public void Dispose()
6666
{
6767
_appShutdownCts?.Cancel();
68-
_appShutdownCts?.Dispose();
69-
_httpClient?.Dispose();
68+
try
69+
{
70+
if (_updateLoopTask != null)
71+
{
72+
_ = Task.WhenAny(_updateLoopTask, Task.Delay(TimeSpan.FromSeconds(2))).GetAwaiter().GetResult();
73+
}
74+
}
75+
catch
76+
{
77+
}
78+
finally
79+
{
80+
_appShutdownCts?.Dispose();
81+
_httpClient?.Dispose();
82+
}
7083
}
7184

7285
#endregion IDisposable
@@ -78,17 +91,17 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config)
7891
_dnsServer = dnsServer;
7992
try
8093
{
81-
string configDir = _dnsServer.ApplicationFolder;
82-
Directory.CreateDirectory(configDir);
83-
_cacheFilePath = Path.Combine(configDir, "misp_domain_cache.txt");
84-
8594
_soaRecord = new DnsSOARecordData(_dnsServer.ServerDomain, _dnsServer.ResponsiblePerson.Address, 1, 14400, 3600, 604800, 60);
8695

8796
JsonSerializerOptions options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
8897
_config = JsonSerializer.Deserialize<Config>(config, options);
8998

9099
Validator.ValidateObject(_config, new ValidationContext(_config), validateAllProperties: true);
91100

101+
string configDir = _dnsServer.ApplicationFolder;
102+
Directory.CreateDirectory(configDir);
103+
_domainCacheFilePath = Path.Combine(configDir, "misp_domain_cache.txt");
104+
92105
_updateInterval = ParseUpdateInterval(_config.UpdateInterval);
93106

94107
Uri mispServerUrl = new Uri(_config.MispServerUrl);
@@ -98,9 +111,16 @@ public async Task InitializeAsync(IDnsServer dnsServer, string config)
98111
await LoadBlocklistFromCacheAsync();
99112
_appShutdownCts = new CancellationTokenSource();
100113

101-
// 2. Start the new, long-running update loop task.
102114
// We do not await this, as it's designed to run for the lifetime of the app.
103-
_ = StartUpdateLoopAsync(_appShutdownCts.Token);
115+
_updateLoopTask = StartUpdateLoopAsync(_appShutdownCts.Token);
116+
Task _ = _updateLoopTask.ContinueWith(t =>
117+
{
118+
if (t.IsFaulted)
119+
{
120+
_dnsServer.WriteLog($"FATAL: Update loop terminated unexpectedly: {t.Exception?.GetBaseException().Message}");
121+
_dnsServer.WriteLog(t.Exception);
122+
}
123+
}, TaskContinuationOptions.OnlyOnFaulted);
104124
}
105125
catch (Exception ex)
106126
{
@@ -117,24 +137,28 @@ public Task<bool> IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP)
117137
public Task<DnsDatagram> ProcessRequestAsync(DnsDatagram request, IPEndPoint remoteEP)
118138
{
119139
if (_config?.EnableBlocking != true)
140+
{
120141
return Task.FromResult<DnsDatagram>(null);
142+
}
121143

122144
DnsQuestionRecord question = request.Question[0];
123-
if (!IsDomainBlocked(question.Name, out string blockedDomain))
145+
bool domainBlocked = IsDomainBlocked(question.Name, out string blockedDomain);
146+
if (!domainBlocked)
124147
{
125148
return Task.FromResult<DnsDatagram>(null);
126149
}
127150

128151
string blockingReport = $"source=misp-connector;domain={blockedDomain}";
152+
129153
EDnsOption[] options = null;
130154
if (_config.AddExtendedDnsError && request.EDNS is not null)
131155
{
132-
options = new EDnsOption[] { new EDnsOption(EDnsOptionCode.EXTENDED_DNS_ERROR, new EDnsExtendedDnsErrorOptionData(EDnsExtendedDnsErrorCode.Blocked, blockingReport)) };
156+
options = new EDnsOption[] { new EDnsOption(EDnsOptionCode.EXTENDED_DNS_ERROR, new EDnsExtendedDnsErrorOptionData(EDnsExtendedDnsErrorCode.Blocked, string.Empty)) };
133157
}
134158

135159
if (_config.AllowTxtBlockingReport && question.Type == DnsResourceRecordType.TXT)
136160
{
137-
DnsResourceRecord[] answer = new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.TXT, question.Class, 60, new DnsTXTRecordData(blockingReport)) };
161+
DnsResourceRecord[] answer = new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.TXT, question.Class, 60, new DnsTXTRecordData(string.Empty)) };
138162
return Task.FromResult(new DnsDatagram(
139163
ID: request.Identifier,
140164
isResponse: true,
@@ -156,7 +180,7 @@ public Task<DnsDatagram> ProcessRequestAsync(DnsDatagram request, IPEndPoint rem
156180
));
157181
}
158182

159-
DnsResourceRecord[] authority = { new DnsResourceRecord(blockedDomain, DnsResourceRecordType.SOA, question.Class, 60, _soaRecord) };
183+
DnsResourceRecord[] authority = { new DnsResourceRecord(question.Name, DnsResourceRecordType.SOA, question.Class, 60, _soaRecord) };
160184
return Task.FromResult(new DnsDatagram(
161185
ID: request.Identifier,
162186
isResponse: true,
@@ -184,26 +208,27 @@ public Task<DnsDatagram> ProcessRequestAsync(DnsDatagram request, IPEndPoint rem
184208
private async Task StartUpdateLoopAsync(CancellationToken cancellationToken)
185209
{
186210
await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(5, 30)), cancellationToken);
187-
using var timer = new PeriodicTimer(_updateInterval);
188-
189-
while (!cancellationToken.IsCancellationRequested)
211+
using (PeriodicTimer timer = new PeriodicTimer(_updateInterval))
190212
{
191-
try
192-
{
193-
await UpdateIocsAsync(cancellationToken);
194-
}
195-
catch (OperationCanceledException)
213+
while (!cancellationToken.IsCancellationRequested)
196214
{
197-
_dnsServer.WriteLog("Update loop is shutting down gracefully.");
198-
break;
199-
}
200-
catch (Exception ex)
201-
{
202-
_dnsServer.WriteLog($"FATAL: The MispConnector update task failed unexpectedly. Error: {ex.Message}");
203-
_dnsServer.WriteLog(ex);
204-
}
215+
try
216+
{
217+
await UpdateIocsAsync(cancellationToken);
218+
}
219+
catch (OperationCanceledException)
220+
{
221+
_dnsServer.WriteLog("Update loop is shutting down gracefully.");
222+
break;
223+
}
224+
catch (Exception ex)
225+
{
226+
_dnsServer.WriteLog($"FATAL: The MispConnector update task failed unexpectedly. Error: {ex.Message}");
227+
_dnsServer.WriteLog(ex);
228+
}
205229

206-
await timer.WaitForNextTickAsync(cancellationToken);
230+
await timer.WaitForNextTickAsync(cancellationToken);
231+
}
207232
}
208233
}
209234
private static TimeSpan ParseUpdateInterval(string interval)
@@ -247,10 +272,11 @@ private async Task<bool> CheckTcpPortAsync(Uri serverUri, CancellationToken canc
247272

248273
try
249274
{
250-
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, new CancellationTokenSource(timeout).Token);
251-
using TcpClient client = new TcpClient();
252-
253-
await client.ConnectAsync(host, port, cts.Token);
275+
using (CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, new CancellationTokenSource(timeout).Token))
276+
using (TcpClient client = new TcpClient())
277+
{
278+
await client.ConnectAsync(host, port, cts.Token);
279+
}
254280

255281
_dnsServer.WriteLog($"Pre-flight TCP check successful for {host}:{port}.");
256282
return true;
@@ -284,19 +310,16 @@ private HttpClient CreateHttpClient(Uri serverUrl, bool disableTlsValidation)
284310

285311
if (disableTlsValidation)
286312
{
287-
handler.SslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
288-
{
289-
return true;
290-
};
313+
handler.SslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true;
291314
_dnsServer.WriteLog($"WARNING: TLS certificate validation is DISABLED for MISP server: {serverUrl}");
292315
}
293316

294317
return new HttpClient(new HttpClientNetworkHandler(handler, _dnsServer.PreferIPv6 ? HttpClientNetworkType.PreferIPv6 : HttpClientNetworkType.Default, _dnsServer));
295318
}
296319

297-
private async Task<FrozenSet<string>> FetchDomainsFromMispAsync(CancellationToken cancellationToken)
320+
private async Task<HashSet<string>> FetchIocFromMispAsync(CancellationToken cancellationToken)
298321
{
299-
HashSet<string> domains = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
322+
HashSet<string> iocSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
300323
int page = 1;
301324
int limit = _config.PaginationLimit;
302325
bool hasMorePages = true;
@@ -330,18 +353,18 @@ private async Task<FrozenSet<string>> FetchDomainsFromMispAsync(CancellationToke
330353
request.Headers.Add("Accept", "application/json");
331354

332355
_dnsServer.WriteLog($"Fetching page {page}, attempt {attempt}/{maxRetries}...");
333-
using HttpResponseMessage response = await _httpClient.SendAsync(request);
356+
using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken);
334357

335358
if (!response.IsSuccessStatusCode)
336359
{
337360
// This is a definitive failure from the server (e.g., 403, 500).
338361
// We should not retry this. Abort immediately.
339-
string errorBody = await response.Content.ReadAsStringAsync();
362+
string errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
340363
throw new HttpRequestException($"MISP API returned a non-success status code: {(int)response.StatusCode}. Body: {errorBody}", null, response.StatusCode);
341364
}
342365

343-
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
344-
mispResponse = await JsonSerializer.DeserializeAsync<MispResponse>(responseStream);
366+
await using (Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken))
367+
mispResponse = await JsonSerializer.DeserializeAsync<MispResponse>(responseStream, cancellationToken: cancellationToken);
345368

346369
break;
347370
}
@@ -373,10 +396,13 @@ private async Task<FrozenSet<string>> FetchDomainsFromMispAsync(CancellationToke
373396

374397
foreach (MispAttribute attribute in attributes)
375398
{
376-
string domain = attribute.Value?.Trim().ToLowerInvariant();
377-
if (!string.IsNullOrEmpty(domain) && DnsClient.IsDomainNameValid(domain))
399+
string ioc = attribute.Value?.Trim().ToLowerInvariant();
400+
if (!string.IsNullOrEmpty(ioc))
378401
{
379-
domains.Add(domain);
402+
if (DnsClient.IsDomainNameValid(ioc))
403+
{
404+
iocSet.Add(ioc);
405+
}
380406
}
381407
}
382408

@@ -391,13 +417,13 @@ private async Task<FrozenSet<string>> FetchDomainsFromMispAsync(CancellationToke
391417
}
392418
}
393419

394-
_dnsServer.WriteLog($"Finished paginated fetch. Freezing {domains.Count} domains for optimal read performance...");
395-
return domains.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
420+
_dnsServer.WriteLog($"Finished paginated fetch. Freezing {iocSet.Count} IOCs for optimal read performance...");
421+
return iocSet;
396422
}
397423

398424
private bool IsDomainBlocked(string domain, out string foundZone)
399425
{
400-
FrozenSet<string> currentBlocklist = _globalBlocklist;
426+
FrozenSet<string> currentBlocklist = _domainBlocklist;
401427

402428
ReadOnlySpan<char> currentSpan = domain.AsSpan();
403429

@@ -426,24 +452,21 @@ private bool IsDomainBlocked(string domain, out string foundZone)
426452

427453
private async Task LoadBlocklistFromCacheAsync()
428454
{
429-
if (!File.Exists(_cacheFilePath)) return;
430-
try
455+
if (File.Exists(_domainCacheFilePath))
431456
{
432-
FrozenSet<string> domains = (await File.ReadAllLinesAsync(_cacheFilePath)).ToHashSet(StringComparer.OrdinalIgnoreCase).ToFrozenSet(StringComparer.OrdinalIgnoreCase);
433-
ReloadBlocklist(domains);
434-
_dnsServer.WriteLog($"MISP Connector: Loaded {domains.Count} domains from cache.");
435-
}
436-
catch (IOException ex)
437-
{
438-
_dnsServer.WriteLog($"ERROR: Failed to read cache file '{_cacheFilePath}'. Error: {ex.Message}");
457+
try
458+
{
459+
FrozenSet<string> domains = (await File.ReadAllLinesAsync(_domainCacheFilePath)).ToHashSet(StringComparer.OrdinalIgnoreCase).ToFrozenSet(StringComparer.OrdinalIgnoreCase);
460+
Interlocked.Exchange(ref _domainBlocklist, domains);
461+
_dnsServer.WriteLog($"MISP Connector: Loaded {domains.Count} domains from cache.");
462+
}
463+
catch (IOException ex)
464+
{
465+
_dnsServer.WriteLog($"ERROR: Failed to read cache file '{_domainCacheFilePath}'. Error: {ex.Message}");
466+
}
439467
}
440468
}
441469

442-
private void ReloadBlocklist(FrozenSet<string> newBlocklist)
443-
{
444-
Interlocked.Exchange(ref _globalBlocklist, newBlocklist);
445-
}
446-
447470
private async Task UpdateIocsAsync(CancellationToken cancellationToken)
448471
{
449472
if (!await CheckTcpPortAsync(new Uri(_config.MispServerUrl), cancellationToken))
@@ -452,13 +475,15 @@ private async Task UpdateIocsAsync(CancellationToken cancellationToken)
452475
}
453476

454477
_dnsServer.WriteLog("MISP Connector: Starting IOC update...");
455-
FrozenSet<string> domains = await FetchDomainsFromMispAsync(cancellationToken);
478+
479+
HashSet<string> tmpDomains = await FetchIocFromMispAsync(cancellationToken);
456480
cancellationToken.ThrowIfCancellationRequested();
481+
FrozenSet<string> domains = tmpDomains.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
457482

458-
if (!domains.SetEquals(_globalBlocklist))
483+
if (!domains.SetEquals(_domainBlocklist))
459484
{
460-
await WriteDomainsToCacheAsync(domains, cancellationToken);
461-
ReloadBlocklist(domains);
485+
await WriteIocsToCacheAsync(domains, cancellationToken);
486+
Interlocked.Exchange(ref _domainBlocklist, domains);
462487
_dnsServer.WriteLog($"MISP Connector: Successfully updated blocklist with {domains.Count} domains.");
463488
}
464489
else
@@ -467,11 +492,11 @@ private async Task UpdateIocsAsync(CancellationToken cancellationToken)
467492
}
468493
}
469494

470-
private async Task WriteDomainsToCacheAsync(FrozenSet<string> domains, CancellationToken cancellationToken)
495+
private async Task WriteIocsToCacheAsync(FrozenSet<string> iocs, CancellationToken cancellationToken)
471496
{
472-
string tempPath = _cacheFilePath + ".tmp";
473-
await File.WriteAllLinesAsync(tempPath, domains, cancellationToken);
474-
File.Move(tempPath, _cacheFilePath, true);
497+
string tempPath = _domainCacheFilePath + ".tmp";
498+
await File.WriteAllLinesAsync(tempPath, iocs, cancellationToken);
499+
File.Move(tempPath, _domainCacheFilePath, true);
475500
}
476501

477502
#endregion private

Apps/MispConnectorApp/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# MISP Connector for Technitium DNS Server
2+
3+
A plugin that pulls malicious domain names from MISP feeds and enforces blocking in Technitium DNS.
4+
5+
It maintains in-memory blocklists with disk-backed caching and periodically refreshes from the source.
6+
7+
## Features
8+
9+
- Retrieves indicators of compromise (IOCs) aka. malicious domain names from a MISP server via its REST API.
10+
- Handles paginated fetches with exponential backoff and retry on transient failures.
11+
- Stores the latest blocklist in memory for fast lookup and persists it to disk for faster startup.
12+
- Blocks matching DNS requests by returning NXDOMAIN or, for TXT queries when enabled, a human-readable blocking report.
13+
- Optionally includes extended DNS error metadata.
14+
- Configurable refresh interval and age window for which indicators are considered.
15+
- Optional disabling of TLS certificate validation with explicit warning in logs.
16+
17+
## Configuration
18+
19+
Supply a JSON configuration like the following:
20+
21+
```json
22+
{
23+
"enableBlocking": true,
24+
"mispServerUrl": "https://misp.example.com",
25+
"mispApiKey": "YourMispApiKeyHere",
26+
"disableTlsValidation": false,
27+
"updateInterval": "2h",
28+
"maxIocAge": "15d",
29+
"allowTxtBlockingReport": true,
30+
"paginationLimit": 5000,
31+
"addExtendedDnsError": true
32+
}
33+
```
34+
35+
- You can disable the app without uninstalling.
36+
- You can disable TLS validation for test instances and homelabs, but **it is not recommended use this option in production**.
37+
- The `maxIocAge` option is used for filtering IOCs wih `lastSeen` attributes on MISP. So, you can dynamically filter for recent campaigns.
38+
- The `allowTxtBlockingReport` rewrites the response with a blocking report.
39+
- The `addExtendedDnsError` is useful when logs are exported to a SIEM. The blocking report gets added to EDNS payload of the package.
40+
41+
# Acknowledgement
42+
43+
Thanks to everyone who has been part of or contributed to [MISP Project](https://www.misp-project.org/) for being an amazing resource.

0 commit comments

Comments
 (0)