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
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Domain registrar adapters for setup_bunny_storage.py.
|
|
2
|
+
|
|
3
|
+
Registrar operations are conceptually separate from DNS record operations:
|
|
4
|
+
a registrar owns the WHOIS record for a domain and controls which nameservers
|
|
5
|
+
the TLD delegates to. A DNS provider owns the zone and controls the records
|
|
6
|
+
inside it.
|
|
7
|
+
|
|
8
|
+
When granny creates a Bunny DNS zone for a domain, the zone gets a pair of
|
|
9
|
+
auto-assigned Bunny nameservers. Someone still has to walk over to the
|
|
10
|
+
registrar and paste those NS values into the domain's WHOIS record. That's
|
|
11
|
+
what a Registrar adapter automates.
|
|
12
|
+
|
|
13
|
+
Currently implemented:
|
|
14
|
+
- ManualRegistrar: no-op, logs a checklist. Default for unsupported
|
|
15
|
+
registrars.
|
|
16
|
+
- EasynameRegistrar: easyname.eu REST API. Used for .at/.eu/.com domains
|
|
17
|
+
registered at Austria's easyname.
|
|
18
|
+
|
|
19
|
+
Adding a new registrar: subclass ``Registrar``, implement the two methods,
|
|
20
|
+
register in ``_REGISTRARS``. See the factory ``get_registrar()``.
|
|
21
|
+
|
|
22
|
+
Credentials: read from env vars, same convention as the DNS providers in
|
|
23
|
+
setup_bunny_storage.py. No Vault coupling — callers can source .deploy.env
|
|
24
|
+
before invoking granny.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import base64
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import time
|
|
35
|
+
from abc import ABC, abstractmethod
|
|
36
|
+
from typing import Optional
|
|
37
|
+
from urllib.parse import quote_plus
|
|
38
|
+
|
|
39
|
+
import requests
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# =============================================================================
|
|
43
|
+
# Registrar ABC
|
|
44
|
+
# =============================================================================
|
|
45
|
+
|
|
46
|
+
class Registrar(ABC):
|
|
47
|
+
"""Abstract base class for domain registrar adapters.
|
|
48
|
+
|
|
49
|
+
Minimal interface: look up a domain's registrar-level id (registrars
|
|
50
|
+
number their registered domains), then replace its authoritative
|
|
51
|
+
nameserver list. Everything else (WHOIS contacts, auto-renew, transfer
|
|
52
|
+
locks) is out of scope — granny doesn't manage those yet.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def get_domain_id(self, domain: str) -> Optional[str]:
|
|
57
|
+
"""Look up the registrar's internal id for ``domain``.
|
|
58
|
+
|
|
59
|
+
Returns ``None`` if the domain is not registered in this account.
|
|
60
|
+
Callers should treat ``None`` as a hard error: you can't flip
|
|
61
|
+
nameservers for a domain you don't own.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def set_nameservers(self, domain: str, nameservers: list[str]) -> None:
|
|
66
|
+
"""Replace the delegated nameservers for ``domain``.
|
|
67
|
+
|
|
68
|
+
``nameservers`` is an ordered list of FQDNs (e.g.
|
|
69
|
+
``['kiki.bunny.net', 'buckx.bunny.net']``). Implementations should:
|
|
70
|
+
|
|
71
|
+
- Validate length (most TLDs allow 2-6 NS records).
|
|
72
|
+
- Reject empty / duplicate / unresolvable entries if the underlying
|
|
73
|
+
API does.
|
|
74
|
+
- Treat "already set correctly" as a success (idempotent).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# =============================================================================
|
|
79
|
+
# ManualRegistrar — default, no-op, prints a checklist
|
|
80
|
+
# =============================================================================
|
|
81
|
+
|
|
82
|
+
class ManualRegistrar(Registrar):
|
|
83
|
+
"""No-op registrar adapter. Prints instructions for the user to follow.
|
|
84
|
+
|
|
85
|
+
Used when the domain is at a registrar that granny doesn't automate yet,
|
|
86
|
+
or when the user wants to flip NS records by hand.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def get_domain_id(self, domain: str) -> Optional[str]:
|
|
90
|
+
# Pretend the domain exists so the caller can still call set_nameservers.
|
|
91
|
+
return "manual"
|
|
92
|
+
|
|
93
|
+
def set_nameservers(self, domain: str, nameservers: list[str]) -> None:
|
|
94
|
+
logging.info("=" * 60)
|
|
95
|
+
logging.info(f"MANUAL ACTION REQUIRED: nameserver change for {domain}")
|
|
96
|
+
logging.info("Log into your registrar and set these NS records:")
|
|
97
|
+
for ns in nameservers:
|
|
98
|
+
logging.info(f" {ns}")
|
|
99
|
+
logging.info("=" * 60)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# =============================================================================
|
|
103
|
+
# EasynameRegistrar — easyname.eu REST API
|
|
104
|
+
# =============================================================================
|
|
105
|
+
|
|
106
|
+
class EasynameRegistrar(Registrar):
|
|
107
|
+
"""easyname.eu registrar adapter.
|
|
108
|
+
|
|
109
|
+
Uses the public REST API at ``https://api.easyname.com``. The API only
|
|
110
|
+
supports domain-level operations (not DNS record CRUD — that's a gap
|
|
111
|
+
on easyname's side, not ours). We use:
|
|
112
|
+
|
|
113
|
+
GET /domain -> list all owned domains
|
|
114
|
+
POST /domain/{id}/nameserverchange -> replace NS records
|
|
115
|
+
|
|
116
|
+
Authentication
|
|
117
|
+
--------------
|
|
118
|
+
Two layers, both mirroring the official easyname PHP SDK
|
|
119
|
+
(https://github.com/easyname/php-sdk):
|
|
120
|
+
|
|
121
|
+
1. ``X-User-ApiKey`` header with the raw API key.
|
|
122
|
+
2. ``X-User-Authentication`` header computed as
|
|
123
|
+
``base64(md5(auth_salt_template % (user_id, email)))``
|
|
124
|
+
where ``auth_salt_template`` is a printf-style string with two ``%s``
|
|
125
|
+
placeholders (e.g. ``"abc%sdef%sghi"``). The MD5 digest is taken as
|
|
126
|
+
its *hex* string, and then base64 is applied to that hex — this
|
|
127
|
+
replicates PHP's ``base64_encode(md5(...))`` which operates on the
|
|
128
|
+
hex form by default.
|
|
129
|
+
|
|
130
|
+
Plus, every POST body is a JSON envelope::
|
|
131
|
+
|
|
132
|
+
{"data": {...}, "timestamp": <unix>, "signature": <sig>}
|
|
133
|
+
|
|
134
|
+
where ``signature`` is computed from the sorted concatenation of data
|
|
135
|
+
values + timestamp, split in half, with the signing salt inserted in
|
|
136
|
+
the middle, MD5'd (hex), and base64'd. Again, exactly matching the PHP
|
|
137
|
+
SDK so we don't have to reverse-engineer anything.
|
|
138
|
+
|
|
139
|
+
**Final oddity**: the POST body is ``urlencode(json_encode(body))`` even
|
|
140
|
+
though the ``Content-Type`` header says ``application/json``. The server
|
|
141
|
+
URL-decodes before parsing. We replicate this with ``quote_plus``.
|
|
142
|
+
|
|
143
|
+
Credentials (env vars)
|
|
144
|
+
----------------------
|
|
145
|
+
``EASYNAME_USER_ID`` numeric user id
|
|
146
|
+
``EASYNAME_EMAIL`` account email
|
|
147
|
+
``EASYNAME_API_KEY`` the "API key" from easyname account settings
|
|
148
|
+
``EASYNAME_AUTH_SALT`` the "API authentication salt" (contains %s %s)
|
|
149
|
+
``EASYNAME_SIGN_SALT`` the "API signing salt"
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
API_BASE = "https://api.easyname.com"
|
|
153
|
+
|
|
154
|
+
def __init__(self) -> None:
|
|
155
|
+
self.user_id = os.environ.get("EASYNAME_USER_ID")
|
|
156
|
+
self.email = os.environ.get("EASYNAME_EMAIL")
|
|
157
|
+
self.api_key = os.environ.get("EASYNAME_API_KEY")
|
|
158
|
+
self.auth_salt = os.environ.get("EASYNAME_AUTH_SALT")
|
|
159
|
+
self.sign_salt = os.environ.get("EASYNAME_SIGN_SALT")
|
|
160
|
+
|
|
161
|
+
missing = [
|
|
162
|
+
k for k, v in {
|
|
163
|
+
"EASYNAME_USER_ID": self.user_id,
|
|
164
|
+
"EASYNAME_EMAIL": self.email,
|
|
165
|
+
"EASYNAME_API_KEY": self.api_key,
|
|
166
|
+
"EASYNAME_AUTH_SALT": self.auth_salt,
|
|
167
|
+
"EASYNAME_SIGN_SALT": self.sign_salt,
|
|
168
|
+
}.items() if not v
|
|
169
|
+
]
|
|
170
|
+
if missing:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"EasynameRegistrar: missing env vars: {', '.join(missing)}. "
|
|
173
|
+
"Grab them from https://my.easyname.com/en/account/api — you need "
|
|
174
|
+
"User ID, email, API Key, Authentication Salt, and Signing Salt."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Defensive: the auth salt must contain two %s placeholders or the
|
|
178
|
+
# sprintf fill below will ValueError / produce garbage.
|
|
179
|
+
if self.auth_salt.count("%s") != 2:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"EASYNAME_AUTH_SALT must be a printf template with exactly two "
|
|
182
|
+
"%s placeholders (for user_id and email). Got: "
|
|
183
|
+
+ repr(self.auth_salt)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
self.session = requests.Session()
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------ auth
|
|
189
|
+
|
|
190
|
+
def _auth_header(self) -> str:
|
|
191
|
+
"""Compute the ``X-User-Authentication`` header value."""
|
|
192
|
+
filled = self.auth_salt % (str(self.user_id), self.email)
|
|
193
|
+
hex_digest = hashlib.md5(filled.encode("utf-8")).hexdigest()
|
|
194
|
+
return base64.b64encode(hex_digest.encode("ascii")).decode("ascii")
|
|
195
|
+
|
|
196
|
+
def _sign_body(self, data: dict, timestamp: int) -> str:
|
|
197
|
+
"""Compute the ``signature`` field of the POST body envelope.
|
|
198
|
+
|
|
199
|
+
Replicates the PHP SDK's ``signRequest`` method byte-for-byte so
|
|
200
|
+
signatures validate server-side.
|
|
201
|
+
"""
|
|
202
|
+
keys = sorted(list(data.keys()) + ["timestamp"])
|
|
203
|
+
buf = ""
|
|
204
|
+
for k in keys:
|
|
205
|
+
if k == "timestamp":
|
|
206
|
+
buf += str(timestamp)
|
|
207
|
+
else:
|
|
208
|
+
buf += str(data[k])
|
|
209
|
+
|
|
210
|
+
length = len(buf)
|
|
211
|
+
# PHP: if odd length, first half gets the extra char.
|
|
212
|
+
half = length // 2 if length % 2 == 0 else (length // 2) + 1
|
|
213
|
+
first, second = buf[:half], buf[half:]
|
|
214
|
+
payload = first + self.sign_salt + second
|
|
215
|
+
hex_digest = hashlib.md5(payload.encode("utf-8")).hexdigest()
|
|
216
|
+
return base64.b64encode(hex_digest.encode("ascii")).decode("ascii")
|
|
217
|
+
|
|
218
|
+
def _build_body(self, data: dict) -> str:
|
|
219
|
+
"""Build the urlencoded JSON envelope for POST requests."""
|
|
220
|
+
timestamp = int(time.time())
|
|
221
|
+
envelope = {
|
|
222
|
+
"data": data,
|
|
223
|
+
"timestamp": timestamp,
|
|
224
|
+
"signature": self._sign_body(data, timestamp),
|
|
225
|
+
}
|
|
226
|
+
# PHP uses compact JSON (no spaces). Match that so the wire format
|
|
227
|
+
# matches — the signature itself is computed from `data` values not
|
|
228
|
+
# the encoded JSON, so the separators don't affect validity, but we
|
|
229
|
+
# stay consistent for debuggability.
|
|
230
|
+
json_str = json.dumps(envelope, separators=(",", ":"), ensure_ascii=True)
|
|
231
|
+
return quote_plus(json_str)
|
|
232
|
+
|
|
233
|
+
def _headers(self) -> dict:
|
|
234
|
+
return {
|
|
235
|
+
"X-User-ApiKey": self.api_key,
|
|
236
|
+
"X-User-Authentication": self._auth_header(),
|
|
237
|
+
"Accept": "application/json",
|
|
238
|
+
"Content-Type": "application/json",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
def _get(self, path: str) -> dict:
|
|
242
|
+
resp = self.session.get(
|
|
243
|
+
f"{self.API_BASE}{path}",
|
|
244
|
+
headers=self._headers(),
|
|
245
|
+
timeout=30,
|
|
246
|
+
)
|
|
247
|
+
return self._parse(resp)
|
|
248
|
+
|
|
249
|
+
def _post(self, path: str, data: dict) -> dict:
|
|
250
|
+
body = self._build_body(data)
|
|
251
|
+
resp = self.session.post(
|
|
252
|
+
f"{self.API_BASE}{path}",
|
|
253
|
+
headers=self._headers(),
|
|
254
|
+
data=body,
|
|
255
|
+
timeout=30,
|
|
256
|
+
)
|
|
257
|
+
return self._parse(resp)
|
|
258
|
+
|
|
259
|
+
def _parse(self, resp: requests.Response) -> dict:
|
|
260
|
+
if resp.status_code >= 400:
|
|
261
|
+
raise RuntimeError(
|
|
262
|
+
f"easyname API {resp.request.method} {resp.request.url} "
|
|
263
|
+
f"-> HTTP {resp.status_code}: {resp.text[:500]}"
|
|
264
|
+
)
|
|
265
|
+
try:
|
|
266
|
+
payload = resp.json()
|
|
267
|
+
except ValueError:
|
|
268
|
+
raise RuntimeError(
|
|
269
|
+
f"easyname API returned non-JSON: {resp.text[:500]}"
|
|
270
|
+
)
|
|
271
|
+
# easyname responses look like {"status": {"code": 200, ...}, "data": ...}
|
|
272
|
+
status = payload.get("status") if isinstance(payload, dict) else None
|
|
273
|
+
if status and isinstance(status, dict):
|
|
274
|
+
code = status.get("code", 200)
|
|
275
|
+
if int(code) >= 400:
|
|
276
|
+
raise RuntimeError(
|
|
277
|
+
f"easyname API error: status={code} "
|
|
278
|
+
f"message={status.get('message')}"
|
|
279
|
+
)
|
|
280
|
+
return payload
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------ Registrar
|
|
283
|
+
|
|
284
|
+
def get_domain_id(self, domain: str) -> Optional[str]:
|
|
285
|
+
"""Scan all owned domains for a name match and return its id."""
|
|
286
|
+
logging.info(f"Looking up domain '{domain}' at easyname...")
|
|
287
|
+
|
|
288
|
+
offset = 0
|
|
289
|
+
page_size = 100
|
|
290
|
+
while True:
|
|
291
|
+
resp = self._get(f"/domain?limit={page_size}&offset={offset}")
|
|
292
|
+
data = resp.get("data") if isinstance(resp, dict) else None
|
|
293
|
+
items = data if isinstance(data, list) else (
|
|
294
|
+
data.get("items") if isinstance(data, dict) else []
|
|
295
|
+
)
|
|
296
|
+
if not items:
|
|
297
|
+
return None
|
|
298
|
+
for item in items:
|
|
299
|
+
# easyname may return the domain under "domain" or "name"
|
|
300
|
+
name = item.get("domain") or item.get("name")
|
|
301
|
+
if name and name.lower() == domain.lower():
|
|
302
|
+
domain_id = item.get("id")
|
|
303
|
+
if domain_id is None:
|
|
304
|
+
return None
|
|
305
|
+
logging.info(f"Found '{domain}' at easyname (id: {domain_id})")
|
|
306
|
+
return str(domain_id)
|
|
307
|
+
if len(items) < page_size:
|
|
308
|
+
return None
|
|
309
|
+
offset += page_size
|
|
310
|
+
|
|
311
|
+
def set_nameservers(self, domain: str, nameservers: list[str]) -> None:
|
|
312
|
+
if not nameservers:
|
|
313
|
+
raise ValueError("set_nameservers: nameservers list is empty")
|
|
314
|
+
if len(nameservers) > 6:
|
|
315
|
+
raise ValueError(
|
|
316
|
+
f"set_nameservers: easyname accepts at most 6 nameservers, got {len(nameservers)}"
|
|
317
|
+
)
|
|
318
|
+
if len(set(ns.lower() for ns in nameservers)) != len(nameservers):
|
|
319
|
+
raise ValueError(f"set_nameservers: duplicate nameservers in {nameservers}")
|
|
320
|
+
|
|
321
|
+
domain_id = self.get_domain_id(domain)
|
|
322
|
+
if domain_id is None:
|
|
323
|
+
raise RuntimeError(
|
|
324
|
+
f"easyname: domain '{domain}' not found in this account. "
|
|
325
|
+
"Check EASYNAME_USER_ID is correct and the domain is registered there."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
logging.info(f"Setting nameservers for {domain} -> {nameservers}")
|
|
329
|
+
data = {}
|
|
330
|
+
for i, ns in enumerate(nameservers, start=1):
|
|
331
|
+
data[f"nameserver{i}"] = ns
|
|
332
|
+
|
|
333
|
+
self._post(f"/domain/{domain_id}/nameserverchange", data)
|
|
334
|
+
logging.info(f"easyname: nameserver change submitted for {domain}")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# =============================================================================
|
|
338
|
+
# Factory
|
|
339
|
+
# =============================================================================
|
|
340
|
+
|
|
341
|
+
_REGISTRARS: dict[str, type[Registrar]] = {
|
|
342
|
+
"manual": ManualRegistrar,
|
|
343
|
+
"easyname": EasynameRegistrar,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def get_registrar(name: str) -> Registrar:
|
|
348
|
+
"""Factory: registrar name -> instance.
|
|
349
|
+
|
|
350
|
+
Raises ValueError if ``name`` is not registered. Env-var-missing errors
|
|
351
|
+
bubble up from the constructor so they're surfaced at CLI invocation
|
|
352
|
+
time, not lazily.
|
|
353
|
+
"""
|
|
354
|
+
if name not in _REGISTRARS:
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f"Unknown registrar: {name!r}. Known: {sorted(_REGISTRARS.keys())}"
|
|
357
|
+
)
|
|
358
|
+
return _REGISTRARS[name]()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def registrar_choices() -> list[str]:
|
|
362
|
+
"""For argparse ``choices=``."""
|
|
363
|
+
return sorted(_REGISTRARS.keys())
|