From 3277444ddaaac732fac5b2e4b742671db3805c20 Mon Sep 17 00:00:00 2001 From: Alan Edwardes Date: Sat, 5 Aug 2023 15:57:45 +0100 Subject: [PATCH] Overhauls the stats server. --- misc/Ae.Dns.Console/Startup.cs | 179 +++++++++++++++++++------- src/Ae.Dns.Client/DnsMetricsClient.cs | 11 +- 2 files changed, 137 insertions(+), 53 deletions(-) diff --git a/misc/Ae.Dns.Console/Startup.cs b/misc/Ae.Dns.Console/Startup.cs index f15978b..4a9384a 100644 --- a/misc/Ae.Dns.Console/Startup.cs +++ b/misc/Ae.Dns.Console/Startup.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Data; using System.Diagnostics.Metrics; using System.Linq; using System.Net; @@ -8,10 +9,13 @@ using System.Threading.Tasks; using Ae.Dns.Client; using Ae.Dns.Protocol; +using Ae.Dns.Protocol.Enums; using Ae.Dns.Server.Http; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Ae.Dns.Console { @@ -44,6 +48,48 @@ async Task WriteHeader(string header) await context.Response.WriteAsync(Environment.NewLine); } + async Task WriteTable(DataTable table) + { + await context.Response.WriteAsync(""); + await context.Response.WriteAsync(""); + await context.Response.WriteAsync(""); + foreach (DataColumn heading in table.Columns) + { + await context.Response.WriteAsync($""); + } + await context.Response.WriteAsync(""); + await context.Response.WriteAsync(""); + + await context.Response.WriteAsync(""); + foreach (DataRow row in table.Rows) + { + await context.Response.WriteAsync(""); + foreach (var item in row.ItemArray) + { + await context.Response.WriteAsync($""); + } + await context.Response.WriteAsync(""); + } + await context.Response.WriteAsync(""); + await context.Response.WriteAsync("
{heading.ColumnName}
{item}
"); + } + + async Task GroupToTable(IEnumerable> groups, params string[] headings) + { + var table = new DataTable(); + + foreach (var heading in headings) + { + table.Columns.Add(heading); + } + + foreach (var group in groups.OrderByDescending(x => x.Count()).Take(20)) + { + table.Rows.Add(group.Key, group.Count()); + } + await WriteTable(table); + } + var resolverCache = context.RequestServices.GetRequiredService(); if (context.Request.Path.StartsWithSegments("/cache/remove")) @@ -55,6 +101,14 @@ async Task WriteHeader(string header) return; } + if (context.Request.Path.StartsWithSegments("/reset")) + { + Reset(); + context.Response.StatusCode = StatusCodes.Status307TemporaryRedirect; + context.Response.Headers.Location = "/"; + return; + } + if (context.Request.Path.StartsWithSegments("/cache")) { var cacheEntries = resolverCache.Where(x => x.Value is DnsCachingClient.DnsCacheEntry) @@ -102,49 +156,74 @@ async Task WriteHeader(string header) if (context.Request.Path == "/") { context.Response.StatusCode = StatusCodes.Status200OK; - context.Response.ContentType = "text/plain; charset=utf-8"; + context.Response.ContentType = "text/html; charset=utf-8"; - var statsSets = new Dictionary> - { - { "Top Blocked Domains", _topBlockedDomains }, - { "Top Permitted Domains", _topPermittedDomains }, - { "Top Missing Domains", _topMissingDomains }, - { "Top Other Error Domains", _topOtherErrorDomains }, - { "Top Exception Error Domains", _topExceptionDomains } - }; - - await WriteHeader(resolverCache.Name); - await context.Response.WriteAsync($"Cached Objects = {resolverCache.Count()}"); - await context.Response.WriteAsync(Environment.NewLine); - await context.Response.WriteAsync(Environment.NewLine); + var answeredQueries = _queries.Where(x => x.Answer != null); + var missingQueries = answeredQueries.Where(x => x.Answer.Header.ResponseCode == DnsResponseCode.NXDomain).ToArray(); + var successfulQueries = answeredQueries.Where(x => x.Answer.Header.ResponseCode == DnsResponseCode.NoError).ToArray(); + var refusedQueries = answeredQueries.Where(x => x.Answer.Header.ResponseCode == DnsResponseCode.Refused).ToArray(); + var notAnsweredQueries = _queries.Where(x => x.Answer == null).ToArray(); - foreach (var statsSet in statsSets) - { - await WriteHeader(statsSet.Key); + await context.Response.WriteAsync($"

Metrics Server

"); + await context.Response.WriteAsync($"

" + + $"Metrics since {_lastReset} (current time is {DateTime.UtcNow}). " + + $"There were {_queries.Count} total queries, and there are {resolverCache.Count()} cache entries. " + + $"Of those queries, {successfulQueries.Length} were successful, {missingQueries.Length} were missing, " + + $"{refusedQueries.Length} were refused, and {notAnsweredQueries.Length} were not answered." + + $"

"); + await context.Response.WriteAsync($"

Reset statistics

"); - if (!statsSet.Value.Any()) - { - await context.Response.WriteAsync("None"); - await context.Response.WriteAsync(Environment.NewLine); - } + await context.Response.WriteAsync($"

Top Top Level Domains

"); + await context.Response.WriteAsync($"

Top level domains (permitted and refused).

"); + await GroupToTable(_queries.GroupBy(x => string.Join('.', x.Query.Header.Host.Split('.').Reverse().First())), "Top Level Domain", "Hits"); - foreach (var statistic in statsSet.Key == "Statistics" ? statsSet.Value.OrderBy(x => x.Key) : statsSet.Value.OrderByDescending(x => x.Value).Take(50)) - { - await context.Response.WriteAsync($"{statistic.Key} = {statistic.Value}"); - await context.Response.WriteAsync(Environment.NewLine); - } + await context.Response.WriteAsync($"

Top Refused Root Domains

"); + await context.Response.WriteAsync($"

Top domain names which were refused.

"); + await GroupToTable(refusedQueries.GroupBy(x => string.Join('.', x.Query.Header.Host.Split('.').Reverse().Take(2).Reverse())), "Root Domain", "Hits"); - await context.Response.WriteAsync(Environment.NewLine); - } + await context.Response.WriteAsync($"

Top Permitted Root Domains

"); + await context.Response.WriteAsync($"

Top domain names which were permitted.

"); + await GroupToTable(successfulQueries.GroupBy(x => string.Join('.', x.Query.Header.Host.Split('.').Reverse().Take(2).Reverse())), "Root Domain", "Hits"); + + await context.Response.WriteAsync($"

Top Missing Root Domains

"); + await context.Response.WriteAsync($"

Root domain names which were missing (NXDomain).

"); + await GroupToTable(missingQueries.GroupBy(x => string.Join('.', x.Query.Header.Host.Split('.').Reverse().Take(2).Reverse())), "Root Domain", "Hits"); + + await context.Response.WriteAsync($"

Top Clients

"); + await context.Response.WriteAsync($"

Top DNS clients.

"); + await GroupToTable(_queries.GroupBy(x => x.Sender.Address.ToString()), "Client Address", "Hits"); + + await context.Response.WriteAsync($"

Top Responses

"); + await context.Response.WriteAsync($"

Top response codes for all queries.

"); + await GroupToTable(_queries.GroupBy(x => x.Answer?.Header.ResponseCode.ToString()), "Response Code", "Hits"); + + await context.Response.WriteAsync($"

Top Query Types

"); + await context.Response.WriteAsync($"

Top query types across all queries.

"); + await GroupToTable(_queries.GroupBy(x => x.Query.Header.QueryType.ToString()), "Query Type", "Hits"); + + await context.Response.WriteAsync($"

Top Answer Sources

"); + await context.Response.WriteAsync($"

Top sources of query responses in terms of the code or upstream which generated them.

"); + await GroupToTable(answeredQueries.GroupBy(x => (x.Answer.Header.Tags.ContainsKey("Resolver") ? x.Answer.Header.Tags["Resolver"] : "").ToString()), "Answer Source", "Hits"); } }); } - private readonly ConcurrentDictionary _topPermittedDomains = new(); - private readonly ConcurrentDictionary _topBlockedDomains = new(); - private readonly ConcurrentDictionary _topMissingDomains = new(); - private readonly ConcurrentDictionary _topOtherErrorDomains = new(); - private readonly ConcurrentDictionary _topExceptionDomains = new(); + private sealed class DnsQuery + { + public DnsMessage Query { get; set; } + public DnsMessage? Answer { get; set; } + public IPEndPoint Sender { get; set; } + public TimeSpan? Elapsed { get; set; } + } + + private readonly ConcurrentBag _queries = new ConcurrentBag(); + private DateTime _lastReset = DateTime.UtcNow; + + private void Reset() + { + _queries.Clear(); + _lastReset = DateTime.UtcNow; + } private void OnMeasurementRecorded(Instrument instrument, int measurement, ReadOnlySpan> tags, object state) { @@ -160,27 +239,29 @@ static TObject GetObjectFromTags(ReadOnlySpan> - { - { DnsMetricsClient.SuccessCounterName, _topPermittedDomains }, - { DnsMetricsClient.OtherErrorCounterName, _topOtherErrorDomains }, - { DnsMetricsClient.MissingErrorCounterName, _topMissingDomains }, - { DnsMetricsClient.RefusedErrorCounterName, _topBlockedDomains }, - { DnsMetricsClient.ExceptionErrorCounterName, _topExceptionDomains } - }; - - if (meterMap.TryGetValue(instrument.Name, out var domainCounts)) - { - var query = GetObjectFromTags(tags, "Query"); - var sourceEndpoint = query.Header.Tags.TryGetValue("Sender", out var rawEndpoint) && rawEndpoint is IPEndPoint endpoint ? endpoint : throw new Exception(); + var query = GetObjectFromTags(tags, "Query"); + var answer = GetObjectFromTags(tags, "Answer"); + var elapsed = GetObjectFromTags(tags, "Elapsed"); + var sender = query.Header.Tags.TryGetValue("Sender", out var rawEndpoint) && rawEndpoint is IPEndPoint endpoint ? endpoint : throw new Exception(); - domainCounts.AddOrUpdate(sourceEndpoint.Address.ToString() + ' ' + query.Header.QueryType.ToString() + ' ' + query.Header.Host, 1, (id, count) => count + 1); + // Ensure we don't run out of memory + if (_queries.Count > 1_000_000) + { + Reset(); } + + _queries.Add(new DnsQuery + { + Query = query, + Answer = answer, + Sender = sender, + Elapsed = elapsed + }); } } } diff --git a/src/Ae.Dns.Client/DnsMetricsClient.cs b/src/Ae.Dns.Client/DnsMetricsClient.cs index 17e0538..dbabd7f 100644 --- a/src/Ae.Dns.Client/DnsMetricsClient.cs +++ b/src/Ae.Dns.Client/DnsMetricsClient.cs @@ -85,19 +85,22 @@ public async Task Query(DnsMessage query, CancellationToken token = sw.Stop(); } + var answerTag = new KeyValuePair("Answer", answer); + var elapsedTag = new KeyValuePair("Elapsed", sw.Elapsed); + switch (answer.Header.ResponseCode) { case DnsResponseCode.NoError: - _successCounter.Add(1, queryTag); + _successCounter.Add(1, queryTag, answerTag, elapsedTag); break; case DnsResponseCode.NXDomain: - _missingCounter.Add(1, queryTag); + _missingCounter.Add(1, queryTag, answerTag, elapsedTag); break; case DnsResponseCode.Refused: - _refusedCounter.Add(1, queryTag); + _refusedCounter.Add(1, queryTag, answerTag, elapsedTag); break; default: - _otherErrorCounter.Add(1, queryTag); + _otherErrorCounter.Add(1, queryTag, answerTag, elapsedTag); break; }