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.
- granny/__init__.py +19 -0
- granny/analyze/__init__.py +6 -0
- granny/analyze/lambdas.py +59 -0
- granny/analyze/vpcs.py +57 -0
- granny/cdn/__init__.py +9 -0
- granny/cdn/bunny.py +231 -0
- granny/cli/__init__.py +0 -0
- granny/cli/analyze.py +66 -0
- granny/cli/cdn.py +210 -0
- granny/cli/create.py +94 -0
- granny/cli/credentials.py +99 -0
- granny/cli/dns.py +290 -0
- granny/cli/docker.py +165 -0
- granny/cli/edge.py +106 -0
- granny/cli/email.py +224 -0
- granny/cli/main.py +98 -0
- granny/cli/serverless.py +278 -0
- granny/cli/storage.py +249 -0
- granny/create/__init__.py +4 -0
- granny/create/auto_certificate.py +1899 -0
- granny/create/cloudfront-security-headers.js +53 -0
- granny/create/manage-dns.sh +321 -0
- granny/create/manage_mailjet_contacts.py +619 -0
- granny/create/registrars.py +363 -0
- granny/create/setup_aws_cloudfront.py +2808 -0
- granny/create/setup_bunny_edge_script.py +923 -0
- granny/create/setup_bunny_storage.py +1719 -0
- granny/create/setup_cognito_identity_pool.py +740 -0
- granny/create/setup_hetzner_bunny.py +1482 -0
- granny/create/setup_mailjet_dns.py +1103 -0
- granny/create/setup_private_cdn.py +547 -0
- granny/create/setup_s3_website.py +1512 -0
- granny/create/setup_scaleway_faas.py +1165 -0
- granny/create/setup_workmail.py +1217 -0
- granny/create/www-redirect-function.js +17 -0
- granny/credentials/__init__.py +15 -0
- granny/credentials/secrets.py +403 -0
- granny/dns/__init__.py +22 -0
- granny/dns/base.py +113 -0
- granny/dns/bunny.py +150 -0
- granny/dns/cloudflare.py +192 -0
- granny/dns/cloudns.py +162 -0
- granny/dns/desec.py +152 -0
- granny/dns/factory.py +72 -0
- granny/dns/hetzner.py +165 -0
- granny/dns/manual.py +64 -0
- granny/dns/records.py +29 -0
- granny/docker/__init__.py +5 -0
- granny/docker/build_base.py +204 -0
- granny/edge/__init__.py +5 -0
- granny/edge/bunny.py +147 -0
- granny/email/__init__.py +7 -0
- granny/email/mailjet.py +119 -0
- granny/email/mailjet_contacts.py +115 -0
- granny/email/ses_forwarding.py +281 -0
- granny/email/workmail.py +145 -0
- granny/report.py +128 -0
- granny/serverless/__init__.py +5 -0
- granny/serverless/scaleway.py +264 -0
- granny/storage/__init__.py +7 -0
- granny/storage/aws.py +113 -0
- granny/storage/bunny.py +98 -0
- granny/storage/hetzner.py +118 -0
- granny_devops-0.4.0.dist-info/METADATA +445 -0
- granny_devops-0.4.0.dist-info/RECORD +68 -0
- granny_devops-0.4.0.dist-info/WHEEL +4 -0
- granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
- 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)
|
granny/dns/cloudflare.py
ADDED
|
@@ -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)
|