diff --git a/Apps/QueryLogsDuckDBApp/App.cs b/Apps/QueryLogsDuckDBApp/App.cs new file mode 100644 index 00000000..976fac43 --- /dev/null +++ b/Apps/QueryLogsDuckDBApp/App.cs @@ -0,0 +1,811 @@ +/* +Technitium DNS Server +Copyright (C) 2025 Shreyas Zare (shreyas@technitium.com) +Copyright (C) 2025 Zafer Balkan (zafer@zaferbalkan.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 DuckDB.NET.Data; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.ResourceRecords; + +namespace QueryLogsDuckDB +{ + public sealed class App : IDnsApplication, IDnsQueryLogger, IDnsQueryLogs + { + #region variables + // Initial capacity for answer StringBuilder + private const int DEFAULT_ANSWER_SIZE = 256; + + // To prevent excessive memory usage, answers larger than this will be truncated. Used the value of MySQL app. + private const int MAX_ANSWER_SIZE = 4000; + + // Maximum number of log entries to process in a single batch + private const int MAX_BATCH_SIZE = 1000; + + [ThreadStatic] + private static StringBuilder? _sb; + private readonly SemaphoreSlim _dbGate = new(1, 1); + private readonly JsonSerializerOptions _options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + private Channel? _channel; + Config? _config; + private DuckDBConnection? _conn; + private Task? _consumerTask; + private bool _disposed; + private IDnsServer? _dnsServer; + private Task? _retentionTask; + #endregion variables + + #region IDisposable + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // To prevent blocking shutdown, we attempt to complete the channel and wait for the consumer task to finish. + // This ensures that the application can shut down gracefully without being hindered by logging operations. + try { _channel?.Writer.TryComplete(); } + catch (Exception ex) + { + _dnsServer?.WriteLog("QueryLogsDuckDB.App: Error while completing log channel during Dispose: " + ex); + } + try + { + if (_consumerTask != null) + { + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(5000)); + try + { + _consumerTask.WaitAsync(cts.Token).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + // Ignore timeout/cancellation during dispose to avoid blocking shutdown indefinitely. + } + _consumerTask.Dispose(); + } + } + catch (Exception ex) + { + _dnsServer?.WriteLog("QueryLogsDuckDB.App: Error while waiting for consumer task during Dispose: " + ex); + } + try + { + if (_retentionTask != null) + { + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(5000)); + try + { + _retentionTask.WaitAsync(cts.Token).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + // Ignore timeout/cancellation during dispose to avoid blocking shutdown indefinitely. + } + _retentionTask.Dispose(); + } + } + catch (Exception ex) + { + _dnsServer?.WriteLog("QueryLogsDuckDB.App: Error while waiting for retention task during Dispose: " + ex); + } + try { _conn?.Close(); _conn?.Dispose(); } + catch (Exception ex) + { + _dnsServer?.WriteLog("QueryLogsDuckDB.App: Error while closing/disposing DuckDB connection during Dispose: " + ex); + } + + _dbGate.Dispose(); + } + + _disposed = true; + } + } + #endregion IDisposable + + #region private + + private static StringBuilder GetStringBuilder() + { + StringBuilder? sb = _sb; + + if (sb is null) + return _sb = new StringBuilder(DEFAULT_ANSWER_SIZE, MAX_ANSWER_SIZE); + + sb.Clear(); + return sb; + } + + private async Task BulkInsertAsync(List logs) + { + await _dbGate.WaitAsync(); + try + { + // We create a new appender for each batch to avoid issues with concurrent usage + // By default, the appender performs commits every 204,800 rows. + // Since we are using smaller batches, we are forcing appender to close after each batch. + // It makes the flush to disk more frequent, but ensures data integrity in case of crashes. + // Each batch flush is atomic. + using DuckDBAppender appender = _conn!.CreateAppender("dns_logs"); + foreach (LogEntry log in logs + .Where(log => log.Request is not null && log.Response is not null)) + { + DnsQuestionRecord? question = + log.Request.Question.Count > 0 + ? log.Request.Question[0] + : null; + + //Response Type(Aligned) + DnsServerResponseType responseType = log.Response.Tag is null + ? DnsServerResponseType.Recursive + : (DnsServerResponseType)log.Response.Tag; + + //RTT + double? rtt = null; + + if (responseType == DnsServerResponseType.Recursive && + log.Response.Metadata is not null) + { + rtt = log.Response.Metadata.RoundTripTime; + } + + // Answer (bounded, safe) + string? answer = null; + + if (log.Response.Answer.Count > 0) + { + if (log.Response.IsZoneTransfer && log.Response.Answer.Count > 2) + { + answer = "[ZONE TRANSFER]"; + } + else + { + StringBuilder sb = GetStringBuilder(); + bool first = true; + + foreach (DnsResourceRecord record in log.Response.Answer) + { + if (!first) + { + if (sb.Length + 2 >= MAX_ANSWER_SIZE) + break; + + sb.Append(", "); + } + + string part = $"{record.Type} {record.RDATA}"; + + int remaining = + MAX_ANSWER_SIZE - sb.Length; + + if (remaining <= 0) + break; + + if (part.Length > remaining) + { + sb.Append(part.AsSpan(0, remaining)); + break; + } + + sb.Append(part); + first = false; + } + + answer = sb.Length == 0 ? null : sb.ToString(); + } + } + + //Insert Row + IDuckDBAppenderRow row = appender.CreateRow(); + + row.AppendValue(_dnsServer!.ServerDomain); + row.AppendValue(log.Timestamp); + row.AppendValue(log.RemoteEP.Address.ToString()); + row.AppendValue((byte)log.Protocol); + + row.AppendValue((byte)responseType); + + if (rtt is null) + row.AppendNullValue(); + else + row.AppendValue(rtt.Value); + + row.AppendValue((byte)log.Response.RCODE); + + if (question is null) + { + row.AppendNullValue(); + row.AppendNullValue(); + row.AppendNullValue(); + } + else + { + row.AppendValue(question.Name.ToLowerInvariant()); + row.AppendValue((ushort)question.Type); + row.AppendValue((ushort)question.Class); + } + + if (answer is null) + row.AppendNullValue(); + else + row.AppendValue(answer); + + row.EndRow(); + } + } + catch (Exception ex) + { + _dnsServer?.WriteLog(ex); + // No need for a wait as this is running synchronously in the background, + // we just log and continue. + // No risk of concurrency issues and waiting here would block the logging thread. + } + finally + { + _dbGate.Release(); + } + } + + private async Task CreateSchemaAsync() + { + using DuckDBCommand cmd = _conn!.CreateCommand(); + + cmd.CommandText = @" +CREATE TABLE IF NOT EXISTS dns_logs ( + server VARCHAR(255) NOT NULL, + timestamp TIMESTAMP NOT NULL, + client_ip VARCHAR(39) NOT NULL, + protocol UTINYINT NOT NULL, + response_type UTINYINT, + response_rtt DOUBLE, + rcode UTINYINT NOT NULL, + qname VARCHAR(255), + qtype USMALLINT, + qclass USMALLINT, + answer TEXT +);"; + await cmd.ExecuteNonQueryAsync(); + + string index = "CREATE INDEX IF NOT EXISTS idx_ts_srv_ip ON dns_logs(timestamp, server, client_ip);"; + cmd.CommandText = index; + await cmd.ExecuteNonQueryAsync(); + } + + private async Task ProcessLogsAsync() + { + List batch = new List(MAX_BATCH_SIZE); + + while (!_disposed && await _channel!.Reader.WaitToReadAsync()) + { + while (batch.Count < MAX_BATCH_SIZE && + _channel.Reader.TryRead(out LogEntry log)) + { + batch.Add(log); + } + + if (batch.Count > 0) + { + await BulkInsertAsync(batch); + batch.Clear(); + } + } + + if (batch.Count > 0) + await BulkInsertAsync(batch); + } + + private async Task RetentionLoopAsync() + { + await Task.Delay(TimeSpan.FromMinutes(1)); + + while (!_disposed) + { + try + { + await Task.Delay(TimeSpan.FromMinutes(15)); + if (_disposed) break; + await RunRetentionAsync(); + } + catch (Exception ex) + { + _dnsServer?.WriteLog(ex); + } + } + } + + private async Task RunRetentionAsync() + { + if (_conn is null || _config is null) + return; + + await _dbGate.WaitAsync(); + try + { + using DuckDBCommand cmd = _conn.CreateCommand(); + + long deleted = 0; + + /* --------------------------------- + Max records + --------------------------------- */ + + if (_config.MaxLogRecords > 0) + { + // Count first to avoid running a DELETE when there is nothing to prune. + cmd.Parameters.Clear(); + cmd.CommandText = "SELECT count() FROM dns_logs;"; + long totalRows = Convert.ToInt64(await cmd.ExecuteScalarAsync()); + if (totalRows > _config.MaxLogRecords) + { + // Keep newest N rows (by timestamp). The cutoff is the Nth newest row. + // NOTE: Timestamp collisions can still cause >N rows to be retained; strict N + // requires a stable tiebreaker column (handled in a separate PR if needed). + cmd.Parameters.Clear(); + + + cmd.CommandText = @" +WITH cutoff AS ( + SELECT timestamp + FROM dns_logs + ORDER BY timestamp DESC + OFFSET ($limit - 1) + LIMIT 1 +) +DELETE FROM dns_logs +WHERE timestamp < (SELECT timestamp FROM cutoff); +"; + cmd.Parameters.Add( + new DuckDBParameter("limit", _config.MaxLogRecords)); + + deleted += await cmd.ExecuteNonQueryAsync(); + } + } + + /* --------------------------------- + Max days + --------------------------------- */ + + if (_config.MaxLogDays > 0) + { + DateTime cutoff = + DateTime.UtcNow.AddDays(-_config.MaxLogDays); + + cmd.CommandText = + "DELETE FROM dns_logs WHERE timestamp < $cutoff;"; + + cmd.Parameters.Clear(); + cmd.Parameters.Add( + new DuckDBParameter("cutoff", cutoff)); + + deleted += await cmd.ExecuteNonQueryAsync(); + } + + if (deleted > 0) + { + cmd.Parameters.Clear(); + cmd.CommandText = "CHECKPOINT;"; + await cmd.ExecuteNonQueryAsync(); + _dnsServer?.WriteLog($"DuckDB retention removed {deleted} records."); + } + } + finally + { + _dbGate.Release(); + } + } + #endregion private + + #region public + + public async Task InitializeAsync(IDnsServer dnsServer, string config) + { + _dnsServer = dnsServer; + + _config = JsonSerializer.Deserialize(config, _options); + _config ??= new Config(); + Validator.ValidateObject(_config, new ValidationContext(_config), validateAllProperties: true); + + if (!System.IO.Path.IsPathRooted(_config.DbPath)) + _config.DbPath = System.IO.Path.Combine(dnsServer.ApplicationFolder, _config.DbPath); + + _channel = Channel.CreateBounded( + new BoundedChannelOptions(_config.MaxQueueSize) + { + SingleReader = true, + SingleWriter = true, + FullMode = BoundedChannelFullMode.DropWrite + }); + + _conn = new DuckDBConnection($"Data Source={_config.DbPath}"); + await _conn.OpenAsync(); + await CreateSchemaAsync(); + + _consumerTask = Task.Run(ProcessLogsAsync).ContinueWith( + t => + { + if (t.Exception != null) + _dnsServer?.WriteLog(t.Exception); + }, + TaskContinuationOptions.OnlyOnFaulted); + _retentionTask = Task.Run(RetentionLoopAsync).ContinueWith( + t => + { + if (t.Exception != null) + _dnsServer?.WriteLog(t.Exception); + }, + TaskContinuationOptions.OnlyOnFaulted); + } + + public Task InsertLogAsync( + DateTime timestamp, + DnsDatagram request, + IPEndPoint remoteEP, + DnsTransportProtocol protocol, + DnsDatagram response) + { + if (_disposed) return Task.CompletedTask; + if (_config is null) return Task.CompletedTask; + if (_conn is null) return Task.CompletedTask; + + if (_config.EnableLogging) + _channel!.Writer.TryWrite( + new LogEntry(timestamp, request, remoteEP, protocol, response)); + + return Task.CompletedTask; + } + + public async Task QueryLogsAsync( + long pageNumber, + int entriesPerPage, + bool descendingOrder, + DateTime? start, + DateTime? end, + IPAddress clientIpAddress, + DnsTransportProtocol? protocol, + DnsServerResponseType? responseType, + DnsResponseCode? rcode, + string qname, + DnsResourceRecordType? qtype, + DnsClass? qclass) + { + if (entriesPerPage <= 0) + throw new ArgumentOutOfRangeException( + nameof(entriesPerPage), + "entriesPerPage must be greater than zero."); + + // Prevent pathological page sizes (DoS / memory abuse) + const int MaxPageSize = 10_000; + + if (entriesPerPage > MaxPageSize) + entriesPerPage = MaxPageSize; + + if (pageNumber < 1) + pageNumber = 1; + + // Normalize inverted time ranges + if (start is not null && + end is not null && + start > end) + { + (start, end) = (end, start); + } + + using DuckDBCommand cmd = _conn!.CreateCommand(); + + List filters = new List(); + + /* --------------------------------- + Filters + --------------------------------- */ + + if (start is not null) + { + filters.Add("timestamp >= $s"); + cmd.Parameters.Add(new DuckDBParameter("s", start)); + } + + if (end is not null) + { + filters.Add("timestamp <= $e"); + cmd.Parameters.Add(new DuckDBParameter("e", end)); + } + + if (clientIpAddress is not null) + { + filters.Add("client_ip = $cip"); + cmd.Parameters.Add( + new DuckDBParameter("cip", clientIpAddress.ToString())); + } + + if (protocol is not null) + { + filters.Add("protocol = $proto"); + cmd.Parameters.Add( + new DuckDBParameter("proto", (byte)protocol.Value)); + } + + if (responseType is not null) + { + filters.Add("response_type = $rtype"); + cmd.Parameters.Add( + new DuckDBParameter("rtype", (byte)responseType.Value)); + } + + if (rcode is not null) + { + filters.Add("rcode = $rcode"); + cmd.Parameters.Add( + new DuckDBParameter("rcode", (byte)rcode.Value)); + } + + if (!string.IsNullOrWhiteSpace(qname)) + { + qname = qname.Trim().ToLowerInvariant(); + if (qname.Contains('*')) + { + qname = qname.Replace('*', '%'); + } + filters.Add("qname LIKE $qname"); + cmd.Parameters.Add( + new DuckDBParameter("qname", qname)); + } + + if (qtype is not null) + { + filters.Add("qtype = $qtype"); + cmd.Parameters.Add( + new DuckDBParameter("qtype", (ushort)qtype.Value)); + } + + if (qclass is not null) + { + filters.Add("qclass = $qclass"); + cmd.Parameters.Add( + new DuckDBParameter("qclass", (ushort)qclass.Value)); + } + + string whereSql = + filters.Count > 0 + ? " WHERE " + string.Join(" AND ", filters) + : string.Empty; + + /* --------------------------------- + Count + --------------------------------- */ + + cmd.CommandText = + $"SELECT count() FROM dns_logs {whereSql}"; + + long totalEntries = + Convert.ToInt64(await cmd.ExecuteScalarAsync()); + + long totalPages = + totalEntries == 0 + ? 1 + : (long)Math.Ceiling( + (double)totalEntries / entriesPerPage); + + pageNumber = + Math.Clamp(pageNumber, 1, totalPages); + + /* --------------------------------- + Offset (overflow-safe) + --------------------------------- */ + + long offset; + + try + { + checked + { + offset = + (pageNumber - 1) * entriesPerPage; + } + } + catch (OverflowException) + { + offset = 0; + pageNumber = 1; + } + + /* --------------------------------- + Pagination parameters + --------------------------------- */ + + cmd.Parameters.Add( + new DuckDBParameter("limit", entriesPerPage)); + + cmd.Parameters.Add( + new DuckDBParameter("offset", offset)); + + /* --------------------------------- + Main query + --------------------------------- */ + + cmd.CommandText = $@" +SELECT server, + timestamp, + client_ip, + protocol, + response_type, + response_rtt, + rcode, + qname, + qtype, + qclass, + answer +FROM dns_logs +{whereSql} +ORDER BY timestamp {(descendingOrder ? "DESC" : "ASC")} +LIMIT $limit +OFFSET $offset"; + + List list = new List(entriesPerPage); + + /* --------------------------------- + Read + --------------------------------- */ + await _dbGate.WaitAsync(); + try + { + using System.Data.Common.DbDataReader reader = await cmd.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + DateTime ts = reader.GetDateTime(1); + + IPAddress ip = + IPAddress.Parse(reader.GetString(2)); + + DnsTransportProtocol proto = + (DnsTransportProtocol)reader.GetByte(3); + + DnsServerResponseType respType = + (DnsServerResponseType)reader.GetByte(4); + + double? rtt = + reader.IsDBNull(5) + ? null + : reader.GetDouble(5); + + DnsResponseCode rc = + (DnsResponseCode)reader.GetByte(6); + + string? qn = + reader.IsDBNull(7) + ? null + : reader.GetString(7); + + DnsQuestionRecord? question = null; + + if (qn is not null && + !reader.IsDBNull(8) && + !reader.IsDBNull(9)) + { + question = new DnsQuestionRecord( + qn, + (DnsResourceRecordType)reader.GetFieldValue(8), + (DnsClass)reader.GetFieldValue(9), + false); + } + + string? ans = + reader.IsDBNull(10) + ? null + : reader.GetString(10); + + list.Add( + new DnsLogEntry( + 0, + ts, + ip, + proto, + respType, + rtt, + rc, + question, + ans)); + } + + return new DnsLogPage( + pageNumber, + totalPages, + totalEntries, + list); + } + finally + { + _dbGate.Release(); + } + } + + #endregion public + + #region properties + + public string Description + { get { return "Logs all incoming DNS requests and their responses in a DuckDB database that can be queried from the DNS Server web console."; } } + + #endregion properties + + private readonly struct LogEntry + { + #region variables + + public readonly DnsTransportProtocol Protocol; + public readonly IPEndPoint RemoteEP; + public readonly DnsDatagram Request; + public readonly DnsDatagram Response; + public readonly DateTime Timestamp; + + #endregion variables + + #region constructor + + public LogEntry(DateTime timestamp, DnsDatagram request, IPEndPoint remoteEP, DnsTransportProtocol protocol, DnsDatagram response) + { + Timestamp = timestamp; + Request = request; + RemoteEP = remoteEP; + Protocol = protocol; + Response = response; + } + + #endregion constructor + } + + private class Config + { + [JsonPropertyName("dbPath")] + public string DbPath { get; set; } = "querylogs.db"; + + [JsonPropertyName("enableLogging")] + public bool EnableLogging { get; set; } = true; + + [JsonPropertyName("maxLogDays")] + [Range(0, 365)] + public int MaxLogDays { get; set; } = 30; + + [JsonPropertyName("maxLogRecords")] + [Range(0, 5_000_000)] + public long MaxLogRecords { get; set; } = 1_000_000; + + [JsonPropertyName("maxQueueSize")] + [Range(1_000, 1_000_000)] + public int MaxQueueSize { get; set; } = 200_000; + } + } +} \ No newline at end of file diff --git a/Apps/QueryLogsDuckDBApp/QueryLogsDuckDBApp.csproj b/Apps/QueryLogsDuckDBApp/QueryLogsDuckDBApp.csproj new file mode 100644 index 00000000..f706684f --- /dev/null +++ b/Apps/QueryLogsDuckDBApp/QueryLogsDuckDBApp.csproj @@ -0,0 +1,48 @@ + + + + net9.0 + false + true + 1.0 + false + Technitium + Technitium DNS Server + Zafer Balkan + QueryLogsDuckDBApp + QueryLogsDuckDB + https://technitium.com/dns/ + https://github.com/TechnitiumSoftware/DnsServer + Logs all incoming DNS requests and their responses in a DuckDB database that can be queried from the DNS Server web console. + false + Library + enable + + + + + + + + + false + + + + + + ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.Net.dll + false + + + ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.dll + false + + + + + + PreserveNewest + + + diff --git a/Apps/QueryLogsDuckDBApp/dnsApp.config b/Apps/QueryLogsDuckDBApp/dnsApp.config new file mode 100644 index 00000000..081b6ccd --- /dev/null +++ b/Apps/QueryLogsDuckDBApp/dnsApp.config @@ -0,0 +1,7 @@ +{ + "enableLogging": true, + "dbPath": "querylogs.db", + "maxQueueSize": 200000, + "maxLogDays": 30, + "maxLogRecords": 1000000 +} diff --git a/DnsServer.sln b/DnsServer.sln index 0a2a6756..976abfe5 100644 --- a/DnsServer.sln +++ b/DnsServer.sln @@ -68,8 +68,11 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryLogsMySqlApp", "Apps\QueryLogsMySqlApp\QueryLogsMySqlApp.csproj", "{699E2A1D-D917-4825-939E-65CDB2B16A96}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MispConnectorApp", "Apps\MispConnectorApp\MispConnectorApp.csproj", "{83C8180A-0F86-F9A0-8F41-6FD61FAC41CB}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DnsServerCore.HttpApi", "DnsServerCore.HttpApi\DnsServerCore.HttpApi.csproj", "{1A49D371-D08C-475E-B7A2-6E8ECD181FD6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryLogsDuckDBApp", "Apps\QueryLogsDuckDBApp\QueryLogsDuckDBApp.csproj", "{B4F714DB-B90F-467A-9DD1-4F944A84B4F2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -208,6 +211,10 @@ Global {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Debug|Any CPU.Build.0 = Debug|Any CPU {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A49D371-D08C-475E-B7A2-6E8ECD181FD6}.Release|Any CPU.Build.0 = Release|Any CPU + {B4F714DB-B90F-467A-9DD1-4F944A84B4F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4F714DB-B90F-467A-9DD1-4F944A84B4F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4F714DB-B90F-467A-9DD1-4F944A84B4F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4F714DB-B90F-467A-9DD1-4F944A84B4F2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -240,6 +247,7 @@ Global {6F655C97-FD43-4FE1-B15A-6C783D2D91C9} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} {699E2A1D-D917-4825-939E-65CDB2B16A96} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} {83C8180A-0F86-F9A0-8F41-6FD61FAC41CB} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} + {B4F714DB-B90F-467A-9DD1-4F944A84B4F2} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6747BB6D-2826-4356-A213-805FBCCF9201}