granny-devops 0.16.0__tar.gz → 0.17.0__tar.gz

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 (111) hide show
  1. {granny_devops-0.16.0 → granny_devops-0.17.0}/PKG-INFO +10 -2
  2. {granny_devops-0.16.0 → granny_devops-0.17.0}/README.md +8 -1
  3. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/__init__.py +1 -1
  4. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/create.py +1 -0
  5. granny_devops-0.17.0/granny/create/letsencrypt_cert.py +192 -0
  6. {granny_devops-0.16.0 → granny_devops-0.17.0}/pyproject.toml +2 -1
  7. {granny_devops-0.16.0 → granny_devops-0.17.0}/.gitignore +0 -0
  8. {granny_devops-0.16.0 → granny_devops-0.17.0}/LICENSE +0 -0
  9. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/analyze/__init__.py +0 -0
  10. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/analyze/costs.py +0 -0
  11. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/analyze/credits.py +0 -0
  12. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/analyze/gpu_pricing.py +0 -0
  13. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/analyze/gpus.py +0 -0
  14. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/analyze/lambdas.py +0 -0
  15. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/analyze/vpcs.py +0 -0
  16. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/authentik/__init__.py +0 -0
  17. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/authentik/client.py +0 -0
  18. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/authentik/provision.py +0 -0
  19. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/aws/__init__.py +0 -0
  20. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/aws/bedrock.py +0 -0
  21. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/aws/quota.py +0 -0
  22. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/azure/__init__.py +0 -0
  23. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/azure/_client.py +0 -0
  24. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/azure/account.py +0 -0
  25. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/azure/deployment.py +0 -0
  26. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/azure/openai.py +0 -0
  27. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/azure/quota.py +0 -0
  28. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/azure/resourcegroup.py +0 -0
  29. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/azure/role.py +0 -0
  30. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/azure/sku.py +0 -0
  31. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/azure/vm.py +0 -0
  32. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/azure/webapp.py +0 -0
  33. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cdn/__init__.py +0 -0
  34. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cdn/bunny.py +0 -0
  35. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/__init__.py +0 -0
  36. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/analyze.py +0 -0
  37. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/authentik.py +0 -0
  38. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/aws.py +0 -0
  39. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/azure.py +0 -0
  40. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/cdn.py +0 -0
  41. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/cloudflare.py +0 -0
  42. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/credentials.py +0 -0
  43. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/dns.py +0 -0
  44. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/docker.py +0 -0
  45. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/edge.py +0 -0
  46. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/elk.py +0 -0
  47. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/email.py +0 -0
  48. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/indexing.py +0 -0
  49. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/main.py +0 -0
  50. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/serverless.py +0 -0
  51. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/storage.py +0 -0
  52. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cli/zitadel.py +0 -0
  53. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cloudflare/__init__.py +0 -0
  54. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cloudflare/d1.py +0 -0
  55. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cloudflare/r2.py +0 -0
  56. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/cloudflare/workers.py +0 -0
  57. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/__init__.py +0 -0
  58. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/auto_certificate.py +0 -0
  59. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/cloudfront-security-headers.js +0 -0
  60. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/manage-dns.sh +0 -0
  61. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/manage_mailjet_contacts.py +0 -0
  62. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/registrars.py +0 -0
  63. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/setup_aws_cloudfront.py +0 -0
  64. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/setup_bunny_edge_script.py +0 -0
  65. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/setup_bunny_storage.py +0 -0
  66. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  67. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/setup_hetzner_bunny.py +0 -0
  68. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/setup_mailjet_dns.py +0 -0
  69. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/setup_private_cdn.py +0 -0
  70. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/setup_s3_website.py +0 -0
  71. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/setup_scaleway_container.py +0 -0
  72. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/setup_scaleway_faas.py +0 -0
  73. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/setup_workmail.py +0 -0
  74. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/create/www-redirect-function.js +0 -0
  75. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/credentials/__init__.py +0 -0
  76. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/credentials/secrets.py +0 -0
  77. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/dns/__init__.py +0 -0
  78. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/dns/base.py +0 -0
  79. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/dns/bunny.py +0 -0
  80. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/dns/cloudflare.py +0 -0
  81. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/dns/cloudns.py +0 -0
  82. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/dns/desec.py +0 -0
  83. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/dns/factory.py +0 -0
  84. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/dns/hetzner.py +0 -0
  85. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/dns/inwx.py +0 -0
  86. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/dns/manual.py +0 -0
  87. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/dns/records.py +0 -0
  88. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/docker/__init__.py +0 -0
  89. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/docker/build_base.py +0 -0
  90. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/edge/__init__.py +0 -0
  91. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/edge/bunny.py +0 -0
  92. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/elk/__init__.py +0 -0
  93. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/elk/client.py +0 -0
  94. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/email/__init__.py +0 -0
  95. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/email/mailjet.py +0 -0
  96. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/email/mailjet_contacts.py +0 -0
  97. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/email/ses_forwarding.py +0 -0
  98. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/email/stalwart.py +0 -0
  99. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/email/workmail.py +0 -0
  100. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/indexing/__init__.py +0 -0
  101. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/indexing/google.py +0 -0
  102. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/indexing/indexnow.py +0 -0
  103. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/report.py +0 -0
  104. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/serverless/__init__.py +0 -0
  105. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/serverless/scaleway.py +0 -0
  106. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/storage/__init__.py +0 -0
  107. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/storage/aws.py +0 -0
  108. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/storage/bunny.py +0 -0
  109. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/storage/hetzner.py +0 -0
  110. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/zitadel/__init__.py +0 -0
  111. {granny_devops-0.16.0 → granny_devops-0.17.0}/granny/zitadel/client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
5
5
  Author-email: Martin Wieser <martin.wieser@pseekoo.com>
6
6
  License: MIT License
@@ -26,6 +26,7 @@ License: MIT License
26
26
  SOFTWARE.
27
27
  License-File: LICENSE
28
28
  Requires-Python: >=3.13
29
+ Requires-Dist: acme>=2.11
29
30
  Requires-Dist: boto3>=1.38
30
31
  Requires-Dist: click>=8.1
31
32
  Requires-Dist: pyjwt[crypto]>=2.8
@@ -292,6 +293,10 @@ granny email workmail create-user example.com --email user@example.com
292
293
  granny create s3-website example.com --help
293
294
  granny create scaleway-container --name my-app --port 3000
294
295
  granny create mailjet-dns example.com
296
+ granny create letsencrypt-cert \
297
+ --domain '*.example.com' --domain example.com \
298
+ --dns inwx --zone example.com \
299
+ --email ops@example.com --out-dir ./certs # real LE cert via ACME dns-01, any granny DNS provider
295
300
 
296
301
  # Authentik admin (provider + application + group plumbing)
297
302
  granny authentik provision-oauth-app my-app \
@@ -315,6 +320,9 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
315
320
  granny zitadel create-project platform
316
321
  granny zitadel create-oidc-app platform my-app \
317
322
  --redirect-uri https://app.example.com/oidc/callback
323
+ granny zitadel set-redirect-uris --project platform --app my-app \
324
+ --redirect-uri https://app.example.com/oidc/callback \
325
+ --redirect-uri https://app.example.com/oidc/callback2 # merges by default; --replace to set exactly
318
326
  granny zitadel rotate-secret platform my-app
319
327
  granny zitadel create-user alice --email alice@example.com --password 's3cret'
320
328
  granny zitadel ensure-role platform admin # Zitadel uses project roles, not groups
@@ -370,7 +378,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
370
378
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
371
379
  | AWS inventory | VPCs, Lambdas |
372
380
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
373
- | SSL automation | Bunny, Cloudflare, ACM |
381
+ | SSL automation | Bunny, Cloudflare, ACM, Let's Encrypt (ACME dns-01 over any granny DNS provider) |
374
382
  | SSO / IdP | Authentik (provider, application, group, and user operations) |
375
383
  | Observability admin | Elasticsearch / Kibana native-user management |
376
384
  | Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
@@ -159,6 +159,10 @@ granny email workmail create-user example.com --email user@example.com
159
159
  granny create s3-website example.com --help
160
160
  granny create scaleway-container --name my-app --port 3000
161
161
  granny create mailjet-dns example.com
162
+ granny create letsencrypt-cert \
163
+ --domain '*.example.com' --domain example.com \
164
+ --dns inwx --zone example.com \
165
+ --email ops@example.com --out-dir ./certs # real LE cert via ACME dns-01, any granny DNS provider
162
166
 
163
167
  # Authentik admin (provider + application + group plumbing)
164
168
  granny authentik provision-oauth-app my-app \
@@ -182,6 +186,9 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
182
186
  granny zitadel create-project platform
183
187
  granny zitadel create-oidc-app platform my-app \
184
188
  --redirect-uri https://app.example.com/oidc/callback
189
+ granny zitadel set-redirect-uris --project platform --app my-app \
190
+ --redirect-uri https://app.example.com/oidc/callback \
191
+ --redirect-uri https://app.example.com/oidc/callback2 # merges by default; --replace to set exactly
185
192
  granny zitadel rotate-secret platform my-app
186
193
  granny zitadel create-user alice --email alice@example.com --password 's3cret'
187
194
  granny zitadel ensure-role platform admin # Zitadel uses project roles, not groups
@@ -237,7 +244,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
237
244
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
238
245
  | AWS inventory | VPCs, Lambdas |
239
246
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
240
- | SSL automation | Bunny, Cloudflare, ACM |
247
+ | SSL automation | Bunny, Cloudflare, ACM, Let's Encrypt (ACME dns-01 over any granny DNS provider) |
241
248
  | SSO / IdP | Authentik (provider, application, group, and user operations) |
242
249
  | Observability admin | Elasticsearch / Kibana native-user management |
243
250
  | Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.16.0"
3
+ __version__ = "0.17.0"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -33,6 +33,7 @@ COMMANDS: dict[str, str] = {
33
33
  "scaleway-container": "setup_scaleway_container.py",
34
34
  "workmail": "setup_workmail.py",
35
35
  "auto-certificate": "auto_certificate.py",
36
+ "letsencrypt-cert": "letsencrypt_cert.py",
36
37
  "mailjet-contacts": "manage_mailjet_contacts.py",
37
38
  }
38
39
 
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ """Let's Encrypt certificate issuance via ACME DNS-01, driven by granny's DNS
3
+ providers.
4
+
5
+ Unlike ``auto_certificate.py`` (AWS ACM / Bunny), this issues a real
6
+ Let's Encrypt certificate (fullchain + private key PEM) usable by Caddy,
7
+ nginx, Kubernetes Secrets, etc. The ACME ``dns-01`` challenge is solved by
8
+ creating ``_acme-challenge`` TXT records through **any** granny DNS provider
9
+ (``--dns inwx`` / ``cloudflare`` / ``hetzner`` / ...), so it works for zones
10
+ like ``obachan.dev`` (INWX) where no cert-manager webhook exists.
11
+
12
+ Example::
13
+
14
+ granny create letsencrypt-cert \\
15
+ --domain '*.obachan.dev' --domain obachan.dev \\
16
+ --dns inwx --zone obachan.dev \\
17
+ --email ops@example.com --out-dir ./certs
18
+
19
+ Credentials for the DNS provider resolve through granny's normal vault/env
20
+ pipeline (e.g. INWX_USERNAME / INWX_PASSWORD / INWX_SHARED_SECRET).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+
30
+ import requests
31
+
32
+ try: # granny's vault→env pipeline (same as auto_certificate.py)
33
+ from granny.credentials import load_secrets_into_env
34
+
35
+ load_secrets_into_env()
36
+ except Exception: # pragma: no cover - best effort
37
+ pass
38
+
39
+ _LE_PROD = "https://acme-v02.api.letsencrypt.org/directory"
40
+ _LE_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
41
+ _DOH = "https://dns.google/resolve"
42
+
43
+
44
+ def _new_rsa_key(): # noqa: ANN202
45
+ from cryptography.hazmat.primitives.asymmetric import rsa
46
+
47
+ return rsa.generate_private_key(public_exponent=65537, key_size=2048)
48
+
49
+
50
+ def _key_pem(key) -> bytes: # noqa: ANN001
51
+ from cryptography.hazmat.primitives import serialization
52
+
53
+ return key.private_bytes(
54
+ serialization.Encoding.PEM,
55
+ serialization.PrivateFormat.TraditionalOpenSSL,
56
+ serialization.NoEncryption(),
57
+ )
58
+
59
+
60
+ def _relative_name(fqdn: str, zone: str) -> str:
61
+ """Strip the trailing ``.<zone>`` so provider APIs get the short name."""
62
+ fqdn = fqdn.rstrip(".")
63
+ suffix = "." + zone.rstrip(".")
64
+ return fqdn[: -len(suffix)] if fqdn.endswith(suffix) else fqdn
65
+
66
+
67
+ def _txt_propagated(fqdn: str, value: str, *, attempts: int, delay: int) -> bool:
68
+ """Poll public DNS (DoH) until the challenge TXT value is visible."""
69
+ for i in range(attempts):
70
+ try:
71
+ resp = requests.get(
72
+ _DOH, params={"name": fqdn, "type": "TXT"}, timeout=10
73
+ ).json()
74
+ answers = [a.get("data", "").strip('"') for a in resp.get("Answer", [])]
75
+ if value in answers:
76
+ return True
77
+ except Exception: # noqa: BLE001 - transient resolver errors are fine
78
+ pass
79
+ if i < attempts - 1:
80
+ time.sleep(delay)
81
+ return False
82
+
83
+
84
+ def issue_certificate(
85
+ domains: list[str],
86
+ *,
87
+ dns: str,
88
+ zone: str,
89
+ email: str,
90
+ staging: bool = False,
91
+ propagation_attempts: int = 20,
92
+ propagation_delay: int = 15,
93
+ ) -> tuple[str, str]:
94
+ """Issue a Let's Encrypt cert for ``domains``; return (fullchain_pem, key_pem).
95
+
96
+ ``dns`` is a granny DNS provider name; ``zone`` is the registrable zone the
97
+ challenge records live in (e.g. ``obachan.dev``).
98
+ """
99
+ import josepy as jose
100
+ from acme import challenges, client, crypto_util, messages
101
+
102
+ from granny.dns.factory import get_provider
103
+
104
+ provider = get_provider(dns)
105
+ zone_id = provider.get_zone_id(zone)
106
+
107
+ directory_url = _LE_STAGING if staging else _LE_PROD
108
+ account_key = jose.JWKRSA(key=_new_rsa_key())
109
+ net = client.ClientNetwork(account_key, user_agent="granny-letsencrypt")
110
+ directory = client.ClientV2.get_directory(directory_url, net)
111
+ acme = client.ClientV2(directory, net)
112
+ acme.new_account(
113
+ messages.NewRegistration.from_data(email=email, terms_of_service_agreed=True)
114
+ )
115
+
116
+ cert_key = _new_rsa_key()
117
+ cert_key_pem = _key_pem(cert_key)
118
+ csr_pem = crypto_util.make_csr(cert_key_pem, domains)
119
+ order = acme.new_order(csr_pem)
120
+
121
+ created: list[tuple[str, str, str]] = [] # (fqdn, value, record_id) for cleanup
122
+ try:
123
+ # 1. Publish every dns-01 TXT record first (a base domain + its wildcard
124
+ # share `_acme-challenge.<domain>`, so multiple values can coexist).
125
+ pending = []
126
+ for authz in order.authorizations:
127
+ dom = authz.body.identifier.value
128
+ chall = next(
129
+ c for c in authz.body.challenges if isinstance(c.chall, challenges.DNS01)
130
+ )
131
+ response, validation = chall.response_and_validation(account_key)
132
+ fqdn = chall.chall.validation_domain_name(dom)
133
+ rec = provider.create_record(
134
+ zone_id, _relative_name(fqdn, zone), "TXT", validation
135
+ )
136
+ created.append((fqdn, validation, rec.id))
137
+ pending.append((fqdn, validation, chall, response))
138
+ print(f" published TXT {fqdn} = {validation[:12]}…", file=sys.stderr)
139
+
140
+ # 2. Wait for propagation, then answer each challenge.
141
+ for fqdn, validation, chall, response in pending:
142
+ if not _txt_propagated(
143
+ fqdn, validation, attempts=propagation_attempts, delay=propagation_delay
144
+ ):
145
+ raise TimeoutError(f"TXT for {fqdn} did not propagate in time")
146
+ acme.answer_challenge(chall, response)
147
+ print(f" answered challenge for {fqdn}", file=sys.stderr)
148
+
149
+ # 3. Finalize + download.
150
+ order = acme.poll_and_finalize(order)
151
+ finally:
152
+ for _fqdn, _val, rec_id in created:
153
+ try:
154
+ provider.delete_record(zone_id, rec_id)
155
+ except Exception: # noqa: BLE001 - best-effort cleanup
156
+ pass
157
+
158
+ return order.fullchain_pem, cert_key_pem.decode("utf-8")
159
+
160
+
161
+ def main(argv: list[str] | None = None) -> int:
162
+ parser = argparse.ArgumentParser(description="Issue a Let's Encrypt cert via DNS-01.")
163
+ parser.add_argument("--domain", action="append", required=True, help="Domain (repeatable; wildcards ok).")
164
+ parser.add_argument("--dns", required=True, help="granny DNS provider (e.g. inwx, cloudflare).")
165
+ parser.add_argument("--zone", required=True, help="Registrable zone the challenge records live in.")
166
+ parser.add_argument("--email", required=True, help="ACME account contact email.")
167
+ parser.add_argument("--out-dir", default=".", help="Directory to write fullchain.pem + key.pem.")
168
+ parser.add_argument("--staging", action="store_true", help="Use the LE staging CA (no rate limits).")
169
+ parser.add_argument("--propagation-attempts", type=int, default=20)
170
+ parser.add_argument("--propagation-delay", type=int, default=15)
171
+ args = parser.parse_args(argv)
172
+
173
+ fullchain, key = issue_certificate(
174
+ args.domain,
175
+ dns=args.dns,
176
+ zone=args.zone,
177
+ email=args.email,
178
+ staging=args.staging,
179
+ propagation_attempts=args.propagation_attempts,
180
+ propagation_delay=args.propagation_delay,
181
+ )
182
+
183
+ out = Path(args.out_dir)
184
+ out.mkdir(parents=True, exist_ok=True)
185
+ (out / "fullchain.pem").write_text(fullchain, encoding="utf-8")
186
+ (out / "key.pem").write_text(key, encoding="utf-8")
187
+ print(f"wrote {out / 'fullchain.pem'} and {out / 'key.pem'}")
188
+ return 0
189
+
190
+
191
+ if __name__ == "__main__":
192
+ raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.16.0"
3
+ version = "0.17.0"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -12,6 +12,7 @@ dependencies = [
12
12
  "python-dotenv>=1.1.0",
13
13
  "requests>=2.32",
14
14
  "pyjwt[crypto]>=2.8", # Zitadel service-account JWT-profile auth (`granny zitadel`)
15
+ "acme>=2.11", # Let's Encrypt ACME DNS-01 issuance (granny create letsencrypt-cert)
15
16
  ]
16
17
 
17
18
  [project.optional-dependencies]
File without changes