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
@@ -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())