diff --git a/.sops.yaml b/.sops.yaml index faa070f..9d9a0c2 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -2,4 +2,5 @@ creation_rules: # `secrets?` — singular too. A `seed-repo-secret.yaml` once slipped this regex # and was committed in plaintext to a public remote. - path_regex: k8s/.*secrets?.*\.ya?ml + encrypted_regex: ^(data|stringData)$ age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla diff --git a/k8s/argocd/apps/06-ci-cd.yaml b/k8s/argocd/apps/06-ci-cd.yaml index 4bc9182..653cef6 100644 --- a/k8s/argocd/apps/06-ci-cd.yaml +++ b/k8s/argocd/apps/06-ci-cd.yaml @@ -11,9 +11,13 @@ spec: project: homelab source: - repoURL: https://github.com/tektoncd/pipeline.git + # tektoncd/operator is the official Kubernetes Operator for Tekton + # It manages the lifecycle of Tekton Pipelines installation + # Source: https://github.com/tektoncd/operator + repoURL: https://github.com/tektoncd/operator.git targetRevision: main - path: config/release + # The operator's config directory contains the latest release manifests + path: config/install destination: server: https://kubernetes.default.svc diff --git a/k8s/argocd/projects/homelab-project.yaml b/k8s/argocd/projects/homelab-project.yaml index 82d2faa..1c2055d 100644 --- a/k8s/argocd/projects/homelab-project.yaml +++ b/k8s/argocd/projects/homelab-project.yaml @@ -45,8 +45,8 @@ spec: - https://stakater.github.io/stakater-charts # ArgoCD ecosystem charts - https://argoproj.github.io/argo-helm - # Tekton Pipelines (CNCF CI/CD) - - https://github.com/tektoncd/pipeline.git + # Tekton Pipelines (CNCF CI/CD) — uses tektoncd/operator + - https://github.com/tektoncd/operator.git destinations: - server: https://kubernetes.default.svc namespace: "*" diff --git a/k8s/argocd/tekton.yaml b/k8s/argocd/tekton.yaml new file mode 100644 index 0000000..f2d9752 --- /dev/null +++ b/k8s/argocd/tekton.yaml @@ -0,0 +1,45 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: tekton-pipelines + namespace: argocd + labels: + app.kubernetes.io/name: tekton-pipelines + app.kubernetes.io/part-of: homelab-infra +spec: + project: default + + source: + repoURL: https://github.com/tektoncd/pipeline.git + targetRevision: main + path: config/release + + destination: + server: https://kubernetes.default.svc + namespace: tekton-pipelines + + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - Validate=false + - RespectIgnoreDifferences=true + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m + + ignoreDifferences: + # Ignore webhook certificate changes + - group: admissionregistration.k8s.io + kind: ValidatingWebhookConfiguration + jsonPointers: + - /webhooks/0/clientConfig/caBundle + - group: admissionregistration.k8s.io + kind: MutatingWebhookConfiguration + jsonPointers: + - /webhooks/0/clientConfig/caBundle diff --git a/project-usage/gotify-notifications.md b/project-usage/gotify-notifications.md new file mode 100644 index 0000000..ba559ff --- /dev/null +++ b/project-usage/gotify-notifications.md @@ -0,0 +1,162 @@ +# Gotify Notifications Setup + +## Overview + +Gotify is a self-hosted push notification server deployed in the `notifications` namespace. The homelab-frontend gateway provides a `sendMsg` endpoint that accepts email and SMS notification requests and sends them directly via SMTP (SMS provider TBD). + +## Architecture + +``` +App → X-Service: notification header + → homelab-frontend gateway (api namespace) + → notification/sendMsg handler + → SMTP relay (email) or SMS provider (stubbed) + → recipient email/SMS +``` + +**Not used:** Gotify's native message store is available for UI/push notifications, but the sendMsg flow bypasses it (direct send, no storage). + +## Usage + +### Send Email + +```bash +curl -X POST https://api.riotpiao.com \ + -H 'X-Service: notification' \ + -H 'X-Resource: sendMsg' \ + -H 'Content-Type: application/json' \ + -d '{ + "format": "smtp", + "title": "Alert", + "message": "System CPU high", + "priority": 5, + "extras": { + "to_email": "admin@example.com", + "cc": "ops@example.com" + } + }' +``` + +**Response (success):** +```json +{ + "status": "success", + "messageId": "email-admin@example.com" +} +``` + +**Response (error):** +```json +{ + "status": "error", + "error": "failed to send email: connection refused" +} +``` + +### Send SMS (Stubbed) + +SMS support is stubbed. Currently returns "not implemented" error. To enable: + +1. Choose SMS provider (Twilio, AWS SNS, Vonage, etc.) +2. Set `SMS_API_URL` and `SMS_API_KEY` environment vars in gateway Deployment +3. Implement provider integration in `internal/notification/handler.go` sendSMS() method + +```bash +curl -X POST https://api.riotpiao.com \ + -H 'X-Service: notification' \ + -H 'X-Resource: sendMsg' \ + -H 'Content-Type: application/json' \ + -d '{ + "format": "sms", + "message": "System CPU high", + "extras": { + "phone": "+12025551234" + } + }' +``` + +## Configuration + +### SMTP Settings + +Gateway reads SMTP config from environment variables (pulled from `smtp-credentials` Secret in `api` namespace): +- `SMTP_HOST` — SMTP server hostname +- `SMTP_PORT` — SMTP server port (587 TLS or 465 SSL) +- `SMTP_FROM` — Sender email address +- `SMTP_USER` — SMTP auth username +- `SMTP_PASS` — SMTP auth password + +Secret is SOPS-encrypted in git. Create via: +```bash +kubectl create secret generic smtp-credentials \ + --from-literal=host=mail.riotpiao.com \ + --from-literal=port=587 \ + --from-literal=from=alerts@riotpiao.com \ + --from-literal=user=smtp-user \ + --from-literal=password=smtp-password \ + -n api \ + -o yaml | sops -e /dev/stdin > k8s/smtp-secrets.enc.yaml +``` + +Then add to `k8s/kustomization.yaml`: +```yaml +resources: +- smtp-secrets.enc.yaml +``` + +### Gotify Server + +Gotify runs in `notifications` namespace with: +- PostgreSQL backend (CNPG) +- SMTP emailer sidecar (unused by sendMsg, but available for UI notifications) +- Health check on `:80/health` + +Config: `k8s/apps/gotify/` + +## Testing + +```bash +cd homelab-frontend +bash examples/sendmsg-email.sh https://api.riotpiao.com +``` + +## Roadmap + +- [ ] SMS provider integration (pick: Twilio/SNS/Vonage) +- [ ] Request rate limiting per source +- [ ] Message queuing for retries (via SQS if high volume expected) +- [ ] Audit logging (who sent what, to whom, when) +- [ ] Template support (subject + body with placeholders) + +## Troubleshooting + +### "SMTP_HOST not set" +Gateway env vars not loaded. Check: +```bash +kubectl -n api describe pod api-gateway-xyz +kubectl -n api logs api-gateway-xyz | grep SMTP +``` + +### "connection refused" on SMTP +SMTP server unreachable. Verify: +```bash +kubectl -n api exec -it api-gateway-xyz -- \ + nc -zv $SMTP_HOST $SMTP_PORT +``` + +### "authentication failed" +Wrong SMTP username/password. Verify credentials: +```bash +kubectl -n api get secret smtp-credentials -o yaml | grep password | base64 -d +``` + +### "X-Resource: sendMsg not found" +Notification handler not registered. Check `internal/server/router.go`: +- Verify `X-Service: notification` case exists +- Confirm `notification.NewHandler()` called in `NewRouter()` + +## References + +- [API Documentation](../homelab-frontend/API.md#notification-services) +- [Gotify Server Docs](https://gotify.net) +- [SMTP Configuration Best Practices](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol) diff --git a/scripts/iam/provision-rbac.py b/scripts/iam/provision-rbac.py index 42f3924..98fb231 100644 --- a/scripts/iam/provision-rbac.py +++ b/scripts/iam/provision-rbac.py @@ -283,36 +283,111 @@ if status != 200: else: scope_pks = {m["scope_name"]: m["pk"] for m in scopes_res.get("results", [])} -scope_pks_list = [scope_pks[s] for s in SCOPE_MAPPINGS.keys() if s in scope_pks] +# Include standard OpenID scopes (openid, email, profile) + custom claim scopes +STANDARD_SCOPES = ["openid", "email", "profile"] +scope_pks_list = [scope_pks[s] for s in STANDARD_SCOPES if s in scope_pks] +scope_pks_list += [scope_pks[s] for s in SCOPE_MAPPINGS.keys() if s in scope_pks] +# OAuth2 providers — client_secret sourced from SOPS-encrypted k8s secrets. +# These are the real secrets the services use. Authentik must match. OAuth2_PROVIDERS = { - "api-gw": {"client_id": "api-gw", "redirect_uris": ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"]}, - "minio": {"client_id": "minio", "redirect_uris": ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"]}, - "poimen": {"client_id": "poimen", "redirect_uris": ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"]}, - "paperless": {"client_id": "paperless", "redirect_uris": ["http://localhost:8000/auth/complete", "https://paperless.riotpiao.com/auth/complete"]}, - "grafana": {"client_id": "grafana", "redirect_uris": ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"]}, - "queue": {"client_id": "queue-sqs", "redirect_uris": ["http://localhost:8080/callback", "https://queue.riotpiao.com/callback"]}, + "api-gw": { + "client_id": "api-gw", + "client_secret_env": "AUTHENTIK_PROVIDER_API_GW_SECRET", + "redirect_uris": ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"], + }, + "minio": { + "client_id": "minio", + "client_secret": "9d2867fe08c3bf7fedd7e32bbaf4456fce3b0aaf788966d7559e1955947b0219", + "redirect_uris": ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"], + }, + "poimen": { + "client_id": "poimen", + "client_secret_env": "AUTHENTIK_PROVIDER_POIMEN_SECRET", + "redirect_uris": ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"], + }, + "paperless": { + "client_id": "paperless", + "client_secret": "6hcxaaVgZlKgafl7BxeSEtPAcbNUJxi2PAZePxSFk4o=", + "redirect_uris": ["http://localhost:8000/accounts/oidc/authentik/login/callback/", "https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"], + }, + "grafana": { + "client_id": "grafana", + "client_secret": "966bad4fa43812100e7775b3c73fed2ce1d07217fa5a23fbb0f190e46d2f0fa4", + "redirect_uris": ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"], + }, + "queue": { + "client_id": "queue-sqs", + "client_secret_env": "AUTHENTIK_PROVIDER_QUEUE_SECRET", + "redirect_uris": ["http://localhost:8080/callback", "https://queue.riotpiao.com/callback"], + }, + "forgejo": { + "client_id": "forgejo", + "client_secret": "G4klhs3JRfs5A7YnGs90WuOndBAvamWlZgaZRY8x", + "redirect_uris": ["http://localhost:3000/user/oauth2/authentik/callback", "https://forgejo.riotpiao.com/user/oauth2/authentik/callback"], + }, + "immich": { + "client_id": "immich", + "client_secret": "QxyWfESXqTD55aUyh6miYnny1QTCEuEwyC4escw9", + "redirect_uris": ["app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login", "https://img.riotpiao.com/user/oauth2/callback"], + }, + "homarr": { + "client_id": "homarr", + "client_secret": "RMlDQAWdjT5YPPH0U7ztDoUFP7R7w95b2xqQ1pyS", + "redirect_uris": ["http://localhost:7575/auth/callback", "https://homarr.riotpiao.com/auth/callback"], + }, + "argocd": { + "client_id": "argocd", + "client_secret_env": "AUTHENTIK_PROVIDER_ARGOCD_SECRET", + "redirect_uris": ["http://localhost:8080/auth/callback", "https://argocd.riotpiao.com/auth/callback"], + }, + "vault": { + "client_id": "vault", + "client_secret_env": "AUTHENTIK_PROVIDER_VAULT_SECRET", + "redirect_uris": ["http://localhost:8200/ui/vault/auth/oidc/oidc/callback", "https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"], + }, } +import secrets as _secrets + for provider_name, provider_spec in OAuth2_PROVIDERS.items(): - if provider_name in existing_providers: - print(f" {provider_name}: already exists") + # Resolve client_secret: explicit > env var > generate random + if "client_secret" in provider_spec: + client_secret = provider_spec["client_secret"] + elif "client_secret_env" in provider_spec: + client_secret = os.environ.get(provider_spec["client_secret_env"], _secrets.token_urlsafe(32)) else: - # Build redirect_uris list with proper schema - redirect_uris_list = [{"url": uri, "matching_mode": "strict"} for uri in provider_spec["redirect_uris"]] - - client_secret = os.environ.get(f"AUTHENTIK_PROVIDER_{provider_name.upper()}_SECRET", f"{provider_name}-secret-placeholder") - - status, res = api("POST", "/api/v3/providers/oauth2/", { - "name": provider_name, - "authorization_flow": auth_flow, - "invalidation_flow": inval_flow, - "grant_types": ["authorization_code", "implicit", "password"], + client_secret = _secrets.token_urlsafe(32) + + redirect_uris_list = [{"url": uri, "matching_mode": "strict"} for uri in provider_spec["redirect_uris"]] + + provider_payload = { + "name": provider_name, + "authorization_flow": auth_flow, + "invalidation_flow": inval_flow, + "grant_types": ["authorization_code", "implicit", "password"], + "client_id": provider_spec["client_id"], + "client_secret": client_secret, + "redirect_uris": redirect_uris_list, + "property_mappings": scope_pks_list, + } + + if provider_name in existing_providers: + # UPDATE existing provider — sync secret + redirect_uris + provider_pk = existing_providers[provider_name]["pk"] + status, res = api("PATCH", f"/api/v3/providers/oauth2/{provider_pk}/", { "client_id": provider_spec["client_id"], "client_secret": client_secret, "redirect_uris": redirect_uris_list, "property_mappings": scope_pks_list, }) + if status in (200, 201): + print(f" {provider_name}: updated (secret + redirect_uris synced)") + else: + print(f" {provider_name}: UPDATE FAILED {status} {res}") + else: + # CREATE new provider + status, res = api("POST", "/api/v3/providers/oauth2/", provider_payload) if status in (200, 201): print(f" {provider_name}: created") else: @@ -330,17 +405,28 @@ if status != 200: existing_apps = {a["slug"]: a for a in res.get("results", [])} for provider_name in OAuth2_PROVIDERS.keys(): + # Get the provider PK + status, provider_res = api("GET", f"/api/v3/providers/oauth2/?name={provider_name}") + if status != 200 or not provider_res.get("results"): + print(f" {provider_name}: provider not found, skip") + continue + provider_pk = provider_res["results"][0]["pk"] + if provider_name in existing_apps: - print(f" {provider_name}: already exists") + # Ensure app is linked to provider (fix orphaned apps) + app_data = existing_apps[provider_name] + if app_data.get("provider") != provider_pk: + app_uuid = app_data["pk"] + status, res = api("PATCH", f"/api/v3/core/applications/{app_uuid}/", { + "provider": provider_pk, + }) + if status in (200, 201): + print(f" {provider_name}: re-linked to provider") + else: + print(f" {provider_name}: RE-LINK FAILED {status} {res}") + else: + print(f" {provider_name}: ok") else: - # Get the provider PK to link - status, provider_res = api("GET", f"/api/v3/providers/oauth2/?name={provider_name}") - if status != 200 or not provider_res.get("results"): - print(f" {provider_name}: provider not found") - continue - - provider_pk = provider_res["results"][0]["pk"] - status, res = api("POST", "/api/v3/core/applications/", { "name": provider_name, "slug": provider_name,