granny-devops 0.12.1__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.12.1 → granny_devops-0.14.0}/PKG-INFO +6 -3
  2. {granny_devops-0.12.1 → granny_devops-0.14.0}/README.md +5 -2
  3. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/__init__.py +19 -19
  4. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/dns.py +9 -6
  5. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/email.py +119 -0
  6. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/zitadel.py +51 -3
  7. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/credentials/secrets.py +10 -3
  8. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/bunny.py +57 -5
  9. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/cloudflare.py +62 -5
  10. granny_devops-0.14.0/granny/email/__init__.py +14 -0
  11. granny_devops-0.14.0/granny/email/stalwart.py +322 -0
  12. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/zitadel/client.py +32 -3
  13. {granny_devops-0.12.1 → granny_devops-0.14.0}/pyproject.toml +1 -1
  14. granny_devops-0.12.1/granny/email/__init__.py +0 -7
  15. {granny_devops-0.12.1 → granny_devops-0.14.0}/.gitignore +0 -0
  16. {granny_devops-0.12.1 → granny_devops-0.14.0}/LICENSE +0 -0
  17. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/__init__.py +0 -0
  18. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/costs.py +0 -0
  19. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/credits.py +0 -0
  20. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/gpu_pricing.py +0 -0
  21. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/gpus.py +0 -0
  22. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/lambdas.py +0 -0
  23. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/vpcs.py +0 -0
  24. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/authentik/__init__.py +0 -0
  25. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/authentik/client.py +0 -0
  26. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/authentik/provision.py +0 -0
  27. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/aws/__init__.py +0 -0
  28. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/aws/quota.py +0 -0
  29. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/__init__.py +0 -0
  30. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/_client.py +0 -0
  31. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/account.py +0 -0
  32. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/deployment.py +0 -0
  33. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/openai.py +0 -0
  34. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/quota.py +0 -0
  35. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/role.py +0 -0
  36. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/sku.py +0 -0
  37. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/vm.py +0 -0
  38. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/webapp.py +0 -0
  39. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cdn/__init__.py +0 -0
  40. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cdn/bunny.py +0 -0
  41. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/__init__.py +0 -0
  42. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/analyze.py +0 -0
  43. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/authentik.py +0 -0
  44. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/aws.py +0 -0
  45. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/azure.py +0 -0
  46. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/cdn.py +0 -0
  47. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/cloudflare.py +0 -0
  48. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/create.py +0 -0
  49. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/credentials.py +0 -0
  50. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/docker.py +0 -0
  51. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/edge.py +0 -0
  52. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/elk.py +0 -0
  53. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/indexing.py +0 -0
  54. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/main.py +0 -0
  55. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/serverless.py +0 -0
  56. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/storage.py +0 -0
  57. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cloudflare/__init__.py +0 -0
  58. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cloudflare/d1.py +0 -0
  59. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cloudflare/r2.py +0 -0
  60. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cloudflare/workers.py +0 -0
  61. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/__init__.py +0 -0
  62. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/auto_certificate.py +0 -0
  63. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/cloudfront-security-headers.js +0 -0
  64. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/manage-dns.sh +0 -0
  65. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/manage_mailjet_contacts.py +0 -0
  66. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/registrars.py +0 -0
  67. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_aws_cloudfront.py +0 -0
  68. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_bunny_edge_script.py +0 -0
  69. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_bunny_storage.py +0 -0
  70. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  71. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_hetzner_bunny.py +0 -0
  72. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_mailjet_dns.py +0 -0
  73. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_private_cdn.py +0 -0
  74. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_s3_website.py +0 -0
  75. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_scaleway_container.py +0 -0
  76. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_scaleway_faas.py +0 -0
  77. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_workmail.py +0 -0
  78. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/www-redirect-function.js +0 -0
  79. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/credentials/__init__.py +0 -0
  80. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/__init__.py +0 -0
  81. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/base.py +0 -0
  82. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/cloudns.py +0 -0
  83. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/desec.py +0 -0
  84. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/factory.py +0 -0
  85. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/hetzner.py +0 -0
  86. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/inwx.py +0 -0
  87. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/manual.py +0 -0
  88. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/records.py +0 -0
  89. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/docker/__init__.py +0 -0
  90. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/docker/build_base.py +0 -0
  91. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/edge/__init__.py +0 -0
  92. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/edge/bunny.py +0 -0
  93. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/elk/__init__.py +0 -0
  94. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/elk/client.py +0 -0
  95. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/email/mailjet.py +0 -0
  96. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/email/mailjet_contacts.py +0 -0
  97. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/email/ses_forwarding.py +0 -0
  98. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/email/workmail.py +0 -0
  99. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/indexing/__init__.py +0 -0
  100. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/indexing/google.py +0 -0
  101. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/indexing/indexnow.py +0 -0
  102. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/report.py +0 -0
  103. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/serverless/__init__.py +0 -0
  104. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/serverless/scaleway.py +0 -0
  105. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/storage/__init__.py +0 -0
  106. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/storage/aws.py +0 -0
  107. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/storage/bunny.py +0 -0
  108. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/storage/hetzner.py +0 -0
  109. {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/zitadel/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.12.1
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
@@ -301,10 +301,13 @@ granny authentik add-user-to-group user@example.com # defaults to dash_admin
301
301
  granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
302
302
 
303
303
  # Zitadel admin (self-hosted OIDC server — same shape as authentik)
304
- # Point granny at your Zitadel: set ZITADEL_DOMAIN + a machine credential:
304
+ # ZITADEL_DOMAIN (e.g. https://id.example.com) plain config, NOT a secret;
305
+ # set it via env or .env/.deploy.env (granny auto-loads those). Not vaulted.
306
+ # Plus one machine credential:
305
307
  # ZITADEL_API_TOKEN (a PAT, used as a bearer), OR
306
308
  # ZITADEL_SA_KEY / ZITADEL_SA_KEY_FILE (service-account key JSON; JWT-profile auth)
307
- # Vault keys (with the [vault] extra): granny/infra/zitadel-domain, -api-token, -sa-key
309
+ # Secret vault keys (with the [vault] extra): granny/infra/zitadel-api-token, -sa-key
310
+ # Mint a PAT reproducibly: granny zitadel create-pat iam-admin --expiration 2027-06-27T00:00:00Z
308
311
  granny zitadel create-project platform
309
312
  granny zitadel create-oidc-app platform my-app \
310
313
  --redirect-uri https://app.example.com/oidc/callback
@@ -168,10 +168,13 @@ granny authentik add-user-to-group user@example.com # defaults to dash_admin
168
168
  granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
169
169
 
170
170
  # Zitadel admin (self-hosted OIDC server — same shape as authentik)
171
- # Point granny at your Zitadel: set ZITADEL_DOMAIN + a machine credential:
171
+ # ZITADEL_DOMAIN (e.g. https://id.example.com) plain config, NOT a secret;
172
+ # set it via env or .env/.deploy.env (granny auto-loads those). Not vaulted.
173
+ # Plus one machine credential:
172
174
  # ZITADEL_API_TOKEN (a PAT, used as a bearer), OR
173
175
  # ZITADEL_SA_KEY / ZITADEL_SA_KEY_FILE (service-account key JSON; JWT-profile auth)
174
- # Vault keys (with the [vault] extra): granny/infra/zitadel-domain, -api-token, -sa-key
176
+ # Secret vault keys (with the [vault] extra): granny/infra/zitadel-api-token, -sa-key
177
+ # Mint a PAT reproducibly: granny zitadel create-pat iam-admin --expiration 2027-06-27T00:00:00Z
175
178
  granny zitadel create-project platform
176
179
  granny zitadel create-oidc-app platform my-app \
177
180
  --redirect-uri https://app.example.com/oidc/callback
@@ -1,19 +1,19 @@
1
- """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
-
3
- __version__ = "0.12.1"
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)
@@ -275,19 +275,61 @@ def delete_user_cmd(user: str, dry_run: bool) -> None:
275
275
  click.echo(json.dumps({"user": user, "deleted": True}, indent=2))
276
276
 
277
277
 
278
+ # ── personal access tokens ─────────────────────────────────────────────────
279
+
280
+
281
+ @zitadel.command("create-pat")
282
+ @click.argument("user")
283
+ @click.option(
284
+ "--expiration",
285
+ default=None,
286
+ help="RFC3339 expiry, e.g. 2027-06-27T00:00:00Z (omit for non-expiring).",
287
+ )
288
+ @click.option("--dry-run", is_flag=True, help="Change nothing.")
289
+ def create_pat_cmd(user: str, expiration: str | None, dry_run: bool) -> None:
290
+ """Mint a personal access token for machine USER (userName or id).
291
+
292
+ Prints {tokenId, token}. The token is shown ONCE — capture it and store it
293
+ at vault `granny/infra/zitadel-api-token` (or export ZITADEL_API_TOKEN).
294
+ """
295
+ if dry_run:
296
+ click.echo(f"[DRY RUN] Would create a PAT for {user!r} (expiration={expiration})")
297
+ return
298
+ client = _client()
299
+ out = client.create_pat(_resolve_user(client, user), expiration_date=expiration)
300
+ click.echo(json.dumps({"tokenId": out.get("tokenId"), "token": out.get("token")}, indent=2))
301
+
302
+
303
+ @zitadel.command("revoke-pat")
304
+ @click.argument("user")
305
+ @click.argument("token_id")
306
+ @click.option("--dry-run", is_flag=True, help="Change nothing.")
307
+ def revoke_pat_cmd(user: str, token_id: str, dry_run: bool) -> None:
308
+ """Revoke PAT TOKEN_ID belonging to machine USER (userName or id)."""
309
+ if dry_run:
310
+ click.echo(f"[DRY RUN] Would revoke PAT {token_id} of {user!r}")
311
+ return
312
+ client = _client()
313
+ client.delete_pat(_resolve_user(client, user), token_id)
314
+ click.echo(json.dumps({"user": user, "tokenId": token_id, "revoked": True}, indent=2))
315
+
316
+
278
317
  # ── list ─────────────────────────────────────────────────────────────────
279
318
 
280
319
 
281
320
  @zitadel.command("list")
282
- @click.argument("kind", type=click.Choice(["users", "projects", "apps", "roles"]))
321
+ @click.argument("kind", type=click.Choice(["users", "projects", "apps", "roles", "pats"]))
283
322
  @click.option(
284
323
  "--project", default=None, help="Project (id or name) — required for apps/roles."
285
324
  )
325
+ @click.option(
326
+ "--user", default=None, help="User (userName or id) — required for pats."
327
+ )
286
328
  @click.option(
287
329
  "--json", "as_json", is_flag=True, help="Emit full JSON instead of a table."
288
330
  )
289
- def list_cmd(kind: str, project: str | None, as_json: bool) -> None:
290
- """List Zitadel objects of KIND (users | projects | apps | roles)."""
331
+ def list_cmd(kind: str, project: str | None, user: str | None, as_json: bool) -> None:
332
+ """List Zitadel objects of KIND (users | projects | apps | roles | pats)."""
291
333
  client = _client()
292
334
  if kind == "users":
293
335
  rows = client.list_users()
@@ -295,6 +337,12 @@ def list_cmd(kind: str, project: str | None, as_json: bool) -> None:
295
337
  elif kind == "projects":
296
338
  rows = client.list_projects()
297
339
  cols = ("id", "name", "state")
340
+ elif kind == "pats":
341
+ if not user:
342
+ click.echo("--user is required for `list pats`", err=True)
343
+ sys.exit(1)
344
+ rows = client.list_pats(_resolve_user(client, user))
345
+ cols = ("id", "expirationDate", "creationDate")
298
346
  else:
299
347
  if not project:
300
348
  click.echo(f"--project is required for `list {kind}`", err=True)
@@ -172,15 +172,22 @@ SECRET_MAP: dict[str, str] = {
172
172
  # superuser-issued token (Authentik UI → Directory → Tokens). Falls back
173
173
  # to AK_TOKEN env var in granny.authentik.AuthentikClient.from_environment.
174
174
  "AUTHENTIK_API_TOKEN": "authentik-api-token",
175
- # Zitadel OIDC server (`granny zitadel …`): the IdP base URL, an optional
176
- # PAT, and the service-account key JSON used for JWT-profile machine auth.
177
- "ZITADEL_DOMAIN": "zitadel-domain",
175
+ # Zitadel OIDC server (`granny zitadel …`): an optional PAT and the
176
+ # service-account key JSON used for JWT-profile machine auth. The IdP base
177
+ # URL (ZITADEL_DOMAIN) is plain config, NOT a secret — set it via env /
178
+ # .env / .deploy.env, so it is deliberately not mapped to the vault.
178
179
  "ZITADEL_API_TOKEN": "zitadel-api-token",
179
180
  "ZITADEL_SA_KEY": "zitadel-sa-key",
180
181
  # Elasticsearch / Kibana security API credentials for `granny elk …`.
181
182
  "ELASTICSEARCH_API_KEY": "elasticsearch-api-key",
182
183
  "ELASTICSEARCH_USERNAME": "elasticsearch-username",
183
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",
184
191
  }
185
192
 
186
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
+ ]