/* Technitium DNS Server Copyright (C) 2024 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.Net; using System.Net.Http; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using TechnitiumLibrary; using TechnitiumLibrary.Net; using TechnitiumLibrary.Net.Dns; using TechnitiumLibrary.Net.Dns.ResourceRecords; using TechnitiumLibrary.Net.Http.Client; using TechnitiumLibrary.Net.Proxy; namespace Failover { enum HealthCheckType { Unknown = 0, Ping = 1, Tcp = 2, Http = 3, Https = 4 } class HealthCheck : IDisposable { #region variables const string HTTP_HEALTH_CHECK_USER_AGENT = "DNS Failover App (Technitium DNS Server)"; readonly HealthService _service; readonly string _name; HealthCheckType _type; int _interval; int _retries; int _timeout; int _port; Uri _url; EmailAlert _emailAlert; WebHook _webHook; SocketsHttpHandler _httpHandler; HttpClientNetworkHandler _httpCustomResolverHandler; HttpClient _httpClient; #endregion #region constructor public HealthCheck(HealthService service, JsonElement jsonHealthCheck) { _service = service; _name = jsonHealthCheck.GetPropertyValue("name", "default"); Reload(jsonHealthCheck); } #endregion #region IDisposable bool _disposed; protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { if (_httpClient != null) { _httpClient.Dispose(); _httpClient = null; } if (_httpHandler != null) { _httpHandler.Dispose(); _httpHandler = null; } } _disposed = true; } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } #endregion #region private private void ConditionalHttpReload() { switch (_type) { case HealthCheckType.Http: case HealthCheckType.Https: bool handlerChanged = false; NetProxy proxy = _service.DnsServer.Proxy; if (_httpHandler is null) { SocketsHttpHandler httpHandler = new SocketsHttpHandler(); httpHandler.ConnectTimeout = TimeSpan.FromMilliseconds(_timeout); httpHandler.PooledConnectionIdleTimeout = TimeSpan.FromMilliseconds(Math.Max(10000, _timeout)); httpHandler.Proxy = proxy; httpHandler.UseProxy = proxy is not null; httpHandler.AllowAutoRedirect = false; _httpHandler = httpHandler; handlerChanged = true; } else { if ((_httpHandler.ConnectTimeout.TotalMilliseconds != _timeout) || (_httpHandler.Proxy != proxy)) { SocketsHttpHandler httpHandler = new SocketsHttpHandler(); httpHandler.ConnectTimeout = TimeSpan.FromMilliseconds(_timeout); httpHandler.PooledConnectionIdleTimeout = TimeSpan.FromMilliseconds(Math.Max(10000, _timeout)); httpHandler.Proxy = proxy; httpHandler.UseProxy = proxy is not null; httpHandler.AllowAutoRedirect = false; SocketsHttpHandler oldHttpHandler = _httpHandler; _httpHandler = httpHandler; handlerChanged = true; oldHttpHandler.Dispose(); } } if ((_httpCustomResolverHandler is null) || handlerChanged) _httpCustomResolverHandler = new HttpClientNetworkHandler(_httpHandler, _service.DnsServer.PreferIPv6 ? HttpClientNetworkType.PreferIPv6 : HttpClientNetworkType.Default, _service.DnsServer); if (_httpClient is null) { HttpClient httpClient = new HttpClient(_httpCustomResolverHandler); httpClient.Timeout = TimeSpan.FromMilliseconds(_timeout); httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(HTTP_HEALTH_CHECK_USER_AGENT); httpClient.DefaultRequestHeaders.ConnectionClose = true; _httpClient = httpClient; } else { if (handlerChanged || (_httpClient.Timeout.TotalMilliseconds != _timeout)) { HttpClient httpClient = new HttpClient(_httpCustomResolverHandler); httpClient.Timeout = TimeSpan.FromMilliseconds(_timeout); httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(HTTP_HEALTH_CHECK_USER_AGENT); httpClient.DefaultRequestHeaders.ConnectionClose = true; HttpClient oldHttpClient = _httpClient; _httpClient = httpClient; oldHttpClient.Dispose(); } } break; default: if (_httpClient != null) { _httpClient.Dispose(); _httpClient = null; } if (_httpHandler != null) { _httpHandler.Dispose(); _httpHandler = null; } break; } } #endregion #region public public void Reload(JsonElement jsonHealthCheck) { _type = Enum.Parse(jsonHealthCheck.GetPropertyValue("type", "Tcp"), true); _interval = jsonHealthCheck.GetPropertyValue("interval", 60) * 1000; _retries = jsonHealthCheck.GetPropertyValue("retries", 3); _timeout = jsonHealthCheck.GetPropertyValue("timeout", 10) * 1000; _port = jsonHealthCheck.GetPropertyValue("port", 80); if (jsonHealthCheck.TryGetProperty("url", out JsonElement jsonUrl) && (jsonUrl.ValueKind != JsonValueKind.Null)) _url = new Uri(jsonUrl.GetString()); else _url = null; if (jsonHealthCheck.TryGetProperty("emailAlert", out JsonElement jsonEmailAlert) && _service.EmailAlerts.TryGetValue(jsonEmailAlert.GetString(), out EmailAlert emailAlert)) _emailAlert = emailAlert; else _emailAlert = null; if (jsonHealthCheck.TryGetProperty("webHook", out JsonElement jsonWebHook) && _service.WebHooks.TryGetValue(jsonWebHook.GetString(), out WebHook webHook)) _webHook = webHook; else _webHook = null; ConditionalHttpReload(); } public async Task IsHealthyAsync(string domain, DnsResourceRecordType type, Uri healthCheckUrl) { switch (type) { case DnsResourceRecordType.A: { DnsDatagram response = await _service.DnsServer.DirectQueryAsync(new DnsQuestionRecord(domain, type, DnsClass.IN)); if ((response is null) || (response.Answer.Count == 0)) return new HealthCheckResponse(HealthStatus.Failed, "Failed to resolve address."); IReadOnlyList addresses = DnsClient.ParseResponseA(response); if (addresses.Count > 0) { HealthCheckResponse lastResponse = null; foreach (IPAddress address in addresses) { lastResponse = await IsHealthyAsync(address, healthCheckUrl); if (lastResponse.Status == HealthStatus.Healthy) return lastResponse; } return lastResponse; } return new HealthCheckResponse(HealthStatus.Failed, "Failed to resolve address."); } case DnsResourceRecordType.AAAA: { DnsDatagram response = await _service.DnsServer.DirectQueryAsync(new DnsQuestionRecord(domain, type, DnsClass.IN)); if ((response is null) || (response.Answer.Count == 0)) return new HealthCheckResponse(HealthStatus.Failed, "Failed to resolve address."); IReadOnlyList addresses = DnsClient.ParseResponseAAAA(response); if (addresses.Count > 0) { HealthCheckResponse lastResponse = null; foreach (IPAddress address in addresses) { lastResponse = await IsHealthyAsync(address, healthCheckUrl); if (lastResponse.Status == HealthStatus.Healthy) return lastResponse; } return lastResponse; } return new HealthCheckResponse(HealthStatus.Failed, "Failed to resolve address."); } default: return new HealthCheckResponse(HealthStatus.Failed, "Not supported."); } } public async Task IsHealthyAsync(IPAddress address, Uri healthCheckUrl) { foreach (KeyValuePair network in _service.UnderMaintenance) { if (network.Key.Contains(address)) { if (network.Value) return new HealthCheckResponse(HealthStatus.Maintenance); break; } } switch (_type) { case HealthCheckType.Ping: { if (_service.DnsServer.Proxy != null) throw new NotSupportedException("Health check type 'ping' is not supported over proxy."); using (Ping ping = new Ping()) { string lastReason; int retry = 0; do { PingReply reply = await ping.SendPingAsync(address, _timeout); if (reply.Status == IPStatus.Success) return new HealthCheckResponse(HealthStatus.Healthy); lastReason = reply.Status.ToString(); } while (++retry < _retries); return new HealthCheckResponse(HealthStatus.Failed, lastReason); } } case HealthCheckType.Tcp: { Exception lastException; string lastReason = null; int retry = 0; do { try { NetProxy proxy = _service.DnsServer.Proxy; if (proxy is null) { using (Socket socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)) { await TechnitiumLibrary.TaskExtensions.TimeoutAsync(delegate (CancellationToken cancellationToken1) { return socket.ConnectAsync(address, _port, cancellationToken1).AsTask(); }, _timeout); } } else { using (Socket socket = await TechnitiumLibrary.TaskExtensions.TimeoutAsync(delegate (CancellationToken cancellationToken1) { return proxy.ConnectAsync(new IPEndPoint(address, _port), cancellationToken1); }, _timeout)) { //do nothing } } return new HealthCheckResponse(HealthStatus.Healthy); } catch (TimeoutException ex) { lastReason = "Connection timed out."; lastException = ex; } catch (SocketException ex) { lastReason = ex.Message; lastException = ex; } catch (Exception ex) { lastException = ex; } } while (++retry < _retries); return new HealthCheckResponse(HealthStatus.Failed, lastReason, lastException); } case HealthCheckType.Http: case HealthCheckType.Https: { ConditionalHttpReload(); Exception lastException; string lastReason = null; int retry = 0; do { try { Uri url; if (_url is null) url = healthCheckUrl; else url = _url; if (url is null) return new HealthCheckResponse(HealthStatus.Failed, "Missing health check URL in APP record as well as in app config."); if (_type == HealthCheckType.Http) { if (url.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) url = new Uri("http://" + url.Host + (url.IsDefaultPort ? "" : ":" + url.Port) + url.PathAndQuery); } else { if (url.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)) url = new Uri("https://" + url.Host + (url.IsDefaultPort ? "" : ":" + url.Port) + url.PathAndQuery); } IPEndPoint ep = new IPEndPoint(address, url.Port); Uri queryUri = new Uri(url.Scheme + "://" + ep.ToString() + url.PathAndQuery); HttpRequestMessage httpRequest = new HttpRequestMessage(HttpMethod.Get, queryUri); if (url.IsDefaultPort) httpRequest.Headers.Host = url.Host; else httpRequest.Headers.Host = url.Host + ":" + url.Port; HttpResponseMessage httpResponse = await _httpClient.SendAsync(httpRequest); if (httpResponse.IsSuccessStatusCode) return new HealthCheckResponse(HealthStatus.Healthy); return new HealthCheckResponse(HealthStatus.Failed, "Received HTTP status code: " + (int)httpResponse.StatusCode + " " + httpResponse.StatusCode.ToString() + "; URL: " + url.AbsoluteUri); } catch (TaskCanceledException ex) { lastReason = "Connection timed out."; lastException = ex; } catch (HttpRequestException ex) { lastReason = ex.Message; lastException = ex; } catch (Exception ex) { lastException = ex; } } while (++retry < _retries); return new HealthCheckResponse(HealthStatus.Failed, lastReason, lastException); } default: throw new NotSupportedException(); } } #endregion #region properties public string Name { get { return _name; } } public HealthCheckType Type { get { return _type; } } public int Interval { get { return _interval; } } public int Retries { get { return _retries; } } public int Timeout { get { return _timeout; } } public int Port { get { return _port; } } public Uri Url { get { return _url; } } public EmailAlert EmailAlert { get { return _emailAlert; } } public WebHook WebHook { get { return _webHook; } } #endregion } }