diff --git a/Apps/LogExporterApp/App.cs b/Apps/LogExporterApp/App.cs
new file mode 100644
index 00000000..b4d70e46
--- /dev/null
+++ b/Apps/LogExporterApp/App.cs
@@ -0,0 +1,214 @@
+/*
+Technitium DNS Server
+Copyright (C) 2025 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.ApplicationCommon;
+using LogExporter.Strategy;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using TechnitiumLibrary;
+using TechnitiumLibrary.Net.Dns;
+
+namespace LogExporter
+{
+ public sealed class App : IDnsApplication, IDnsQueryLogger
+ {
+ #region variables
+
+ IDnsServer? _dnsServer;
+ BufferManagementConfig? _config;
+
+ readonly ExportManager _exportManager = new ExportManager();
+
+ bool _enableLogging;
+
+ readonly ConcurrentQueue _queuedLogs = new ConcurrentQueue();
+ readonly Timer _queueTimer;
+ const int QUEUE_TIMER_INTERVAL = 10000;
+ const int BULK_INSERT_COUNT = 1000;
+
+ bool _disposed;
+
+ #endregion
+
+ #region constructor
+
+ public App()
+ {
+ _queueTimer = new Timer(HandleExportLogCallback);
+ }
+
+ #endregion
+
+ #region IDisposable
+
+ public void Dispose()
+ {
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+
+ private void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing)
+ {
+ _queueTimer?.Dispose();
+
+ ExportLogsAsync().Sync(); //flush any pending logs
+
+ _exportManager.Dispose();
+ }
+
+ _disposed = true;
+ }
+ }
+
+ #endregion
+
+ #region public
+
+ public Task InitializeAsync(IDnsServer dnsServer, string config)
+ {
+ _dnsServer = dnsServer;
+ _config = BufferManagementConfig.Deserialize(config);
+
+ if (_config is null)
+ throw new DnsClientException("Invalid application configuration.");
+
+ if (_config.FileTarget!.Enabled)
+ {
+ _exportManager.RemoveStrategy(typeof(FileExportStrategy));
+ _exportManager.AddStrategy(new FileExportStrategy(_config.FileTarget!.Path));
+ }
+ else
+ {
+ _exportManager.RemoveStrategy(typeof(FileExportStrategy));
+ }
+
+ if (_config.HttpTarget!.Enabled)
+ {
+ _exportManager.RemoveStrategy(typeof(HttpExportStrategy));
+ _exportManager.AddStrategy(new HttpExportStrategy(_config.HttpTarget.Endpoint, _config.HttpTarget.Headers));
+ }
+ else
+ {
+ _exportManager.RemoveStrategy(typeof(HttpExportStrategy));
+ }
+
+ if (_config.SyslogTarget!.Enabled)
+ {
+ _exportManager.RemoveStrategy(typeof(SyslogExportStrategy));
+ _exportManager.AddStrategy(new SyslogExportStrategy(_config.SyslogTarget.Address, _config.SyslogTarget.Port, _config.SyslogTarget.Protocol));
+ }
+ else
+ {
+ _exportManager.RemoveStrategy(typeof(SyslogExportStrategy));
+ }
+
+ _enableLogging = _exportManager.HasStrategy();
+
+ if (_enableLogging)
+ _queueTimer.Change(QUEUE_TIMER_INTERVAL, Timeout.Infinite);
+ else
+ _queueTimer.Change(Timeout.Infinite, Timeout.Infinite);
+
+ return Task.CompletedTask;
+ }
+
+ public Task InsertLogAsync(DateTime timestamp, DnsDatagram request, IPEndPoint remoteEP, DnsTransportProtocol protocol, DnsDatagram response)
+ {
+ if (_enableLogging)
+ {
+ if (_queuedLogs.Count < _config!.MaxQueueSize)
+ _queuedLogs.Enqueue(new LogEntry(timestamp, remoteEP, protocol, request, response));
+ }
+
+ return Task.CompletedTask;
+ }
+
+ #endregion
+
+ #region private
+
+ private async Task ExportLogsAsync()
+ {
+ try
+ {
+ List logs = new List(BULK_INSERT_COUNT);
+
+ while (true)
+ {
+ while (logs.Count < BULK_INSERT_COUNT && _queuedLogs.TryDequeue(out LogEntry? log))
+ {
+ logs.Add(log);
+ }
+
+ if (logs.Count < 1)
+ break;
+
+ await _exportManager.ImplementStrategyAsync(logs);
+
+ logs.Clear();
+ }
+ }
+ catch (Exception ex)
+ {
+ _dnsServer?.WriteLog(ex);
+ }
+ }
+
+ private async void HandleExportLogCallback(object? state)
+ {
+ try
+ {
+ // Process logs within the timer interval, then let the timer reschedule
+ await ExportLogsAsync();
+ }
+ catch (Exception ex)
+ {
+ _dnsServer?.WriteLog(ex);
+ }
+ finally
+ {
+ try
+ {
+ _queueTimer?.Change(QUEUE_TIMER_INTERVAL, Timeout.Infinite);
+ }
+ catch (ObjectDisposedException)
+ { }
+ }
+ }
+
+ #endregion
+
+ #region properties
+
+ public string Description
+ {
+ get { return "Allows exporting query logs to third party sinks. It supports exporting to File, HTTP endpoint, and Syslog (UDP, TCP, TLS, and Local protocols)."; }
+ }
+
+ #endregion
+ }
+}
diff --git a/Apps/LogExporterApp/BufferManagementConfig.cs b/Apps/LogExporterApp/BufferManagementConfig.cs
new file mode 100644
index 00000000..198a452b
--- /dev/null
+++ b/Apps/LogExporterApp/BufferManagementConfig.cs
@@ -0,0 +1,94 @@
+/*
+Technitium DNS Server
+Copyright (C) 2025 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 System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace LogExporter
+{
+ public class BufferManagementConfig
+ {
+ [JsonPropertyName("maxQueueSize")]
+ public int MaxQueueSize
+ { get; set; }
+
+ [JsonPropertyName("file")]
+ public FileTarget? FileTarget { get; set; }
+
+ [JsonPropertyName("http")]
+ public HttpTarget? HttpTarget { get; set; }
+
+ [JsonPropertyName("syslog")]
+ public SyslogTarget? SyslogTarget { get; set; }
+
+ // Load configuration from JSON
+ public static BufferManagementConfig? Deserialize(string json)
+ {
+ return JsonSerializer.Deserialize(json, DnsConfigSerializerOptions.Default);
+ }
+ }
+
+ public class TargetBase
+ {
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; }
+ }
+
+ public class SyslogTarget : TargetBase
+ {
+ [JsonPropertyName("address")]
+ public string Address { get; set; }
+
+ [JsonPropertyName("port")]
+ public int? Port { get; set; }
+
+ [JsonPropertyName("protocol")]
+ public string? Protocol { get; set; }
+ }
+
+ public class FileTarget : TargetBase
+ {
+ [JsonPropertyName("path")]
+ public string Path { get; set; }
+ }
+
+ public class HttpTarget : TargetBase
+ {
+ [JsonPropertyName("endpoint")]
+ public string Endpoint { get; set; }
+
+ [JsonPropertyName("headers")]
+ public Dictionary? Headers { get; set; }
+ }
+
+ // Setup reusable options with a single instance
+ public static class DnsConfigSerializerOptions
+ {
+ public static readonly JsonSerializerOptions Default = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // Convert properties to camelCase
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // For safe encoding
+ NumberHandling = JsonNumberHandling.Strict,
+ AllowTrailingCommas = true, // Allow trailing commas in JSON
+ DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, // Convert dictionary keys to camelCase
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull // Ignore null values
+ };
+ }
+}
diff --git a/Apps/LogExporterApp/LogEntry.cs b/Apps/LogExporterApp/LogEntry.cs
new file mode 100644
index 00000000..1a2ac5ab
--- /dev/null
+++ b/Apps/LogExporterApp/LogEntry.cs
@@ -0,0 +1,138 @@
+/*
+Technitium DNS Server
+Copyright (C) 2025 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.ApplicationCommon;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using TechnitiumLibrary.Net.Dns;
+using TechnitiumLibrary.Net.Dns.ResourceRecords;
+
+namespace LogExporter
+{
+ public class LogEntry
+ {
+ public DateTime Timestamp { get; private set; }
+ public string ClientIp { get; private set; }
+ public DnsTransportProtocol Protocol { get; private set; }
+ public DnsServerResponseType ResponseType { get; private set; }
+ public double? ResponseRtt { get; private set; }
+ public DnsResponseCode ResponseCode { get; private set; }
+ public DnsQuestion? Question { get; private set; }
+ public List Answers { get; private set; }
+
+ public LogEntry(DateTime timestamp, IPEndPoint remoteEP, DnsTransportProtocol protocol, DnsDatagram request, DnsDatagram response)
+ {
+ // Assign timestamp and ensure it's in UTC
+ Timestamp = timestamp.Kind == DateTimeKind.Utc ? timestamp : timestamp.ToUniversalTime();
+
+ // Extract client information
+ ClientIp = remoteEP.Address.ToString();
+ Protocol = protocol;
+ ResponseType = response.Tag == null ? DnsServerResponseType.Recursive : (DnsServerResponseType)response.Tag;
+
+ if ((ResponseType == DnsServerResponseType.Recursive) && (response.Metadata is not null))
+ ResponseRtt = response.Metadata.RoundTripTime;
+
+ ResponseCode = response.RCODE;
+
+ // Extract request information
+ if (request.Question.Count > 0)
+ {
+ DnsQuestionRecord query = request.Question[0];
+
+ Question = new DnsQuestion
+ {
+ QuestionName = query.Name,
+ QuestionType = query.Type,
+ QuestionClass = query.Class,
+ };
+ }
+
+ // Convert answer section into a simple string summary (comma-separated for multiple answers)
+ Answers = new List(response.Answer.Count);
+ if (response.Answer.Count > 0)
+ {
+ Answers.AddRange(response.Answer.Select(record => new DnsResourceRecord
+ {
+ Name = record.Name,
+ RecordType = record.Type,
+ RecordClass = record.Class,
+ RecordTtl = record.TTL,
+ RecordData = record.RDATA.ToString(),
+ DnssecStatus = record.DnssecStatus,
+ }));
+ }
+ }
+
+ public class DnsQuestion
+ {
+ public string QuestionName { get; set; }
+ public DnsResourceRecordType QuestionType { get; set; }
+ public DnsClass QuestionClass { get; set; }
+ }
+
+ public class DnsResourceRecord
+ {
+ public string Name { get; set; }
+ public DnsResourceRecordType RecordType { get; set; }
+ public DnsClass RecordClass { get; set; }
+ public uint RecordTtl { get; set; }
+ public string RecordData { get; set; }
+ public DnssecStatus DnssecStatus { get; set; }
+ }
+
+ public override string ToString()
+ {
+ return JsonSerializer.Serialize(this, DnsLogSerializerOptions.Default);
+ }
+
+ // Custom DateTime converter to handle UTC serialization in ISO 8601 format
+ public class JsonDateTimeConverter : JsonConverter
+ {
+ public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ string? dts = reader.GetString();
+ return dts == null ? DateTime.MinValue : DateTime.Parse(dts);
+ }
+
+ public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
+ }
+ }
+
+ // Setup reusable options with a single instance
+ public static class DnsLogSerializerOptions
+ {
+ public static readonly JsonSerializerOptions Default = new JsonSerializerOptions
+ {
+ WriteIndented = false, // Newline delimited logs should not be multiline
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // Convert properties to camelCase
+ Converters = { new JsonStringEnumConverter(), new JsonDateTimeConverter() }, // Handle enums and DateTime conversion
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // For safe encoding
+ NumberHandling = JsonNumberHandling.Strict,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull // Ignore null values
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/Apps/LogExporterApp/LogExporterApp.csproj b/Apps/LogExporterApp/LogExporterApp.csproj
new file mode 100644
index 00000000..337b3bb4
--- /dev/null
+++ b/Apps/LogExporterApp/LogExporterApp.csproj
@@ -0,0 +1,53 @@
+
+
+
+ net8.0
+ false
+ true
+ 1.0
+ false
+ Technitium
+ Technitium DNS Server
+ Zafer Balkan
+ LogExporterApp
+ LogExporter
+ https://technitium.com/dns/
+ https://github.com/TechnitiumSoftware/DnsServer
+ Allows exporting query logs to third party sinks. It supports exporting to File, HTTP endpoint, and Syslog (UDP, TCP, TLS, and Local protocols).
+ false
+ Library
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
+ ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.Net.dll
+ false
+
+
+ ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.dll
+ false
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/Apps/LogExporterApp/Strategy/ExportManager.cs b/Apps/LogExporterApp/Strategy/ExportManager.cs
new file mode 100644
index 00000000..50fe2e96
--- /dev/null
+++ b/Apps/LogExporterApp/Strategy/ExportManager.cs
@@ -0,0 +1,82 @@
+/*
+Technitium DNS Server
+Copyright (C) 2025 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 System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LogExporter.Strategy
+{
+ public sealed class ExportManager : IDisposable
+ {
+ #region variables
+
+ readonly ConcurrentDictionary _exportStrategies = new ConcurrentDictionary();
+
+ #endregion
+
+ #region IDisposable
+
+ public void Dispose()
+ {
+ foreach (KeyValuePair exportStrategy in _exportStrategies)
+ exportStrategy.Value.Dispose();
+ }
+
+ #endregion
+
+ #region public
+
+ public void AddStrategy(IExportStrategy strategy)
+ {
+ if (!_exportStrategies.TryAdd(strategy.GetType(), strategy))
+ throw new InvalidOperationException();
+ }
+
+ public void RemoveStrategy(Type type)
+ {
+ if (_exportStrategies.TryRemove(type, out IExportStrategy? existing))
+ existing?.Dispose();
+ }
+
+ public bool HasStrategy()
+ {
+ return !_exportStrategies.IsEmpty;
+ }
+
+ public async Task ImplementStrategyAsync(IReadOnlyList logs)
+ {
+ List tasks = new List(_exportStrategies.Count);
+
+ foreach (KeyValuePair strategy in _exportStrategies)
+ {
+ tasks.Add(Task.Factory.StartNew(delegate (object? state)
+ {
+ return strategy.Value.ExportAsync(logs);
+ }, null, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Current));
+ }
+
+ await Task.WhenAll(tasks);
+ }
+
+ #endregion
+ }
+}
diff --git a/Apps/LogExporterApp/Strategy/FileExportStrategy.cs b/Apps/LogExporterApp/Strategy/FileExportStrategy.cs
new file mode 100644
index 00000000..1749d6a3
--- /dev/null
+++ b/Apps/LogExporterApp/Strategy/FileExportStrategy.cs
@@ -0,0 +1,71 @@
+/*
+Technitium DNS Server
+Copyright (C) 2025 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 Serilog;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace LogExporter.Strategy
+{
+ public sealed class FileExportStrategy : IExportStrategy
+ {
+ #region variables
+
+ readonly Serilog.Core.Logger _sender;
+
+ bool _disposed;
+
+ #endregion
+
+ #region constructor
+
+ public FileExportStrategy(string filePath)
+ {
+ _sender = new LoggerConfiguration().WriteTo.File(filePath, outputTemplate: "{Message:lj}{NewLine}{Exception}").CreateLogger();
+ }
+
+ #endregion
+
+ #region IDisposable
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _sender.Dispose();
+
+ _disposed = true;
+ }
+ }
+
+ #endregion
+
+ #region public
+
+ public Task ExportAsync(IReadOnlyList logs)
+ {
+ foreach (LogEntry logEntry in logs)
+ _sender.Information(logEntry.ToString());
+
+ return Task.CompletedTask;
+ }
+
+ #endregion
+ }
+}
diff --git a/Apps/LogExporterApp/Strategy/HttpExportStrategy.cs b/Apps/LogExporterApp/Strategy/HttpExportStrategy.cs
new file mode 100644
index 00000000..7441b992
--- /dev/null
+++ b/Apps/LogExporterApp/Strategy/HttpExportStrategy.cs
@@ -0,0 +1,119 @@
+/*
+Technitium DNS Server
+Copyright (C) 2025 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 Microsoft.Extensions.Configuration;
+using Serilog;
+using Serilog.Sinks.Http;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LogExporter.Strategy
+{
+ public sealed class HttpExportStrategy : IExportStrategy
+ {
+ #region variables
+
+ readonly Serilog.Core.Logger _sender;
+
+ bool _disposed;
+
+ #endregion
+
+ #region constructor
+
+ public HttpExportStrategy(string endpoint, Dictionary? headers = null)
+ {
+ IConfigurationRoot? configuration = null;
+ if (headers != null)
+ {
+ configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(headers)
+ .Build();
+ }
+
+ _sender = new LoggerConfiguration().WriteTo.Http(endpoint, null, httpClient: new CustomHttpClient(), configuration: configuration).Enrich.FromLogContext().CreateLogger();
+ }
+
+ #endregion
+
+ #region IDisposable
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _sender.Dispose();
+
+ _disposed = true;
+ }
+ }
+
+ #endregion
+
+ #region public
+
+ public Task ExportAsync(IReadOnlyList logs)
+ {
+ foreach (LogEntry logEntry in logs)
+ _sender.Information(logEntry.ToString());
+
+ return Task.CompletedTask;
+ }
+
+ #endregion
+
+ public class CustomHttpClient : IHttpClient
+ {
+ readonly HttpClient _httpClient;
+
+ public CustomHttpClient()
+ {
+ _httpClient = new HttpClient();
+ }
+
+ public void Configure(IConfiguration configuration)
+ {
+ foreach (IConfigurationSection pair in configuration.GetChildren())
+ {
+ _httpClient.DefaultRequestHeaders.Add(pair.Key, pair.Value);
+ }
+ }
+
+ public void Dispose()
+ {
+ _httpClient?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ public async Task PostAsync(string requestUri, Stream contentStream, CancellationToken cancellationToken)
+ {
+ StreamContent content = new StreamContent(contentStream);
+ content.Headers.Add("Content-Type", "application/json");
+
+ return await _httpClient
+ .PostAsync(requestUri, content, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ }
+}
diff --git a/Apps/LogExporterApp/Strategy/IExportStrategy.cs b/Apps/LogExporterApp/Strategy/IExportStrategy.cs
new file mode 100644
index 00000000..d322330e
--- /dev/null
+++ b/Apps/LogExporterApp/Strategy/IExportStrategy.cs
@@ -0,0 +1,33 @@
+/*
+Technitium DNS Server
+Copyright (C) 2025 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 System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace LogExporter.Strategy
+{
+ ///
+ /// Strategy interface to decide the sinks for exporting the logs.
+ ///
+ public interface IExportStrategy: IDisposable
+ {
+ Task ExportAsync(IReadOnlyList logs);
+ }
+}
diff --git a/Apps/LogExporterApp/Strategy/SyslogExportStrategy.cs b/Apps/LogExporterApp/Strategy/SyslogExportStrategy.cs
new file mode 100644
index 00000000..2b8034c5
--- /dev/null
+++ b/Apps/LogExporterApp/Strategy/SyslogExportStrategy.cs
@@ -0,0 +1,171 @@
+/*
+Technitium DNS Server
+Copyright (C) 2025 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 Serilog;
+using Serilog.Events;
+using Serilog.Parsing;
+using Serilog.Sinks.Syslog;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace LogExporter.Strategy
+{
+ public sealed class SyslogExportStrategy : IExportStrategy
+ {
+ #region variables
+
+ const string _appName = "Technitium DNS Server";
+ const string _sdId = "meta";
+ const string DEFAUL_PROTOCOL = "udp";
+ const int DEFAULT_PORT = 514;
+
+ readonly Facility _facility = Facility.Local6;
+
+ readonly Rfc5424Formatter _formatter;
+ readonly Serilog.Core.Logger _sender;
+
+ bool _disposed;
+
+ #endregion
+
+ #region constructor
+
+ public SyslogExportStrategy(string address, int? port, string? protocol)
+ {
+ port ??= DEFAULT_PORT;
+ protocol ??= DEFAUL_PROTOCOL;
+
+ LoggerConfiguration conf = new LoggerConfiguration();
+
+ _sender = protocol.ToLowerInvariant() switch
+ {
+ "tls" => conf.WriteTo.TcpSyslog(address, port.Value, _appName, FramingType.OCTET_COUNTING, SyslogFormat.RFC5424, _facility, useTls: true).Enrich.FromLogContext().CreateLogger(),
+ "tcp" => conf.WriteTo.TcpSyslog(address, port.Value, _appName, FramingType.OCTET_COUNTING, SyslogFormat.RFC5424, _facility, useTls: false).Enrich.FromLogContext().CreateLogger(),
+ "udp" => conf.WriteTo.UdpSyslog(address, port.Value, _appName, SyslogFormat.RFC5424, _facility).Enrich.FromLogContext().CreateLogger(),
+ "local" => conf.WriteTo.LocalSyslog(_appName, _facility).Enrich.FromLogContext().CreateLogger(),
+ _ => throw new NotSupportedException("Syslog protocol is not supported: " + protocol),
+ };
+
+ _formatter = new Rfc5424Formatter(_facility, _appName, null, _sdId, Environment.MachineName);
+ }
+
+ #endregion
+
+ #region IDisposable
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _sender.Dispose();
+
+ _disposed = true;
+ }
+ }
+
+ #endregion
+
+ #region public
+
+ public Task ExportAsync(IReadOnlyList logs)
+ {
+ foreach (LogEntry log in logs)
+ _sender.Information(_formatter.FormatMessage((LogEvent?)Convert(log)));
+
+ return Task.CompletedTask;
+ }
+
+ #endregion
+
+ #region private
+
+ private static LogEvent Convert(LogEntry log)
+ {
+ // Initialize properties with base log details
+ List properties = new List
+ {
+ new LogEventProperty("timestamp", new ScalarValue(log.Timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"))),
+ new LogEventProperty("clientIp", new ScalarValue(log.ClientIp)),
+ new LogEventProperty("protocol", new ScalarValue(log.Protocol.ToString())),
+ new LogEventProperty("responseType", new ScalarValue(log.ResponseType.ToString())),
+ new LogEventProperty("responseRtt", new ScalarValue(log.ResponseRtt?.ToString())),
+ new LogEventProperty("rCode", new ScalarValue(log.ResponseCode.ToString()))
+ };
+
+ // Add each question as properties
+ if (log.Question != null)
+ {
+ LogEntry.DnsQuestion question = log.Question;
+ properties.Add(new LogEventProperty("qName", new ScalarValue(question.QuestionName)));
+ properties.Add(new LogEventProperty("qType", new ScalarValue(question.QuestionType.ToString())));
+ properties.Add(new LogEventProperty("qClass", new ScalarValue(question.QuestionClass.ToString())));
+
+ string questionSummary = $"QNAME: {question.QuestionName}, QTYPE: {question.QuestionType.ToString()}, QCLASS: {question.QuestionClass.ToString()}";
+ properties.Add(new LogEventProperty("questionsSummary", new ScalarValue(questionSummary)));
+ }
+ else
+ {
+ properties.Add(new LogEventProperty("questionsSummary", new ScalarValue(string.Empty)));
+ }
+
+ // Add each answer as properties
+ if (log.Answers.Count > 0)
+ {
+ for (int i = 0; i < log.Answers.Count; i++)
+ {
+ LogEntry.DnsResourceRecord answer = log.Answers[i];
+
+ properties.Add(new LogEventProperty($"aName_{i}", new ScalarValue(answer.Name)));
+ properties.Add(new LogEventProperty($"aType_{i}", new ScalarValue(answer.RecordType.ToString())));
+ properties.Add(new LogEventProperty($"aClass_{i}", new ScalarValue(answer.RecordClass.ToString())));
+ properties.Add(new LogEventProperty($"aTtl_{i}", new ScalarValue(answer.RecordTtl.ToString())));
+ properties.Add(new LogEventProperty($"aRData_{i}", new ScalarValue(answer.RecordData)));
+ properties.Add(new LogEventProperty($"aDnssecStatus_{i}", new ScalarValue(answer.DnssecStatus.ToString())));
+ }
+
+ // Generate answers summary
+ string answerSummary = string.Join(", ", log.Answers.Select(a => a.RecordData));
+ properties.Add(new LogEventProperty("answersSummary", new ScalarValue(answerSummary)));
+ }
+ else
+ {
+ properties.Add(new LogEventProperty("answersSummary", new ScalarValue(string.Empty)));
+ }
+
+ // Define the message template to match the original summary format
+ const string templateText = "{questionsSummary}; RCODE: {rCode}; ANSWER: [{answersSummary}]";
+
+ // Parse the template
+ MessageTemplate template = new MessageTemplateParser().Parse(templateText);
+
+ // Create the LogEvent and return it
+ return new LogEvent(
+ timestamp: log.Timestamp,
+ level: LogEventLevel.Information,
+ exception: null,
+ messageTemplate: template,
+ properties: properties
+ );
+ }
+
+ #endregion
+ }
+}
diff --git a/Apps/LogExporterApp/dnsApp.config b/Apps/LogExporterApp/dnsApp.config
new file mode 100644
index 00000000..ce6c31ea
--- /dev/null
+++ b/Apps/LogExporterApp/dnsApp.config
@@ -0,0 +1,20 @@
+{
+ "maxQueueSize": 1000000,
+ "file": {
+ "path": "./dns_logs.json",
+ "enabled": false
+ },
+ "http": {
+ "endpoint": "http://localhost:5000/logs",
+ "headers": {
+ "Authorization": "Bearer abc123"
+ },
+ "enabled": false
+ },
+ "syslog": {
+ "address": "127.0.0.1",
+ "port": 514,
+ "protocol": "UDP",
+ "enabled": false
+ }
+}
\ No newline at end of file
diff --git a/DnsServer.sln b/DnsServer.sln
index 06764378..05f4513d 100644
--- a/DnsServer.sln
+++ b/DnsServer.sln
@@ -31,6 +31,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DropRequestsApp", "Apps\Dro
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QueryLogsSqliteApp", "Apps\QueryLogsSqliteApp\QueryLogsSqliteApp.csproj", "{186DEF23-863E-4954-BE16-5E5FCA75ECA2}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LogExporterApp", "Apps\LogExporterApp\LogExporterApp.csproj", "{6F9BCCA9-6422-484B-A065-EF8AF9DA74B5}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdvancedBlockingApp", "Apps\AdvancedBlockingApp\AdvancedBlockingApp.csproj", "{A4C31093-CA65-42D4-928A-11907076C0DE}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NxDomainApp", "Apps\NxDomainApp\NxDomainApp.csproj", "{BB0010FC-20E9-4397-BF9B-C9955D9AD339}"
@@ -61,6 +63,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DnsRebindingProtectionApp",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FilterAaaaApp", "Apps\FilterAaaaApp\FilterAaaaApp.csproj", "{0A9B7F39-80DA-4084-AD47-8707576927ED}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3746EF13-91C5-4858-9DC2-D3C2504BD135}"
+ ProjectSection(SolutionItems) = preProject
+ .editorconfig = .editorconfig
+ EndProjectSection
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -119,6 +126,10 @@ Global
{186DEF23-863E-4954-BE16-5E5FCA75ECA2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{186DEF23-863E-4954-BE16-5E5FCA75ECA2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{186DEF23-863E-4954-BE16-5E5FCA75ECA2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6F9BCCA9-6422-484B-A065-EF8AF9DA74B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6F9BCCA9-6422-484B-A065-EF8AF9DA74B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6F9BCCA9-6422-484B-A065-EF8AF9DA74B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6F9BCCA9-6422-484B-A065-EF8AF9DA74B5}.Release|Any CPU.Build.0 = Release|Any CPU
{A4C31093-CA65-42D4-928A-11907076C0DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4C31093-CA65-42D4-928A-11907076C0DE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4C31093-CA65-42D4-928A-11907076C0DE}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -192,6 +203,7 @@ Global
{099D27AF-3AEB-495A-A5D0-46DA59CC9213} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2}
{738079D1-FA5A-40CD-8A27-D831919EE209} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2}
{186DEF23-863E-4954-BE16-5E5FCA75ECA2} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2}
+ {6F9BCCA9-6422-484B-A065-EF8AF9DA74B5} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2}
{A4C31093-CA65-42D4-928A-11907076C0DE} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2}
{BB0010FC-20E9-4397-BF9B-C9955D9AD339} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2}
{45C6F9AD-57D6-4D6D-9498-10B5C828E47E} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2}