Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
59db1c4
Fixed typo
zbalkan Nov 26, 2025
5439bb1
Fixed space
zbalkan Nov 26, 2025
ddad746
Added README
zbalkan Nov 26, 2025
c1754e9
Updated version
zbalkan Nov 26, 2025
8bdab01
Added responding nameserver IP to logs for cluster setups
zbalkan Nov 28, 2025
7a25db9
Added domain name enrichment
zbalkan Nov 28, 2025
98c3b4a
Replaced ConcurrentQueue with Channels
zbalkan Nov 28, 2025
2980cde
Used RecyclableMemoryStreamManager for memory management in strategies
zbalkan Nov 28, 2025
0135361
Updated README
zbalkan Nov 28, 2025
3e1d3bd
Minor refactor on SyslogExporter
zbalkan Nov 28, 2025
419d542
Added ConsoleExportStrategy for container loads and debugging
zbalkan Nov 28, 2025
ef87c9b
Updated README
zbalkan Nov 28, 2025
dbab7f6
Ensure graceful shutdown: disable logging, complete channel, and drai…
zbalkan Nov 30, 2025
6469ef9
Cleaned up packages
zbalkan Nov 30, 2025
25d511e
Fix: remove per-batch cloning and improve export safety
zbalkan Nov 30, 2025
b3c9b6a
Fix ExportManager.ImplementStrategyAsync concurrency bug
zbalkan Nov 30, 2025
f53f897
Harden EDNS Extended Error parsing (prevent remote-triggered exceptions)
zbalkan Nov 30, 2025
43a5991
Make domain parsing (PSL) fully fail-safe
zbalkan Nov 30, 2025
ffe1644
Make _enableLogging assignment race-free by moving it to the end of i…
zbalkan Nov 30, 2025
96cbbac
Ensure each export strategy becomes a no-op after disposal
zbalkan Nov 30, 2025
49358e3
Added configuration validation
zbalkan Nov 30, 2025
9318522
Stop swallowing exceptions in App.Dispose and log them
zbalkan Nov 30, 2025
ebad5f5
Fix the PSL (DomainParser) initialization so plugin startup never blo…
zbalkan Nov 30, 2025
f641750
Introduce a shared NDJSON serialization helper
zbalkan Nov 30, 2025
77d5da4
Introduce cancellation into the export pipeline
zbalkan Nov 30, 2025
587dbff
Passed CancellationTokens
zbalkan Nov 30, 2025
259e710
Simplified _parser
zbalkan Nov 30, 2025
01ca2d1
Added configure await for token
zbalkan Nov 30, 2025
1193ee0
Replace Task.Run with proper async loop + cancellation
zbalkan Nov 30, 2025
b920e53
Add ADR to Dispose explaining why we don’t dispose _stdout
zbalkan Nov 30, 2025
7fe820b
Fixed "ExportManager.Dispose() does not clear the dictionary nor set …
zbalkan Nov 30, 2025
35c9b2c
Improve batch flushing logic in App.BackgroundWorkerAsync / DrainRema…
zbalkan Nov 30, 2025
20b4a29
Batch reallocation in BackgroundWorkerAsync
zbalkan Nov 30, 2025
2ac557b
Refactor LogEntry to use SIEVE-based DomainCache for domain parsing
zbalkan Dec 4, 2025
cbf1979
Removed additional comma at the end of NDJSON line
zbalkan Dec 5, 2025
261ef81
Update Apps/LogExporterApp/LogEntry.cs
zbalkan Dec 8, 2025
6431cd0
Update Apps/LogExporterApp/App.cs
zbalkan Dec 8, 2025
7f520f2
Minor improvements
zbalkan Dec 8, 2025
c5382b1
Merge branch 'log-exporter-2' of https://github.com/zbalkan/DnsServer…
zbalkan Dec 8, 2025
cd75a91
Minor changes
zbalkan Dec 8, 2025
8b7bcab
Major rename
zbalkan Dec 8, 2025
d962238
Minor renaming
zbalkan Dec 8, 2025
9a96a91
Error handling
zbalkan Dec 8, 2025
ca25751
Fix draining logic so shutdown does not discard queued logs
zbalkan Dec 8, 2025
152bba8
Fixed async issues
zbalkan Dec 8, 2025
051f2aa
Simplifie DrainRemainingLogs
zbalkan Dec 8, 2025
24b3968
Added enrichment pipeline
zbalkan Dec 8, 2025
bc9fd89
Updated README
zbalkan Dec 8, 2025
37c1016
Incremented version
zbalkan Dec 8, 2025
4a51fad
Major refactor
zbalkan Dec 27, 2025
4599d90
Added tagging feature
zbalkan Dec 27, 2025
e113420
Minor optimizations on cache
zbalkan Jan 2, 2026
34343ea
Typo
zbalkan Jan 6, 2026
6c52654
Added ndjson flag
zbalkan Mar 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
386 changes: 291 additions & 95 deletions Apps/LogExporterApp/App.cs

Large diffs are not rendered by default.

154 changes: 119 additions & 35 deletions Apps/LogExporterApp/AppConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,69 +19,153 @@ You should have received a copy of the GNU General Public License
*/

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.Text.Json.Serialization;
using TechnitiumLibrary.Net.Dns;
using static LogExporter.SinkConfig;

namespace LogExporter
{
public class AppConfig
{
[JsonPropertyName("maxQueueSize")]
public int MaxQueueSize { get; set; }
[JsonPropertyName("sinks")]
public SinkConfig Sinks { get; set; }

[JsonPropertyName("pipeline")]
public PipelineConfig Pipeline { get; set; }

/// <summary>
/// Loads config and enforces DataAnnotations validation.
///<para>
/// ADR: Validation is intentionally centralized here so that:
/// - App receives only a fully valid configuration.
/// - Errors surface early with domain-specific messages.
/// - No runtime failures occur deep inside the logging pipeline.
/// This ensures plugin initialization is deterministic and safe.
/// </para>
/// </summary>
public static AppConfig Deserialize(string json)
{
AppConfig config = JsonSerializer.Deserialize<AppConfig>(json, DnsConfigSerializerOptions.Default)
?? throw new DnsClientException("Configuration could not be deserialized.");

[JsonPropertyName("enableEdnsLogging ")]
public bool EnableEdnsLogging { get; set; }
ValidateObject(config);

[JsonPropertyName("file")]
public FileTarget? FileTarget { get; set; }
// Validate enabled targets only — disabled ones may be incomplete by design.

[JsonPropertyName("http")]
public HttpTarget? HttpTarget { get; set; }
if (config.Sinks.FileSinkConfig?.Enabled is true)
ValidateObject(config.Sinks.FileSinkConfig);

[JsonPropertyName("syslog")]
public SyslogTarget? SyslogTarget { get; set; }
if (config.Sinks.HttpSinkConfig?.Enabled is true)
ValidateObject(config.Sinks.HttpSinkConfig);

if (config.Sinks.SyslogSinkConfig?.Enabled is true)
ValidateObject(config.Sinks.SyslogSinkConfig);

// Load configuration from JSON
public static AppConfig? Deserialize(string json)
return config;
}

private static void ValidateObject(object instance)
{
return JsonSerializer.Deserialize<AppConfig>(json, DnsConfigSerializerOptions.Default);
ValidationContext ctx = new ValidationContext(instance);
Validator.ValidateObject(instance, ctx, validateAllProperties: true);
}
}

public class TargetBase
public class FeatureBase
{
[JsonPropertyName("enabled")]
public bool Enabled { get; set; }
public bool Enabled { get; set; } = true;
}

public class SyslogTarget : TargetBase
public class SinkConfig
{
[JsonPropertyName("address")]
public required string Address { get; set; }
[Range(1, int.MaxValue, ErrorMessage = "maxQueueSize must be greater than zero.")]

[JsonPropertyName("port")]
public int? Port { get; set; }
[JsonPropertyName("maxQueueSize")]
public int MaxQueueSize { get; set; } = int.MaxValue;

[JsonPropertyName("protocol")]
public string? Protocol { get; set; }
}
[JsonPropertyName("enableEdnsLogging")]
public bool EnableEdnsLogging { get; set; } = true;

public class FileTarget : TargetBase
{
[JsonPropertyName("path")]
public required string Path { get; set; }
[JsonPropertyName("console")]
public ConsoleSink ConsoleSinkConfig { get; set; }

[JsonPropertyName("file")]
public FileSink FileSinkConfig { get; set; }

[JsonPropertyName("http")]
public HttpSink HttpSinkConfig { get; set; }

[JsonPropertyName("syslog")]
public SyslogSink SyslogSinkConfig { get; set; }

public class SyslogSink : FeatureBase
{
[Required(ErrorMessage = "syslog.address is required when syslog logging is enabled.")]
[JsonPropertyName("address")]
public string Address { get; set; }

[Range(1, 65535)]
[JsonPropertyName("port")]
public int? Port { get; set; }

[AllowedValues(["UDP", "TCP", "TLS", "LOCAL"])]
[JsonPropertyName("protocol")]
public string Protocol { get; set; }
}

public class ConsoleSink : FeatureBase
{
}

public class FileSink : FeatureBase
{
[Required(ErrorMessage = "file.path is required when syslog logging is enabled.")]
[JsonPropertyName("path")]
public string Path { get; set; }
}

public class HttpSink : FeatureBase
{

[Required(ErrorMessage = "http.endpoint is required when HTTP logging is enabled.")]
[Url]
[JsonPropertyName("endpoint")]
public string Endpoint { get; set; }

[JsonPropertyName("headers")]
public Dictionary<string, string?>? Headers { get; set; }

[JsonPropertyName("ndjson")]
public bool NdJson { get; set; } = true;
}
}

public class HttpTarget : TargetBase
public class PipelineConfig
{
[JsonPropertyName("endpoint")]
public required string Endpoint { get; set; }
[JsonPropertyName("normalize")]
public NormalizeProcess NormalizeProcessConfig { get; set; }

[JsonPropertyName("headers")]
public Dictionary<string, string?>? Headers { get; set; }
}
[JsonPropertyName("tagging")]
public TaggingProcess TaggingProcessConfig { get; set; }

// Setup reusable options with a single instance
public class NormalizeProcess : FeatureBase
{}

public class TaggingProcess : FeatureBase
{
[Required(ErrorMessage = "tags are required when tagging is enabled.")]
[JsonPropertyName("tags")]
public List<string> Tags { get; set; } = new List<string>();
}

}
/// <summary>
/// Shared serializer configuration for reading dnsApp.config.
/// ADR: The serializer options are centralized so that parsing behavior
/// is stable and predictable across the entire plugin lifetime.
/// </summary>
public static class DnsConfigSerializerOptions
{
public static readonly JsonSerializerOptions Default = new JsonSerializerOptions
Expand Down
128 changes: 97 additions & 31 deletions Apps/LogExporterApp/LogEntry.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*
/*
Technitium DNS Server
Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com)
Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.com)
Expand All @@ -14,10 +14,9 @@ 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 <http://www.gnu.org/licenses/>.
along with this program. If not, see <http://www.gnu.org/licenses/>

*/

using DnsServerCore.ApplicationCommon;
using System;
using System.Collections.Generic;
Expand All @@ -33,11 +32,18 @@ namespace LogExporter
{
public class LogEntry
{
// Reuse empty lists to avoid allocations when there are no answers or EDNS data
private static readonly DnsResourceRecord[] EmptyAnswers = Array.Empty<DnsResourceRecord>();
private static readonly EDNSLog[] EmptyEdns = Array.Empty<EDNSLog>();

public LogEntry(DateTime timestamp, IPEndPoint remoteEP, DnsTransportProtocol protocol, DnsDatagram request, DnsDatagram response, bool ednsLogging = false)
{
// Assign timestamp and ensure it's in UTC
Timestamp = timestamp.Kind == DateTimeKind.Utc ? timestamp : timestamp.ToUniversalTime();

// Set hostname
NameServer = request.Metadata.NameServer.Host;

// Extract client information
ClientIp = remoteEP.Address.ToString();
Protocol = protocol;
Expand All @@ -61,48 +67,108 @@ public LogEntry(DateTime timestamp, IPEndPoint remoteEP, DnsTransportProtocol pr
};
}

// Convert answer section into a simple string summary (comma-separated for multiple answers)
Answers = new List<DnsResourceRecord>(response.Answer.Count);
// Convert answer section - reuse empty list when no answers
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,
}));
Answers = new List<DnsResourceRecord>(
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,
})).ToArray();
}
else
{
Answers = EmptyAnswers;
}

PopulateEDNSLogs(response, ednsLogging);
}

EDNS = new List<EDNSLog>();
private void PopulateEDNSLogs(DnsDatagram response, bool ednsLogging)
{
// Handle EDNS - reuse empty list when no EDNS logging or no errors
if (!ednsLogging || response.EDNS is null)
{
EDNS = EmptyEdns;
return;
}

List<EDnsOption> ednsErrors = response.EDNS.Options.Where(o => o.Code == EDnsOptionCode.EXTENDED_DNS_ERROR).ToList();
if (ednsErrors.Count == 0)
{
EDNS = EmptyEdns;
return;
}

foreach (EDnsOption extendedErrorLog in response.EDNS.Options.Where(o => o.Code == EDnsOptionCode.EXTENDED_DNS_ERROR))
List<EDNSLog> edns = new List<EDNSLog>(ednsErrors.Count);
foreach (EDnsOption extendedErrorLog in ednsErrors)
{
string[] extractedData = extendedErrorLog.Data.ToString().Replace("[", string.Empty).Replace("]", string.Empty).Split(":", StringSplitOptions.TrimEntries);
// ADR: EDNS extended error comes from network input and may not follow
// the expected "type: message" format. Previously this code assumed
// a well-formed structure and could throw IndexOutOfRangeException,
// allowing remote parties to crash the logging pipeline.
// We now parse defensively and treat malformed data as a best-effort message.

string? raw = extendedErrorLog.Data?.ToString();
if (string.IsNullOrWhiteSpace(raw))
continue;

raw = raw.Replace("[", "").Replace("]", "");

string? errType = null;
string? message = null;

EDNS.Add(new EDNSLog
string[] parts = raw.Split(':', 2, StringSplitOptions.TrimEntries);
if (parts.Length == 2)
{
ErrType = extractedData[0],
Message = extractedData[1]
errType = parts[0];
message = parts[1];
}
else
{
// fallback: treat the raw payload as the message
message = raw;
}

edns.Add(new EDNSLog
{
ErrType = errType,
Message = message
});
}

// If no valid EDNS entries were added, use the empty list
EDNS = edns.Count == 0 ? EmptyEdns : edns.ToArray();
}

public List<DnsResourceRecord> Answers { get; private set; }
public string ClientIp { get; private set; }
public List<EDNSLog> EDNS { get; private set; }
public DnsTransportProtocol Protocol { get; private set; }
public DnsQuestion? Question { get; private set; }
public DnsResponseCode ResponseCode { get; private set; }
public DnsResourceRecord[] Answers { get; }

public string ClientIp { get; }

public EDNSLog[] EDNS { get; private set; }

public string NameServer { get; }

public DnsTransportProtocol Protocol { get; }

public DnsQuestion? Question { get; }

public DnsResponseCode ResponseCode { get; }

public double? ResponseRtt { get; private set; }
public DnsServerResponseType ResponseType { get; private set; }
public DateTime Timestamp { get; private set; }

public DnsServerResponseType ResponseType { get; }

public DateTime Timestamp { get; }

// Meta bag populated by pipeline stages
public Dictionary<string, object> Meta { get; } = new();

public override string ToString()
{
return JsonSerializer.Serialize(this, DnsLogSerializerOptions.Default);
Expand All @@ -124,16 +190,16 @@ public static class DnsLogSerializerOptions
public class DnsQuestion
{
public DnsClass QuestionClass { get; set; }
public required string QuestionName { get; set; }
public string QuestionName { get; set; }
public DnsResourceRecordType QuestionType { get; set; }
}

public class DnsResourceRecord
{
public DnssecStatus DnssecStatus { get; set; }
public required string Name { get; set; }
public string Name { get; set; }
public DnsClass RecordClass { get; set; }
public required string RecordData { get; set; }
public string RecordData { get; set; }
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The required keyword has been removed from these properties, but they're now initialized with string.Empty as default values. This changes the nullability contract - previously these were required in the JSON, now they're optional with empty string fallback. This could silently accept invalid configurations. Consider keeping required to ensure proper validation at deserialization time, or add more explicit validation that these aren't empty when the target is enabled.

Suggested change
public string RecordData { get; set; }
public required string RecordData { get; set; }

Copilot uses AI. Check for mistakes.
public uint RecordTtl { get; set; }
public DnsResourceRecordType RecordType { get; set; }
}
Expand Down
Loading