granny-devops 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. granny/__init__.py +19 -0
  2. granny/analyze/__init__.py +6 -0
  3. granny/analyze/lambdas.py +59 -0
  4. granny/analyze/vpcs.py +57 -0
  5. granny/cdn/__init__.py +9 -0
  6. granny/cdn/bunny.py +231 -0
  7. granny/cli/__init__.py +0 -0
  8. granny/cli/analyze.py +66 -0
  9. granny/cli/cdn.py +210 -0
  10. granny/cli/create.py +94 -0
  11. granny/cli/credentials.py +99 -0
  12. granny/cli/dns.py +290 -0
  13. granny/cli/docker.py +165 -0
  14. granny/cli/edge.py +106 -0
  15. granny/cli/email.py +224 -0
  16. granny/cli/main.py +98 -0
  17. granny/cli/serverless.py +278 -0
  18. granny/cli/storage.py +249 -0
  19. granny/create/__init__.py +4 -0
  20. granny/create/auto_certificate.py +1899 -0
  21. granny/create/cloudfront-security-headers.js +53 -0
  22. granny/create/manage-dns.sh +321 -0
  23. granny/create/manage_mailjet_contacts.py +619 -0
  24. granny/create/registrars.py +363 -0
  25. granny/create/setup_aws_cloudfront.py +2808 -0
  26. granny/create/setup_bunny_edge_script.py +923 -0
  27. granny/create/setup_bunny_storage.py +1719 -0
  28. granny/create/setup_cognito_identity_pool.py +740 -0
  29. granny/create/setup_hetzner_bunny.py +1482 -0
  30. granny/create/setup_mailjet_dns.py +1103 -0
  31. granny/create/setup_private_cdn.py +547 -0
  32. granny/create/setup_s3_website.py +1512 -0
  33. granny/create/setup_scaleway_faas.py +1165 -0
  34. granny/create/setup_workmail.py +1217 -0
  35. granny/create/www-redirect-function.js +17 -0
  36. granny/credentials/__init__.py +15 -0
  37. granny/credentials/secrets.py +403 -0
  38. granny/dns/__init__.py +22 -0
  39. granny/dns/base.py +113 -0
  40. granny/dns/bunny.py +150 -0
  41. granny/dns/cloudflare.py +192 -0
  42. granny/dns/cloudns.py +162 -0
  43. granny/dns/desec.py +152 -0
  44. granny/dns/factory.py +72 -0
  45. granny/dns/hetzner.py +165 -0
  46. granny/dns/manual.py +64 -0
  47. granny/dns/records.py +29 -0
  48. granny/docker/__init__.py +5 -0
  49. granny/docker/build_base.py +204 -0
  50. granny/edge/__init__.py +5 -0
  51. granny/edge/bunny.py +147 -0
  52. granny/email/__init__.py +7 -0
  53. granny/email/mailjet.py +119 -0
  54. granny/email/mailjet_contacts.py +115 -0
  55. granny/email/ses_forwarding.py +281 -0
  56. granny/email/workmail.py +145 -0
  57. granny/report.py +128 -0
  58. granny/serverless/__init__.py +5 -0
  59. granny/serverless/scaleway.py +264 -0
  60. granny/storage/__init__.py +7 -0
  61. granny/storage/aws.py +113 -0
  62. granny/storage/bunny.py +98 -0
  63. granny/storage/hetzner.py +118 -0
  64. granny_devops-0.4.0.dist-info/METADATA +445 -0
  65. granny_devops-0.4.0.dist-info/RECORD +68 -0
  66. granny_devops-0.4.0.dist-info/WHEEL +4 -0
  67. granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
  68. granny_devops-0.4.0.dist-info/licenses/LICENSE +21 -0
granny/dns/bunny.py ADDED
@@ -0,0 +1,150 @@
1
+ """Bunny DNS provider — full CRUD against ``api.bunny.net``.
2
+
3
+ Lifted and generalized from ``tools/create/setup_bunny_storage.py``. The
4
+ existing script keeps its local copy; this module is the shared home used by
5
+ ``granny dns`` and any future callers.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any
10
+
11
+ import requests
12
+
13
+ from granny.credentials.secrets import get_secret
14
+ from granny.dns.base import DNSProvider
15
+ from granny.dns.records import DNSRecord
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ API_BASE = "https://api.bunny.net"
20
+
21
+ # Bunny's numeric record type ids (from the public API docs).
22
+ RECORD_TYPES = {
23
+ "A": 0,
24
+ "AAAA": 1,
25
+ "CNAME": 2,
26
+ "TXT": 3,
27
+ "MX": 4,
28
+ "REDIRECT": 5,
29
+ "FLATTEN": 6,
30
+ "PULLZONE": 7,
31
+ "SRV": 8,
32
+ "CAA": 9,
33
+ "PTR": 10,
34
+ "SCRIPT": 11,
35
+ "NS": 12,
36
+ }
37
+ RECORD_TYPE_NAMES = {v: k for k, v in RECORD_TYPES.items()}
38
+
39
+
40
+ class BunnyDNSProvider(DNSProvider):
41
+ name = "bunny"
42
+
43
+ def __init__(self, api_key: str | None = None) -> None:
44
+ api_key = api_key or get_secret("BUNNY_API_KEY")
45
+ if not api_key:
46
+ raise ValueError("BUNNY_API_KEY not set (env or vault).")
47
+ self.session = requests.Session()
48
+ self.session.headers.update(
49
+ {"AccessKey": api_key, "Content-Type": "application/json"}
50
+ )
51
+ self._zone_cache: dict[str, str] = {}
52
+
53
+ def _request(self, method: str, path: str, data: dict | None = None) -> Any:
54
+ url = f"{API_BASE}{path}"
55
+ resp = getattr(self.session, method.lower())(url, json=data, timeout=30)
56
+ if resp.status_code >= 400:
57
+ raise RuntimeError(
58
+ f"Bunny DNS API {method} {path} failed: {resp.status_code} {resp.text}"
59
+ )
60
+ if resp.status_code == 204 or not resp.text:
61
+ return {}
62
+ return resp.json()
63
+
64
+ def _find_zone(self, zone_name: str) -> dict | None:
65
+ result = self._request("GET", "/dnszone")
66
+ zones = result.get("Items", []) if isinstance(result, dict) else result
67
+ for zone in zones:
68
+ if zone.get("Domain") == zone_name:
69
+ return zone
70
+ return None
71
+
72
+ def get_zone_id(self, zone_name: str) -> str:
73
+ if zone_name in self._zone_cache:
74
+ return self._zone_cache[zone_name]
75
+ zone = self._find_zone(zone_name)
76
+ if not zone:
77
+ raise RuntimeError(f"Bunny DNS zone not found: {zone_name}")
78
+ zone_id = str(zone["Id"])
79
+ self._zone_cache[zone_name] = zone_id
80
+ return zone_id
81
+
82
+ def get_nameservers(self, zone_name: str) -> list[str]:
83
+ zone = self._find_zone(zone_name)
84
+ if not zone:
85
+ return []
86
+ return [zone[k] for k in ("NameServer1", "NameServer2") if zone.get(k)]
87
+
88
+ def _raw_records(self, zone_id: str) -> list[dict]:
89
+ result = self._request("GET", f"/dnszone/{zone_id}")
90
+ return result.get("Records", []) or []
91
+
92
+ def list_records(
93
+ self,
94
+ zone_id: str,
95
+ name: str | None = None,
96
+ record_type: str | None = None,
97
+ ) -> list[DNSRecord]:
98
+ raw = self._raw_records(zone_id)
99
+ type_num = RECORD_TYPES.get(record_type.upper()) if record_type else None
100
+ out: list[DNSRecord] = []
101
+ for rec in raw:
102
+ if name is not None and rec.get("Name", "") != name:
103
+ continue
104
+ if type_num is not None and rec.get("Type") != type_num:
105
+ continue
106
+ out.append(
107
+ DNSRecord(
108
+ name=rec.get("Name", "") or "",
109
+ type=RECORD_TYPE_NAMES.get(rec.get("Type", -1), "?"),
110
+ value=rec.get("Value", ""),
111
+ ttl=int(rec.get("Ttl", 300)),
112
+ id=str(rec.get("Id")),
113
+ provider_data=rec,
114
+ )
115
+ )
116
+ return out
117
+
118
+ def create_record(
119
+ self,
120
+ zone_id: str,
121
+ name: str,
122
+ record_type: str,
123
+ value: str,
124
+ ttl: int = 300,
125
+ proxied: bool = False,
126
+ ) -> DNSRecord:
127
+ type_num = RECORD_TYPES.get(record_type.upper())
128
+ if type_num is None:
129
+ raise ValueError(f"Unknown record type: {record_type}")
130
+ short_name = "" if name in ("@", None) else name
131
+ payload = {
132
+ "Type": type_num,
133
+ "Name": short_name,
134
+ "Value": value.rstrip(".") if record_type.upper() == "CNAME" else value,
135
+ "Ttl": ttl,
136
+ }
137
+ result = self._request("PUT", f"/dnszone/{zone_id}/records", payload)
138
+ logger.info("Bunny DNS: created %s %s -> %s", record_type, short_name, value)
139
+ return DNSRecord(
140
+ name=short_name,
141
+ type=record_type.upper(),
142
+ value=payload["Value"],
143
+ ttl=ttl,
144
+ id=str(result.get("Id")) if isinstance(result, dict) else None,
145
+ provider_data=result if isinstance(result, dict) else {},
146
+ )
147
+
148
+ def delete_record(self, zone_id: str, record_id: str) -> None:
149
+ self._request("DELETE", f"/dnszone/{zone_id}/records/{record_id}")
150
+ logger.info("Bunny DNS: deleted record %s", record_id)
@@ -0,0 +1,192 @@
1
+ """Cloudflare DNS provider — full CRUD against the v4 REST API.
2
+
3
+ Ported from ``tools/create/manage-dns.sh`` (the bash script this package
4
+ replaces) and the partial Cloudflare adapters in ``setup_bunny_storage.py``
5
+ and ``auto_certificate.py``. Uses ``requests`` directly so we don't depend
6
+ on the ``cloudflare`` SDK.
7
+ """
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ import requests
13
+
14
+ from granny.credentials.secrets import get_secret
15
+ from granny.dns.base import DNSProvider
16
+ from granny.dns.records import DNSRecord
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ API_BASE = "https://api.cloudflare.com/client/v4"
21
+
22
+
23
+ class CloudflareDNSProvider(DNSProvider):
24
+ name = "cloudflare"
25
+
26
+ def __init__(self, api_token: str | None = None) -> None:
27
+ api_token = api_token or get_secret("CLOUDFLARE_API_TOKEN")
28
+ if not api_token:
29
+ raise ValueError("CLOUDFLARE_API_TOKEN not set (env or vault).")
30
+ self.session = requests.Session()
31
+ self.session.headers.update(
32
+ {
33
+ "Authorization": f"Bearer {api_token}",
34
+ "Content-Type": "application/json",
35
+ }
36
+ )
37
+ self._zone_cache: dict[str, str] = {}
38
+
39
+ def _request(
40
+ self, method: str, path: str, *, params: dict | None = None, json: dict | None = None
41
+ ) -> dict:
42
+ url = f"{API_BASE}{path}"
43
+ resp = getattr(self.session, method.lower())(
44
+ url, params=params, json=json, timeout=30
45
+ )
46
+ if resp.status_code >= 400:
47
+ raise RuntimeError(
48
+ f"Cloudflare {method} {path} failed: {resp.status_code} {resp.text}"
49
+ )
50
+ body = resp.json()
51
+ if not body.get("success", True):
52
+ errors = body.get("errors", [])
53
+ raise RuntimeError(f"Cloudflare {method} {path} error: {errors}")
54
+ return body
55
+
56
+ def get_zone_id(self, zone_name: str) -> str:
57
+ if zone_name in self._zone_cache:
58
+ return self._zone_cache[zone_name]
59
+ body = self._request("GET", "/zones", params={"name": zone_name})
60
+ result = body.get("result") or []
61
+ if not result:
62
+ raise RuntimeError(f"Cloudflare zone not found: {zone_name}")
63
+ zone_id = result[0]["id"]
64
+ self._zone_cache[zone_name] = zone_id
65
+ return zone_id
66
+
67
+ def get_nameservers(self, zone_name: str) -> list[str]:
68
+ body = self._request("GET", "/zones", params={"name": zone_name})
69
+ result = body.get("result") or []
70
+ if not result:
71
+ return []
72
+ return list(result[0].get("name_servers") or [])
73
+
74
+ def _fqdn(self, zone_id: str, name: str) -> str:
75
+ # Cloudflare's record API uses full FQDN for `name`. The caller passes
76
+ # us a short name relative to the zone, so we need the zone name to
77
+ # build the full one. We reverse-lookup via /zones/{id} once.
78
+ body = self._request("GET", f"/zones/{zone_id}")
79
+ zone_name = body["result"]["name"]
80
+ if not name or name == "@":
81
+ return zone_name
82
+ if name.endswith(f".{zone_name}") or name == zone_name:
83
+ return name
84
+ return f"{name}.{zone_name}"
85
+
86
+ def list_records(
87
+ self,
88
+ zone_id: str,
89
+ name: str | None = None,
90
+ record_type: str | None = None,
91
+ ) -> list[DNSRecord]:
92
+ params: dict[str, Any] = {"per_page": 100}
93
+ if record_type:
94
+ params["type"] = record_type.upper()
95
+ if name is not None:
96
+ params["name"] = self._fqdn(zone_id, name)
97
+ body = self._request("GET", f"/zones/{zone_id}/dns_records", params=params)
98
+ out: list[DNSRecord] = []
99
+ for rec in body.get("result") or []:
100
+ out.append(
101
+ DNSRecord(
102
+ name=rec.get("name", ""),
103
+ type=rec.get("type", ""),
104
+ value=rec.get("content", ""),
105
+ ttl=int(rec.get("ttl", 300)),
106
+ id=rec.get("id"),
107
+ fqdn=rec.get("name"),
108
+ provider_data=rec,
109
+ )
110
+ )
111
+ return out
112
+
113
+ def get_record(
114
+ self, zone_id: str, name: str, record_type: str
115
+ ) -> DNSRecord | None:
116
+ # Cloudflare stores `name` as the full FQDN on every record, so we
117
+ # must filter by FQDN not short name.
118
+ fqdn = self._fqdn(zone_id, name)
119
+ for rec in self.list_records(zone_id, record_type=record_type):
120
+ if rec.name == fqdn:
121
+ return rec
122
+ return None
123
+
124
+ def create_record(
125
+ self,
126
+ zone_id: str,
127
+ name: str,
128
+ record_type: str,
129
+ value: str,
130
+ ttl: int = 300,
131
+ proxied: bool = False,
132
+ ) -> DNSRecord:
133
+ fqdn = self._fqdn(zone_id, name)
134
+ payload = {
135
+ "type": record_type.upper(),
136
+ "name": fqdn,
137
+ "content": value,
138
+ # Cloudflare uses ttl=1 as the sentinel for "automatic". Anything
139
+ # else must be >= 60. Match manage-dns.sh's behavior: if caller
140
+ # passes ttl<=1, use automatic; otherwise send the value.
141
+ "ttl": 1 if ttl <= 1 else ttl,
142
+ "proxied": proxied,
143
+ }
144
+ body = self._request("POST", f"/zones/{zone_id}/dns_records", json=payload)
145
+ rec = body.get("result") or {}
146
+ logger.info("Cloudflare: created %s %s -> %s", record_type, fqdn, value)
147
+ return DNSRecord(
148
+ name=rec.get("name", fqdn),
149
+ type=rec.get("type", record_type.upper()),
150
+ value=rec.get("content", value),
151
+ ttl=int(rec.get("ttl", ttl)),
152
+ id=rec.get("id"),
153
+ fqdn=rec.get("name", fqdn),
154
+ provider_data=rec,
155
+ )
156
+
157
+ def update_record(
158
+ self,
159
+ zone_id: str,
160
+ record_id: str,
161
+ name: str,
162
+ record_type: str,
163
+ value: str,
164
+ ttl: int = 300,
165
+ proxied: bool = False,
166
+ ) -> DNSRecord:
167
+ fqdn = self._fqdn(zone_id, name)
168
+ payload = {
169
+ "type": record_type.upper(),
170
+ "name": fqdn,
171
+ "content": value,
172
+ "ttl": 1 if ttl <= 1 else ttl,
173
+ "proxied": proxied,
174
+ }
175
+ body = self._request(
176
+ "PUT", f"/zones/{zone_id}/dns_records/{record_id}", json=payload
177
+ )
178
+ rec = body.get("result") or {}
179
+ logger.info("Cloudflare: updated %s %s -> %s", record_type, fqdn, value)
180
+ return DNSRecord(
181
+ name=rec.get("name", fqdn),
182
+ type=rec.get("type", record_type.upper()),
183
+ value=rec.get("content", value),
184
+ ttl=int(rec.get("ttl", ttl)),
185
+ id=rec.get("id", record_id),
186
+ fqdn=rec.get("name", fqdn),
187
+ provider_data=rec,
188
+ )
189
+
190
+ def delete_record(self, zone_id: str, record_id: str) -> None:
191
+ self._request("DELETE", f"/zones/{zone_id}/dns_records/{record_id}")
192
+ logger.info("Cloudflare: deleted record %s", record_id)
granny/dns/cloudns.py ADDED
@@ -0,0 +1,162 @@
1
+ """ClouDNS provider — CRUD via https://api.cloudns.net.
2
+
3
+ Lifted from ``tools/create/setup_bunny_storage.py``. ClouDNS uses form-encoded
4
+ POST requests for every operation, including reads. The zone identifier is
5
+ the domain name itself. TTLs are quantized to a fixed set.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any
10
+
11
+ import requests
12
+
13
+ from granny.credentials.secrets import get_secret
14
+ from granny.dns.base import DNSProvider
15
+ from granny.dns.records import DNSRecord
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ API_BASE = "https://api.cloudns.net"
20
+ VALID_TTLS = [
21
+ 60, 300, 900, 1800, 3600, 21600, 43200, 86400,
22
+ 172800, 259200, 604800, 1209600, 2592000,
23
+ ]
24
+
25
+
26
+ def _quantize_ttl(ttl: int) -> int:
27
+ for valid in VALID_TTLS:
28
+ if ttl <= valid:
29
+ return valid
30
+ return VALID_TTLS[-1]
31
+
32
+
33
+ class ClouDNSDNSProvider(DNSProvider):
34
+ name = "cloudns"
35
+
36
+ def __init__(
37
+ self,
38
+ auth_id: str | None = None,
39
+ auth_password: str | None = None,
40
+ sub_auth_id: str | None = None,
41
+ sub_auth_user: str | None = None,
42
+ ) -> None:
43
+ self.auth_id = auth_id or get_secret("CLOUDNS_AUTH_ID")
44
+ self.auth_password = auth_password or get_secret("CLOUDNS_AUTH_PASSWORD")
45
+ self.sub_auth_id = sub_auth_id or get_secret("CLOUDNS_SUB_AUTH_ID")
46
+ self.sub_auth_user = sub_auth_user or get_secret("CLOUDNS_SUB_AUTH_USER")
47
+ if not self.auth_password:
48
+ raise ValueError("CLOUDNS_AUTH_PASSWORD not set (env or vault).")
49
+ if not self.auth_id and not self.sub_auth_id and not self.sub_auth_user:
50
+ raise ValueError(
51
+ "ClouDNS requires auth-id, sub-auth-id, or sub-auth-user."
52
+ )
53
+
54
+ def _auth_params(self) -> dict[str, str]:
55
+ p = {"auth-password": self.auth_password}
56
+ if self.auth_id:
57
+ p["auth-id"] = self.auth_id
58
+ elif self.sub_auth_id:
59
+ p["sub-auth-id"] = self.sub_auth_id
60
+ elif self.sub_auth_user:
61
+ p["sub-auth-user"] = self.sub_auth_user
62
+ return p
63
+
64
+ def _request(self, endpoint: str, params: dict | None = None) -> Any:
65
+ all_params = self._auth_params()
66
+ if params:
67
+ all_params.update(params)
68
+ resp = requests.post(f"{API_BASE}{endpoint}", data=all_params, timeout=30)
69
+ if resp.status_code >= 400:
70
+ raise RuntimeError(
71
+ f"ClouDNS {endpoint} failed: {resp.status_code} {resp.text}"
72
+ )
73
+ data = resp.json()
74
+ if isinstance(data, dict) and data.get("status") == "Failed":
75
+ raise RuntimeError(
76
+ f"ClouDNS {endpoint} error: {data.get('statusDescription')}"
77
+ )
78
+ return data
79
+
80
+ def get_zone_id(self, zone_name: str) -> str:
81
+ result = self._request(
82
+ "/dns/list-zones.json", {"page": 1, "rows-per-page": 100}
83
+ )
84
+ zones = result if isinstance(result, list) else list(result.values() or [])
85
+ for zone in zones:
86
+ if isinstance(zone, dict) and zone.get("name") == zone_name:
87
+ return zone_name
88
+ raise RuntimeError(f"ClouDNS zone not found: {zone_name}")
89
+
90
+ def list_records(
91
+ self,
92
+ zone_id: str,
93
+ name: str | None = None,
94
+ record_type: str | None = None,
95
+ ) -> list[DNSRecord]:
96
+ result = self._request("/dns/records.json", {"domain-name": zone_id})
97
+ if isinstance(result, dict):
98
+ records = list(result.values())
99
+ elif isinstance(result, list):
100
+ records = result
101
+ else:
102
+ records = []
103
+ short_name = "" if name in ("@", None) else name
104
+ rtype = record_type.upper() if record_type else None
105
+ out: list[DNSRecord] = []
106
+ for rec in records:
107
+ if not isinstance(rec, dict):
108
+ continue
109
+ if name is not None and rec.get("host", "") != short_name:
110
+ continue
111
+ if rtype is not None and rec.get("type") != rtype:
112
+ continue
113
+ out.append(
114
+ DNSRecord(
115
+ name=rec.get("host", ""),
116
+ type=rec.get("type", ""),
117
+ value=rec.get("record", ""),
118
+ ttl=int(rec.get("ttl", 3600)),
119
+ id=str(rec.get("id")),
120
+ provider_data=rec,
121
+ )
122
+ )
123
+ return out
124
+
125
+ def create_record(
126
+ self,
127
+ zone_id: str,
128
+ name: str,
129
+ record_type: str,
130
+ value: str,
131
+ ttl: int = 300,
132
+ proxied: bool = False,
133
+ ) -> DNSRecord:
134
+ short = "" if not name or name == "@" else name
135
+ result = self._request(
136
+ "/dns/add-record.json",
137
+ {
138
+ "domain-name": zone_id,
139
+ "record-type": record_type.upper(),
140
+ "host": short,
141
+ "record": value.rstrip(".") if record_type.upper() == "CNAME" else value,
142
+ "ttl": _quantize_ttl(ttl),
143
+ },
144
+ )
145
+ record_id = None
146
+ if isinstance(result, dict):
147
+ record_id = str(result.get("data", {}).get("id") or "")
148
+ logger.info("ClouDNS: created %s %s -> %s", record_type, short, value)
149
+ return DNSRecord(
150
+ name=short,
151
+ type=record_type.upper(),
152
+ value=value,
153
+ ttl=_quantize_ttl(ttl),
154
+ id=record_id or None,
155
+ )
156
+
157
+ def delete_record(self, zone_id: str, record_id: str) -> None:
158
+ self._request(
159
+ "/dns/delete-record.json",
160
+ {"domain-name": zone_id, "record-id": record_id},
161
+ )
162
+ logger.info("ClouDNS: deleted record %s", record_id)
granny/dns/desec.py ADDED
@@ -0,0 +1,152 @@
1
+ """deSEC DNS provider — CRUD via https://desec.io/api/v1.
2
+
3
+ deSEC models records as RRsets (one set per subname+type), and uses the
4
+ domain name itself as the "zone id". The provider-level ``id`` we expose is
5
+ an opaque ``subname|type`` pair so ``delete_record`` can round-trip to the
6
+ right rrset endpoint.
7
+ """
8
+
9
+ import logging
10
+ import time
11
+
12
+ import requests
13
+
14
+ from granny.credentials.secrets import get_secret
15
+ from granny.dns.base import DNSProvider
16
+ from granny.dns.records import DNSRecord
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ API_BASE = "https://desec.io/api/v1"
21
+ MIN_TTL = 3600 # deSEC API minimum
22
+
23
+
24
+ class DeSECDNSProvider(DNSProvider):
25
+ name = "desec"
26
+
27
+ def __init__(self, api_token: str | None = None) -> None:
28
+ api_token = api_token or get_secret("DESEC_API_TOKEN")
29
+ if not api_token:
30
+ raise ValueError("DESEC_API_TOKEN not set (env or vault).")
31
+ self.session = requests.Session()
32
+ self.session.headers.update(
33
+ {"Authorization": f"Token {api_token}", "Content-Type": "application/json"}
34
+ )
35
+
36
+ def _request(self, method: str, path: str, json: dict | None = None) -> dict:
37
+ url = f"{API_BASE}{path}"
38
+ resp = getattr(self.session, method.lower())(url, json=json, timeout=30)
39
+ if resp.status_code == 429:
40
+ delay = int(resp.headers.get("Retry-After", 5))
41
+ logger.warning("deSEC rate limited, sleeping %ss", delay)
42
+ time.sleep(delay)
43
+ return self._request(method, path, json=json)
44
+ if resp.status_code == 404:
45
+ raise KeyError(path)
46
+ if resp.status_code >= 400:
47
+ raise RuntimeError(
48
+ f"deSEC {method} {path} failed: {resp.status_code} {resp.text}"
49
+ )
50
+ if resp.status_code == 204 or not resp.text:
51
+ return {}
52
+ return resp.json()
53
+
54
+ def get_zone_id(self, zone_name: str) -> str:
55
+ try:
56
+ self._request("GET", f"/domains/{zone_name}/")
57
+ except KeyError:
58
+ raise RuntimeError(f"deSEC domain not found: {zone_name}")
59
+ return zone_name
60
+
61
+ def get_nameservers(self, zone_name: str) -> list[str]:
62
+ # deSEC's nameservers are fixed.
63
+ return ["ns1.desec.io", "ns2.desec.org"]
64
+
65
+ def _normalize_subname(self, name: str | None) -> str:
66
+ if name is None or name in ("@", ""):
67
+ return ""
68
+ return name
69
+
70
+ def list_records(
71
+ self,
72
+ zone_id: str,
73
+ name: str | None = None,
74
+ record_type: str | None = None,
75
+ ) -> list[DNSRecord]:
76
+ rrsets = self._request("GET", f"/domains/{zone_id}/rrsets/")
77
+ if not isinstance(rrsets, list):
78
+ rrsets = []
79
+ subname_filter = self._normalize_subname(name) if name is not None else None
80
+ rtype = record_type.upper() if record_type else None
81
+ out: list[DNSRecord] = []
82
+ for rrset in rrsets:
83
+ if subname_filter is not None and rrset.get("subname", "") != subname_filter:
84
+ continue
85
+ if rtype is not None and rrset.get("type") != rtype:
86
+ continue
87
+ for value in rrset.get("records") or []:
88
+ out.append(
89
+ DNSRecord(
90
+ name=rrset.get("subname", ""),
91
+ type=rrset.get("type", ""),
92
+ value=value,
93
+ ttl=int(rrset.get("ttl", MIN_TTL)),
94
+ id=f"{rrset.get('subname', '')}|{rrset.get('type', '')}",
95
+ provider_data=rrset,
96
+ )
97
+ )
98
+ return out
99
+
100
+ def create_record(
101
+ self,
102
+ zone_id: str,
103
+ name: str,
104
+ record_type: str,
105
+ value: str,
106
+ ttl: int = 300,
107
+ proxied: bool = False,
108
+ ) -> DNSRecord:
109
+ subname = self._normalize_subname(name)
110
+ rtype = record_type.upper()
111
+ ttl = max(ttl, MIN_TTL)
112
+ # CNAME values in deSEC must be fully-qualified with trailing dot.
113
+ stored = value if value.endswith(".") else f"{value}."
114
+ payload_value = stored if rtype in ("CNAME", "MX", "NS", "PTR", "SRV") else value
115
+ payload = {
116
+ "subname": subname,
117
+ "type": rtype,
118
+ "records": [payload_value],
119
+ "ttl": ttl,
120
+ }
121
+ try:
122
+ self._request("POST", f"/domains/{zone_id}/rrsets/", json=payload)
123
+ except RuntimeError as exc:
124
+ # If the rrset already exists, upgrade to PATCH (merges records).
125
+ if "already" in str(exc).lower():
126
+ self._request(
127
+ "PATCH",
128
+ f"/domains/{zone_id}/rrsets/{subname}.../{rtype}/",
129
+ json={"records": [payload_value], "ttl": ttl},
130
+ )
131
+ else:
132
+ raise
133
+ logger.info("deSEC: created %s %s -> %s", rtype, subname or "@", payload_value)
134
+ return DNSRecord(
135
+ name=subname,
136
+ type=rtype,
137
+ value=payload_value,
138
+ ttl=ttl,
139
+ id=f"{subname}|{rtype}",
140
+ )
141
+
142
+ def delete_record(self, zone_id: str, record_id: str) -> None:
143
+ # record_id is our synthetic "subname|type" pair. Deleting the whole
144
+ # rrset is deSEC-idiomatic — per-value deletes require PATCH with the
145
+ # remaining set.
146
+ subname, _, rtype = record_id.partition("|")
147
+ path = f"/domains/{zone_id}/rrsets/{subname}.../{rtype}/"
148
+ try:
149
+ self._request("DELETE", path)
150
+ except KeyError:
151
+ pass
152
+ logger.info("deSEC: deleted rrset %s/%s", subname or "@", rtype)