CNAME.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. /*
  2. Technitium DNS Server
  3. Copyright (C) 2024 Shreyas Zare (shreyas@technitium.com)
  4. This program is free software: you can redistribute it and/or modify
  5. it under the terms of the GNU General Public License as published by
  6. the Free Software Foundation, either version 3 of the License, or
  7. (at your option) any later version.
  8. This program is distributed in the hope that it will be useful,
  9. but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. GNU General Public License for more details.
  12. You should have received a copy of the GNU General Public License
  13. along with this program. If not, see <http://www.gnu.org/licenses/>.
  14. */
  15. using DnsServerCore.ApplicationCommon;
  16. using System;
  17. using System.Collections.Generic;
  18. using System.Net;
  19. using System.Text.Json;
  20. using System.Threading.Tasks;
  21. using TechnitiumLibrary;
  22. using TechnitiumLibrary.Net.Dns;
  23. using TechnitiumLibrary.Net.Dns.ResourceRecords;
  24. namespace Failover
  25. {
  26. public sealed class CNAME : IDnsApplication, IDnsAppRecordRequestHandler
  27. {
  28. #region variables
  29. HealthService _healthService;
  30. #endregion
  31. #region IDisposable
  32. bool _disposed;
  33. public void Dispose()
  34. {
  35. if (_disposed)
  36. return;
  37. if (_healthService is not null)
  38. _healthService.Dispose();
  39. _disposed = true;
  40. }
  41. #endregion
  42. #region private
  43. private DnsResourceRecord[] GetAnswers(string domain, DnsQuestionRecord question, string zoneName, uint appRecordTtl, string healthCheck, Uri healthCheckUrl)
  44. {
  45. DnsResourceRecordType healthCheckRecordType;
  46. if (question.Type == DnsResourceRecordType.AAAA)
  47. healthCheckRecordType = DnsResourceRecordType.AAAA;
  48. else
  49. healthCheckRecordType = DnsResourceRecordType.A;
  50. HealthCheckResponse response = _healthService.QueryStatus(domain, healthCheckRecordType, healthCheck, healthCheckUrl, true);
  51. switch (response.Status)
  52. {
  53. case HealthStatus.Unknown:
  54. if (question.Name.Equals(zoneName, StringComparison.OrdinalIgnoreCase)) //check for zone apex
  55. return new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.ANAME, DnsClass.IN, 10, new DnsANAMERecordData(domain)) }; //use ANAME
  56. else
  57. return new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.CNAME, DnsClass.IN, 10, new DnsCNAMERecordData(domain)) };
  58. case HealthStatus.Healthy:
  59. if (question.Name.Equals(zoneName, StringComparison.OrdinalIgnoreCase)) //check for zone apex
  60. return new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.ANAME, DnsClass.IN, appRecordTtl, new DnsANAMERecordData(domain)) }; //use ANAME
  61. else
  62. return new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.CNAME, DnsClass.IN, appRecordTtl, new DnsCNAMERecordData(domain)) };
  63. }
  64. return null;
  65. }
  66. private void GetStatusAnswers(string domain, FailoverType type, DnsQuestionRecord question, uint appRecordTtl, string healthCheck, Uri healthCheckUrl, List<DnsResourceRecord> answers)
  67. {
  68. {
  69. HealthCheckResponse response = _healthService.QueryStatus(domain, DnsResourceRecordType.A, healthCheck, healthCheckUrl, false);
  70. string text = "app=failover; cnameType=" + type.ToString() + "; domain=" + domain + "; qType: A; healthCheck=" + healthCheck + (healthCheckUrl is null ? "" : "; healthCheckUrl=" + healthCheckUrl.AbsoluteUri) + "; healthStatus=" + response.Status.ToString() + ";";
  71. if (response.Status == HealthStatus.Failed)
  72. text += " failureReason=" + response.FailureReason + ";";
  73. answers.Add(new DnsResourceRecord(question.Name, DnsResourceRecordType.TXT, question.Class, appRecordTtl, new DnsTXTRecordData(text)));
  74. }
  75. {
  76. HealthCheckResponse response = _healthService.QueryStatus(domain, DnsResourceRecordType.AAAA, healthCheck, healthCheckUrl, false);
  77. string text = "app=failover; cnameType=" + type.ToString() + "; domain=" + domain + "; qType: AAAA; healthCheck=" + healthCheck + (healthCheckUrl is null ? "" : "; healthCheckUrl=" + healthCheckUrl.AbsoluteUri) + "; healthStatus=" + response.Status.ToString() + ";";
  78. if (response.Status == HealthStatus.Failed)
  79. text += " failureReason=" + response.FailureReason + ";";
  80. answers.Add(new DnsResourceRecord(question.Name, DnsResourceRecordType.TXT, question.Class, appRecordTtl, new DnsTXTRecordData(text)));
  81. }
  82. }
  83. #endregion
  84. #region public
  85. public Task InitializeAsync(IDnsServer dnsServer, string config)
  86. {
  87. if (_healthService is null)
  88. _healthService = HealthService.Create(dnsServer);
  89. //let Address class initialize config
  90. return Task.CompletedTask;
  91. }
  92. public Task<DnsDatagram> ProcessRequestAsync(DnsDatagram request, IPEndPoint remoteEP, DnsTransportProtocol protocol, bool isRecursionAllowed, string zoneName, string appRecordName, uint appRecordTtl, string appRecordData)
  93. {
  94. DnsQuestionRecord question = request.Question[0];
  95. if (!question.Name.Equals(appRecordName, StringComparison.OrdinalIgnoreCase) && !appRecordName.StartsWith('*'))
  96. return Task.FromResult<DnsDatagram>(null);
  97. using JsonDocument jsonDocument = JsonDocument.Parse(appRecordData);
  98. JsonElement jsonAppRecordData = jsonDocument.RootElement;
  99. string healthCheck = jsonAppRecordData.GetPropertyValue("healthCheck", null);
  100. Uri healthCheckUrl = null;
  101. if (_healthService.HealthChecks.TryGetValue(healthCheck, out HealthCheck hc) && ((hc.Type == HealthCheckType.Https) || (hc.Type == HealthCheckType.Http)) && (hc.Url is null))
  102. {
  103. //read health check url only for http/https type checks and only when app config does not have an url configured
  104. if (jsonAppRecordData.TryGetProperty("healthCheckUrl", out JsonElement jsonHealthCheckUrl) && (jsonHealthCheckUrl.ValueKind != JsonValueKind.Null))
  105. {
  106. healthCheckUrl = new Uri(jsonHealthCheckUrl.GetString());
  107. }
  108. else
  109. {
  110. if (hc.Type == HealthCheckType.Https)
  111. healthCheckUrl = new Uri("https://" + question.Name);
  112. else
  113. healthCheckUrl = new Uri("http://" + question.Name);
  114. }
  115. }
  116. IReadOnlyList<DnsResourceRecord> answers = null;
  117. if (question.Type == DnsResourceRecordType.TXT)
  118. {
  119. bool allowTxtStatus = jsonAppRecordData.GetPropertyValue("allowTxtStatus", false);
  120. if (!allowTxtStatus)
  121. return Task.FromResult<DnsDatagram>(null);
  122. List<DnsResourceRecord> txtAnswers = new List<DnsResourceRecord>();
  123. if (jsonAppRecordData.TryGetProperty("primary", out JsonElement jsonPrimary))
  124. GetStatusAnswers(jsonPrimary.GetString(), FailoverType.Primary, question, 30, healthCheck, healthCheckUrl, txtAnswers);
  125. if (jsonAppRecordData.TryGetProperty("secondary", out JsonElement jsonSecondary))
  126. {
  127. foreach (JsonElement jsonDomain in jsonSecondary.EnumerateArray())
  128. GetStatusAnswers(jsonDomain.GetString(), FailoverType.Secondary, question, 30, healthCheck, healthCheckUrl, txtAnswers);
  129. }
  130. answers = txtAnswers;
  131. }
  132. else
  133. {
  134. if (jsonAppRecordData.TryGetProperty("primary", out JsonElement jsonPrimary))
  135. answers = GetAnswers(jsonPrimary.GetString(), question, zoneName, appRecordTtl, healthCheck, healthCheckUrl);
  136. if (answers is null)
  137. {
  138. if (jsonAppRecordData.TryGetProperty("secondary", out JsonElement jsonSecondary))
  139. {
  140. foreach (JsonElement jsonDomain in jsonSecondary.EnumerateArray())
  141. {
  142. answers = GetAnswers(jsonDomain.GetString(), question, zoneName, appRecordTtl, healthCheck, healthCheckUrl);
  143. if (answers is not null)
  144. break;
  145. }
  146. }
  147. if (answers is null)
  148. {
  149. if (!jsonAppRecordData.TryGetProperty("serverDown", out JsonElement jsonServerDown) || (jsonServerDown.ValueKind == JsonValueKind.Null))
  150. return Task.FromResult<DnsDatagram>(null);
  151. string serverDown = jsonServerDown.GetString();
  152. if (question.Name.Equals(zoneName, StringComparison.OrdinalIgnoreCase)) //check for zone apex
  153. answers = new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.ANAME, DnsClass.IN, 30, new DnsANAMERecordData(serverDown)) }; //use ANAME
  154. else
  155. answers = new DnsResourceRecord[] { new DnsResourceRecord(question.Name, DnsResourceRecordType.CNAME, DnsClass.IN, 30, new DnsCNAMERecordData(serverDown)) };
  156. }
  157. }
  158. }
  159. return Task.FromResult(new DnsDatagram(request.Identifier, true, request.OPCODE, true, false, request.RecursionDesired, isRecursionAllowed, false, false, DnsResponseCode.NoError, request.Question, answers));
  160. }
  161. #endregion
  162. #region properties
  163. public string Description
  164. { get { return "Returns CNAME record for primary domain name with a continous health check as configured in the app config. When the primary domain name is unhealthy, the app returns one of the secondary domain names in the given order of preference that is healthy. When none of the primary and secondary domain names are healthy, the app returns the server down domain name. The server down feature is expected to be used for showing a service status page and not to serve the actual content. Note that the app will return ANAME record for an APP record at zone apex.\n\nIf an URL is provided for the health check in the app's config then it will override the 'healthCheckUrl' parameter. When an URL is not provided in 'healthCheckUrl' parameter for 'http' or 'https' type health check, the domain name of the APP record will be used to auto generate an URL.\n\nSet 'allowTxtStatus' parameter to 'true' in your APP record data to allow checking health status by querying for TXT record."; } }
  165. public string ApplicationRecordDataTemplate
  166. {
  167. get
  168. {
  169. return @"{
  170. ""primary"": ""in.example.org"",
  171. ""secondary"": [
  172. ""sg.example.org"",
  173. ""eu.example.org""
  174. ],
  175. ""serverDown"": ""status.example.org"",
  176. ""healthCheck"": ""tcp443"",
  177. ""healthCheckUrl"": null,
  178. ""allowTxtStatus"": false
  179. }";
  180. }
  181. }
  182. #endregion
  183. }
  184. }