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.
- {granny_devops-0.12.1 → granny_devops-0.14.0}/PKG-INFO +6 -3
- {granny_devops-0.12.1 → granny_devops-0.14.0}/README.md +5 -2
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/__init__.py +19 -19
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/dns.py +9 -6
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/email.py +119 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/zitadel.py +51 -3
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/credentials/secrets.py +10 -3
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/bunny.py +57 -5
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/cloudflare.py +62 -5
- granny_devops-0.14.0/granny/email/__init__.py +14 -0
- granny_devops-0.14.0/granny/email/stalwart.py +322 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/zitadel/client.py +32 -3
- {granny_devops-0.12.1 → granny_devops-0.14.0}/pyproject.toml +1 -1
- granny_devops-0.12.1/granny/email/__init__.py +0 -7
- {granny_devops-0.12.1 → granny_devops-0.14.0}/.gitignore +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/LICENSE +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/costs.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/credits.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/gpu_pricing.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/authentik/client.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/authentik/provision.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/aws/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/aws/quota.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/_client.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/account.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/deployment.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/openai.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/quota.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/role.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/sku.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/vm.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/azure/webapp.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/analyze.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/authentik.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/aws.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/azure.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/cdn.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/create.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/credentials.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/docker.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/edge.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/elk.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/indexing.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/main.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/serverless.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cli/storage.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/registrars.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/base.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/desec.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/factory.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/inwx.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/manual.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/dns/records.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/docker/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/docker/build_base.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/edge/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/edge/bunny.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/elk/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/elk/client.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/email/mailjet.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/email/workmail.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/indexing/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/indexing/google.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/indexing/indexnow.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/report.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/storage/__init__.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/storage/aws.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/storage/bunny.py +0 -0
- {granny_devops-0.12.1 → granny_devops-0.14.0}/granny/storage/hetzner.py +0 -0
- {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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
|
|
55
|
-
|
|
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 …`):
|
|
176
|
-
#
|
|
177
|
-
|
|
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=
|
|
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
|
-
|
|
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":
|
|
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=
|
|
142
|
-
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=
|
|
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=
|
|
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=
|
|
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
|
+
]
|