pdns.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import json
  2. import re
  3. import socket
  4. from functools import cache
  5. from hashlib import sha1
  6. import requests
  7. from django.conf import settings
  8. from django.core.exceptions import SuspiciousOperation
  9. from desecapi import metrics
  10. from desecapi.exceptions import PDNSException, RequestEntityTooLarge
  11. SUPPORTED_RRSET_TYPES = {
  12. # https://doc.powerdns.com/authoritative/appendices/types.html
  13. # "major" types
  14. "A",
  15. "AAAA",
  16. "AFSDB",
  17. "ALIAS",
  18. "APL",
  19. "CAA",
  20. "CERT",
  21. "CDNSKEY",
  22. "CDS",
  23. "CNAME",
  24. "CSYNC",
  25. "DNSKEY",
  26. "DNAME",
  27. "DS",
  28. "HINFO",
  29. "HTTPS",
  30. "KEY",
  31. "LOC", # TODO track https://github.com/PowerDNS/pdns/issues/10558
  32. "MX",
  33. "NAPTR",
  34. "NS",
  35. "NSEC",
  36. "NSEC3",
  37. "NSEC3PARAM",
  38. "OPENPGPKEY",
  39. "PTR",
  40. "RP",
  41. "RRSIG",
  42. "SOA",
  43. "SPF",
  44. "SSHFP",
  45. "SRV",
  46. "SVCB",
  47. "TLSA",
  48. "SMIMEA",
  49. "TXT",
  50. "URI",
  51. # "additional" types, without obsolete ones
  52. "DHCID",
  53. "DLV",
  54. "EUI48",
  55. "EUI64",
  56. "IPSECKEY",
  57. "KX",
  58. "MINFO",
  59. "MR",
  60. "RKEY",
  61. "WKS",
  62. # https://doc.powerdns.com/authoritative/changelog/4.5.html#change-4.5.0-alpha1-New-Features
  63. "NID",
  64. "L32",
  65. "L64",
  66. "LP",
  67. }
  68. NSLORD = object()
  69. NSMASTER = object()
  70. _config = {
  71. NSLORD: {
  72. "base_url": settings.NSLORD_PDNS_API,
  73. "apikey": settings.NSLORD_PDNS_API_TOKEN,
  74. },
  75. NSMASTER: {
  76. "base_url": settings.NSMASTER_PDNS_API,
  77. "apikey": settings.NSMASTER_PDNS_API_TOKEN,
  78. },
  79. }
  80. @cache
  81. def gethostbyname_cached(host):
  82. return socket.gethostbyname(host)
  83. def _pdns_request(
  84. method, *, server, path, data=None, accept="application/json", **kwargs
  85. ):
  86. if data is not None:
  87. data = json.dumps(data)
  88. if data is not None and len(data) > settings.PDNS_MAX_BODY_SIZE:
  89. raise RequestEntityTooLarge
  90. headers = {
  91. "Accept": accept,
  92. "User-Agent": "desecapi",
  93. "X-API-Key": _config[server]["apikey"],
  94. }
  95. r = requests.request(
  96. method, _config[server]["base_url"] + path, data=data, headers=headers
  97. )
  98. if r.status_code not in range(200, 300):
  99. metrics.get("desecapi_pdns_request_failure").labels(
  100. method, path, r.status_code
  101. ).inc()
  102. raise PDNSException(response=r)
  103. metrics.get("desecapi_pdns_request_success").labels(method, r.status_code).inc()
  104. return r
  105. def _pdns_post(server, path, data, **kwargs):
  106. return _pdns_request("post", server=server, path=path, data=data, **kwargs)
  107. def _pdns_patch(server, path, data, **kwargs):
  108. return _pdns_request("patch", server=server, path=path, data=data, **kwargs)
  109. def _pdns_get(server, path, **kwargs):
  110. return _pdns_request("get", server=server, path=path, **kwargs)
  111. def _pdns_put(server, path, **kwargs):
  112. return _pdns_request("put", server=server, path=path, **kwargs)
  113. def _pdns_delete(server, path, **kwargs):
  114. return _pdns_request("delete", server=server, path=path, **kwargs)
  115. def pdns_id(name):
  116. # See also pdns code, apiZoneNameToId() in ws-api.cc (with the exception of forward slash)
  117. if not re.match(r"^[a-zA-Z0-9_.-]+$", name):
  118. raise SuspiciousOperation("Invalid hostname " + name)
  119. name = name.translate(str.maketrans({"/": "=2F", "_": "=5F"}))
  120. return name.rstrip(".") + "."
  121. def get_keys(domain):
  122. """
  123. Retrieves a dict representation of the DNSSEC key information
  124. """
  125. r = _pdns_get(NSLORD, "/zones/%s/cryptokeys" % pdns_id(domain.name))
  126. metrics.get("desecapi_pdns_keys_fetched").inc()
  127. field_map = {
  128. "dnskey": "dnskey",
  129. "cds": "ds",
  130. "flags": "flags", # deprecated
  131. "keytype": "keytype", # deprecated
  132. }
  133. return [
  134. {v: key.get(k, []) for k, v in field_map.items()}
  135. for key in r.json()
  136. if key["published"]
  137. ]
  138. def get_zone(domain):
  139. """
  140. Retrieves a dict representation of the zone from pdns
  141. """
  142. r = _pdns_get(NSLORD, "/zones/" + pdns_id(domain.name))
  143. return r.json()
  144. def get_zonefile(domain) -> bin:
  145. """
  146. Retrieves the zonefile (presentation format) of a given zone as binary string
  147. """
  148. r = _pdns_get(
  149. NSLORD, "/zones/" + pdns_id(domain.name) + "/export", accept="text/dns"
  150. )
  151. return r.content
  152. def get_rrset_datas(domain):
  153. """
  154. Retrieves a dict representation of the RRsets in a given zone
  155. """
  156. return [
  157. {
  158. "domain": domain,
  159. "subname": rrset["name"][: -(len(domain.name) + 2)],
  160. "type": rrset["type"],
  161. "records": [record["content"] for record in rrset["records"]],
  162. "ttl": rrset["ttl"],
  163. }
  164. for rrset in get_zone(domain)["rrsets"]
  165. ]
  166. def update_catalog(zone, delete=False):
  167. """
  168. Updates the catalog zone information (`settings.CATALOG_ZONE`) for the given zone.
  169. """
  170. content = _pdns_patch(
  171. NSMASTER,
  172. "/zones/" + pdns_id(settings.CATALOG_ZONE),
  173. {"rrsets": [construct_catalog_rrset(zone=zone, delete=delete)]},
  174. )
  175. metrics.get("desecapi_pdns_catalog_updated").inc()
  176. return content
  177. def create_zone_lord(name):
  178. name = name.rstrip(".") + "."
  179. _pdns_post(
  180. NSLORD,
  181. "/zones?rrsets=false",
  182. {
  183. "name": name,
  184. "kind": "MASTER",
  185. "dnssec": True,
  186. "nsec3param": "1 0 0 -",
  187. "nameservers": settings.DEFAULT_NS,
  188. "rrsets": [
  189. {
  190. "name": name,
  191. "type": "SOA",
  192. # SOA RRset TTL: 300 (used as TTL for negative replies including NSEC3 records)
  193. "ttl": 300,
  194. "records": [
  195. {
  196. # SOA refresh: 1 day (only needed for nslord --> nsmaster replication after RRSIG rotation)
  197. # SOA retry = 1h
  198. # SOA expire: 4 weeks (all signatures will have expired anyways)
  199. # SOA minimum: 3600 (for CDS, CDNSKEY, DNSKEY, NSEC3PARAM)
  200. "content": "get.desec.io. get.desec.io. 1 86400 3600 2419200 3600",
  201. "disabled": False,
  202. }
  203. ],
  204. }
  205. ],
  206. },
  207. )
  208. def create_zone_master(name):
  209. name = name.rstrip(".") + "."
  210. _pdns_post(
  211. NSMASTER,
  212. "/zones?rrsets=false",
  213. {
  214. "name": name,
  215. "kind": "SLAVE",
  216. "masters": [gethostbyname_cached("nslord")],
  217. "master_tsig_key_ids": ["default"],
  218. },
  219. )
  220. def delete_zone(name, server):
  221. _pdns_delete(server, "/zones/" + pdns_id(name))
  222. def delete_zone_lord(name):
  223. _pdns_delete(NSLORD, "/zones/" + pdns_id(name))
  224. def delete_zone_master(name):
  225. _pdns_delete(NSMASTER, "/zones/" + pdns_id(name))
  226. def update_zone(name, data):
  227. _pdns_patch(NSLORD, "/zones/" + pdns_id(name), data)
  228. def axfr_to_master(zone):
  229. _pdns_put(NSMASTER, "/zones/%s/axfr-retrieve" % pdns_id(zone))
  230. def construct_catalog_rrset(
  231. zone=None, delete=False, subname=None, qtype="PTR", rdata=None
  232. ):
  233. # subname can be generated from zone for convenience; exactly one needs to be given
  234. assert (zone is None) ^ (subname is None)
  235. # sanity check: one can't delete an rrset and give record data at the same time
  236. assert not (delete and rdata)
  237. if subname is None:
  238. zone = zone.rstrip(".") + "."
  239. m_unique = sha1(zone.encode()).hexdigest()
  240. subname = f"{m_unique}.zones"
  241. if rdata is None:
  242. rdata = zone
  243. return {
  244. "name": f"{subname}.{settings.CATALOG_ZONE}".strip(".") + ".",
  245. "type": qtype,
  246. "ttl": 0, # as per the specification
  247. "changetype": "REPLACE",
  248. "records": [] if delete else [{"content": rdata, "disabled": False}],
  249. }
  250. def get_serials():
  251. return {
  252. zone["name"]: zone["edited_serial"]
  253. for zone in _pdns_get(NSMASTER, "/zones").json()
  254. }