granny-devops 0.16.0__tar.gz → 0.17.1__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.16.0 → granny_devops-0.17.1}/PKG-INFO +10 -2
- {granny_devops-0.16.0 → granny_devops-0.17.1}/README.md +8 -1
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/__init__.py +1 -1
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/create.py +1 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/zitadel.py +177 -7
- granny_devops-0.17.1/granny/create/letsencrypt_cert.py +192 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/pyproject.toml +2 -1
- {granny_devops-0.16.0 → granny_devops-0.17.1}/.gitignore +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/LICENSE +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/analyze/costs.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/analyze/credits.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/analyze/gpu_pricing.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/authentik/client.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/authentik/provision.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/aws/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/aws/bedrock.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/aws/quota.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/azure/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/azure/_client.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/azure/account.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/azure/deployment.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/azure/openai.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/azure/quota.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/azure/resourcegroup.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/azure/role.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/azure/sku.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/azure/vm.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/azure/webapp.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/analyze.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/authentik.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/aws.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/azure.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/cdn.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/credentials.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/dns.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/docker.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/edge.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/elk.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/email.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/indexing.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/main.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/serverless.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cli/storage.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/registrars.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/credentials/secrets.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/dns/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/dns/base.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/dns/bunny.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/dns/desec.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/dns/factory.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/dns/inwx.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/dns/manual.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/dns/records.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/docker/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/docker/build_base.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/edge/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/edge/bunny.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/elk/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/elk/client.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/email/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/email/mailjet.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/email/stalwart.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/email/workmail.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/indexing/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/indexing/google.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/indexing/indexnow.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/report.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/storage/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/storage/aws.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/storage/bunny.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/storage/hetzner.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/zitadel/__init__.py +0 -0
- {granny_devops-0.16.0 → granny_devops-0.17.1}/granny/zitadel/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: granny-devops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.1
|
|
4
4
|
Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
|
|
5
5
|
Author-email: Martin Wieser <martin.wieser@pseekoo.com>
|
|
6
6
|
License: MIT License
|
|
@@ -26,6 +26,7 @@ License: MIT License
|
|
|
26
26
|
SOFTWARE.
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Python: >=3.13
|
|
29
|
+
Requires-Dist: acme>=2.11
|
|
29
30
|
Requires-Dist: boto3>=1.38
|
|
30
31
|
Requires-Dist: click>=8.1
|
|
31
32
|
Requires-Dist: pyjwt[crypto]>=2.8
|
|
@@ -292,6 +293,10 @@ granny email workmail create-user example.com --email user@example.com
|
|
|
292
293
|
granny create s3-website example.com --help
|
|
293
294
|
granny create scaleway-container --name my-app --port 3000
|
|
294
295
|
granny create mailjet-dns example.com
|
|
296
|
+
granny create letsencrypt-cert \
|
|
297
|
+
--domain '*.example.com' --domain example.com \
|
|
298
|
+
--dns inwx --zone example.com \
|
|
299
|
+
--email ops@example.com --out-dir ./certs # real LE cert via ACME dns-01, any granny DNS provider
|
|
295
300
|
|
|
296
301
|
# Authentik admin (provider + application + group plumbing)
|
|
297
302
|
granny authentik provision-oauth-app my-app \
|
|
@@ -315,6 +320,9 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
315
320
|
granny zitadel create-project platform
|
|
316
321
|
granny zitadel create-oidc-app platform my-app \
|
|
317
322
|
--redirect-uri https://app.example.com/oidc/callback
|
|
323
|
+
granny zitadel set-redirect-uris --project platform --app my-app \
|
|
324
|
+
--redirect-uri https://app.example.com/oidc/callback \
|
|
325
|
+
--redirect-uri https://app.example.com/oidc/callback2 # merges by default; --replace to set exactly
|
|
318
326
|
granny zitadel rotate-secret platform my-app
|
|
319
327
|
granny zitadel create-user alice --email alice@example.com --password 's3cret'
|
|
320
328
|
granny zitadel ensure-role platform admin # Zitadel uses project roles, not groups
|
|
@@ -370,7 +378,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
370
378
|
| Email send infra | Mailjet, AWS SES, AWS WorkMail |
|
|
371
379
|
| AWS inventory | VPCs, Lambdas |
|
|
372
380
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
373
|
-
| SSL automation | Bunny, Cloudflare, ACM |
|
|
381
|
+
| SSL automation | Bunny, Cloudflare, ACM, Let's Encrypt (ACME dns-01 over any granny DNS provider) |
|
|
374
382
|
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
375
383
|
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
376
384
|
| Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
|
|
@@ -159,6 +159,10 @@ granny email workmail create-user example.com --email user@example.com
|
|
|
159
159
|
granny create s3-website example.com --help
|
|
160
160
|
granny create scaleway-container --name my-app --port 3000
|
|
161
161
|
granny create mailjet-dns example.com
|
|
162
|
+
granny create letsencrypt-cert \
|
|
163
|
+
--domain '*.example.com' --domain example.com \
|
|
164
|
+
--dns inwx --zone example.com \
|
|
165
|
+
--email ops@example.com --out-dir ./certs # real LE cert via ACME dns-01, any granny DNS provider
|
|
162
166
|
|
|
163
167
|
# Authentik admin (provider + application + group plumbing)
|
|
164
168
|
granny authentik provision-oauth-app my-app \
|
|
@@ -182,6 +186,9 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
182
186
|
granny zitadel create-project platform
|
|
183
187
|
granny zitadel create-oidc-app platform my-app \
|
|
184
188
|
--redirect-uri https://app.example.com/oidc/callback
|
|
189
|
+
granny zitadel set-redirect-uris --project platform --app my-app \
|
|
190
|
+
--redirect-uri https://app.example.com/oidc/callback \
|
|
191
|
+
--redirect-uri https://app.example.com/oidc/callback2 # merges by default; --replace to set exactly
|
|
185
192
|
granny zitadel rotate-secret platform my-app
|
|
186
193
|
granny zitadel create-user alice --email alice@example.com --password 's3cret'
|
|
187
194
|
granny zitadel ensure-role platform admin # Zitadel uses project roles, not groups
|
|
@@ -237,7 +244,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
237
244
|
| Email send infra | Mailjet, AWS SES, AWS WorkMail |
|
|
238
245
|
| AWS inventory | VPCs, Lambdas |
|
|
239
246
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
240
|
-
| SSL automation | Bunny, Cloudflare, ACM |
|
|
247
|
+
| SSL automation | Bunny, Cloudflare, ACM, Let's Encrypt (ACME dns-01 over any granny DNS provider) |
|
|
241
248
|
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
242
249
|
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
243
250
|
| Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
|
|
@@ -33,6 +33,7 @@ COMMANDS: dict[str, str] = {
|
|
|
33
33
|
"scaleway-container": "setup_scaleway_container.py",
|
|
34
34
|
"workmail": "setup_workmail.py",
|
|
35
35
|
"auto-certificate": "auto_certificate.py",
|
|
36
|
+
"letsencrypt-cert": "letsencrypt_cert.py",
|
|
36
37
|
"mailjet-contacts": "manage_mailjet_contacts.py",
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -8,7 +8,10 @@ env/vault pipeline. See :class:`granny.zitadel.ZitadelClient`.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
+
import secrets
|
|
12
|
+
import string
|
|
11
13
|
import sys
|
|
14
|
+
from pathlib import Path
|
|
12
15
|
|
|
13
16
|
import click
|
|
14
17
|
|
|
@@ -23,6 +26,35 @@ def _client() -> ZitadelClient:
|
|
|
23
26
|
sys.exit(1)
|
|
24
27
|
|
|
25
28
|
|
|
29
|
+
def _client_for(domain: str, key_file: str | None, pat: str | None) -> ZitadelClient:
|
|
30
|
+
"""Build a client for an EXPLICIT instance (SA key file or PAT).
|
|
31
|
+
|
|
32
|
+
Unlike :func:`_client` (``from_environment``), this never falls back to the
|
|
33
|
+
ambient/locke credential — required when addressing a specific instance
|
|
34
|
+
(e.g. the dev Zitadel) rather than whatever the environment defaults to.
|
|
35
|
+
"""
|
|
36
|
+
if key_file:
|
|
37
|
+
try:
|
|
38
|
+
key = json.loads(Path(key_file).read_text(encoding="utf-8"))
|
|
39
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
40
|
+
raise click.ClickException(f"cannot read SA key {key_file}: {exc}") from exc
|
|
41
|
+
return ZitadelClient.from_service_account(key, domain)
|
|
42
|
+
if pat:
|
|
43
|
+
return ZitadelClient(base_url=domain, token=pat)
|
|
44
|
+
raise click.ClickException(
|
|
45
|
+
f"provide a service-account key file or PAT for {domain}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _generate_temp_password() -> str:
|
|
50
|
+
"""Strong, policy-safe temp password: 4 upper + 4 lower + 3 digit + 2 symbol, shuffled."""
|
|
51
|
+
pools = (string.ascii_uppercase, string.ascii_lowercase, string.digits, "!@#$%*-_?")
|
|
52
|
+
counts = (4, 4, 3, 2)
|
|
53
|
+
chars = [secrets.choice(p) for p, n in zip(pools, counts) for _ in range(n)]
|
|
54
|
+
secrets.SystemRandom().shuffle(chars)
|
|
55
|
+
return "".join(chars)
|
|
56
|
+
|
|
57
|
+
|
|
26
58
|
@click.group()
|
|
27
59
|
def zitadel() -> None:
|
|
28
60
|
"""Manage Zitadel (projects, OIDC apps, MFA policy) via its REST API."""
|
|
@@ -355,11 +387,15 @@ def create_pat_cmd(user: str, expiration: str | None, dry_run: bool) -> None:
|
|
|
355
387
|
at vault `granny/infra/zitadel-api-token` (or export ZITADEL_API_TOKEN).
|
|
356
388
|
"""
|
|
357
389
|
if dry_run:
|
|
358
|
-
click.echo(
|
|
390
|
+
click.echo(
|
|
391
|
+
f"[DRY RUN] Would create a PAT for {user!r} (expiration={expiration})"
|
|
392
|
+
)
|
|
359
393
|
return
|
|
360
394
|
client = _client()
|
|
361
395
|
out = client.create_pat(_resolve_user(client, user), expiration_date=expiration)
|
|
362
|
-
click.echo(
|
|
396
|
+
click.echo(
|
|
397
|
+
json.dumps({"tokenId": out.get("tokenId"), "token": out.get("token")}, indent=2)
|
|
398
|
+
)
|
|
363
399
|
|
|
364
400
|
|
|
365
401
|
@zitadel.command("revoke-pat")
|
|
@@ -373,20 +409,22 @@ def revoke_pat_cmd(user: str, token_id: str, dry_run: bool) -> None:
|
|
|
373
409
|
return
|
|
374
410
|
client = _client()
|
|
375
411
|
client.delete_pat(_resolve_user(client, user), token_id)
|
|
376
|
-
click.echo(
|
|
412
|
+
click.echo(
|
|
413
|
+
json.dumps({"user": user, "tokenId": token_id, "revoked": True}, indent=2)
|
|
414
|
+
)
|
|
377
415
|
|
|
378
416
|
|
|
379
417
|
# ── list ─────────────────────────────────────────────────────────────────
|
|
380
418
|
|
|
381
419
|
|
|
382
420
|
@zitadel.command("list")
|
|
383
|
-
@click.argument(
|
|
384
|
-
|
|
385
|
-
"--project", default=None, help="Project (id or name) — required for apps/roles."
|
|
421
|
+
@click.argument(
|
|
422
|
+
"kind", type=click.Choice(["users", "projects", "apps", "roles", "pats"])
|
|
386
423
|
)
|
|
387
424
|
@click.option(
|
|
388
|
-
"--
|
|
425
|
+
"--project", default=None, help="Project (id or name) — required for apps/roles."
|
|
389
426
|
)
|
|
427
|
+
@click.option("--user", default=None, help="User (userName or id) — required for pats.")
|
|
390
428
|
@click.option(
|
|
391
429
|
"--json", "as_json", is_flag=True, help="Emit full JSON instead of a table."
|
|
392
430
|
)
|
|
@@ -527,3 +565,135 @@ def revoke_user_role_cmd(project: str, user: str, dry_run: bool) -> None:
|
|
|
527
565
|
click.echo(
|
|
528
566
|
json.dumps({"project": project, "user": user, "revoked": True}, indent=2)
|
|
529
567
|
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# ── mirror users across instances ──────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@zitadel.command("mirror-users")
|
|
574
|
+
@click.option(
|
|
575
|
+
"--source-domain",
|
|
576
|
+
required=True,
|
|
577
|
+
help="Source Zitadel base URL (e.g. https://id.pseekoo.io).",
|
|
578
|
+
)
|
|
579
|
+
@click.option(
|
|
580
|
+
"--source-key-file", default=None, help="Source service-account key JSON file."
|
|
581
|
+
)
|
|
582
|
+
@click.option(
|
|
583
|
+
"--source-pat", default=None, help="Source PAT (alternative to --source-key-file)."
|
|
584
|
+
)
|
|
585
|
+
@click.option(
|
|
586
|
+
"--target-domain",
|
|
587
|
+
required=True,
|
|
588
|
+
help="Target Zitadel base URL (e.g. https://id-dev.obachan.dev).",
|
|
589
|
+
)
|
|
590
|
+
@click.option(
|
|
591
|
+
"--target-key-file", default=None, help="Target service-account key JSON file."
|
|
592
|
+
)
|
|
593
|
+
@click.option(
|
|
594
|
+
"--target-pat", default=None, help="Target PAT (alternative to --target-key-file)."
|
|
595
|
+
)
|
|
596
|
+
@click.option(
|
|
597
|
+
"--password-out",
|
|
598
|
+
required=True,
|
|
599
|
+
type=click.Path(dir_okay=False),
|
|
600
|
+
help="File to WRITE generated temp passwords (TSV). Secrets go here only, never stdout.",
|
|
601
|
+
)
|
|
602
|
+
@click.option("--skip", "skip", multiple=True, help="Username to skip (repeatable).")
|
|
603
|
+
@click.option(
|
|
604
|
+
"--no-email-verified",
|
|
605
|
+
is_flag=True,
|
|
606
|
+
help="Do not pre-verify copied emails (default: mark verified).",
|
|
607
|
+
)
|
|
608
|
+
@click.option(
|
|
609
|
+
"--dry-run", is_flag=True, help="List what would be created; write nothing."
|
|
610
|
+
)
|
|
611
|
+
def mirror_users_cmd(
|
|
612
|
+
source_domain: str,
|
|
613
|
+
source_key_file: str | None,
|
|
614
|
+
source_pat: str | None,
|
|
615
|
+
target_domain: str,
|
|
616
|
+
target_key_file: str | None,
|
|
617
|
+
target_pat: str | None,
|
|
618
|
+
password_out: str,
|
|
619
|
+
skip: tuple[str, ...],
|
|
620
|
+
no_email_verified: bool,
|
|
621
|
+
dry_run: bool,
|
|
622
|
+
) -> None:
|
|
623
|
+
"""Copy HUMAN users from a source Zitadel to a target with generated temp passwords.
|
|
624
|
+
|
|
625
|
+
Each created user gets a random strong temp password with
|
|
626
|
+
``passwordChangeRequired`` (forced reset on first login). Passwords are
|
|
627
|
+
WRITTEN ONLY to ``--password-out`` (TSV), never printed. Idempotent: users
|
|
628
|
+
already present on the target are skipped; machine/service accounts are never
|
|
629
|
+
copied. Source and target are addressed EXPLICITLY (key file or PAT) so this
|
|
630
|
+
works across two instances (e.g. prod -> dev) without relying on the ambient
|
|
631
|
+
credential.
|
|
632
|
+
"""
|
|
633
|
+
src = _client_for(source_domain, source_key_file, source_pat)
|
|
634
|
+
tgt = _client_for(target_domain, target_key_file, target_pat)
|
|
635
|
+
skipset = set(skip)
|
|
636
|
+
|
|
637
|
+
src_humans: dict[str, dict[str, str | None]] = {}
|
|
638
|
+
for u in src.list_users():
|
|
639
|
+
human = u.get("human")
|
|
640
|
+
if not human:
|
|
641
|
+
continue # never copy machine/service accounts
|
|
642
|
+
username = u.get("userName") or u.get("username")
|
|
643
|
+
if not username or username in skipset:
|
|
644
|
+
continue
|
|
645
|
+
profile = human.get("profile", {})
|
|
646
|
+
src_humans[username] = {
|
|
647
|
+
"email": human.get("email", {}).get("email"),
|
|
648
|
+
"first": profile.get("firstName", ""),
|
|
649
|
+
"last": profile.get("lastName", ""),
|
|
650
|
+
"display": profile.get("displayName"),
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
existing = {u.get("userName") or u.get("username") for u in tgt.list_users()}
|
|
654
|
+
todo = {un: info for un, info in src_humans.items() if un not in existing}
|
|
655
|
+
|
|
656
|
+
if dry_run:
|
|
657
|
+
for un, info in todo.items():
|
|
658
|
+
click.echo(f"[DRY RUN] would create {un} ({info['email']})")
|
|
659
|
+
click.echo(
|
|
660
|
+
f"[DRY RUN] {len(todo)} to create, "
|
|
661
|
+
f"{len(src_humans) - len(todo)} already present on target"
|
|
662
|
+
)
|
|
663
|
+
return
|
|
664
|
+
|
|
665
|
+
out_path = Path(password_out)
|
|
666
|
+
created: list[str] = []
|
|
667
|
+
failed: list[dict[str, str]] = []
|
|
668
|
+
with out_path.open("w", encoding="utf-8") as fh:
|
|
669
|
+
fh.write("username\temail\ttemp_password\tuserId\n")
|
|
670
|
+
for un, info in todo.items():
|
|
671
|
+
pw = _generate_temp_password()
|
|
672
|
+
try:
|
|
673
|
+
res = tgt.create_user(
|
|
674
|
+
un,
|
|
675
|
+
email=info["email"],
|
|
676
|
+
first_name=info["first"] or "",
|
|
677
|
+
last_name=info["last"] or "",
|
|
678
|
+
display_name=info["display"],
|
|
679
|
+
password=pw,
|
|
680
|
+
email_verified=not no_email_verified,
|
|
681
|
+
password_change_required=True,
|
|
682
|
+
)
|
|
683
|
+
uid = res.get("userId") or res.get("id") or ""
|
|
684
|
+
fh.write(f"{un}\t{info['email']}\t{pw}\t{uid}\n")
|
|
685
|
+
created.append(un)
|
|
686
|
+
except ZitadelError as exc:
|
|
687
|
+
failed.append({"user": un, "error": str(exc)[:160]})
|
|
688
|
+
|
|
689
|
+
click.echo(
|
|
690
|
+
json.dumps(
|
|
691
|
+
{
|
|
692
|
+
"created": created,
|
|
693
|
+
"skipped_existing": len(src_humans) - len(todo),
|
|
694
|
+
"failed": failed,
|
|
695
|
+
"passwords_written_to": str(out_path),
|
|
696
|
+
},
|
|
697
|
+
indent=2,
|
|
698
|
+
)
|
|
699
|
+
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Let's Encrypt certificate issuance via ACME DNS-01, driven by granny's DNS
|
|
3
|
+
providers.
|
|
4
|
+
|
|
5
|
+
Unlike ``auto_certificate.py`` (AWS ACM / Bunny), this issues a real
|
|
6
|
+
Let's Encrypt certificate (fullchain + private key PEM) usable by Caddy,
|
|
7
|
+
nginx, Kubernetes Secrets, etc. The ACME ``dns-01`` challenge is solved by
|
|
8
|
+
creating ``_acme-challenge`` TXT records through **any** granny DNS provider
|
|
9
|
+
(``--dns inwx`` / ``cloudflare`` / ``hetzner`` / ...), so it works for zones
|
|
10
|
+
like ``obachan.dev`` (INWX) where no cert-manager webhook exists.
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
granny create letsencrypt-cert \\
|
|
15
|
+
--domain '*.obachan.dev' --domain obachan.dev \\
|
|
16
|
+
--dns inwx --zone obachan.dev \\
|
|
17
|
+
--email ops@example.com --out-dir ./certs
|
|
18
|
+
|
|
19
|
+
Credentials for the DNS provider resolve through granny's normal vault/env
|
|
20
|
+
pipeline (e.g. INWX_USERNAME / INWX_PASSWORD / INWX_SHARED_SECRET).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import requests
|
|
31
|
+
|
|
32
|
+
try: # granny's vault→env pipeline (same as auto_certificate.py)
|
|
33
|
+
from granny.credentials import load_secrets_into_env
|
|
34
|
+
|
|
35
|
+
load_secrets_into_env()
|
|
36
|
+
except Exception: # pragma: no cover - best effort
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
_LE_PROD = "https://acme-v02.api.letsencrypt.org/directory"
|
|
40
|
+
_LE_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
41
|
+
_DOH = "https://dns.google/resolve"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _new_rsa_key(): # noqa: ANN202
|
|
45
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
46
|
+
|
|
47
|
+
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _key_pem(key) -> bytes: # noqa: ANN001
|
|
51
|
+
from cryptography.hazmat.primitives import serialization
|
|
52
|
+
|
|
53
|
+
return key.private_bytes(
|
|
54
|
+
serialization.Encoding.PEM,
|
|
55
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
56
|
+
serialization.NoEncryption(),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _relative_name(fqdn: str, zone: str) -> str:
|
|
61
|
+
"""Strip the trailing ``.<zone>`` so provider APIs get the short name."""
|
|
62
|
+
fqdn = fqdn.rstrip(".")
|
|
63
|
+
suffix = "." + zone.rstrip(".")
|
|
64
|
+
return fqdn[: -len(suffix)] if fqdn.endswith(suffix) else fqdn
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _txt_propagated(fqdn: str, value: str, *, attempts: int, delay: int) -> bool:
|
|
68
|
+
"""Poll public DNS (DoH) until the challenge TXT value is visible."""
|
|
69
|
+
for i in range(attempts):
|
|
70
|
+
try:
|
|
71
|
+
resp = requests.get(
|
|
72
|
+
_DOH, params={"name": fqdn, "type": "TXT"}, timeout=10
|
|
73
|
+
).json()
|
|
74
|
+
answers = [a.get("data", "").strip('"') for a in resp.get("Answer", [])]
|
|
75
|
+
if value in answers:
|
|
76
|
+
return True
|
|
77
|
+
except Exception: # noqa: BLE001 - transient resolver errors are fine
|
|
78
|
+
pass
|
|
79
|
+
if i < attempts - 1:
|
|
80
|
+
time.sleep(delay)
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def issue_certificate(
|
|
85
|
+
domains: list[str],
|
|
86
|
+
*,
|
|
87
|
+
dns: str,
|
|
88
|
+
zone: str,
|
|
89
|
+
email: str,
|
|
90
|
+
staging: bool = False,
|
|
91
|
+
propagation_attempts: int = 20,
|
|
92
|
+
propagation_delay: int = 15,
|
|
93
|
+
) -> tuple[str, str]:
|
|
94
|
+
"""Issue a Let's Encrypt cert for ``domains``; return (fullchain_pem, key_pem).
|
|
95
|
+
|
|
96
|
+
``dns`` is a granny DNS provider name; ``zone`` is the registrable zone the
|
|
97
|
+
challenge records live in (e.g. ``obachan.dev``).
|
|
98
|
+
"""
|
|
99
|
+
import josepy as jose
|
|
100
|
+
from acme import challenges, client, crypto_util, messages
|
|
101
|
+
|
|
102
|
+
from granny.dns.factory import get_provider
|
|
103
|
+
|
|
104
|
+
provider = get_provider(dns)
|
|
105
|
+
zone_id = provider.get_zone_id(zone)
|
|
106
|
+
|
|
107
|
+
directory_url = _LE_STAGING if staging else _LE_PROD
|
|
108
|
+
account_key = jose.JWKRSA(key=_new_rsa_key())
|
|
109
|
+
net = client.ClientNetwork(account_key, user_agent="granny-letsencrypt")
|
|
110
|
+
directory = client.ClientV2.get_directory(directory_url, net)
|
|
111
|
+
acme = client.ClientV2(directory, net)
|
|
112
|
+
acme.new_account(
|
|
113
|
+
messages.NewRegistration.from_data(email=email, terms_of_service_agreed=True)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
cert_key = _new_rsa_key()
|
|
117
|
+
cert_key_pem = _key_pem(cert_key)
|
|
118
|
+
csr_pem = crypto_util.make_csr(cert_key_pem, domains)
|
|
119
|
+
order = acme.new_order(csr_pem)
|
|
120
|
+
|
|
121
|
+
created: list[tuple[str, str, str]] = [] # (fqdn, value, record_id) for cleanup
|
|
122
|
+
try:
|
|
123
|
+
# 1. Publish every dns-01 TXT record first (a base domain + its wildcard
|
|
124
|
+
# share `_acme-challenge.<domain>`, so multiple values can coexist).
|
|
125
|
+
pending = []
|
|
126
|
+
for authz in order.authorizations:
|
|
127
|
+
dom = authz.body.identifier.value
|
|
128
|
+
chall = next(
|
|
129
|
+
c for c in authz.body.challenges if isinstance(c.chall, challenges.DNS01)
|
|
130
|
+
)
|
|
131
|
+
response, validation = chall.response_and_validation(account_key)
|
|
132
|
+
fqdn = chall.chall.validation_domain_name(dom)
|
|
133
|
+
rec = provider.create_record(
|
|
134
|
+
zone_id, _relative_name(fqdn, zone), "TXT", validation
|
|
135
|
+
)
|
|
136
|
+
created.append((fqdn, validation, rec.id))
|
|
137
|
+
pending.append((fqdn, validation, chall, response))
|
|
138
|
+
print(f" published TXT {fqdn} = {validation[:12]}…", file=sys.stderr)
|
|
139
|
+
|
|
140
|
+
# 2. Wait for propagation, then answer each challenge.
|
|
141
|
+
for fqdn, validation, chall, response in pending:
|
|
142
|
+
if not _txt_propagated(
|
|
143
|
+
fqdn, validation, attempts=propagation_attempts, delay=propagation_delay
|
|
144
|
+
):
|
|
145
|
+
raise TimeoutError(f"TXT for {fqdn} did not propagate in time")
|
|
146
|
+
acme.answer_challenge(chall, response)
|
|
147
|
+
print(f" answered challenge for {fqdn}", file=sys.stderr)
|
|
148
|
+
|
|
149
|
+
# 3. Finalize + download.
|
|
150
|
+
order = acme.poll_and_finalize(order)
|
|
151
|
+
finally:
|
|
152
|
+
for _fqdn, _val, rec_id in created:
|
|
153
|
+
try:
|
|
154
|
+
provider.delete_record(zone_id, rec_id)
|
|
155
|
+
except Exception: # noqa: BLE001 - best-effort cleanup
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
return order.fullchain_pem, cert_key_pem.decode("utf-8")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main(argv: list[str] | None = None) -> int:
|
|
162
|
+
parser = argparse.ArgumentParser(description="Issue a Let's Encrypt cert via DNS-01.")
|
|
163
|
+
parser.add_argument("--domain", action="append", required=True, help="Domain (repeatable; wildcards ok).")
|
|
164
|
+
parser.add_argument("--dns", required=True, help="granny DNS provider (e.g. inwx, cloudflare).")
|
|
165
|
+
parser.add_argument("--zone", required=True, help="Registrable zone the challenge records live in.")
|
|
166
|
+
parser.add_argument("--email", required=True, help="ACME account contact email.")
|
|
167
|
+
parser.add_argument("--out-dir", default=".", help="Directory to write fullchain.pem + key.pem.")
|
|
168
|
+
parser.add_argument("--staging", action="store_true", help="Use the LE staging CA (no rate limits).")
|
|
169
|
+
parser.add_argument("--propagation-attempts", type=int, default=20)
|
|
170
|
+
parser.add_argument("--propagation-delay", type=int, default=15)
|
|
171
|
+
args = parser.parse_args(argv)
|
|
172
|
+
|
|
173
|
+
fullchain, key = issue_certificate(
|
|
174
|
+
args.domain,
|
|
175
|
+
dns=args.dns,
|
|
176
|
+
zone=args.zone,
|
|
177
|
+
email=args.email,
|
|
178
|
+
staging=args.staging,
|
|
179
|
+
propagation_attempts=args.propagation_attempts,
|
|
180
|
+
propagation_delay=args.propagation_delay,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
out = Path(args.out_dir)
|
|
184
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
(out / "fullchain.pem").write_text(fullchain, encoding="utf-8")
|
|
186
|
+
(out / "key.pem").write_text(key, encoding="utf-8")
|
|
187
|
+
print(f"wrote {out / 'fullchain.pem'} and {out / 'key.pem'}")
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
raise SystemExit(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "granny-devops"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.17.1"
|
|
4
4
|
description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -12,6 +12,7 @@ dependencies = [
|
|
|
12
12
|
"python-dotenv>=1.1.0",
|
|
13
13
|
"requests>=2.32",
|
|
14
14
|
"pyjwt[crypto]>=2.8", # Zitadel service-account JWT-profile auth (`granny zitadel`)
|
|
15
|
+
"acme>=2.11", # Let's Encrypt ACME DNS-01 issuance (granny create letsencrypt-cert)
|
|
15
16
|
]
|
|
16
17
|
|
|
17
18
|
[project.optional-dependencies]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|