granny-devops 0.13.0__tar.gz → 0.14.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 (109) hide show
  1. {granny_devops-0.13.0 → granny_devops-0.14.0}/PKG-INFO +1 -1
  2. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/__init__.py +19 -19
  3. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/dns.py +9 -6
  4. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/email.py +119 -0
  5. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/credentials/secrets.py +6 -0
  6. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/bunny.py +57 -5
  7. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/cloudflare.py +62 -5
  8. granny_devops-0.14.0/granny/email/__init__.py +14 -0
  9. granny_devops-0.14.0/granny/email/stalwart.py +322 -0
  10. {granny_devops-0.13.0 → granny_devops-0.14.0}/pyproject.toml +1 -1
  11. granny_devops-0.13.0/granny/email/__init__.py +0 -7
  12. {granny_devops-0.13.0 → granny_devops-0.14.0}/.gitignore +0 -0
  13. {granny_devops-0.13.0 → granny_devops-0.14.0}/LICENSE +0 -0
  14. {granny_devops-0.13.0 → granny_devops-0.14.0}/README.md +0 -0
  15. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/__init__.py +0 -0
  16. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/costs.py +0 -0
  17. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/credits.py +0 -0
  18. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/gpu_pricing.py +0 -0
  19. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/gpus.py +0 -0
  20. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/lambdas.py +0 -0
  21. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/analyze/vpcs.py +0 -0
  22. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/authentik/__init__.py +0 -0
  23. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/authentik/client.py +0 -0
  24. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/authentik/provision.py +0 -0
  25. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/aws/__init__.py +0 -0
  26. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/aws/quota.py +0 -0
  27. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/__init__.py +0 -0
  28. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/_client.py +0 -0
  29. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/account.py +0 -0
  30. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/deployment.py +0 -0
  31. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/openai.py +0 -0
  32. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/quota.py +0 -0
  33. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/role.py +0 -0
  34. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/sku.py +0 -0
  35. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/vm.py +0 -0
  36. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/azure/webapp.py +0 -0
  37. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cdn/__init__.py +0 -0
  38. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cdn/bunny.py +0 -0
  39. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/__init__.py +0 -0
  40. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/analyze.py +0 -0
  41. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/authentik.py +0 -0
  42. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/aws.py +0 -0
  43. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/azure.py +0 -0
  44. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/cdn.py +0 -0
  45. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/cloudflare.py +0 -0
  46. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/create.py +0 -0
  47. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/credentials.py +0 -0
  48. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/docker.py +0 -0
  49. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/edge.py +0 -0
  50. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/elk.py +0 -0
  51. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/indexing.py +0 -0
  52. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/main.py +0 -0
  53. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/serverless.py +0 -0
  54. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/storage.py +0 -0
  55. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cli/zitadel.py +0 -0
  56. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cloudflare/__init__.py +0 -0
  57. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cloudflare/d1.py +0 -0
  58. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cloudflare/r2.py +0 -0
  59. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/cloudflare/workers.py +0 -0
  60. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/__init__.py +0 -0
  61. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/auto_certificate.py +0 -0
  62. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/cloudfront-security-headers.js +0 -0
  63. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/manage-dns.sh +0 -0
  64. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/manage_mailjet_contacts.py +0 -0
  65. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/registrars.py +0 -0
  66. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_aws_cloudfront.py +0 -0
  67. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_bunny_edge_script.py +0 -0
  68. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_bunny_storage.py +0 -0
  69. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  70. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_hetzner_bunny.py +0 -0
  71. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_mailjet_dns.py +0 -0
  72. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_private_cdn.py +0 -0
  73. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_s3_website.py +0 -0
  74. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_scaleway_container.py +0 -0
  75. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_scaleway_faas.py +0 -0
  76. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/setup_workmail.py +0 -0
  77. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/create/www-redirect-function.js +0 -0
  78. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/credentials/__init__.py +0 -0
  79. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/__init__.py +0 -0
  80. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/base.py +0 -0
  81. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/cloudns.py +0 -0
  82. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/desec.py +0 -0
  83. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/factory.py +0 -0
  84. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/hetzner.py +0 -0
  85. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/inwx.py +0 -0
  86. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/manual.py +0 -0
  87. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/dns/records.py +0 -0
  88. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/docker/__init__.py +0 -0
  89. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/docker/build_base.py +0 -0
  90. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/edge/__init__.py +0 -0
  91. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/edge/bunny.py +0 -0
  92. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/elk/__init__.py +0 -0
  93. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/elk/client.py +0 -0
  94. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/email/mailjet.py +0 -0
  95. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/email/mailjet_contacts.py +0 -0
  96. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/email/ses_forwarding.py +0 -0
  97. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/email/workmail.py +0 -0
  98. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/indexing/__init__.py +0 -0
  99. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/indexing/google.py +0 -0
  100. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/indexing/indexnow.py +0 -0
  101. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/report.py +0 -0
  102. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/serverless/__init__.py +0 -0
  103. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/serverless/scaleway.py +0 -0
  104. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/storage/__init__.py +0 -0
  105. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/storage/aws.py +0 -0
  106. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/storage/bunny.py +0 -0
  107. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/storage/hetzner.py +0 -0
  108. {granny_devops-0.13.0 → granny_devops-0.14.0}/granny/zitadel/__init__.py +0 -0
  109. {granny_devops-0.13.0 → granny_devops-0.14.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.13.0
3
+ Version: 0.14.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
@@ -1,19 +1,19 @@
1
- """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
-
3
- __version__ = "0.13.0"
4
- __all__ = [
5
- "get_secret",
6
- "load_secrets_into_env",
7
- ]
8
-
9
-
10
- def __getattr__(name: str):
11
- if name == "get_secret":
12
- from granny.credentials.secrets import get_secret
13
-
14
- return get_secret
15
- if name == "load_secrets_into_env":
16
- from granny.credentials.secrets import load_secrets_into_env
17
-
18
- return load_secrets_into_env
19
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
1
+ """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
+
3
+ __version__ = "0.14.0"
4
+ __all__ = [
5
+ "get_secret",
6
+ "load_secrets_into_env",
7
+ ]
8
+
9
+
10
+ def __getattr__(name: str):
11
+ if name == "get_secret":
12
+ from granny.credentials.secrets import get_secret
13
+
14
+ return get_secret
15
+ if name == "load_secrets_into_env":
16
+ from granny.credentials.secrets import load_secrets_into_env
17
+
18
+ return load_secrets_into_env
19
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -43,16 +43,19 @@ def _split_fqdn(value: str, domain: str | None) -> tuple[str, str]:
43
43
 
44
44
  if "." in value:
45
45
  value_lower = value.lower()
46
- if domain and value_lower != domain and not value_lower.endswith(f".{domain}"):
47
- raise click.UsageError(
48
- f"{value!r} is not under domain {domain!r}"
49
- )
50
46
  if domain is None:
51
47
  return ("@", value_lower)
52
48
  if value_lower == domain:
53
49
  return ("@", domain)
54
- short = value_lower[: -(len(domain) + 1)]
55
- return (short, domain)
50
+ if value_lower.endswith(f".{domain}"):
51
+ short = value_lower[: -(len(domain) + 1)]
52
+ return (short, domain)
53
+ # A dotted name that's neither the zone apex nor a FQDN under it.
54
+ # With an explicit --domain this is a multi-label *relative* subname
55
+ # (e.g. ``_smtp._tls`` or ``v1-rsa-20260629._domainkey``), so treat it
56
+ # as relative to the zone rather than rejecting it. The resulting FQDN
57
+ # is echoed back, so a genuine wrong-domain typo stays visible.
58
+ return (value_lower, domain)
56
59
 
57
60
  # Bare label — needs a domain.
58
61
  if not domain:
@@ -330,3 +330,122 @@ def workmail_reset_password(
330
330
  raise click.ClickException(f"User not found: {user_email}")
331
331
  wm.reset_password(org_id, user["Id"], password)
332
332
  click.echo(f"Password reset for {user_email}.")
333
+
334
+
335
+ # -- Stalwart subgroup --------------------------------------------------------
336
+
337
+
338
+ @email.group("stalwart")
339
+ def stalwart() -> None:
340
+ """Stalwart mail server admin (domains, mailboxes, DNS, delivery test)."""
341
+
342
+
343
+ @stalwart.command("list-domains")
344
+ def stalwart_list_domains() -> None:
345
+ """List configured local domains."""
346
+ from granny.email.stalwart import StalwartClient
347
+
348
+ c = StalwartClient.from_environment()
349
+ domains = c.list_domains()
350
+ if not domains:
351
+ click.echo("No domains configured.")
352
+ return
353
+ for d in domains:
354
+ dkim = (d.get("dkimManagement") or {}).get("@type", "?")
355
+ click.echo(
356
+ f" {d.get('id', '?'):<5} {d.get('name', '?'):<30} "
357
+ f"enabled={d.get('isEnabled')} dkim={dkim}"
358
+ )
359
+
360
+
361
+ @stalwart.command("add-domain")
362
+ @click.argument("name")
363
+ def stalwart_add_domain(name: str) -> None:
364
+ """Create a new local domain (Manual cert, Automatic DKIM, Manual DNS)."""
365
+ from granny.email.stalwart import StalwartClient
366
+
367
+ c = StalwartClient.from_environment()
368
+ created = c.add_domain(name)
369
+ click.echo(f"Domain {name} ready (id={created.get('id')})")
370
+ click.echo(f"Next: granny email stalwart show-dns {name}")
371
+
372
+
373
+ @stalwart.command("list-mailboxes")
374
+ def stalwart_list_mailboxes() -> None:
375
+ """List individual mailboxes."""
376
+ from granny.email.stalwart import StalwartClient
377
+
378
+ c = StalwartClient.from_environment()
379
+ mailboxes = c.list_mailboxes()
380
+ if not mailboxes:
381
+ click.echo("No mailboxes configured.")
382
+ return
383
+ for a in mailboxes:
384
+ role = (a.get("roles") or {}).get("@type", "?")
385
+ click.echo(
386
+ f" {a.get('id', '?'):<5} {a.get('emailAddress', '?'):<40} role={role}"
387
+ )
388
+
389
+
390
+ @stalwart.command("add-mailbox")
391
+ @click.argument("email_address")
392
+ @click.option("--description", default=None, help="Free-text mailbox description.")
393
+ @click.option(
394
+ "--password",
395
+ default=None,
396
+ help="Use this password instead of generating one (NOT recommended).",
397
+ )
398
+ def stalwart_add_mailbox(
399
+ email_address: str, description: str | None, password: str | None,
400
+ ) -> None:
401
+ """Create a mailbox under an existing local domain.
402
+
403
+ The generated password is shown ONCE on stdout. Save it immediately
404
+ (e.g. into Vaultwarden) — Stalwart hashes it server-side.
405
+ """
406
+ from granny.email.stalwart import StalwartClient
407
+
408
+ c = StalwartClient.from_environment()
409
+ record, generated = c.add_mailbox(
410
+ email_address, password=password, description=description,
411
+ )
412
+ click.echo(f"Mailbox created (id={record.get('id')})")
413
+ click.echo("")
414
+ click.echo(f" Email: {email_address}")
415
+ click.echo(f" Password: {generated}")
416
+ click.echo("")
417
+ base = c.base_url.removeprefix("https://").removeprefix("http://")
418
+ click.echo(f" SMTP submission: {base}:587 (STARTTLS) or :465 (implicit TLS)")
419
+ click.echo(f" IMAP: {base}:993")
420
+ click.echo(f" JMAP / WebDAV: {c.base_url}/")
421
+ click.echo("")
422
+ click.echo(" This password is shown ONCE — save it now.")
423
+
424
+
425
+ @stalwart.command("show-dns")
426
+ @click.argument("name")
427
+ def stalwart_show_dns(name: str) -> None:
428
+ """Print Stalwart's generated DNS zone for a domain.
429
+
430
+ Output is in BIND zone-file syntax (SPF, MX, DKIM, DMARC, MTA-STS,
431
+ TLS-RPT, SRV autoconfig). Publish at the domain's registrar.
432
+ """
433
+ from granny.email.stalwart import StalwartClient
434
+
435
+ c = StalwartClient.from_environment()
436
+ click.echo(c.get_dns_zone(name))
437
+
438
+
439
+ @stalwart.command("delivery-test")
440
+ @click.argument("recipient")
441
+ def stalwart_delivery_test(recipient: str) -> None:
442
+ """Stream Stalwart's live deliverability report for one recipient.
443
+
444
+ Mirrors the admin UI's *Live Delivery Test* page (MX lookup,
445
+ MTA-STS, TLS-RPT, banner, STARTTLS, etc.).
446
+ """
447
+ from granny.email.stalwart import StalwartClient
448
+
449
+ c = StalwartClient.from_environment()
450
+ for line in c.stream_delivery_test(recipient):
451
+ click.echo(line)
@@ -182,6 +182,12 @@ SECRET_MAP: dict[str, str] = {
182
182
  "ELASTICSEARCH_API_KEY": "elasticsearch-api-key",
183
183
  "ELASTICSEARCH_USERNAME": "elasticsearch-username",
184
184
  "ELASTICSEARCH_PASSWORD": "elasticsearch-password",
185
+ # Stalwart mail server admin (Basic auth on JMAP / REST). The username is
186
+ # not secret but is registered alongside the password so both can come from
187
+ # the same vault item. Base URL defaults to https://mail.pseekoo.io in
188
+ # StalwartClient.from_environment() and is overridable via STALWART_URL.
189
+ "STALWART_ADMIN_USER": "stalwart-admin-user",
190
+ "STALWART_ADMIN_PASSWORD": "stalwart-admin-password",
185
191
  }
186
192
 
187
193
  # Module-level cache so vault is only contacted once per process
@@ -107,7 +107,7 @@ class BunnyDNSProvider(DNSProvider):
107
107
  DNSRecord(
108
108
  name=rec.get("Name", "") or "",
109
109
  type=RECORD_TYPE_NAMES.get(rec.get("Type", -1), "?"),
110
- value=rec.get("Value", ""),
110
+ value=self._record_value(rec),
111
111
  ttl=int(rec.get("Ttl", 300)),
112
112
  id=str(rec.get("Id")),
113
113
  provider_data=rec,
@@ -124,27 +124,79 @@ class BunnyDNSProvider(DNSProvider):
124
124
  ttl: int = 300,
125
125
  proxied: bool = False,
126
126
  ) -> DNSRecord:
127
- type_num = RECORD_TYPES.get(record_type.upper())
127
+ rtype = record_type.upper()
128
+ type_num = RECORD_TYPES.get(rtype)
128
129
  if type_num is None:
129
130
  raise ValueError(f"Unknown record type: {record_type}")
130
131
  short_name = "" if name in ("@", None) else name
132
+ record_value = value.rstrip(".") if rtype == "CNAME" else value
133
+ priority: int | None = None
134
+ extra: dict[str, int] = {}
135
+ if rtype == "MX":
136
+ # BIND-style "10 mail.example.com" → Priority + target.
137
+ head, _, tail = value.strip().partition(" ")
138
+ if head.isdigit() and tail:
139
+ priority = int(head)
140
+ record_value = tail.rstrip(".")
141
+ elif rtype == "SRV":
142
+ # BIND-style "priority weight port target" → Bunny's separate
143
+ # Priority/Weight/Port fields plus Value=target.
144
+ parts = value.split()
145
+ if len(parts) != 4:
146
+ raise ValueError(
147
+ f"SRV value must be 'priority weight port target', got {value!r}"
148
+ )
149
+ prio, weight, port, target = parts
150
+ priority = int(prio)
151
+ extra = {"Weight": int(weight), "Port": int(port)}
152
+ record_value = target.rstrip(".")
131
153
  payload = {
132
154
  "Type": type_num,
133
155
  "Name": short_name,
134
- "Value": value.rstrip(".") if record_type.upper() == "CNAME" else value,
156
+ "Value": record_value,
135
157
  "Ttl": ttl,
136
158
  }
159
+ if priority is not None:
160
+ payload["Priority"] = priority
161
+ payload.update(extra)
137
162
  result = self._request("PUT", f"/dnszone/{zone_id}/records", payload)
138
163
  logger.info("Bunny DNS: created %s %s -> %s", record_type, short_name, value)
164
+ # Build the display value from what we sent — Bunny's create response
165
+ # doesn't reliably echo Priority/Weight/Port back.
166
+ if rtype == "MX" and priority is not None:
167
+ display_value = f"{priority} {record_value}"
168
+ elif rtype == "SRV":
169
+ display_value = f"{priority} {extra['Weight']} {extra['Port']} {record_value}"
170
+ else:
171
+ display_value = record_value
139
172
  return DNSRecord(
140
173
  name=short_name,
141
- type=record_type.upper(),
142
- value=payload["Value"],
174
+ type=rtype,
175
+ value=display_value,
143
176
  ttl=ttl,
144
177
  id=str(result.get("Id")) if isinstance(result, dict) else None,
145
178
  provider_data=result if isinstance(result, dict) else {},
146
179
  )
147
180
 
181
+ @staticmethod
182
+ def _record_value(rec: dict) -> str:
183
+ """Reconstruct a BIND-style value from a Bunny record.
184
+
185
+ MX prepends ``Priority``; SRV expands to ``priority weight port
186
+ target``. Keeps round-tripped values in the same shape callers pass,
187
+ so ``upsert_record`` compares equal and stays idempotent.
188
+ """
189
+ value = rec.get("Value", "") or ""
190
+ rtype = rec.get("Type")
191
+ if rtype == RECORD_TYPES["MX"]:
192
+ return f"{rec.get('Priority', 0)} {value}".strip()
193
+ if rtype == RECORD_TYPES["SRV"]:
194
+ return (
195
+ f"{rec.get('Priority', 0)} {rec.get('Weight', 0)} "
196
+ f"{rec.get('Port', 0)} {value}"
197
+ )
198
+ return value
199
+
148
200
  def delete_record(self, zone_id: str, record_id: str) -> None:
149
201
  self._request("DELETE", f"/dnszone/{zone_id}/records/{record_id}")
150
202
  logger.info("Bunny DNS: deleted record %s", record_id)
@@ -101,7 +101,7 @@ class CloudflareDNSProvider(DNSProvider):
101
101
  DNSRecord(
102
102
  name=rec.get("name", ""),
103
103
  type=rec.get("type", ""),
104
- value=rec.get("content", ""),
104
+ value=self._record_value(rec),
105
105
  ttl=int(rec.get("ttl", 300)),
106
106
  id=rec.get("id"),
107
107
  fqdn=rec.get("name"),
@@ -110,6 +110,61 @@ class CloudflareDNSProvider(DNSProvider):
110
110
  )
111
111
  return out
112
112
 
113
+ @staticmethod
114
+ def _record_value(rec: dict) -> str:
115
+ """Reconstruct a BIND-style value string from a Cloudflare record.
116
+
117
+ MX prepends its ``priority``; SRV expands its ``data`` object to
118
+ ``priority weight port target``. Everything else uses ``content``.
119
+ Keeping the round-tripped value in this shape lets ``upsert_record``
120
+ compare against the value callers pass and stay idempotent.
121
+ """
122
+ rtype = rec.get("type", "")
123
+ content = rec.get("content", "") or ""
124
+ if rtype == "MX" and rec.get("priority") is not None:
125
+ return f"{rec['priority']} {content}".strip()
126
+ if rtype == "SRV":
127
+ data = rec.get("data") or {}
128
+ if data:
129
+ return (
130
+ f"{data.get('priority', 0)} {data.get('weight', 0)} "
131
+ f"{data.get('port', 0)} {str(data.get('target', '')).rstrip('.')}"
132
+ )
133
+ return content
134
+
135
+ @staticmethod
136
+ def _type_payload(record_type: str, value: str) -> dict:
137
+ """Build the type-specific Cloudflare payload fields.
138
+
139
+ MX and SRV can't be expressed with a bare ``content`` string —
140
+ Cloudflare requires a separate ``priority`` (MX) or a structured
141
+ ``data`` object (SRV). Both accept the BIND-style value granny passes
142
+ around: ``"10 mail.example.com"`` for MX and
143
+ ``"0 1 443 mail.example.com"`` (priority weight port target) for SRV.
144
+ A bare MX target defaults to priority 10.
145
+ """
146
+ rtype = record_type.upper()
147
+ parts = value.split()
148
+ if rtype == "MX":
149
+ if len(parts) == 2 and parts[0].isdigit():
150
+ return {"content": parts[1].rstrip("."), "priority": int(parts[0])}
151
+ return {"content": value.rstrip("."), "priority": 10}
152
+ if rtype == "SRV":
153
+ if len(parts) != 4:
154
+ raise ValueError(
155
+ f"SRV value must be 'priority weight port target', got {value!r}"
156
+ )
157
+ prio, weight, port, target = parts
158
+ return {
159
+ "data": {
160
+ "priority": int(prio),
161
+ "weight": int(weight),
162
+ "port": int(port),
163
+ "target": target.rstrip("."),
164
+ }
165
+ }
166
+ return {"content": value}
167
+
113
168
  def get_record(
114
169
  self, zone_id: str, name: str, record_type: str
115
170
  ) -> DNSRecord | None:
@@ -134,20 +189,22 @@ class CloudflareDNSProvider(DNSProvider):
134
189
  payload = {
135
190
  "type": record_type.upper(),
136
191
  "name": fqdn,
137
- "content": value,
138
192
  # Cloudflare uses ttl=1 as the sentinel for "automatic". Anything
139
193
  # else must be >= 60. Match manage-dns.sh's behavior: if caller
140
194
  # passes ttl<=1, use automatic; otherwise send the value.
141
195
  "ttl": 1 if ttl <= 1 else ttl,
142
196
  "proxied": proxied,
143
197
  }
198
+ # MX needs a separate `priority`, SRV a structured `data` object;
199
+ # everything else is a plain `content` string.
200
+ payload.update(self._type_payload(record_type, value))
144
201
  body = self._request("POST", f"/zones/{zone_id}/dns_records", json=payload)
145
202
  rec = body.get("result") or {}
146
203
  logger.info("Cloudflare: created %s %s -> %s", record_type, fqdn, value)
147
204
  return DNSRecord(
148
205
  name=rec.get("name", fqdn),
149
206
  type=rec.get("type", record_type.upper()),
150
- value=rec.get("content", value),
207
+ value=self._record_value(rec) or value,
151
208
  ttl=int(rec.get("ttl", ttl)),
152
209
  id=rec.get("id"),
153
210
  fqdn=rec.get("name", fqdn),
@@ -168,10 +225,10 @@ class CloudflareDNSProvider(DNSProvider):
168
225
  payload = {
169
226
  "type": record_type.upper(),
170
227
  "name": fqdn,
171
- "content": value,
172
228
  "ttl": 1 if ttl <= 1 else ttl,
173
229
  "proxied": proxied,
174
230
  }
231
+ payload.update(self._type_payload(record_type, value))
175
232
  body = self._request(
176
233
  "PUT", f"/zones/{zone_id}/dns_records/{record_id}", json=payload
177
234
  )
@@ -180,7 +237,7 @@ class CloudflareDNSProvider(DNSProvider):
180
237
  return DNSRecord(
181
238
  name=rec.get("name", fqdn),
182
239
  type=rec.get("type", record_type.upper()),
183
- value=rec.get("content", value),
240
+ value=self._record_value(rec) or value,
184
241
  ttl=int(rec.get("ttl", ttl)),
185
242
  id=rec.get("id", record_id),
186
243
  fqdn=rec.get("name", fqdn),
@@ -0,0 +1,14 @@
1
+ """Email management — Mailjet, AWS WorkMail, Stalwart."""
2
+
3
+ from granny.email.mailjet import MailjetClient
4
+ from granny.email.mailjet_contacts import MailjetContactClient
5
+ from granny.email.stalwart import StalwartClient, StalwartError
6
+ from granny.email.workmail import WorkMailManager
7
+
8
+ __all__ = [
9
+ "MailjetClient",
10
+ "MailjetContactClient",
11
+ "StalwartClient",
12
+ "StalwartError",
13
+ "WorkMailManager",
14
+ ]
@@ -0,0 +1,322 @@
1
+ """Stalwart mail server admin client.
2
+
3
+ Wraps Stalwart's JMAP management endpoint (POST ``/jmap``) and the small
4
+ REST surface granny needs. The web admin UI uses the
5
+ ``urn:stalwart:jmap`` capability with ``x:Domain/*`` and ``x:Account/*``
6
+ methods; there is no REST equivalent for domain/mailbox CRUD, so we use
7
+ the same JMAP calls.
8
+
9
+ Auth is HTTP Basic with the recovery (or post-bootstrap) admin
10
+ credentials. URLs and credentials resolve via the standard granny
11
+ env-var + Vaultwarden pipeline.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import secrets as _secrets
19
+ import string
20
+ from collections.abc import Iterator
21
+ from typing import Any
22
+
23
+ import requests
24
+
25
+ from granny.credentials import get_secret
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Stalwart's web admin uses a fixed accountId "d333333" for all domain /
30
+ # account management calls. It identifies the directory-management
31
+ # principal, not a user account.
32
+ _DIRECTORY_ACCOUNT_ID = "d333333"
33
+
34
+ _JMAP_CAPS = ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]
35
+
36
+
37
+ class StalwartError(RuntimeError):
38
+ """Raised on non-2xx responses from Stalwart or JMAP-level errors."""
39
+
40
+ def __init__(self, status: int, body: str) -> None:
41
+ super().__init__(f"HTTP {status}: {body[:400]}")
42
+ self.status = status
43
+ self.body = body
44
+
45
+
46
+ class StalwartClient:
47
+ """Authenticated client for Stalwart's JMAP management API.
48
+
49
+ Construct directly when you already have the credentials, or use
50
+ :meth:`from_environment` to resolve them via granny's normal env /
51
+ vault pipeline.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ base_url: str,
57
+ user: str,
58
+ password: str,
59
+ *,
60
+ timeout: float = 30.0,
61
+ ) -> None:
62
+ self.base_url = base_url.rstrip("/")
63
+ self._timeout = timeout
64
+ self._session = requests.Session()
65
+ self._session.auth = (user, password)
66
+ self._session.headers.update({"Accept": "application/json"})
67
+
68
+ @classmethod
69
+ def from_environment(cls) -> "StalwartClient":
70
+ """Build a client from env vars + Vaultwarden fallback.
71
+
72
+ Resolution:
73
+
74
+ - ``STALWART_URL`` (env only; default ``https://mail.pseekoo.io``)
75
+ - ``STALWART_ADMIN_USER`` and ``STALWART_ADMIN_PASSWORD`` via the
76
+ normal :func:`granny.credentials.get_secret` chain (env wins,
77
+ Vaultwarden under ``granny/infra/stalwart-admin-*`` as fallback).
78
+ """
79
+ base_url = os.environ.get("STALWART_URL") or "https://mail.pseekoo.io"
80
+ user = get_secret("STALWART_ADMIN_USER")
81
+ password = get_secret("STALWART_ADMIN_PASSWORD")
82
+ if not user or not password:
83
+ raise StalwartError(
84
+ 0,
85
+ "STALWART_ADMIN_USER / STALWART_ADMIN_PASSWORD not set "
86
+ "(env or vault). Set them in .env / .deploy.env, or push "
87
+ "them to Vaultwarden at granny/infra/stalwart-admin-user "
88
+ "and granny/infra/stalwart-admin-password.",
89
+ )
90
+ return cls(base_url=base_url, user=user, password=password)
91
+
92
+ # -- HTTP plumbing --------------------------------------------------------
93
+
94
+ def _request(
95
+ self,
96
+ method: str,
97
+ path: str,
98
+ *,
99
+ json: Any = None,
100
+ ) -> Any:
101
+ if not path.startswith("/"):
102
+ path = "/" + path
103
+ url = self.base_url + path
104
+ resp = self._session.request(
105
+ method=method.upper(), url=url, json=json, timeout=self._timeout,
106
+ )
107
+ if resp.status_code >= 400:
108
+ raise StalwartError(resp.status_code, resp.text)
109
+ if not resp.content:
110
+ return None
111
+ try:
112
+ return resp.json()
113
+ except ValueError:
114
+ return resp.text
115
+
116
+ def _jmap(self, method: str, args: dict[str, Any]) -> dict[str, Any]:
117
+ """Execute one JMAP method call and return its response args.
118
+
119
+ ``method`` is e.g. ``"x:Domain/get"`` or ``"x:Account/set"``;
120
+ ``args`` is the per-method argument dict. The accountId is filled
121
+ in automatically.
122
+ """
123
+ body = {
124
+ "using": _JMAP_CAPS,
125
+ "methodCalls": [[method, {"accountId": _DIRECTORY_ACCOUNT_ID, **args}, "a"]],
126
+ }
127
+ resp = self._request("POST", "/jmap", json=body)
128
+ if not resp or "methodResponses" not in resp:
129
+ raise StalwartError(0, f"unexpected JMAP response: {resp!r}")
130
+ mc = resp["methodResponses"][0]
131
+ # JMAP-level errors come back as ["error", {type: ..., description: ...}, "a"]
132
+ if mc[0] == "error":
133
+ err = mc[1] or {}
134
+ desc = err.get("description") or err.get("type") or "JMAP error"
135
+ raise StalwartError(0, f"{method}: {desc}")
136
+ return mc[1] or {}
137
+
138
+ # -- Domains --------------------------------------------------------------
139
+
140
+ def list_domains(self) -> list[dict[str, Any]]:
141
+ """Return all configured local domains."""
142
+ out = self._jmap("x:Domain/get", {})
143
+ return list(out.get("list") or [])
144
+
145
+ def get_domain(self, name: str) -> dict[str, Any] | None:
146
+ """Find a domain by its DNS name (e.g. ``"m3rp.ai"``)."""
147
+ for d in self.list_domains():
148
+ if d.get("name") == name:
149
+ return d
150
+ return None
151
+
152
+ def add_domain(
153
+ self,
154
+ name: str,
155
+ *,
156
+ certificate: str = "manual",
157
+ dkim_algorithms: tuple[str, ...] = ("Dkim1RsaSha256", "Dkim1Ed25519Sha256"),
158
+ dkim_rotate_after_ms: int = 7_776_000_000,
159
+ dkim_retire_after_ms: int = 604_800_000,
160
+ dkim_delete_after_ms: int = 2_592_000_000,
161
+ dkim_selector_template: str = "v{version}-{algorithm}-{date-%Y%m%d}",
162
+ dns_management: str = "manual",
163
+ allow_relaying: bool = False,
164
+ report_address_uri: str = "mailto:postmaster",
165
+ ) -> dict[str, Any]:
166
+ """Create a new local domain.
167
+
168
+ Mirrors the production setup (Manual cert because the MX host
169
+ already has a Let's Encrypt cert via Caddy, Automatic DKIM
170
+ rotation, Manual DNS because records are published at the
171
+ domain's registrar).
172
+ """
173
+ existing = self.get_domain(name)
174
+ if existing is not None:
175
+ logger.info("Domain %s already exists (id=%s)", name, existing["id"])
176
+ return existing
177
+
178
+ dkim = {
179
+ "@type": "Automatic",
180
+ "algorithms": {algo: True for algo in dkim_algorithms},
181
+ "selectorTemplate": dkim_selector_template,
182
+ "rotateAfter": dkim_rotate_after_ms,
183
+ "retireAfter": dkim_retire_after_ms,
184
+ "deleteAfter": dkim_delete_after_ms,
185
+ }
186
+ record = {
187
+ "name": name,
188
+ "isEnabled": True,
189
+ "certificateManagement": {"@type": certificate.capitalize()},
190
+ "dkimManagement": dkim,
191
+ "dnsManagement": {"@type": dns_management.capitalize()},
192
+ "subAddressing": {"@type": "Enabled"},
193
+ "allowRelaying": allow_relaying,
194
+ "reportAddressUri": report_address_uri,
195
+ }
196
+ out = self._jmap("x:Domain/set", {"create": {"new": record}})
197
+ not_created = out.get("notCreated") or {}
198
+ if not_created:
199
+ raise StalwartError(0, f"Domain/set notCreated: {not_created!r}")
200
+ created = (out.get("created") or {}).get("new") or {}
201
+ if not created:
202
+ raise StalwartError(0, f"Domain/set returned no created entry: {out!r}")
203
+ return created
204
+
205
+ def get_dns_zone(self, name: str) -> str:
206
+ """Return Stalwart's generated DNS zone file for a domain.
207
+
208
+ Includes SPF, MX, DKIM (one per active key), DMARC, MTA-STS,
209
+ TLS-RPT, and the autoconfig/SRV records.
210
+ """
211
+ dom = self.get_domain(name)
212
+ if dom is None:
213
+ raise StalwartError(0, f"Domain not found: {name}")
214
+ zone = dom.get("dnsZoneFile")
215
+ if zone is None:
216
+ raise StalwartError(0, f"Domain {name} has no dnsZoneFile field")
217
+ return zone
218
+
219
+ # -- Mailboxes (accounts) -------------------------------------------------
220
+
221
+ def list_mailboxes(self) -> list[dict[str, Any]]:
222
+ """Return all individual user / mailbox accounts."""
223
+ out = self._jmap("x:Account/get", {})
224
+ return list(out.get("list") or [])
225
+
226
+ def get_mailbox(self, email: str) -> dict[str, Any] | None:
227
+ """Find a mailbox by its primary email address."""
228
+ for a in self.list_mailboxes():
229
+ if a.get("emailAddress") == email:
230
+ return a
231
+ return None
232
+
233
+ def add_mailbox(
234
+ self,
235
+ email: str,
236
+ *,
237
+ password: str | None = None,
238
+ description: str | None = None,
239
+ encryption_at_rest: bool = False,
240
+ ) -> tuple[dict[str, Any], str]:
241
+ """Create a mailbox under an existing local domain.
242
+
243
+ Returns ``(record, password)`` so the caller can show the
244
+ generated password to the operator. When ``password`` is None,
245
+ a 24-character random password is generated.
246
+ """
247
+ if "@" not in email:
248
+ raise ValueError("email must be 'local@domain' form")
249
+ local_part, domain = email.split("@", 1)
250
+ dom = self.get_domain(domain)
251
+ if dom is None:
252
+ raise StalwartError(
253
+ 0,
254
+ f"Domain '{domain}' is not configured in Stalwart. "
255
+ f"Add it first with add_domain('{domain}').",
256
+ )
257
+ if password is None:
258
+ password = _generate_password()
259
+
260
+ record = {
261
+ "name": local_part,
262
+ "domainId": dom["id"],
263
+ "@type": "User",
264
+ "description": description or f"Mailbox {email}",
265
+ "roles": {"@type": "User"},
266
+ "permissions": {"@type": "Inherit"},
267
+ "encryptionAtRest": {
268
+ "@type": "Enabled" if encryption_at_rest else "Disabled",
269
+ },
270
+ "credentials": {"0": {"@type": "Password", "secret": password}},
271
+ }
272
+ out = self._jmap("x:Account/set", {"create": {"new": record}})
273
+ not_created = out.get("notCreated") or {}
274
+ if not_created:
275
+ raise StalwartError(0, f"Account/set notCreated: {not_created!r}")
276
+ created = (out.get("created") or {}).get("new") or {}
277
+ if not created:
278
+ raise StalwartError(0, f"Account/set returned no created entry: {out!r}")
279
+ return created, password
280
+
281
+ # -- Delivery / health ----------------------------------------------------
282
+
283
+ def get_delivery_token(self) -> str:
284
+ """Mint a short-lived token for the live delivery test SSE stream."""
285
+ resp = self._request("GET", "/api/token/delivery")
286
+ if not isinstance(resp, str) or not resp.strip():
287
+ raise StalwartError(0, f"unexpected delivery token response: {resp!r}")
288
+ return resp.strip()
289
+
290
+ def stream_delivery_test(self, recipient: str) -> Iterator[str]:
291
+ """Yield raw SSE lines from Stalwart's live delivery test.
292
+
293
+ Mirrors the admin UI's *Live Delivery Test* page (MX lookup,
294
+ MTA-STS, TLS-RPT, banner, STARTTLS, etc.). Useful from CLI
295
+ because the browser EventSource flow returns 401 on this build.
296
+ """
297
+ token = self.get_delivery_token()
298
+ url = f"{self.base_url}/api/live/delivery/{recipient}?token={token}"
299
+ with self._session.get(
300
+ url,
301
+ headers={"Accept": "text/event-stream"},
302
+ stream=True,
303
+ timeout=self._timeout,
304
+ ) as resp:
305
+ if resp.status_code >= 400:
306
+ raise StalwartError(resp.status_code, resp.text)
307
+ for line in resp.iter_lines(decode_unicode=True):
308
+ if line is not None:
309
+ yield line
310
+
311
+
312
+ # -- helpers ------------------------------------------------------------------
313
+
314
+
315
+ def _generate_password(length: int = 24) -> str:
316
+ """Generate an URL-safe random password without padding characters.
317
+
318
+ Matches the strength of ``openssl rand -base64 24`` and strips the
319
+ same noisy characters (``/+=``) as the bash helper.
320
+ """
321
+ alphabet = string.ascii_letters + string.digits
322
+ return "".join(_secrets.choice(alphabet) for _ in range(length))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.13.0"
3
+ version = "0.14.0"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -1,7 +0,0 @@
1
- """Email management — Mailjet + AWS WorkMail."""
2
-
3
- from granny.email.mailjet import MailjetClient
4
- from granny.email.mailjet_contacts import MailjetContactClient
5
- from granny.email.workmail import WorkMailManager
6
-
7
- __all__ = ["MailjetClient", "MailjetContactClient", "WorkMailManager"]
File without changes
File without changes