diff --git a/Apps/MispConnectorApp/App.cs b/Apps/MispConnectorApp/App.cs new file mode 100644 index 00000000..70e5fe16 --- /dev/null +++ b/Apps/MispConnectorApp/App.cs @@ -0,0 +1,591 @@ +/* +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 System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.Net.Dns; +using TechnitiumLibrary.Net.Dns.EDnsOptions; +using TechnitiumLibrary.Net.Dns.ResourceRecords; +using TechnitiumLibrary.Net.Http.Client; + +namespace MispConnector +{ + public sealed class App : IDnsApplication, IDnsRequestBlockingHandler + { + #region variables + + string _domainCacheFilePath; + Config _config; + IDnsServer _dnsServer; + FrozenSet _domainBlocklist = FrozenSet.Empty; + HttpClient _httpClient; + + Uri _mispApiUrl; + + DnsSOARecordData _soaRecord; + TimeSpan _updateInterval; + Task _updateLoopTask; + + CancellationTokenSource _appShutdownCts; + #endregion variables + + #region IDisposable + + public void Dispose() + { + _appShutdownCts?.Cancel(); + try + { + if (_updateLoopTask != null) + { + _ = Task.WhenAny(_updateLoopTask, Task.Delay(TimeSpan.FromSeconds(2))).GetAwaiter().GetResult(); + } + } + catch + { + } + finally + { + _appShutdownCts?.Dispose(); + _httpClient?.Dispose(); + } + } + + #endregion IDisposable + + #region public + + public async Task InitializeAsync(IDnsServer dnsServer, string config) + { + _dnsServer = dnsServer; + try + { + _soaRecord = new DnsSOARecordData(_dnsServer.ServerDomain, _dnsServer.ResponsiblePerson.Address, 1, 14400, 3600, 604800, 60); + + JsonSerializerOptions options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + _config = JsonSerializer.Deserialize(config, options); + + Validator.ValidateObject(_config, new ValidationContext(_config), validateAllProperties: true); + + string configDir = _dnsServer.ApplicationFolder; + Directory.CreateDirectory(configDir); + _domainCacheFilePath = Path.Combine(configDir, "misp_domain_cache.txt"); + + _updateInterval = ParseUpdateInterval(_config.UpdateInterval); + + Uri mispServerUrl = new Uri(_config.MispServerUrl); + _mispApiUrl = new Uri(mispServerUrl, "/attributes/restSearch"); + _httpClient = CreateHttpClient(mispServerUrl, _config.DisableTlsValidation); + + await LoadBlocklistFromCacheAsync(); + _appShutdownCts = new CancellationTokenSource(); + + // We do not await this, as it's designed to run for the lifetime of the app. + _updateLoopTask = StartUpdateLoopAsync(_appShutdownCts.Token); + Task _ = _updateLoopTask.ContinueWith(t => + { + if (t.IsFaulted) + { + _dnsServer.WriteLog($"FATAL: Update loop terminated unexpectedly: {t.Exception?.GetBaseException().Message}"); + _dnsServer.WriteLog(t.Exception); + } + }, TaskContinuationOptions.OnlyOnFaulted); + } + catch (Exception ex) + { + _dnsServer.WriteLog($"FATAL: MISP Connector failed to initialize. Check configuration. Error: {ex.Message}"); + _dnsServer.WriteLog(ex); + } + } + + public Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP) + { + return Task.FromResult(false); + } + + public Task ProcessRequestAsync(DnsDatagram request, IPEndPoint remoteEP) + { + if (_config?.EnableBlocking != true) + { + return Task.FromResult(null); + } + + DnsQuestionRecord question = request.Question[0]; + bool domainBlocked = IsDomainBlocked(question.Name, out string blockedDomain); + if (!domainBlocked) + { + return Task.FromResult(null); + } + + string blockingReport = $"source=misp-connector;domain={blockedDomain}"; + + EDnsOption[] options = null; + if (_config.AddExtendedDnsError && request.EDNS is not null) + { + options = new EDnsOption[] { new EDnsOption(EDnsOptionCode.EXTENDED_DNS_ERROR, new EDnsExtendedDnsErrorOptionData(EDnsExtendedDnsErrorCode.Blocked, string.Empty)) }; + } + + if (_config.AllowTxtBlockingReport && question.Type == DnsResourceRecordType.TXT) + { + DnsResourceRecord[] answer = new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.TXT, question.Class, 60, new DnsTXTRecordData(string.Empty)) }; + return Task.FromResult(new DnsDatagram( + ID: request.Identifier, + isResponse: true, + OPCODE: DnsOpcode.StandardQuery, + authoritativeAnswer: false, + truncation: false, + recursionDesired: request.RecursionDesired, + recursionAvailable: true, + authenticData: false, + checkingDisabled: false, + RCODE: DnsResponseCode.NoError, + question: request.Question, + answer: answer, + authority: null, + additional: null, + udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer.UdpPayloadSize, + ednsFlags: EDnsHeaderFlags.None, + options: options + )); + } + + DnsResourceRecord[] authority = { new DnsResourceRecord(question.Name, DnsResourceRecordType.SOA, question.Class, 60, _soaRecord) }; + return Task.FromResult(new DnsDatagram( + ID: request.Identifier, + isResponse: true, + OPCODE: DnsOpcode.StandardQuery, + authoritativeAnswer: true, + truncation: false, + recursionDesired: request.RecursionDesired, + recursionAvailable: true, + authenticData: false, + checkingDisabled: false, + RCODE: DnsResponseCode.NxDomain, + question: request.Question, + answer: null, + authority: authority, + additional: null, + udpPayloadSize: request.EDNS is null ? ushort.MinValue : _dnsServer.UdpPayloadSize, + ednsFlags: EDnsHeaderFlags.None, + options: options + )); + } + + #endregion public + + #region private + private async Task StartUpdateLoopAsync(CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromSeconds(Random.Shared.Next(5, 30)), cancellationToken); + using (PeriodicTimer timer = new PeriodicTimer(_updateInterval)) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await UpdateIocsAsync(cancellationToken); + } + catch (OperationCanceledException) + { + _dnsServer.WriteLog("Update loop is shutting down gracefully."); + break; + } + catch (Exception ex) + { + _dnsServer.WriteLog($"FATAL: The MispConnector update task failed unexpectedly. Error: {ex.Message}"); + _dnsServer.WriteLog(ex); + } + + await timer.WaitForNextTickAsync(cancellationToken); + } + } + } + private static TimeSpan ParseUpdateInterval(string interval) + { + if (string.IsNullOrWhiteSpace(interval) || interval.Length < 2) + { + throw new FormatException("Update interval is not in a valid format (e.g., '60m', '2h', '7d')."); + } + + string unit = interval.Substring(interval.Length - 1).ToLowerInvariant(); + string valueString = interval.Substring(0, interval.Length - 1); + + if (!int.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) || value <= 0) + { + throw new FormatException($"Invalid numeric value '{valueString}' in update interval."); + } + + switch (unit) + { + case "m": + return TimeSpan.FromMinutes(value); + + case "h": + return TimeSpan.FromHours(value); + + case "d": + return TimeSpan.FromDays(value); + + default: + throw new FormatException($"Invalid unit '{unit}' in update interval. Allowed units are 'm', 'h', 'd'."); + } + } + + private async Task CheckTcpPortAsync(Uri serverUri, CancellationToken cancellationToken) + { + string host = serverUri.DnsSafeHost; + int port = serverUri.Port; + TimeSpan timeout = TimeSpan.FromSeconds(5); + + _dnsServer.WriteLog($"Performing pre-flight TCP check for {host}:{port} with a {timeout.TotalSeconds}-second timeout..."); + + try + { + using (CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, new CancellationTokenSource(timeout).Token)) + using (TcpClient client = new TcpClient()) + { + await client.ConnectAsync(host, port, cts.Token); + } + + _dnsServer.WriteLog($"Pre-flight TCP check successful for {host}:{port}."); + return true; + } + catch (OperationCanceledException) + { + _dnsServer.WriteLog($"ERROR: Pre-flight TCP check failed: Connection to {host}:{port} timed out after {timeout.TotalSeconds} seconds. Check firewall rules or network route."); + return false; + } + catch (SocketException ex) + { + _dnsServer.WriteLog($"ERROR: Pre-flight TCP check failed: A network error occurred for {host}:{port}. Error: {ex.Message}"); + return false; + } + catch (Exception ex) + { + _dnsServer.WriteLog($"ERROR: An unexpected error occurred during the pre-flight TCP check for {host}:{port}. Error: {ex.Message}"); + return false; + } + } + + private HttpClient CreateHttpClient(Uri serverUrl, bool disableTlsValidation) + { + SocketsHttpHandler handler = new SocketsHttpHandler + { + Proxy = _dnsServer.Proxy, + UseProxy = _dnsServer.Proxy != null, + SslOptions = new SslClientAuthenticationOptions(), + ConnectTimeout = TimeSpan.FromSeconds(15) + }; + + if (disableTlsValidation) + { + handler.SslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true; + _dnsServer.WriteLog($"WARNING: TLS certificate validation is DISABLED for MISP server: {serverUrl}"); + } + + return new HttpClient(new HttpClientNetworkHandler(handler, _dnsServer.PreferIPv6 ? HttpClientNetworkType.PreferIPv6 : HttpClientNetworkType.Default, _dnsServer)); + } + + private async Task> FetchIocFromMispAsync(CancellationToken cancellationToken) + { + HashSet iocSet = new HashSet(StringComparer.OrdinalIgnoreCase); + int page = 1; + int limit = _config.PaginationLimit; + bool hasMorePages = true; + + _dnsServer.WriteLog($"Starting paginated fetch from MISP API with a page size of {limit}..."); + const int maxRetries = 3; + + while (hasMorePages) + { + int attempt = 0; + MispResponse mispResponse = null; + + while (attempt < maxRetries) + { + attempt++; + try + { + MispRequestBody requestBody = new MispRequestBody + { + Type = "domain", + To_ids = true, + Deleted = false, + Last = _config.MaxIocAge, + Limit = limit, + Page = page + }; + StringContent requestContent = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json"); + + using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, _mispApiUrl) { Content = requestContent }; + request.Headers.Add("Authorization", _config.MispApiKey); + request.Headers.Add("Accept", "application/json"); + + _dnsServer.WriteLog($"Fetching page {page}, attempt {attempt}/{maxRetries}..."); + using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + // This is a definitive failure from the server (e.g., 403, 500). + // We should not retry this. Abort immediately. + string errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"MISP API returned a non-success status code: {(int)response.StatusCode}. Body: {errorBody}", null, response.StatusCode); + } + + await using (Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken)) + mispResponse = await JsonSerializer.DeserializeAsync(responseStream, cancellationToken: cancellationToken); + + break; + } + catch (Exception ex) when (ex is HttpRequestException || ex is SocketException || ex is OperationCanceledException) + { + // These are likely transient network errors, so we should retry. + _dnsServer.WriteLog($"WARNING: A transient network error occurred on page {page}, attempt {attempt}/{maxRetries}. Error: {ex.Message}"); + if (attempt < maxRetries) + { + TimeSpan delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)) + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000)); + _dnsServer.WriteLog($"Waiting for {delay.TotalSeconds:F1} seconds before retrying..."); + await Task.Delay(delay, cancellationToken); + } + else + { + // All retries have failed for this page. + _dnsServer.WriteLog($"ERROR: Failed to fetch page {page} after {maxRetries} attempts. Aborting entire update cycle."); + throw; + } + } + } + + List attributes = mispResponse?.Response?.Attribute; + if (attributes == null || attributes.Count == 0) + { + hasMorePages = false; + continue; + } + + foreach (MispAttribute attribute in attributes) + { + string ioc = attribute.Value?.Trim().ToLowerInvariant(); + if (!string.IsNullOrEmpty(ioc)) + { + if (DnsClient.IsDomainNameValid(ioc)) + { + iocSet.Add(ioc); + } + } + } + + // Assumption: If we received fewer items than our limit, it must be the last page. + if (attributes.Count < limit) + { + hasMorePages = false; + } + else + { + page++; + } + } + + _dnsServer.WriteLog($"Finished paginated fetch. Freezing {iocSet.Count} IOCs for optimal read performance..."); + return iocSet; + } + + private bool IsDomainBlocked(string domain, out string foundZone) + { + FrozenSet currentBlocklist = _domainBlocklist; + + ReadOnlySpan currentSpan = domain.AsSpan(); + + while (true) + { + // To look up in a HashSet, we must provide a string. + string key = new string(currentSpan); + if (currentBlocklist.TryGetValue(key, out foundZone)) + { + return true; + } + + int dotIndex = currentSpan.IndexOf('.'); + if (dotIndex == -1) + { + break; // No more parent domains. + } + + // Slice to the parent domain view. No allocation here. + currentSpan = currentSpan.Slice(dotIndex + 1); + } + + foundZone = null; + return false; + } + + private async Task LoadBlocklistFromCacheAsync() + { + if (File.Exists(_domainCacheFilePath)) + { + try + { + FrozenSet domains = (await File.ReadAllLinesAsync(_domainCacheFilePath)).ToHashSet(StringComparer.OrdinalIgnoreCase).ToFrozenSet(StringComparer.OrdinalIgnoreCase); + Interlocked.Exchange(ref _domainBlocklist, domains); + _dnsServer.WriteLog($"MISP Connector: Loaded {domains.Count} domains from cache."); + } + catch (IOException ex) + { + _dnsServer.WriteLog($"ERROR: Failed to read cache file '{_domainCacheFilePath}'. Error: {ex.Message}"); + } + } + } + + private async Task UpdateIocsAsync(CancellationToken cancellationToken) + { + if (!await CheckTcpPortAsync(new Uri(_config.MispServerUrl), cancellationToken)) + { + return; + } + + _dnsServer.WriteLog("MISP Connector: Starting IOC update..."); + + HashSet tmpDomains = await FetchIocFromMispAsync(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + FrozenSet domains = tmpDomains.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + if (!domains.SetEquals(_domainBlocklist)) + { + await WriteIocsToCacheAsync(domains, cancellationToken); + Interlocked.Exchange(ref _domainBlocklist, domains); + _dnsServer.WriteLog($"MISP Connector: Successfully updated blocklist with {domains.Count} domains."); + } + else + { + _dnsServer.WriteLog("MISP data has not changed. No update to blocklist or cache is necessary."); + } + } + + private async Task WriteIocsToCacheAsync(FrozenSet iocs, CancellationToken cancellationToken) + { + string tempPath = _domainCacheFilePath + ".tmp"; + await File.WriteAllLinesAsync(tempPath, iocs, cancellationToken); + File.Move(tempPath, _domainCacheFilePath, true); + } + + #endregion private + + #region properties + + public string Description + { + get + { + return "A focused connector that imports domain IOCs from a MISP server to block malicious domains using direct REST API calls."; + } + } + + #endregion properties + + private class Config + { + [JsonPropertyName("addExtendedDnsError")] + public bool AddExtendedDnsError { get; set; } = true; + + [JsonPropertyName("allowTxtBlockingReport")] + public bool AllowTxtBlockingReport { get; set; } = true; + + [JsonPropertyName("disableTlsValidation")] + public bool DisableTlsValidation { get; set; } = false; + + [JsonPropertyName("enableBlocking")] + public bool EnableBlocking { get; set; } = true; + [JsonPropertyName("maxIocAge")] + [Required(ErrorMessage = "maxIocAge is a required configuration property.")] + [RegularExpression(@"^\d+[mhd]$", ErrorMessage = "Invalid interval format. Use a number followed by 'm', 'h', or 'd' (e.g., '90m', '2h', '7d').", MatchTimeoutInMilliseconds = 3000)] + public string MaxIocAge { get; set; } + + [JsonPropertyName("mispApiKey")] + [Required(ErrorMessage = "mispApiKey is a required configuration property.")] + [MinLength(1, ErrorMessage = "mispApiKey cannot be empty.")] + public string MispApiKey { get; set; } + + [JsonPropertyName("mispServerUrl")] + [Required(ErrorMessage = "mispServerUrl is a required configuration property.")] + [Url(ErrorMessage = "mispServerUrl must be a valid URL.")] + public string MispServerUrl { get; set; } + [JsonPropertyName("paginationLimit")] + public int PaginationLimit { get; set; } = 5000; + + [JsonPropertyName("updateInterval")] + [Required(ErrorMessage = "updateInterval is a required configuration property.")] + [RegularExpression(@"^\d+[mhd]$", ErrorMessage = "Invalid interval format. Use a number followed by 'm', 'h', or 'd' (e.g., '90m', '2h', '7d').", MatchTimeoutInMilliseconds = 3000)] + public string UpdateInterval { get; set; } + } + + private class MispAttribute + { + [JsonPropertyName("value")] + public string Value { get; set; } + } + + private class MispRequestBody + { + [JsonPropertyName("deleted")] + public bool Deleted { get; set; } + + [JsonPropertyName("last")] + public string Last { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("to_ids")] + public bool To_ids { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + } + + private class MispResponse + { + [JsonPropertyName("response")] + public MispResponseData Response { get; set; } + } + + private class MispResponseData + { + [JsonPropertyName("Attribute")] + public List Attribute { get; set; } + } + } +} \ No newline at end of file diff --git a/Apps/MispConnectorApp/MispConnectorApp.csproj b/Apps/MispConnectorApp/MispConnectorApp.csproj new file mode 100644 index 00000000..acba681d --- /dev/null +++ b/Apps/MispConnectorApp/MispConnectorApp.csproj @@ -0,0 +1,43 @@ + + + + net8.0 + false + 8.0 + false + Technitium + Technitium DNS Server + Zafer Balkan + MispConnectorApp + MispConnector + https://technitium.com/dns/ + https://github.com/TechnitiumSoftware/DnsServer + Block domain names extracted MISP instance by querying IOCs.\n\nNote! This app works independent of the DNS server's built-in blocking feature. The options configured in DNS server Settings section does not apply to this app. + false + Library + + + + + false + + + + + + ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.dll + false + + + ..\..\..\TechnitiumLibrary\bin\TechnitiumLibrary.Net.dll + false + + + + + + PreserveNewest + + + + diff --git a/Apps/MispConnectorApp/README.md b/Apps/MispConnectorApp/README.md new file mode 100644 index 00000000..5b8a4a6c --- /dev/null +++ b/Apps/MispConnectorApp/README.md @@ -0,0 +1,43 @@ +# MISP Connector for Technitium DNS Server + +A plugin that pulls malicious domain names from MISP feeds and enforces blocking in Technitium DNS. + +It maintains in-memory blocklists with disk-backed caching and periodically refreshes from the source. + +## Features + +- Retrieves indicators of compromise (IOCs) aka. malicious domain names from a MISP server via its REST API. +- Handles paginated fetches with exponential backoff and retry on transient failures. +- Stores the latest blocklist in memory for fast lookup and persists it to disk for faster startup. +- Blocks matching DNS requests by returning NXDOMAIN or, for TXT queries when enabled, a human-readable blocking report. +- Optionally includes extended DNS error metadata. +- Configurable refresh interval and age window for which indicators are considered. +- Optional disabling of TLS certificate validation with explicit warning in logs. + +## Configuration + +Supply a JSON configuration like the following: + +```json +{ + "enableBlocking": true, + "mispServerUrl": "https://misp.example.com", + "mispApiKey": "YourMispApiKeyHere", + "disableTlsValidation": false, + "updateInterval": "2h", + "maxIocAge": "15d", + "allowTxtBlockingReport": true, + "paginationLimit": 5000, + "addExtendedDnsError": true +} +``` + +- You can disable the app without uninstalling. +- You can disable TLS validation for test instances and homelabs, but **it is not recommended use this option in production**. +- The `maxIocAge` option is used for filtering IOCs wih `lastSeen` attributes on MISP. So, you can dynamically filter for recent campaigns. +- The `allowTxtBlockingReport` rewrites the response with a blocking report. +- The `addExtendedDnsError` is useful when logs are exported to a SIEM. The blocking report gets added to EDNS payload of the package. + +# Acknowledgement + +Thanks to everyone who has been part of or contributed to [MISP Project](https://www.misp-project.org/) for being an amazing resource. \ No newline at end of file diff --git a/Apps/MispConnectorApp/dnsApp.config b/Apps/MispConnectorApp/dnsApp.config new file mode 100644 index 00000000..e5e76a05 --- /dev/null +++ b/Apps/MispConnectorApp/dnsApp.config @@ -0,0 +1,11 @@ +{ + "enableBlocking": true, + "mispServerUrl": "https://misp.example.com", + "mispApiKey": "YourMispApiKeyHere", + "disableTlsValidation": false, + "updateInterval": "2h", + "maxIocAge": "30d", + "allowTxtBlockingReport": true, + "paginationLimit": 5000, + "addExtendedDnsError": true +} diff --git a/DnsServer.sln b/DnsServer.sln index 24ad9414..676a1040 100644 --- a/DnsServer.sln +++ b/DnsServer.sln @@ -67,6 +67,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryLogsSqlServerApp", "Ap 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -197,6 +199,10 @@ Global {699E2A1D-D917-4825-939E-65CDB2B16A96}.Debug|Any CPU.Build.0 = Debug|Any CPU {699E2A1D-D917-4825-939E-65CDB2B16A96}.Release|Any CPU.ActiveCfg = Release|Any CPU {699E2A1D-D917-4825-939E-65CDB2B16A96}.Release|Any CPU.Build.0 = Release|Any CPU + {83C8180A-0F86-F9A0-8F41-6FD61FAC41CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83C8180A-0F86-F9A0-8F41-6FD61FAC41CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83C8180A-0F86-F9A0-8F41-6FD61FAC41CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83C8180A-0F86-F9A0-8F41-6FD61FAC41CB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -228,6 +234,7 @@ Global {0A9B7F39-80DA-4084-AD47-8707576927ED} = {938BF8EF-74B9-4FE0-B46F-11EBB7A4B3D2} {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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6747BB6D-2826-4356-A213-805FBCCF9201}