granny-devops 0.15.0__tar.gz → 0.17.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {granny_devops-0.15.0 → granny_devops-0.17.0}/PKG-INFO +22 -3
- {granny_devops-0.15.0 → granny_devops-0.17.0}/README.md +20 -2
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/__init__.py +1 -1
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/create.py +1 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/zitadel.py +62 -0
- granny_devops-0.17.0/granny/create/letsencrypt_cert.py +192 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/zitadel/client.py +47 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/pyproject.toml +2 -1
- {granny_devops-0.15.0 → granny_devops-0.17.0}/.gitignore +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/LICENSE +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/costs.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/credits.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/gpu_pricing.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/authentik/client.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/authentik/provision.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/aws/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/aws/bedrock.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/aws/quota.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/_client.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/account.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/deployment.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/openai.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/quota.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/resourcegroup.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/role.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/sku.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/vm.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/webapp.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/analyze.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/authentik.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/aws.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/azure.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/cdn.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/credentials.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/dns.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/docker.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/edge.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/elk.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/email.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/indexing.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/main.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/serverless.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/storage.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/registrars.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/credentials/secrets.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/base.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/bunny.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/desec.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/factory.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/inwx.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/manual.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/records.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/docker/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/docker/build_base.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/edge/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/edge/bunny.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/elk/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/elk/client.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/mailjet.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/stalwart.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/workmail.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/indexing/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/indexing/google.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/indexing/indexnow.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/report.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/storage/__init__.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/storage/aws.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/storage/bunny.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/storage/hetzner.py +0 -0
- {granny_devops-0.15.0 → granny_devops-0.17.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.17.0
|
|
4
4
|
Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
|
|
5
5
|
Author-email: Martin Wieser <martin.wieser@pseekoo.com>
|
|
6
6
|
License: MIT License
|
|
@@ -26,6 +26,7 @@ License: MIT License
|
|
|
26
26
|
SOFTWARE.
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Python: >=3.13
|
|
29
|
+
Requires-Dist: acme>=2.11
|
|
29
30
|
Requires-Dist: boto3>=1.38
|
|
30
31
|
Requires-Dist: click>=8.1
|
|
31
32
|
Requires-Dist: pyjwt[crypto]>=2.8
|
|
@@ -257,6 +258,10 @@ granny storage aws create my-website --website
|
|
|
257
258
|
granny analyze vpcs --json-output
|
|
258
259
|
granny analyze lambdas --region us-east-1 eu-west-1
|
|
259
260
|
|
|
261
|
+
# AWS Bedrock -- foundation models (read-only; on-demand access auto-enables on first invoke)
|
|
262
|
+
granny aws bedrock list-models --profile lularge --region eu-central-1 --by-provider Anthropic
|
|
263
|
+
granny aws bedrock check-access --model-id anthropic.claude-sonnet-4-20250514-v1:0 --profile lularge --region eu-central-1
|
|
264
|
+
|
|
260
265
|
# Cross-cloud GPU / credit / cost inventory (AWS + GCP + Azure)
|
|
261
266
|
granny analyze gpus # running GPUs everywhere
|
|
262
267
|
granny analyze gpus --filter h100,h200 # find Hopper clusters
|
|
@@ -288,6 +293,10 @@ granny email workmail create-user example.com --email user@example.com
|
|
|
288
293
|
granny create s3-website example.com --help
|
|
289
294
|
granny create scaleway-container --name my-app --port 3000
|
|
290
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
|
|
291
300
|
|
|
292
301
|
# Authentik admin (provider + application + group plumbing)
|
|
293
302
|
granny authentik provision-oauth-app my-app \
|
|
@@ -311,6 +320,9 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
311
320
|
granny zitadel create-project platform
|
|
312
321
|
granny zitadel create-oidc-app platform my-app \
|
|
313
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
|
|
314
326
|
granny zitadel rotate-secret platform my-app
|
|
315
327
|
granny zitadel create-user alice --email alice@example.com --password 's3cret'
|
|
316
328
|
granny zitadel ensure-role platform admin # Zitadel uses project roles, not groups
|
|
@@ -329,8 +341,15 @@ granny elk add-user user@example.com \
|
|
|
329
341
|
granny azure account whoami # signed-in identity
|
|
330
342
|
granny azure account list # subscriptions
|
|
331
343
|
granny azure deployment list --subscription <id> --all-groups
|
|
344
|
+
granny azure group create --subscription <id> --name <rg> --location swedencentral
|
|
332
345
|
granny azure openai accounts --subscription <id>
|
|
333
|
-
granny azure openai
|
|
346
|
+
granny azure openai account create --subscription <id> --resource-group <rg> \
|
|
347
|
+
--name <acct> --location swedencentral # create an Azure OpenAI account
|
|
348
|
+
granny azure openai keys --subscription <id> --resource-group <rg> --account <acct>
|
|
349
|
+
granny azure openai deployments --subscription <id> --kind OpenAI --with-usage # + created_at & last-used (Azure Monitor)
|
|
350
|
+
granny azure openai deployment create --subscription <id> --resource-group <rg> \
|
|
351
|
+
--account <acct> --name gpt-5.4 --model gpt-5.4 --model-version 2026-03-05 \
|
|
352
|
+
--sku GlobalStandard --capacity 50 # create a model deployment
|
|
334
353
|
granny azure webapp list --subscription <id>
|
|
335
354
|
granny azure vm-sizes list --subscription <id> --location polandcentral
|
|
336
355
|
granny azure vm list --subscription <id> # running VMs + power state
|
|
@@ -359,7 +378,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
359
378
|
| Email send infra | Mailjet, AWS SES, AWS WorkMail |
|
|
360
379
|
| AWS inventory | VPCs, Lambdas |
|
|
361
380
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
362
|
-
| SSL automation | Bunny, Cloudflare, ACM |
|
|
381
|
+
| SSL automation | Bunny, Cloudflare, ACM, Let's Encrypt (ACME dns-01 over any granny DNS provider) |
|
|
363
382
|
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
364
383
|
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
365
384
|
| Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
|
|
@@ -124,6 +124,10 @@ granny storage aws create my-website --website
|
|
|
124
124
|
granny analyze vpcs --json-output
|
|
125
125
|
granny analyze lambdas --region us-east-1 eu-west-1
|
|
126
126
|
|
|
127
|
+
# AWS Bedrock -- foundation models (read-only; on-demand access auto-enables on first invoke)
|
|
128
|
+
granny aws bedrock list-models --profile lularge --region eu-central-1 --by-provider Anthropic
|
|
129
|
+
granny aws bedrock check-access --model-id anthropic.claude-sonnet-4-20250514-v1:0 --profile lularge --region eu-central-1
|
|
130
|
+
|
|
127
131
|
# Cross-cloud GPU / credit / cost inventory (AWS + GCP + Azure)
|
|
128
132
|
granny analyze gpus # running GPUs everywhere
|
|
129
133
|
granny analyze gpus --filter h100,h200 # find Hopper clusters
|
|
@@ -155,6 +159,10 @@ granny email workmail create-user example.com --email user@example.com
|
|
|
155
159
|
granny create s3-website example.com --help
|
|
156
160
|
granny create scaleway-container --name my-app --port 3000
|
|
157
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
|
|
158
166
|
|
|
159
167
|
# Authentik admin (provider + application + group plumbing)
|
|
160
168
|
granny authentik provision-oauth-app my-app \
|
|
@@ -178,6 +186,9 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
178
186
|
granny zitadel create-project platform
|
|
179
187
|
granny zitadel create-oidc-app platform my-app \
|
|
180
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
|
|
181
192
|
granny zitadel rotate-secret platform my-app
|
|
182
193
|
granny zitadel create-user alice --email alice@example.com --password 's3cret'
|
|
183
194
|
granny zitadel ensure-role platform admin # Zitadel uses project roles, not groups
|
|
@@ -196,8 +207,15 @@ granny elk add-user user@example.com \
|
|
|
196
207
|
granny azure account whoami # signed-in identity
|
|
197
208
|
granny azure account list # subscriptions
|
|
198
209
|
granny azure deployment list --subscription <id> --all-groups
|
|
210
|
+
granny azure group create --subscription <id> --name <rg> --location swedencentral
|
|
199
211
|
granny azure openai accounts --subscription <id>
|
|
200
|
-
granny azure openai
|
|
212
|
+
granny azure openai account create --subscription <id> --resource-group <rg> \
|
|
213
|
+
--name <acct> --location swedencentral # create an Azure OpenAI account
|
|
214
|
+
granny azure openai keys --subscription <id> --resource-group <rg> --account <acct>
|
|
215
|
+
granny azure openai deployments --subscription <id> --kind OpenAI --with-usage # + created_at & last-used (Azure Monitor)
|
|
216
|
+
granny azure openai deployment create --subscription <id> --resource-group <rg> \
|
|
217
|
+
--account <acct> --name gpt-5.4 --model gpt-5.4 --model-version 2026-03-05 \
|
|
218
|
+
--sku GlobalStandard --capacity 50 # create a model deployment
|
|
201
219
|
granny azure webapp list --subscription <id>
|
|
202
220
|
granny azure vm-sizes list --subscription <id> --location polandcentral
|
|
203
221
|
granny azure vm list --subscription <id> # running VMs + power state
|
|
@@ -226,7 +244,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
226
244
|
| Email send infra | Mailjet, AWS SES, AWS WorkMail |
|
|
227
245
|
| AWS inventory | VPCs, Lambdas |
|
|
228
246
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
229
|
-
| SSL automation | Bunny, Cloudflare, ACM |
|
|
247
|
+
| SSL automation | Bunny, Cloudflare, ACM, Let's Encrypt (ACME dns-01 over any granny DNS provider) |
|
|
230
248
|
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
231
249
|
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
232
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
|
|
|
@@ -128,6 +128,68 @@ def create_oidc_app_cmd(
|
|
|
128
128
|
)
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
@zitadel.command("set-redirect-uris")
|
|
132
|
+
@click.option("--project", required=True, help="Project name or id owning the app.")
|
|
133
|
+
@click.option("--app", "app_name", required=True, help="OIDC app name.")
|
|
134
|
+
@click.option(
|
|
135
|
+
"--redirect-uri",
|
|
136
|
+
"redirect_uris",
|
|
137
|
+
multiple=True,
|
|
138
|
+
required=True,
|
|
139
|
+
help="Allowed OIDC redirect URI (repeatable).",
|
|
140
|
+
)
|
|
141
|
+
@click.option(
|
|
142
|
+
"--post-logout-uri",
|
|
143
|
+
"post_logout_uris",
|
|
144
|
+
multiple=True,
|
|
145
|
+
help="Post-logout redirect URI to add (repeatable).",
|
|
146
|
+
)
|
|
147
|
+
@click.option(
|
|
148
|
+
"--replace",
|
|
149
|
+
is_flag=True,
|
|
150
|
+
help="Replace the redirect-URI set instead of merging with the existing one.",
|
|
151
|
+
)
|
|
152
|
+
@click.option("--dry-run", is_flag=True)
|
|
153
|
+
def set_redirect_uris_cmd(
|
|
154
|
+
project: str,
|
|
155
|
+
app_name: str,
|
|
156
|
+
redirect_uris: tuple[str, ...],
|
|
157
|
+
post_logout_uris: tuple[str, ...],
|
|
158
|
+
replace: bool,
|
|
159
|
+
dry_run: bool,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Set an existing OIDC app's allowed redirect URIs (idempotent).
|
|
162
|
+
|
|
163
|
+
Merges with the app's current redirect URIs by default (union, de-duped),
|
|
164
|
+
so re-running with the same set is a no-op. Use ``--replace`` to set exactly
|
|
165
|
+
the URIs given.
|
|
166
|
+
"""
|
|
167
|
+
client = _client()
|
|
168
|
+
found = client.find_project(project)
|
|
169
|
+
project_id = found["id"] if found else project
|
|
170
|
+
app = client.find_oidc_app(project_id, app_name)
|
|
171
|
+
if not app:
|
|
172
|
+
raise click.ClickException(
|
|
173
|
+
f"OIDC app {app_name!r} not found in project {project!r}"
|
|
174
|
+
)
|
|
175
|
+
app_id = app.get("id")
|
|
176
|
+
if dry_run:
|
|
177
|
+
verb = "replace with" if replace else "merge in"
|
|
178
|
+
click.echo(
|
|
179
|
+
f"[DRY RUN] Would {verb} redirect URIs on app {app_name!r} "
|
|
180
|
+
f"({app_id}): {list(redirect_uris)}"
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
result = client.set_oidc_app_redirects(
|
|
184
|
+
project_id,
|
|
185
|
+
app_id, # type: ignore[arg-type]
|
|
186
|
+
list(redirect_uris),
|
|
187
|
+
replace=replace,
|
|
188
|
+
post_logout_uris=list(post_logout_uris),
|
|
189
|
+
)
|
|
190
|
+
click.echo(json.dumps(result, indent=2))
|
|
191
|
+
|
|
192
|
+
|
|
131
193
|
@zitadel.command("enforce-mfa")
|
|
132
194
|
@click.option(
|
|
133
195
|
"--no-passkey",
|
|
@@ -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())
|
|
@@ -262,6 +262,53 @@ class ZitadelClient:
|
|
|
262
262
|
"POST", f"/management/v1/projects/{project_id}/apps/oidc", body=body
|
|
263
263
|
)
|
|
264
264
|
|
|
265
|
+
def set_oidc_app_redirects(
|
|
266
|
+
self,
|
|
267
|
+
project_id: str,
|
|
268
|
+
app_id: str,
|
|
269
|
+
redirect_uris: list[str],
|
|
270
|
+
*,
|
|
271
|
+
replace: bool = False,
|
|
272
|
+
post_logout_uris: list[str] | None = None,
|
|
273
|
+
) -> dict[str, Any]:
|
|
274
|
+
"""Set an existing OIDC app's redirect URIs (idempotent).
|
|
275
|
+
|
|
276
|
+
Reads the app's current ``oidcConfig`` and PUTs it back with the merged
|
|
277
|
+
redirect URIs (Zitadel's update endpoint replaces the whole config, so
|
|
278
|
+
every field is round-tripped). With ``replace=False`` (default) the new
|
|
279
|
+
URIs are unioned with the existing set — order-stable, de-duplicated —
|
|
280
|
+
so re-running with the same URIs is a no-op. ``replace=True`` sets
|
|
281
|
+
exactly ``redirect_uris``.
|
|
282
|
+
"""
|
|
283
|
+
oidc = dict(self.get_app(project_id, app_id).get("oidcConfig") or {})
|
|
284
|
+
existing = list(oidc.get("redirectUris") or [])
|
|
285
|
+
merged = (
|
|
286
|
+
list(dict.fromkeys(redirect_uris))
|
|
287
|
+
if replace
|
|
288
|
+
else list(dict.fromkeys([*existing, *redirect_uris]))
|
|
289
|
+
)
|
|
290
|
+
merged_logout = list(
|
|
291
|
+
dict.fromkeys([*(oidc.get("postLogoutRedirectUris") or []), *(post_logout_uris or [])])
|
|
292
|
+
)
|
|
293
|
+
body = {
|
|
294
|
+
"redirectUris": merged,
|
|
295
|
+
"responseTypes": oidc.get("responseTypes") or ["OIDC_RESPONSE_TYPE_CODE"],
|
|
296
|
+
"grantTypes": oidc.get("grantTypes")
|
|
297
|
+
or ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
|
|
298
|
+
"appType": oidc.get("appType") or "OIDC_APP_TYPE_WEB",
|
|
299
|
+
"authMethodType": oidc.get("authMethodType") or "OIDC_AUTH_METHOD_TYPE_BASIC",
|
|
300
|
+
"postLogoutRedirectUris": merged_logout,
|
|
301
|
+
"version": oidc.get("version") or "OIDC_VERSION_1_0",
|
|
302
|
+
"devMode": oidc.get("devMode", False),
|
|
303
|
+
"accessTokenType": oidc.get("accessTokenType") or "OIDC_TOKEN_TYPE_JWT",
|
|
304
|
+
}
|
|
305
|
+
self.request(
|
|
306
|
+
"PUT",
|
|
307
|
+
f"/management/v1/projects/{project_id}/apps/{app_id}/oidc_config",
|
|
308
|
+
body=body,
|
|
309
|
+
)
|
|
310
|
+
return {"appId": app_id, "redirectUris": merged}
|
|
311
|
+
|
|
265
312
|
# ── Login policy / MFA ───────────────────────────────────────────────
|
|
266
313
|
|
|
267
314
|
def get_login_policy(self) -> dict[str, Any]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "granny-devops"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.17.0"
|
|
4
4
|
description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -12,6 +12,7 @@ dependencies = [
|
|
|
12
12
|
"python-dotenv>=1.1.0",
|
|
13
13
|
"requests>=2.32",
|
|
14
14
|
"pyjwt[crypto]>=2.8", # Zitadel service-account JWT-profile auth (`granny zitadel`)
|
|
15
|
+
"acme>=2.11", # Let's Encrypt ACME DNS-01 issuance (granny create letsencrypt-cert)
|
|
15
16
|
]
|
|
16
17
|
|
|
17
18
|
[project.optional-dependencies]
|
|
File without changes
|
|
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
|