Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f96da880f | ||
|
|
f37e5f32cb | ||
|
|
8c4a900b40 | ||
|
|
e6ba83cd38 | ||
|
|
a02bee50e2 | ||
|
|
f9af686c36 | ||
|
|
3ec46d26f0 | ||
|
|
52bca6b1b6 | ||
|
|
3e079b1cf7 | ||
|
|
227a67a8c1 | ||
|
|
d4e9dbf350 | ||
|
|
be44a576d4 | ||
|
|
39081222a3 | ||
|
|
7e5b793670 | ||
|
|
354814a531 | ||
|
|
54fac02783 | ||
|
|
eb83aa6290 | ||
|
|
6ebecec1c4 | ||
|
|
091ecdd7bb | ||
|
|
7251032081 | ||
|
|
b7fc6ed60b | ||
|
|
6312674058 | ||
|
|
2fe6d50ffd | ||
|
|
e2d9717ce1 | ||
|
|
111b19dac2 | ||
|
|
e335694cbb | ||
|
|
d1a7d3be6e | ||
|
|
2f664c32d9 | ||
|
|
6c98ca7436 | ||
|
|
ebb1b9b02d | ||
|
|
71b3cef2eb |
@@ -52,6 +52,10 @@ terraform/*.tfstate.*
|
||||
terraform/zitadel/*.tfstate
|
||||
terraform/zitadel/*.tfstate.*
|
||||
terraform/zitadel/*.tfvars
|
||||
terraform/edge/*.tfstate
|
||||
terraform/edge/*.tfstate.*
|
||||
terraform/edge/*.tfvars
|
||||
terraform/edge/terraform.tfvars.local
|
||||
terraform.tfvars.local
|
||||
skills-lock.json
|
||||
secrets-plaintext.yaml
|
||||
@@ -81,3 +85,7 @@ scripts/iam/*.py
|
||||
# Runtime credential export from the old Authentik provisioning run. Holds live
|
||||
# M2M tokens; never commit it.
|
||||
/authentik-m2m-tokens.env
|
||||
|
||||
# Never commit backup copies of plaintext tfvars (they carry cluster PKI)
|
||||
terraform/*.bak
|
||||
terraform/edge/.secrets/
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"nextId": 2,
|
||||
"loops": [
|
||||
{
|
||||
"id": "1",
|
||||
"prompt": "Check Longhorn and CNPG recovery progress in the homelab cluster and report terse. Run: kubectl -n longhorn-system get volumes.longhorn.io -o json (count by state/robustness), kubectl -n longhorn-system get replicas.longhorn.io -o json (count running/stopped/failed), kubectl get clusters.postgresql.cnpg.io -A (ready instances per cluster), kubectl -n cicd get pods,pvc | grep forgejo-db. Report: volumes healthy vs degraded, replicas running vs stopped, and each CNPG cluster ready count. If every volume is attached/healthy and every CNPG cluster shows 3/3 ready, say RECOVERED, list any leftover anomalies, and stop this loop with LoopDelete. Otherwise name what is still rebuilding and keep going.",
|
||||
"trigger": {
|
||||
"type": "cron",
|
||||
"schedule": "*/5 * * * *"
|
||||
},
|
||||
"status": "expired",
|
||||
"recurring": true,
|
||||
"createdAt": 1792898370946,
|
||||
"updatedAt": 1792901371403,
|
||||
"expiresAt": 1793503170946,
|
||||
"source": "tool",
|
||||
"readOnly": true,
|
||||
"maxFires": 6,
|
||||
"fireCount": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "Poimen Memory — Schema Relations",
|
||||
"quality_profile": "showcase",
|
||||
"viewBox": [900, 480]
|
||||
},
|
||||
"components": [
|
||||
{ "id": "node", "type": "database", "label": "memory_node", "sublabel": "Episodes (raw messages)", "pos": [40, 60], "size": [150, 55], "tag": "BIGSERIAL" },
|
||||
{ "id": "entity", "type": "database", "label": "memory_entity", "sublabel": "Extracted entities", "pos": [260, 60], "size": [150, 55], "tag": "UUID" },
|
||||
{ "id": "edge", "type": "database", "label": "memory_edge", "sublabel": "Facts / relationships", "pos": [480, 60], "size": [150, 55], "tag": "UUID" },
|
||||
{ "id": "community", "type": "database", "label": "memory_community", "sublabel": "Entity clusters", "pos": [700, 60], "size": [150, 55], "tag": "UUID" },
|
||||
{ "id": "pgvector", "type": "cloud", "label": "pgvector HNSW", "sublabel": "768-dim cosine search", "pos": [140, 260], "size": [160, 55], "tag": "nomic-embed-v2" },
|
||||
{ "id": "tsvector", "type": "cloud", "label": "pg ts_rank", "sublabel": "Full-text search", "pos": [400, 260], "size": [160, 55], "tag": "GIN index" },
|
||||
{ "id": "tenant", "type": "security", "label": "tenant_id", "sublabel": "Multi-tenant isolation", "pos": [660, 260], "size": [140, 55], "tag": "W7 / mig 014" }
|
||||
],
|
||||
"boundaries": [
|
||||
{ "kind": "region", "label": "Temporal Knowledge Graph (Zep §2)", "wraps": ["node", "entity", "edge", "community"] },
|
||||
{ "kind": "security-group", "label": "Search & Isolation", "wraps": ["pgvector", "tsvector", "tenant"] }
|
||||
],
|
||||
"connections": [
|
||||
{ "id": "node-entity", "from": "node", "to": "entity", "variant": "emphasis" },
|
||||
{ "id": "entity-edge", "from": "entity", "to": "edge", "variant": "emphasis" },
|
||||
{ "id": "entity-community", "from": "community", "to": "entity", "variant": "dashed" },
|
||||
{ "id": "edge-node", "from": "edge", "to": "node", "variant": "dashed" },
|
||||
{ "id": "entity-vec", "from": "entity", "to": "pgvector", "variant": "default" },
|
||||
{ "id": "entity-fts", "from": "entity", "to": "tsvector", "variant": "dashed" },
|
||||
{ "id": "edge-fts", "from": "edge", "to": "tsvector", "variant": "dashed" },
|
||||
{ "id": "tenant-all", "from": "tenant", "to": "entity", "variant": "security" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "emerald", "title": "Three-Tier Graph", "items": ["memory_node → episode subgraph", "memory_entity → entity subgraph", "memory_edge → fact relationships", "memory_community → clusters (Phase 4)"] },
|
||||
{ "dot": "rose", "title": "Bi-Temporal", "items": ["t_valid / t_invalid: event time", "t_created / t_expired: transaction time", "Contradiction: active → candidate → confirmed"] },
|
||||
{ "dot": "cyan", "title": "Search", "items": ["HNSW: name, summary, fact embeddings", "ts_rank: BM25 fallback (always available)", "tenant_id: per-table isolation (mig 014)"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "dataflow",
|
||||
"meta": {
|
||||
"title": "Poimen Memory \u2014 Schema Relations",
|
||||
"viewBox": [
|
||||
960,
|
||||
480
|
||||
],
|
||||
"quality_profile": "showcase"
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"label": "Episodes"
|
||||
},
|
||||
{
|
||||
"label": "Entities"
|
||||
},
|
||||
{
|
||||
"label": "Relationships"
|
||||
},
|
||||
{
|
||||
"label": "Indexes"
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node",
|
||||
"type": "database",
|
||||
"label": "memory_node",
|
||||
"sublabel": "Raw messages",
|
||||
"stage": 0,
|
||||
"row": 0,
|
||||
"tag": "BIGSERIAL"
|
||||
},
|
||||
{
|
||||
"id": "entity",
|
||||
"type": "database",
|
||||
"label": "memory_entity",
|
||||
"sublabel": "Named entities",
|
||||
"stage": 1,
|
||||
"row": 0,
|
||||
"tag": "UUID + tenant_id"
|
||||
},
|
||||
{
|
||||
"id": "community",
|
||||
"type": "database",
|
||||
"label": "memory_community",
|
||||
"sublabel": "Clusters (Phase 4)",
|
||||
"stage": 1,
|
||||
"row": 1,
|
||||
"tag": "UUID"
|
||||
},
|
||||
{
|
||||
"id": "edge",
|
||||
"type": "database",
|
||||
"label": "memory_edge",
|
||||
"sublabel": "Facts + bi-temporal",
|
||||
"stage": 2,
|
||||
"row": 0,
|
||||
"tag": "UUID + tenant_id"
|
||||
},
|
||||
{
|
||||
"id": "hnsw",
|
||||
"type": "cloud",
|
||||
"label": "HNSW Index",
|
||||
"sublabel": "768-dim cosine",
|
||||
"stage": 3,
|
||||
"row": 0,
|
||||
"tag": "pgvector"
|
||||
},
|
||||
{
|
||||
"id": "fts",
|
||||
"type": "cloud",
|
||||
"label": "GIN Index",
|
||||
"sublabel": "ts_rank BM25",
|
||||
"stage": 3,
|
||||
"row": 1,
|
||||
"tag": "full-text"
|
||||
}
|
||||
],
|
||||
"flows": [
|
||||
{
|
||||
"from": "node",
|
||||
"to": "entity",
|
||||
"label": "LLM extraction",
|
||||
"classification": "ingest",
|
||||
"variant": "emphasis",
|
||||
"route": "straight"
|
||||
},
|
||||
{
|
||||
"from": "entity",
|
||||
"to": "edge",
|
||||
"label": "source + target FK",
|
||||
"classification": "foreign key",
|
||||
"variant": "emphasis",
|
||||
"route": "straight"
|
||||
},
|
||||
{
|
||||
"from": "entity",
|
||||
"to": "community",
|
||||
"label": "community_id",
|
||||
"classification": "membership",
|
||||
"variant": "dashed",
|
||||
"route": "straight",
|
||||
"labelDy": 30
|
||||
},
|
||||
{
|
||||
"from": "edge",
|
||||
"to": "hnsw",
|
||||
"classification": "vector",
|
||||
"variant": "default",
|
||||
"route": "straight",
|
||||
"label": "embedding",
|
||||
"labelDy": -30
|
||||
},
|
||||
{
|
||||
"from": "edge",
|
||||
"to": "fts",
|
||||
"label": "fact text",
|
||||
"classification": "text",
|
||||
"variant": "dashed",
|
||||
"route": "vertical-channel",
|
||||
"labelDy": 30
|
||||
}
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Three-Tier Graph",
|
||||
"items": [
|
||||
"memory_node \u2192 episodes (raw messages)",
|
||||
"memory_entity \u2192 extracted entities",
|
||||
"memory_edge \u2192 facts between entities",
|
||||
"memory_community \u2192 clusters (Phase 4)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Bi-Temporal + Tenant",
|
||||
"items": [
|
||||
"t_valid/t_invalid: when true in reality",
|
||||
"t_created/t_expired: system transaction time",
|
||||
"tenant_id: row-level multi-tenant isolation",
|
||||
"Contradiction: active \u2192 candidate \u2192 confirmed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "cyan",
|
||||
"title": "Search Indexes",
|
||||
"items": [
|
||||
"HNSW: name, summary, fact embeddings",
|
||||
"GIN: ts_rank on name+summary and fact",
|
||||
"RRF fusion: sem_weight + lex_weight"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "Service Acting on Behalf of User",
|
||||
"viewBox": [1000, 600],
|
||||
"column_fit": "spread",
|
||||
"quality_profile": "showcase"
|
||||
},
|
||||
"participants": [
|
||||
{ "id": "user", "type": "external", "label": "User", "sublabel": "browser" },
|
||||
{ "id": "app", "type": "frontend", "label": "Web App", "sublabel": "poimen UI" },
|
||||
{ "id": "authentik", "type": "security", "label": "Authentik", "sublabel": "OIDC" },
|
||||
{ "id": "worker", "type": "backend", "label": "Backend Worker", "sublabel": "temporal/queue" },
|
||||
{ "id": "memory", "type": "backend", "label": "Memory API", "sublabel": "poimen-memory" },
|
||||
{ "id": "jwks", "type": "security", "label": "JWKS", "sublabel": "RS256" }
|
||||
],
|
||||
"segments": [
|
||||
{ "from": 150, "to": 260, "label": "User Auth" },
|
||||
{ "from": 270, "to": 370, "label": "Delegation" },
|
||||
{ "from": 380, "to": 510, "label": "Downstream" }
|
||||
],
|
||||
"messages": [
|
||||
{ "id": "login", "from": "user", "to": "app", "y": 165, "label": "login (OIDC)", "variant": "default" },
|
||||
{ "id": "oidc-flow", "from": "app", "to": "authentik", "y": 200, "label": "authorization_code flow", "variant": "emphasis" },
|
||||
{ "id": "user-jwt", "from": "authentik", "to": "app", "y": 235, "label": "user JWT {groups,roles}", "variant": "return" },
|
||||
{ "id": "trigger-job", "from": "user", "to": "app", "y": 285, "label": "trigger async job", "variant": "default" },
|
||||
{ "id": "queue-job", "from": "app", "to": "worker", "y": 320, "label": "enqueue job + user JWT", "variant": "emphasis", "note": "JWT passed as job metadata" },
|
||||
{ "id": "job-ack", "from": "worker", "to": "app", "y": 355, "label": "job queued", "variant": "return" },
|
||||
{ "id": "call-memory", "from": "worker", "to": "memory", "y": 400, "label": "POST /memory/ingest", "variant": "emphasis", "note": "Bearer <user JWT>" },
|
||||
{ "id": "fetch-jwks", "from": "memory", "to": "jwks", "y": 435, "label": "GET /jwks", "variant": "dashed" },
|
||||
{ "id": "jwks-keys", "from": "jwks", "to": "memory", "y": 470, "label": "RSA pubkey", "variant": "return" },
|
||||
{ "id": "ingest-ok", "from": "memory", "to": "worker", "y": 505, "label": "202 Accepted", "variant": "return", "note": "uses USER's groups/roles" }
|
||||
],
|
||||
"activations": [
|
||||
{ "participant": "user", "from": 160, "to": 291, "type": "external" },
|
||||
{ "participant": "app", "from": 165, "to": 361, "type": "frontend" },
|
||||
{ "participant": "authentik", "from": 195, "to": 241, "type": "security" },
|
||||
{ "participant": "worker", "from": 315, "to": 511, "type": "backend" },
|
||||
{ "participant": "memory", "from": 395, "to": 511, "type": "backend" },
|
||||
{ "participant": "jwks", "from": 430, "to": 476, "type": "security" }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Delegation Pattern",
|
||||
"items": [
|
||||
"User JWT travels with the job payload",
|
||||
"Worker uses user's token for downstream",
|
||||
"Memory sees USER as the principal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Security Model",
|
||||
"items": [
|
||||
"No service-account escalation",
|
||||
"User's permissions bound the action",
|
||||
"Audit trail traces to user.sub"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "Memory Service Fine-Grained RBAC",
|
||||
"quality_profile": "showcase",
|
||||
"viewBox": [900, 520]
|
||||
},
|
||||
"components": [
|
||||
{ "id": "jwt", "type": "security", "label": "JWT Token", "sublabel": "groups[] + roles[]", "pos": [40, 60], "size": [140, 60], "tag": "Authentik" },
|
||||
{ "id": "cap-check", "type": "backend", "label": "Capability Check", "sublabel": "has_capability()", "pos": [260, 60], "size": [160, 60], "tag": "Phase 1" },
|
||||
{ "id": "scope-checker", "type": "backend", "label": "Scope Checker", "sublabel": "filter_resources()", "pos": [500, 60], "size": [160, 60], "tag": "Phase 2" },
|
||||
{ "id": "decision", "type": "backend", "label": "AccessDecision", "sublabel": "Allow / Deny", "pos": [740, 60], "size": [130, 60] },
|
||||
{ "id": "project-scope", "type": "database", "label": "ProjectScope", "sublabel": "dev/staging/prod/*", "pos": [40, 200], "size": [140, 55] },
|
||||
{ "id": "visibility-scope", "type": "database", "label": "VisibilityScope", "sublabel": "public/private", "pos": [220, 200], "size": [140, 55] },
|
||||
{ "id": "owner-scope", "type": "database", "label": "OwnerScope", "sublabel": "self/any/user", "pos": [400, 200], "size": [140, 55] },
|
||||
{ "id": "group-scope", "type": "database", "label": "GroupScope", "sublabel": "in_any_group()", "pos": [580, 200], "size": [140, 55] },
|
||||
{ "id": "entities", "type": "database", "label": "Entities", "sublabel": "memory_entity", "pos": [40, 340], "size": [120, 50] },
|
||||
{ "id": "edges", "type": "database", "label": "Edges", "sublabel": "memory_edge", "pos": [200, 340], "size": [120, 50] },
|
||||
{ "id": "projects", "type": "database", "label": "Projects", "sublabel": "project_id", "pos": [360, 340], "size": [120, 50] },
|
||||
{ "id": "convos", "type": "database", "label": "Conversations", "sublabel": "owner_id", "pos": [520, 340], "size": [120, 50] }
|
||||
],
|
||||
"boundaries": [
|
||||
{ "kind": "security-group", "label": "Access Control Pipeline", "wraps": ["cap-check", "scope-checker", "decision"] },
|
||||
{ "kind": "region", "label": "Scope Checkers", "wraps": ["project-scope", "visibility-scope", "owner-scope", "group-scope"] },
|
||||
{ "kind": "region", "label": "Protected Resources", "wraps": ["entities", "edges", "projects", "convos"] }
|
||||
],
|
||||
"connections": [
|
||||
{ "id": "jwt-to-cap", "from": "jwt", "to": "cap-check", "label": "claims", "variant": "emphasis" },
|
||||
{ "id": "cap-to-scope", "from": "cap-check", "to": "scope-checker", "variant": "emphasis" },
|
||||
{ "id": "scope-to-decision", "from": "scope-checker", "to": "decision", "variant": "emphasis" },
|
||||
{ "id": "scope-to-proj", "from": "scope-checker", "to": "project-scope", "variant": "dashed", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "id": "scope-to-vis", "from": "scope-checker", "to": "visibility-scope", "variant": "dashed", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "id": "scope-to-owner", "from": "scope-checker", "to": "owner-scope", "variant": "dashed", "fromSide": "bottom", "toSide": "top" },
|
||||
{ "id": "scope-to-group", "from": "scope-checker", "to": "group-scope", "variant": "dashed", "fromSide": "bottom", "toSide": "top" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "emerald", "title": "Phase 1: Capability", "items": ["has_capability(memory:read)", "Checks roles[] claim", "Binary allow/deny"] },
|
||||
{ "dot": "rose", "title": "Phase 2: Scope", "items": ["Project: dev/staging/prod", "Visibility: public/private", "Owner: self-owned only", "Group: poimen-admins/devs"] },
|
||||
{ "dot": "cyan", "title": "JWT Claims", "items": ["groups: user.groups", "roles: attributes.roles", "permissions: group names"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "Service-to-Service (M2M) Authentication",
|
||||
"viewBox": [1080, 520],
|
||||
"column_fit": "spread",
|
||||
"quality_profile": "showcase"
|
||||
},
|
||||
"participants": [
|
||||
{ "id": "worker", "type": "backend", "label": "Ingest Worker", "sublabel": "temporal/cron" },
|
||||
{ "id": "authentik", "type": "security", "label": "Authentik", "sublabel": "OIDC provider" },
|
||||
{ "id": "memory", "type": "backend", "label": "Memory Service", "sublabel": "poimen-memory" },
|
||||
{ "id": "jwks", "type": "security", "label": "JWKS", "sublabel": "RS256 keys" }
|
||||
],
|
||||
"segments": [
|
||||
{ "from": 150, "to": 230, "label": "Token Mint" },
|
||||
{ "from": 240, "to": 380, "label": "API + RBAC" }
|
||||
],
|
||||
"messages": [
|
||||
{ "id": "token-req", "from": "worker", "to": "authentik", "y": 165, "label": "POST /token grant_type=password", "variant": "emphasis", "note": "client_id + username + app_password + scope" },
|
||||
{ "id": "jwt-resp", "from": "authentik", "to": "worker", "y": 205, "label": "JWT {groups,roles,aud,iss}", "variant": "return", "note": "groups=[poimen-admins] roles=[memory:write]" },
|
||||
{ "id": "api-call", "from": "worker", "to": "memory", "y": 260, "label": "POST /memory/ingest", "variant": "emphasis", "note": "Authorization: Bearer <JWT>" },
|
||||
{ "id": "fetch-jwks", "from": "memory", "to": "jwks", "y": 295, "label": "GET /jwks (cached)", "variant": "dashed" },
|
||||
{ "id": "jwks-resp", "from": "jwks", "to": "memory", "y": 330, "label": "RSA pubkey (kid)", "variant": "return" },
|
||||
{ "id": "verify-ok", "from": "memory", "to": "worker", "y": 365, "label": "202 Accepted", "variant": "return", "note": "sig ✓ iss ✓ aud ✓ has_capability ✓" }
|
||||
],
|
||||
"activations": [
|
||||
{ "participant": "worker", "from": 160, "to": 371, "type": "backend" },
|
||||
{ "participant": "authentik", "from": 165, "to": 211, "type": "security" },
|
||||
{ "participant": "memory", "from": 255, "to": 371, "type": "backend" },
|
||||
{ "participant": "jwks", "from": 290, "to": 336, "type": "security" }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "M2M Pattern",
|
||||
"items": [
|
||||
"grant_type=password (Authentik aliases client_credentials)",
|
||||
"App-password token authenticates the service account",
|
||||
"NOT client_secret — that creates nameless SA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "JWT Claims",
|
||||
"items": [
|
||||
"groups: from user.groups (poimen-admins)",
|
||||
"roles: from user.attributes.roles",
|
||||
"iss/aud: per-provider issuer"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "User Direct Access with JWT",
|
||||
"viewBox": [1000, 640],
|
||||
"column_fit": "spread",
|
||||
"quality_profile": "showcase"
|
||||
},
|
||||
"participants": [
|
||||
{ "id": "user", "type": "external", "label": "User", "sublabel": "browser" },
|
||||
{ "id": "app", "type": "frontend", "label": "Web App", "sublabel": "poimen UI" },
|
||||
{ "id": "authentik", "type": "security", "label": "Authentik", "sublabel": "OIDC provider" },
|
||||
{ "id": "memory", "type": "backend", "label": "Memory API", "sublabel": "poimen-memory" },
|
||||
{ "id": "jwks", "type": "security", "label": "JWKS", "sublabel": "RS256 keys" }
|
||||
],
|
||||
"segments": [
|
||||
{ "from": 150, "to": 370, "label": "OIDC Login" },
|
||||
{ "from": 380, "to": 540, "label": "API + RBAC" }
|
||||
],
|
||||
"messages": [
|
||||
{ "id": "click-login", "from": "user", "to": "app", "y": 165, "label": "click Login", "variant": "default" },
|
||||
{ "id": "redirect-auth", "from": "app", "to": "authentik", "y": 200, "label": "redirect /authorize", "variant": "emphasis", "note": "scope=openid groups roles" },
|
||||
{ "id": "show-login", "from": "authentik", "to": "user", "y": 235, "label": "login form", "variant": "default" },
|
||||
{ "id": "submit-creds", "from": "user", "to": "authentik", "y": 270, "label": "username + password", "variant": "security" },
|
||||
{ "id": "auth-code", "from": "authentik", "to": "app", "y": 305, "label": "redirect ?code=xxx", "variant": "return" },
|
||||
{ "id": "exchange-token", "from": "app", "to": "authentik", "y": 340, "label": "POST /token code=xxx", "variant": "emphasis" },
|
||||
{ "id": "jwt-resp", "from": "authentik", "to": "app", "y": 375, "label": "JWT {groups,roles,sub}", "variant": "return" },
|
||||
{ "id": "query-api", "from": "app", "to": "memory", "y": 420, "label": "GET /memory/query", "variant": "emphasis", "note": "Authorization: Bearer <JWT>" },
|
||||
{ "id": "fetch-jwks", "from": "memory", "to": "jwks", "y": 455, "label": "GET /jwks (cached)", "variant": "dashed" },
|
||||
{ "id": "jwks-keys", "from": "jwks", "to": "memory", "y": 490, "label": "RSA pubkey", "variant": "return" },
|
||||
{ "id": "query-resp", "from": "memory", "to": "app", "y": 525, "label": "200 JSON results", "variant": "return", "note": "has_capability(memory:read) ✓" }
|
||||
],
|
||||
"activations": [
|
||||
{ "participant": "user", "from": 160, "to": 276, "type": "external" },
|
||||
{ "participant": "app", "from": 165, "to": 531, "type": "frontend" },
|
||||
{ "participant": "authentik", "from": 195, "to": 381, "type": "security" },
|
||||
{ "participant": "memory", "from": 415, "to": 531, "type": "backend" },
|
||||
{ "participant": "jwks", "from": 450, "to": 496, "type": "security" }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Authorization Code Flow",
|
||||
"items": [
|
||||
"User authenticates directly with Authentik",
|
||||
"App exchanges code for JWT",
|
||||
"Scopes: openid groups roles permissions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "JWT Contents",
|
||||
"items": [
|
||||
"iss: .../application/o/poimen/",
|
||||
"aud: poimen (provider client_id)",
|
||||
"groups: user's group memberships"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# cloudflared ingress configuration.
|
||||
#
|
||||
# This list is the tunnel's routing table: for each incoming hostname the
|
||||
# connector dials the matching origin. The final rule is a catch-all, so a
|
||||
# hostname absent from this list gets 404 from the connector — DNS alone does
|
||||
# not route a request into a service.
|
||||
#
|
||||
# Management model: this file makes the tunnel LOCALLY managed. cloudflared is
|
||||
# given a credentials file instead of a token, so Cloudflare stops pushing the
|
||||
# remotely-managed ingess list and this ConfigMap wins. Revert the Deployment to
|
||||
# `run --token` to return to the dashboard-managed list.
|
||||
#
|
||||
# Origin services self-reference the public hostname. That resolves because
|
||||
# CoreDNS rewrites each of these names to ingress-nginx
|
||||
# (terraform/files/coredns/Corefile), so the connector reaches the in-cluster
|
||||
# ingress instead of looping back out through Cloudflare.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: cloudflared-config
|
||||
namespace: cloudflared
|
||||
data:
|
||||
config.yml: |
|
||||
tunnel: 32d0bf93-e1bd-45b8-93a9-71ff9f83de80
|
||||
credentials-file: /etc/cloudflared/creds/credentials.json
|
||||
|
||||
# WARP-to-Tunnel routing. The remotely-managed config had this enabled;
|
||||
# dropping the key would break WARP access to private addresses. cloudflared
|
||||
# 2026.x has no `enabled` field — the key's presence is what enables it, and
|
||||
# `enabled: true` here makes the binary warn about an unused key.
|
||||
warp-routing: {}
|
||||
|
||||
ingress:
|
||||
- hostname: paperless.riotpiao.com
|
||||
service: https://paperless.riotpiao.com
|
||||
- hostname: img.riotpiao.com
|
||||
service: https://img.riotpiao.com
|
||||
- hostname: riotpiao.com
|
||||
service: https://riotpiao.com
|
||||
- hostname: grafana.riotpiao.com
|
||||
service: https://grafana.riotpiao.com
|
||||
- hostname: gotify.riotpiao.com
|
||||
service: https://gotify.riotpiao.com
|
||||
- hostname: igotify.riotpiao.com
|
||||
service: https://igotify.riotpiao.com
|
||||
- hostname: poimen.riotpiao.com
|
||||
service: https://poimen.riotpiao.com
|
||||
- hostname: comfyui.riotpiao.com
|
||||
service: https://comfyui.riotpiao.com
|
||||
- hostname: forgejo.riotpiao.com
|
||||
service: https://forgejo.riotpiao.com
|
||||
# ZITADEL. Without this rule the connector answers 404 for the hostname
|
||||
# even though the DNS CNAME points at the tunnel.
|
||||
- hostname: zitadel.riotpiao.com
|
||||
service: https://zitadel.riotpiao.com
|
||||
# AUTHENTIK. Same reasoning as ZITADEL: a hostname absent from this table
|
||||
# gets 404 from the connector. CoreDNS rewrites the name to ingress-nginx.
|
||||
- hostname: authentik.riotpiao.com
|
||||
service: https://authentik.riotpiao.com
|
||||
- service: http_status:404
|
||||
@@ -13,21 +13,63 @@ spec:
|
||||
labels:
|
||||
app: cloudflared
|
||||
spec:
|
||||
# Ingress-nginx forces HTTPS (force-ssl-redirect), so cloudflared must
|
||||
# dial https://<hostname> and trust homelab-ca or the TLS handshake fails
|
||||
# with "certificate signed by unknown authority". Same pattern as
|
||||
# authentik-values.yaml: merge the system bundle with homelab-ca.crt.
|
||||
initContainers:
|
||||
- name: merge-ca-certs
|
||||
image: debian:bookworm
|
||||
imagePullPolicy: IfNotPresent
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- (cat /etc/ssl/certs/ca-certificates.crt 2>/dev/null; cat /homelab-ca/ca.crt) > /merged/ca-bundle.crt
|
||||
volumeMounts:
|
||||
- name: homelab-ca
|
||||
mountPath: /homelab-ca
|
||||
readOnly: true
|
||||
- name: merged-ca
|
||||
mountPath: /merged
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: cloudflared-config
|
||||
- name: credentials
|
||||
secret:
|
||||
secretName: cloudflared-credentials
|
||||
- name: homelab-ca
|
||||
configMap:
|
||||
name: homelab-ca
|
||||
- name: merged-ca
|
||||
emptyDir: {}
|
||||
containers:
|
||||
- name: cloudflared
|
||||
image: cloudflare/cloudflared:latest
|
||||
args:
|
||||
- tunnel
|
||||
- --no-autoupdate
|
||||
- --protocol
|
||||
- http2
|
||||
- --config
|
||||
- /etc/cloudflared/config.yml
|
||||
- run
|
||||
- --token
|
||||
- $(TUNNEL_TOKEN)
|
||||
env:
|
||||
- name: TUNNEL_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cloudflared-token
|
||||
key: token
|
||||
- name: SSL_CERT_FILE
|
||||
value: /merged/ca-bundle.crt
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/cloudflared/config.yml
|
||||
subPath: config.yml
|
||||
readOnly: true
|
||||
- name: credentials
|
||||
mountPath: /etc/cloudflared/creds
|
||||
readOnly: true
|
||||
- name: merged-ca
|
||||
mountPath: /merged
|
||||
readOnly: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
|
||||
@@ -3,3 +3,4 @@ kind: Kustomization
|
||||
namespace: cloudflared
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- configmap.yaml
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# Homarr landing page with Authentik SSO
|
||||
|
||||
# Probe tuning (chart DOES expose these — the old PostSync patch-job was
|
||||
# unnecessary and fragile: it only patched one Deployment revision, so any later
|
||||
# rollout reverted to the chart's aggressive defaults). Homarr's first-boot icon
|
||||
# updater blocks the event loop for ~50s ("icons updater took 49553ms"), during
|
||||
# which /api/health/live can't answer within the default 10s×3 window → kubelet
|
||||
# SIGTERMs the pod → CrashLoopBackOff (247 restarts, 503 at the ingress). Give
|
||||
# liveness a wide window so the icon import can finish without a kill.
|
||||
livenessProbe:
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 10
|
||||
readinessProbe:
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
|
||||
image:
|
||||
repository: ghcr.io/homarr-labs/homarr
|
||||
tag: "latest"
|
||||
pullPolicy: Always
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
# Configure SSO via environment variables
|
||||
# Chart supports these via top-level env dict (not array)
|
||||
env:
|
||||
AUTH_PROVIDERS: "oidc,credentials"
|
||||
AUTH_OIDC_ISSUER: "https://authentik.riotpiao.com/application/o/homarr/"
|
||||
# AUTH_OIDC_URI (authorize endpoint) is REQUIRED in addition to ISSUER — homarr
|
||||
# hides the "Sign in with Authentik" button entirely when it's absent (per the
|
||||
# authentik Homarr integration + homarr SSO docs). This was the missing var.
|
||||
AUTH_OIDC_URI: "https://authentik.riotpiao.com/application/o/authorize/"
|
||||
AUTH_OIDC_CLIENT_NAME: "Authentik"
|
||||
AUTH_OIDC_GROUPS_ATTRIBUTE: "groups"
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: "openid email profile groups"
|
||||
AUTH_OIDC_AUTO_LOGIN: "false"
|
||||
# Link the OIDC identity to an existing homarr account with the same email.
|
||||
OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING: "true"
|
||||
# The analytics cron blocked the (single-threaded) Next.js event loop for ~16s
|
||||
# per run ("callback took longer than expected"), compounding CPU pressure.
|
||||
DISABLE_ANALYTICS: "true"
|
||||
BASE_URL: "https://homarr.riotpiao.com"
|
||||
NEXTAUTH_URL: "https://homarr.riotpiao.com"
|
||||
|
||||
# Client credentials from homarr-oidc secret
|
||||
# Chart doesn't support envFrom, so we add via extraEnv
|
||||
extraEnv:
|
||||
- name: AUTH_OIDC_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: homarr-oidc
|
||||
key: client-id
|
||||
- name: AUTH_OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: homarr-oidc
|
||||
key: client-secret
|
||||
|
||||
tolerations:
|
||||
- key: node-role.kubernetes.io/control-plane
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 384Mi
|
||||
limits:
|
||||
# Next.js 16 + bundled redis + the icon-updater (28k icons) saturated the old
|
||||
# 500m limit; CPU throttling made Next.js abort with exit 134 (SIGABRT) and
|
||||
# self-restart in a loop, so nginx saw no upstream and returned 502. Give it
|
||||
# real CPU headroom.
|
||||
cpu: "2"
|
||||
memory: 1Gi
|
||||
@@ -1,7 +0,0 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: dashboard
|
||||
# Probes are now tuned via homarr-values.yaml (chart-native); the old
|
||||
# fix-probes-job PostSync hook is removed. homarr-secrets/auth-oidc/db-encryption
|
||||
# Secrets are delivered by the sops-secrets (ksops) Application.
|
||||
resources: []
|
||||
@@ -12,6 +12,19 @@ rules:
|
||||
- apiGroups: ["kmsvc.io"]
|
||||
resources: ["queues"]
|
||||
verbs: ["get", "list", "watch", "update", "patch"]
|
||||
# The operator watches TemporalWorker objects and creates Deployments for
|
||||
# them. Without these two rules the controller logs
|
||||
# "cannot list resource temporalworkers/deployments ... at the cluster scope"
|
||||
# and never reconciles.
|
||||
- apiGroups: ["kmsvc.io"]
|
||||
resources: ["temporalworkers"]
|
||||
verbs: ["get", "list", "watch", "update", "patch"]
|
||||
- apiGroups: ["kmsvc.io"]
|
||||
resources: ["temporalworkers/status"]
|
||||
verbs: ["get", "update", "patch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch"]
|
||||
- apiGroups: ["kmsvc.io"]
|
||||
resources: ["queues/status"]
|
||||
verbs: ["get", "update", "patch"]
|
||||
|
||||
@@ -48,6 +48,7 @@ spec:
|
||||
mountPath: /backup
|
||||
containers:
|
||||
- name: mc-mirror
|
||||
# bitnamilegacy/minio-client:2025.7.21-debian-12-r3 replaces the dead quay.io/minio/mc image
|
||||
image: docker.io/bitnamilegacy/minio-client:2025.7.21-debian-12-r3@sha256:73bd39f7899a0cef12b8dd5df13aa93a3ed1aaa44236542442e9ac76819ac158
|
||||
env:
|
||||
- name: ACCESS_KEY
|
||||
|
||||
@@ -49,9 +49,9 @@ spec:
|
||||
echo "[init] Fetching JWT token from Authentik for paperless-ai-agent..."
|
||||
|
||||
# Get JWT token via client_credentials grant
|
||||
TOKEN_RESPONSE=$(curl -s -X POST https://authentik.riotpiao.com/application/o/token/ \
|
||||
TOKEN_RESPONSE=$(curl -s -X POST https://zitadel.riotpiao.com/oauth/v2/token \
|
||||
-d "grant_type=client_credentials" \
|
||||
-d "client_id=paperless-ai-agent" \
|
||||
-d "client_id=396757803559027071" \
|
||||
-d "client_secret=29skAhE0tBzWn0qrPvmPTQMva0joFjcCr7msDQr_VXQ" \
|
||||
-d "scope=openid llm:inference" 2>/dev/null)
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ metadata:
|
||||
app.kubernetes.io/name: paperless-ai
|
||||
spec:
|
||||
replicas: 1
|
||||
# Recreate, not RollingUpdate: paperless-ai-data is a single RWO PVC. With
|
||||
# RollingUpdate the new pod tries to mount the PVC while the old pod still
|
||||
# holds it -> "Multi-Attach error" / stuck Init:0/1 forever, rollout wedged
|
||||
# at "1 desired, 1 ready, 1 updated, 1 unavailable". Recreate terminates the
|
||||
# old pod first so the PVC detaches before the new one starts. Same pattern
|
||||
# as the Forgejo gitea Deployment.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperless-ai
|
||||
@@ -43,9 +51,9 @@ spec:
|
||||
|
||||
# Get JWT token via client_credentials grant
|
||||
# Client secret sourced from environment (injected from paperless-ai-config Secret)
|
||||
TOKEN_RESPONSE=$(curl -s -X POST https://authentik.riotpiao.com/application/o/token/ \
|
||||
TOKEN_RESPONSE=$(curl -s -X POST https://zitadel.riotpiao.com/oauth/v2/token \
|
||||
-d "grant_type=client_credentials" \
|
||||
-d "client_id=paperless-ai-agent" \
|
||||
-d "client_id=396757803559027071" \
|
||||
-d "client_secret=${LLM_AUTH_CLIENT_SECRET}" \
|
||||
-d "scope=openid llm:inference" 2>/dev/null)
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
# macOS VM (Docker-OSX) hosting the BlueBubbles server.
|
||||
#
|
||||
# ── Why a VM and not a container ────────────────────────────────────────────
|
||||
# Containers share the host kernel. macOS binaries are Mach-O and need XNU plus
|
||||
# Cocoa/IOKit, which a Linux kernel cannot provide, so no macOS container exists
|
||||
# or can exist. Docker-OSX is QEMU running a macOS guest, packaged in a
|
||||
# container — a VM in a box, not a macOS container.
|
||||
#
|
||||
# ── Why this works on worker-2 ──────────────────────────────────────────────
|
||||
# Verified on the existing hardware: amd64, `vmx` (Intel VT-x) present, and
|
||||
# /dev/kvm exists on Talos nodes (KVM is compiled into Talos' kernel, not a
|
||||
# module). Bare metal, so no nested virtualisation needed.
|
||||
#
|
||||
# ── Read this before relying on it ──────────────────────────────────────────
|
||||
# 1. Setup is INTERACTIVE. First boot runs the macOS installer: connect over
|
||||
# VNC (:5999), erase the disk in Disk Utility, install, create a user, sign
|
||||
# into iMessage, THEN install BlueBubbles inside the guest. This manifest
|
||||
# only provides the machine; it does not provision macOS.
|
||||
# 2. iMessage activation on non-Apple hardware is a coin flip. BlueBubbles'
|
||||
# own guidance: "test sending an iMessage to yourself. If it does not
|
||||
# succeed, it's likely best to restart from the beginning."
|
||||
# 3. Apple's macOS licence permits virtualisation only on Apple hardware. This
|
||||
# is a Hackintosh. Use a throwaway Apple ID, not a primary one.
|
||||
# 4. BlueBubbles labels this path "not for beginners", "no guarantees or
|
||||
# warranty".
|
||||
#
|
||||
# Private API (reactions, typing indicators, edit/unsend) needs SIP disabled
|
||||
# inside the guest and is NOT required for plain send/receive. Skip it.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: macos-bluebubbles
|
||||
labels:
|
||||
app.kubernetes.io/name: macos-bluebubbles
|
||||
app.kubernetes.io/part-of: sms
|
||||
spec:
|
||||
replicas: 1
|
||||
# Recreate: the qcow2 disk is RWO and a second pod must never attach it
|
||||
# concurrently — two QEMU processes on one image corrupts it.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: macos-bluebubbles
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: macos-bluebubbles
|
||||
app.kubernetes.io/part-of: sms
|
||||
spec:
|
||||
# Dedicated node. The taint keeps everything else off worker-2; this
|
||||
# toleration is what lets the VM on. Both halves are required.
|
||||
nodeSelector:
|
||||
workload: imessage
|
||||
tolerations:
|
||||
- key: workload
|
||||
operator: Equal
|
||||
value: imessage
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: macos
|
||||
image: sickcodes/docker-osx:latest@sha256:3a3c82c79bc4e73531f819ccdfa4053b3084efd7c1f645678dbf8b4b3a24369c
|
||||
# QEMU needs /dev/kvm; Talos enforces `baseline` cluster-wide, so this
|
||||
# only schedules because the sms namespace is labelled privileged.
|
||||
securityContext:
|
||||
privileged: true
|
||||
env:
|
||||
# Generates a unique serial / board-serial / UUID / MAC and persists
|
||||
# them to bootdisk.qcow2. This synthetic identity is what iMessage
|
||||
# activates against — it must stay stable across restarts, which is
|
||||
# why the PVC matters.
|
||||
- name: GENERATE_UNIQUE
|
||||
value: "true"
|
||||
# Identity is only plausible if it matches a real product line.
|
||||
- name: DEVICE_MODEL
|
||||
value: "iMacPro1,1"
|
||||
- name: RAM
|
||||
value: "12"
|
||||
- name: CORES
|
||||
value: "6"
|
||||
- name: EXTRA
|
||||
# Expose the BlueBubbles server port from the guest to the pod.
|
||||
# Guest :1234 (BlueBubbles default) -> pod :1234.
|
||||
value: "-device virtio-net-pci,netdev=net0 -netdev user,id=net0,hostfwd=tcp::1234-:1234"
|
||||
ports:
|
||||
- name: vnc
|
||||
containerPort: 5999
|
||||
protocol: TCP
|
||||
- name: bluebubbles
|
||||
containerPort: 1234
|
||||
protocol: TCP
|
||||
resources:
|
||||
requests:
|
||||
cpu: "6"
|
||||
memory: 14Gi
|
||||
limits:
|
||||
cpu: "12"
|
||||
memory: 20Gi
|
||||
volumeMounts:
|
||||
- name: macos-disk
|
||||
mountPath: /home/arch/OSX-KVM/disk
|
||||
- name: kvm
|
||||
mountPath: /dev/kvm
|
||||
# No readiness probe on purpose. The guest takes many minutes to boot,
|
||||
# and until macOS + BlueBubbles are installed BY HAND there is nothing
|
||||
# listening on 1234. A probe here would crash-loop the pod through the
|
||||
# entire interactive install.
|
||||
volumes:
|
||||
- name: macos-disk
|
||||
persistentVolumeClaim:
|
||||
claimName: macos-disk
|
||||
- name: kvm
|
||||
hostPath:
|
||||
path: /dev/kvm
|
||||
type: CharDevice
|
||||
@@ -1,10 +0,0 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: sms
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- storageclass.yaml
|
||||
- pvc-macos.yaml
|
||||
- deployment-macos.yaml
|
||||
- service.yaml
|
||||
- networkpolicy.yaml
|
||||
@@ -1,19 +0,0 @@
|
||||
# iMessage delivery for the cluster.
|
||||
#
|
||||
# BlueBubbles' server is a macOS Electron app paired with an Objective-C helper
|
||||
# that hooks Messages.app private APIs — it cannot be containerised on Linux,
|
||||
# because containers share the host kernel and macOS needs XNU + Cocoa. The only
|
||||
# way to run it on Talos is a full macOS VM under QEMU/KVM (Docker-OSX), which
|
||||
# needs a privileged pod with /dev/kvm.
|
||||
#
|
||||
# Hence privileged PodSecurity: the cluster default from the Talos controlplane
|
||||
# is `enforce: baseline`, which forbids privileged containers and host devices.
|
||||
# Scope is limited to this namespace.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: sms
|
||||
labels:
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
pod-security.kubernetes.io/audit: privileged
|
||||
pod-security.kubernetes.io/warn: privileged
|
||||
@@ -1,22 +0,0 @@
|
||||
# Default-deny. This namespace runs a privileged QEMU VM signed into an Apple
|
||||
# ID and exposes an unauthenticated VNC console; nothing should reach it except
|
||||
# opted-in clients.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: sms-default-deny
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/part-of: sms
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector: {}
|
||||
podSelector:
|
||||
matchLabels:
|
||||
sms-client: "true"
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 1234
|
||||
@@ -1,25 +0,0 @@
|
||||
# Persistent macOS disk image + generated hardware identity (bootdisk.qcow2).
|
||||
#
|
||||
# This volume is NOT disposable: it holds the VM's serial number, board serial,
|
||||
# UUID and MAC, which together form the identity iMessage was activated against.
|
||||
# Losing it means re-running activation, which is the least reliable step of the
|
||||
# whole setup.
|
||||
#
|
||||
# Docker-OSX documents 128GB minimum for the guest image; 200Gi leaves room for
|
||||
# the installer, the base system, and qcow2 growth.
|
||||
#
|
||||
# ⚠️ Single replica (see storageclass.yaml — capacity and IO both rule out 3).
|
||||
# Losing worker-2's disk therefore means losing the activated identity and
|
||||
# redoing iMessage activation. Once the guest is installed and activated, take
|
||||
# a Longhorn snapshot/backup of this volume; that is the only redundancy here.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: macos-disk
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn-imessage-local
|
||||
resources:
|
||||
requests:
|
||||
storage: 200Gi
|
||||
@@ -1,31 +0,0 @@
|
||||
# VNC is how you drive the interactive macOS install. Deliberately ClusterIP —
|
||||
# it is an unauthenticated console onto a machine holding a live Apple ID
|
||||
# session. Reach it with `kubectl port-forward`, never an Ingress.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: macos-vnc
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: macos-bluebubbles
|
||||
ports:
|
||||
- name: vnc
|
||||
port: 5999
|
||||
targetPort: vnc
|
||||
---
|
||||
# The BlueBubbles REST API, once installed inside the guest. This is the stable
|
||||
# name cluster services use, so callers never depend on the pod IP or on whether
|
||||
# the backend is this VM or a real Mac mini later.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: bluebubbles
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: macos-bluebubbles
|
||||
ports:
|
||||
- name: http
|
||||
port: 1234
|
||||
targetPort: bluebubbles
|
||||
@@ -1,32 +0,0 @@
|
||||
# Dedicated StorageClass for the macOS VM disk.
|
||||
#
|
||||
# The default `longhorn` class does not work here, for two independent reasons:
|
||||
#
|
||||
# 1. Replica count. Default is 3, and Longhorn schedules against
|
||||
# storageMaximum - storageScheduled with over-provisioning at 100%. Free
|
||||
# space is cp-1 146Gi / cp-2 8Gi / cp-3 146Gi / worker-1 292Gi, so a 200Gi
|
||||
# volume has only one node that can hold even a single replica — a 3-replica
|
||||
# volume fails outright with ReplicaSchedulingFailure.
|
||||
# 2. Binding mode. `Immediate` provisions the volume the moment the PVC is
|
||||
# created, before any pod is scheduled. Combined with strict-local that
|
||||
# pins the data to an arbitrary node, not the one the VM runs on.
|
||||
#
|
||||
# So: one replica, kept local to the VM, bound only once the pod has a node.
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: longhorn-imessage-local
|
||||
provisioner: driver.longhorn.io
|
||||
allowVolumeExpansion: true
|
||||
reclaimPolicy: Delete
|
||||
# The pod is pinned to worker-2 by nodeSelector; wait for it to be scheduled so
|
||||
# the replica is placed on that node and not somewhere else.
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
parameters:
|
||||
# A qcow2 backing a live VM is latency-sensitive and rewritten constantly.
|
||||
# Serving it over the network from another node's disk would be the single
|
||||
# worst thing for guest responsiveness, so force it local.
|
||||
numberOfReplicas: "1"
|
||||
dataLocality: "strict-local"
|
||||
staleReplicaTimeout: "30"
|
||||
fsType: "ext4"
|
||||
@@ -110,37 +110,6 @@ spec:
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
---
|
||||
# iMessage/SMS delivery. Raw manifests: a privileged macOS VM (Docker-OSX)
|
||||
# running the BlueBubbles server, plus its dedicated local StorageClass.
|
||||
#
|
||||
# Pinned to worker-2 via nodeSelector `workload: imessage` + a matching
|
||||
# toleration for that node's taint. Until worker-2 is provisioned this app
|
||||
# syncs everything except the pod, which stays Pending — that is expected.
|
||||
#
|
||||
# No CreateNamespace: namespace.yaml carries `pod-security: privileged`, which
|
||||
# the VM needs (/dev/kvm, privileged), and an ArgoCD-created namespace would
|
||||
# not have those labels.
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: sms
|
||||
namespace: argocd
|
||||
annotations:
|
||||
argocd.argoproj.io/sync-wave: "8"
|
||||
spec:
|
||||
project: homelab
|
||||
source:
|
||||
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
|
||||
targetRevision: main
|
||||
path: k8s/apps/sms
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: sms
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
---
|
||||
# Document management. Raw manifests (no Helm): postgres is the dedicated
|
||||
# paperless-db CNPG cluster in k8s/infra/databases (wave 2), redis is
|
||||
# in-cluster only (no PVC), media lives on the cp-3 USB HDD (see
|
||||
@@ -202,40 +171,6 @@ spec:
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
---
|
||||
# Consolidated: homarr + homarr-patches → homarr
|
||||
# Helm chart + values + PostSync hook patch (fix-probes-job.yaml)
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: homarr
|
||||
namespace: argocd
|
||||
annotations:
|
||||
argocd.argoproj.io/sync-wave: "8"
|
||||
spec:
|
||||
project: homelab
|
||||
sources:
|
||||
- repoURL: https://homarr-labs.github.io/charts
|
||||
chart: homarr
|
||||
targetRevision: "*"
|
||||
helm:
|
||||
valueFiles:
|
||||
- $values/k8s/apps/homarr/homarr-values.yaml
|
||||
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
|
||||
targetRevision: main
|
||||
ref: values
|
||||
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
|
||||
targetRevision: main
|
||||
path: k8s/apps/homarr # PostSync hook: fix-probes-job.yaml
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: dashboard
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
---
|
||||
# Portfolio site at riotpiao.com - static Next.js site from rock/riotpiao.com repo.
|
||||
# Points directly to infra/portfolio/base (bypassing repo's own argocd-apps.yaml
|
||||
# which has wrong URLs). Image built by Forgejo Actions on rock/portfolio repo.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: talos-repair
|
||||
namespace: argocd
|
||||
annotations:
|
||||
argocd.argoproj.io/sync-wave: "6"
|
||||
spec:
|
||||
project: homelab
|
||||
source:
|
||||
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
|
||||
targetRevision: main
|
||||
path: k8s/infra/talos-repair
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: cicd
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
@@ -35,9 +35,9 @@ spec:
|
||||
- https://grafana.github.io/helm-charts
|
||||
- https://helm.releases.hashicorp.com
|
||||
- https://charts.goauthentik.io
|
||||
- https://charts.zitadel.com
|
||||
- https://strimzi.io/charts/
|
||||
- https://charts.bitnami.com/bitnami
|
||||
- https://homarr-labs.github.io/charts
|
||||
- https://go.temporal.io/helm-charts
|
||||
- https://portainer.github.io/k8s/
|
||||
# Substrate charts (cert-manager, ingress-nginx, reloader) — app-of-apps owned
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: cloudflared-credentials
|
||||
namespace: cloudflared
|
||||
type: Opaque
|
||||
stringData:
|
||||
credentials.json: ENC[AES256_GCM,data:LnJVGuxJAOwDYbLgX00OHICDDldZgwgvGafOIwKoJKnjg3FjFMQYThNCOL/H+6tctuKixJNcAckjg70tmCKWxNq9I35XX/YSqob6Qv4fywv32hsNIAe44BU035MGZ5zmc6bZZ8wAgTJZw6Th4TzY7taFBCRdj2fW4T+qVfq7strn68G4ufVMK6UXlOkEDLjR2Vxj32lqSLdbdMma96XB+265hwL6IVfaugG662mcGhPwgHk=,iv:NFmyHN5m2mJUBgn1qgmJZ2YgkFxBZyp7+gU1Ng6B/7M=,tag:/66JtHK5Yu7pRxB0b0eifQ==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsaDVycUE4RFpoRjdpTlpX
|
||||
dGpzN3hwRFQ2cmtXVXVzWnZJOXJEZXZRWEhNCkJwbm9QV0lGcGtNRTJqbStrc3l1
|
||||
Q2M4bE1WbzJpTXZQWjJyTGpFS0JYcVUKLS0tIDBvU2U5aEtVTTZUaE85eEkyYitG
|
||||
bGtSVkhKNnRuNlZWMWh6Z1M4VXM1WU0KdusvtM85V0xrO+uJFUY+UH0tuwdF0GiW
|
||||
79J4Rlxk8ne25psKsvwjLYLxZ53xjFHCC4FCHTNkYwTKCDEKjw46Lg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
lastmodified: "2026-10-26T02:45:04Z"
|
||||
mac: ENC[AES256_GCM,data:KF7KwXCnNLAg1YI3u0lLI/ho4sITHAq09ac2WO4TK3TCthkuivb89YjhuuJKs0NFhjn6aRLqmZavUqcXH2HujiryqbLNVexOShQM0wSzgCzmWdbi0UzpgJ4KcQA3PufDtsQz4HHLXx396EbZrM3Ie3WSu/PvRm5iWOxzTGCGc9Q=,iv:Eo920oXXD1FLZVJXywZ6C+LRL+qZjmnZXv0+Oa/GMb8=,tag:6LuPVSet4WjvrvGUAxaJlw==,type:str]
|
||||
version: 3.13.2
|
||||
@@ -5,7 +5,7 @@ metadata:
|
||||
namespace: ENC[AES256_GCM,data:ZHnVZyd3wA==,iv:3evUnFTQfRz/ZilUQIl/9dsCba3nsX6o/iQsGXMBfgM=,tag:+9kp+VRu1ksroDTbTGmGPA==,type:str]
|
||||
type: ENC[AES256_GCM,data:bHX+Bu9o,iv:8qqNddsuw4YoHAuJfUcPIqjcqNySIh5Ln8OhsfZxuk8=,tag:P7QGUXo9Io2Y19YqlWv5sQ==,type:str]
|
||||
stringData:
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ENC[AES256_GCM,data:qW2SQSsGD0YupU2SMzRaYwkLRjXAwV1OJBnhATpCJrOHWbEytw6IphOmPPd3VZKfHncekA4agXi4K3rUPHIx0Q==,iv:NCeW/UfIl0RvHV39ubN2mDfFp7GTQNuFv8KiEF+7KOE=,tag:lisx4h4PqUfLwBX8ca9Siw==,type:str]
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ENC[AES256_GCM,data:nJz9pqa6w9ZGOjanZ32D38Bolg2YrdHs1crhb2uZkKyaaKDpklVb4FOKZ46K+NS1NXjoS6XDUAlV69CSvm1PeA==,iv:g9F/iLuzNC0WogtDJ/TAb3+97N6iGkbOTlb4SzYzwx8=,tag:4F3kUheGbtW2U6jgOvRO1A==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
@@ -17,7 +17,7 @@ sops:
|
||||
LS0cIDUhjPFbeu7qUATOrat6vMUi0UREbXSFZ1KyAjNAwJZaUusPMA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
lastmodified: "2026-08-12T18:02:01Z"
|
||||
mac: ENC[AES256_GCM,data:yUBBV7dLEPld9yJacn+YaOT8M1NJ2ploGIxV66aVOH5x4tDAsskcUQJlzApWCQmORuCuMKg6qfpGgLTojfD5msha+ufVVJXHl7ua+STar1/FPJXaHm+z7YyZdsD5j6f3AH8q18DuLy2DYLjXELmPBsp50g7MDJEm3xLhCUXygjQ=,iv:xl0OtA/2umz0YH5b+6QjlAByZWbvfGOmHqomziNjlkw=,tag:xwkQDZx19Lf6QSwU2zPYTQ==,type:str]
|
||||
lastmodified: "2026-10-26T04:03:16Z"
|
||||
mac: ENC[AES256_GCM,data:0cgVNtFMwhbEHBxu7T1XfstcQlgUTxijq9Mz36zC60E7kYXRNwGt2/0KoCaqaE0leypwAdrPn2OjeoDPeCN6x+n0vLRVX2tnli6HazBLbRsPHljDAV0ASF07iNhb0PvgtWay432Mm+sGATkel3ITdAaP0lISNLUYfEe1/TbYHkk=,iv:FZ9Bgtk7LOC3aOSOo1+50zwLGhlcUZYJaUQ9r05cAn0=,tag:tsLQ1frZ7li5l717CCtgoQ==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
apiVersion: ENC[AES256_GCM,data:uiQ=,iv:vknDc16Zrz/lxS1WG3ksG4SHjhJSTDdklVxxwL8eylA=,tag:1srpVd6QTYQGGGYGXT8Cbw==,type:str]
|
||||
kind: ENC[AES256_GCM,data:K6EPYaNE,iv:VWzawfTDNlag+shjBqwSzZGcQQq/TGxeHDqd4fAh5Vc=,tag:ncPqMYjq/73oMzphWhuuFA==,type:str]
|
||||
metadata:
|
||||
name: ENC[AES256_GCM,data:vaRNQHFYfdUz1hzuYO0JxQ==,iv:W7aUJID5mxoO1NQAcyPDYV1FGtBT8myKVMA3s2WgrPk=,tag:4Ef4GBtTR3dVanscvigRmw==,type:str]
|
||||
namespace: ENC[AES256_GCM,data:HDlmfGwad37/,iv:JFPXxz9xp5vDJRcWy73SYGWDMyHKWQq9Bvy/NJTwTnU=,tag:2ldVXgzKn4xp1rZI/VYgUw==,type:str]
|
||||
type: ENC[AES256_GCM,data:UgolDLIV,iv:O4himI90UVwx+OjKJXobu10Zg3a5IoQcjAORsdhaWpg=,tag:xltntRsamhQkPEoBNtMytw==,type:str]
|
||||
stringData:
|
||||
oidc-client-id: ENC[AES256_GCM,data:RLdxXY/a,iv:US/fBReK7u8WA4IS6TSzYiHQkte2CuVgugIMpA/pIlA=,tag:QKrHGQrzI9YpfNQD+ts68A==,type:str]
|
||||
oidc-client-secret: ENC[AES256_GCM,data:SS+I92xEkOcJHTfWvZ1AA8hh1bFAtND5adZ6Uh73nIPiqAo958GSag==,iv:9HcmXWrGtflc6WWQe0n/pgB1Xveakofc3hvz5vuE29A=,tag:0emgeyM4GBftVKn/fx7XPg==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCWkR5V0ZYSTBSSU1OTWF1
|
||||
TzFxdnZkUFFIcGJWZ1VUU21ZUnU2cUdob1drCkhVTm02dkZPZ2lNWktOcWZ2c0Vi
|
||||
dG1PSmZBR2M2anA2Z09iYUU0SjVzTlEKLS0tIGF1eVBBUHdhVk9YSzJlQXVwd3RE
|
||||
bUd3UTlwR1NiYjNlcGpweTBhdmk5b0UK8pwUoVCr1uBX9dQ7EGRgFMAPk/z8N9Fg
|
||||
6GRUORFug5h7x5kjXK9sU0yTHjFSekwBi75GLng0IrLEGJxHVM5wVQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
lastmodified: "2026-08-12T21:07:30Z"
|
||||
mac: ENC[AES256_GCM,data:NupjQWBJHMOo3/VX5n5nRx+RFT14N71XkrjXRC/VCP7i7H8yn3tcRdH9g14usgWunmqg2KKpGMUIZ9bsQ9Vds9sBvUKvLyRkfQnPwPh9+PFCF3ZZLPgzqcT19kg4I3QabmO+LPzQXsZWLKIqgF9/B60EVIgYyKhZmP7XblY5AVo=,iv:r0deq8+txE8TwzdtxOMaIAZbZc2fu+Tp4Jd2HZGbkhM=,tag:qMpZp6LcWjJHYMDp9ecBkA==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
@@ -1,23 +0,0 @@
|
||||
apiVersion: ENC[AES256_GCM,data:FO4=,iv:kKIuJp4uGeX6vECsXk7w43F9S281jObQchmK8r1+kmA=,tag:x5LYd34dzNDLCma/78M4+w==,type:str]
|
||||
kind: ENC[AES256_GCM,data:o4RTN8SK,iv:uyOJWjDmHIPuBl9Wc1S/HxUabV2yytWr1bntHW68sTI=,tag:3On0SnQVJ/ICMMryQe+8JQ==,type:str]
|
||||
metadata:
|
||||
name: ENC[AES256_GCM,data:a89dHmLdSeMHE7ZP0Q==,iv:OWJvJhFqJ7A0UGVJnZILzCJ/4VuVFib9sUDjXuwyqJQ=,tag:1XrIQn1GDh8RQ4vmQAu7+g==,type:str]
|
||||
namespace: ENC[AES256_GCM,data:4wBVpZJgQGM9,iv:3Tn1u/2TP/+SnELu34PDf4Jauf2L4f8+MtgrPIlyKzk=,tag:L3qbKFBjm16g8KX5vUsK6A==,type:str]
|
||||
type: ENC[AES256_GCM,data:mdrF9vUb,iv:7ukOgwXlnRf5mpJ/+6YXtyGRonnqehEgT4pJJIafw7c=,tag:90mt7jpNb3N80kCEi+R/rA==,type:str]
|
||||
stringData:
|
||||
db-encryption-key: ENC[AES256_GCM,data:NWJryAEd/eUJxbNiu05RzLF/l7GDxIQM7RQe3xKBz6XklgRAFwQxxjakcVd85pePUBaQYz3zttFglWhfJlWhkA==,iv:eUWbcs0B/mIheyAQ2+DyKgcA98CRKQGAVC3O0WIkfU8=,tag:be06X6AQqlohYlxuYoZiTg==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1Ym5yQ1pmWHZjVnYxbUVO
|
||||
SkZ5dnI2OXR3cmZjZkM0K1ErMElLNktlK1JFCmpleFBrcXkwcDA4cGFuWWN6VDQv
|
||||
dVV4NjB4MjEreENVQnZ2ejVLblRYOVUKLS0tIG9VbXJvUmQ3SzMzN3IrRDlac2sr
|
||||
eGZNYXBoZFZDeisrVCtUMXVPaEpoNFkKjwhUU+Zsqv4Be+m558fET5UhF/2rO73J
|
||||
AhGbJVaA7PaOCswPWiOLso26zD+MBB3VKHNdUFnenpoM16Nu4pOf5w==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
lastmodified: "2026-08-12T21:07:30Z"
|
||||
mac: ENC[AES256_GCM,data:KrGQY/eOmihYCvQCbfcM6bZ6rfx4zKh62M8NM0VQr1//1dSHpOOh25dYGLuQeECnqLxSpGQNWzr2xjUYWs4NOdSEFGq6FL2iHE3LnqoBZspp/JFJYP6+z/8jSUojwKEW7OuZZdPN6gF2u+IPOeWbGftnjS7X+atoSkYag2ty2vk=,iv:IOPEgBizsxrVoK5oSdWmi0uCBUnReOEnMYXuwSBKTzA=,tag:9k/XQ/AE7wSFq5XMbY0FTA==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
@@ -1,23 +0,0 @@
|
||||
apiVersion: ENC[AES256_GCM,data:4OY=,iv:0Lgui0+X2oTlgCX5HEIIihEsGJD0C2+pDIOTCNi4r+g=,tag:uL7vlreaHjcLJjV7e5I7PQ==,type:str]
|
||||
kind: ENC[AES256_GCM,data:x/ltkind,iv:KRBfS7COsTs/l3Fhyk5FvH7IVc+r/6M6FVOo+kPDe1Q=,tag:UuTbe213hDfK/WUtKS/doQ==,type:str]
|
||||
metadata:
|
||||
name: ENC[AES256_GCM,data:LpOEecTJBuGy5PqNSFM=,iv:Q+DCLGtw3wihhdAgEbzYHyQhFTPWFyE2iJKl9mYaA2E=,tag:E2wrig4lRXGDvSCHec5clQ==,type:str]
|
||||
namespace: ENC[AES256_GCM,data:6kMVCXaF68NO,iv:gnCCkV7GKXA2HWOOeNUGnpAycf8U7pl6F8Vfo6DhLR4=,tag:0VdeySsuhUr2heXyGXZwBg==,type:str]
|
||||
type: ENC[AES256_GCM,data:6muC3Zqh,iv:gBaC+6MXvZY/bIpy/cEG/zWwLBaUFMVfxAHa1OnwfnQ=,tag:v6NYyhAKAwovluHAXNaOVA==,type:str]
|
||||
stringData:
|
||||
SECRET_ENCRYPTION_KEY: ENC[AES256_GCM,data:jX9ULlaBYTSTFGRMplSder5rMrsz7THU3kgJSGef0r5rZvtoaAG9NVYU2QUEuD0sZgfqRooeMOkDCAqFc8qsuw==,iv:uOmx8w3XPKnoxfasf7gqi/bljxigpLwaAC30c2TroKg=,tag:Dmbu6m3+UZa1w0UR0LRh9g==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6TDVyNGRtYWgzMDYwdjdr
|
||||
NXJWcGJZNUx6YzBGWm1nWlp0SlN3WXhaR0VBCjZ3bDBmUTFCUjg3U21wak90eHZK
|
||||
NlRCTUZQSHhoS0ZSUGZ4UHhST01KNmMKLS0tIGUxcGo0NmJ2bmJ6eGJ3QWxzYVM5
|
||||
c2Z6UkFjQmZ4ejIvK1JQQkZ0VFFIZ3cKA8UzmGsX+s3yOermOHqbnnEtekHCTqmC
|
||||
R3Jyzf6OolZMJpNwbetcsOfgO61aM7LHl/Xb/hFLbLjZ+eECrt8VpA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
lastmodified: "2026-08-12T18:02:01Z"
|
||||
mac: ENC[AES256_GCM,data:HTrEEVDm8JqnVBFaY/FJknxma0nI95tRiwVc0K76k+2R4jgKCBA3EUAzPVS+gA4rTAeKHPSHrT2eidlkseT2WQUjqMRrpHcUsbSjNfpTEY2g/U0PIiOXZMRGfwmsQVgmu2fqeMOyzDSyya0scdWJXPraRPNZ+OpEkNVlm4rWcgs=,iv:KY3dnnmooJgsu66wc7gbzeIGUXZNgVrxAhpAwxoG+G8=,tag:7r6d41oTD136N57rtY7auA==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
@@ -1,72 +1,72 @@
|
||||
apiVersion: ENC[AES256_GCM,data:Mdg=,iv:u/JjNJfdyz7ehk8lHZRh1u9zkNuufHosyXv10Gm+T6E=,tag:pkgZVJid6kpGuy61+oEMlQ==,type:str]
|
||||
kind: ENC[AES256_GCM,data:/kWhhqyz,iv:DymezGpBHmKT3BM/CcUKEGIWZw/JXNutReSKfhfmoD4=,tag:DnA+316Z+0eqBWN8YLyv1A==,type:str]
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ENC[AES256_GCM,data:xs1k//nWy1iJ52Q=,iv:NDGvM319FCypxFLWwONcF12osaU/EL0IwD0c2xtqYnU=,tag:6IhfISF08WQbxrUmT49d7g==,type:str]
|
||||
namespace: ENC[AES256_GCM,data:jpUzI7BmZA==,iv:PQrfm67la6RJCRq0H7qpiY8n2hPUjcaK6gmJ/zPQW30=,tag:bp/3GYbRpYOIub5nT0zz/A==,type:str]
|
||||
type: ENC[AES256_GCM,data:fndXB4Y1,iv:EXOe0day1ScxvZ7ozn1QyHgaZgnvlPNmvSQG74aw7lM=,tag:quPmKvFTjRqtZoVUEoQAHQ==,type:str]
|
||||
name: minio-creds
|
||||
namespace: storage
|
||||
type: Opaque
|
||||
stringData:
|
||||
config.env: ENC[AES256_GCM,data:3RIQ1i6NtY1Tv2eJY8JXSgfBEqi9px/9TJVNrPf9+brtGy9Mx3SwjKVnjGXUcA2cYcNZb1JR7temXmznXQdsRfZ9yohe4oc1vL3yPGPY9YfP7AHGYj9E7tghu/rgkHOf93RmfdKv0Rrq6miBcA==,iv:0RQVMi2mMHBRTJ1OX6IDFvq9Qr7zpqgQzt+MPPgBIHA=,tag:HNXLKf5X4P3E+hc7YbDtNQ==,type:str]
|
||||
config.env: ENC[AES256_GCM,data:1yogGyBdwrlg27H8R5Kr9VcPu+xwS5/nPhi7eLcmVYtXmPlNokCsWHA70qnn/+I6i4ODys8GML++VeZ/lmcHiP7RIbY+G6xmw6nfBCXitgE8kQwTPsUUzn9bobOn4Sr1YMBf7FQ/Q0N8aupGJA==,iv:EfCdim4TLkP/FP1u1yoKZCq8YGFzVh7ca4yu1+WzXwE=,tag:rTYe0SIAEsUhh0KHQIEKNw==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtUVJFQ1ErNmkzd1N6b29t
|
||||
L0VWR2tlcEIxeG1CR1hXRkw2cEt0Q2crMWlVClg2UlNhUzdJZGpLZjJtYlAvbk16
|
||||
eHk0ZHZiNzVRTWhyQzlieTU5Y3QraGsKLS0tIHA5UVhvUXhNSDk0QlV0R2RzR1dP
|
||||
azVEQzgvSFNwak9TcW1USFNOb3RvSkUKNLuW3h3cHDSSCZGe9Vyiv5m+wXihtCYX
|
||||
oJtbRK+kJF8oOUo4emI+SLGrCsWq5cG2wN/wgP+ChR1zUgRgfq5oOA==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2SStVR0lpOWs0VnN6dkdp
|
||||
RktMUmNRR2diTUtiVk9tRk1wSjVTMnRtVlJBCkk1S0dwNUNHb25RQlZ4ZFRocisv
|
||||
aS9kbmQydWVmN1AxU3VvbFZsS2tmTHcKLS0tICtRY0ZKeFhCTUdaNW5PSFRNZ2dG
|
||||
OERIZ0xMbHBvSVNyaGxCVFpqRzlxa1EKbrjfk/CFlTSyJYgTzQ67W3JyR1fwv8VF
|
||||
dxGeAgLY4EQrf+2PbODtazRFreS+sl7dxq+rKMOVZTsjGHakkqHy9A==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
lastmodified: "2026-08-12T18:02:01Z"
|
||||
mac: ENC[AES256_GCM,data:hQfimKuhfiIiZQAyNDZQR25HZ5biU+kaWig3ElVPiqMY3B0LrmKyOEs7ittd3j3meUkPEFSmm1yYSXcdCuf6w7cwSbEiL5DG8t7QKPbnx/aRdyHAJW+1VC0e3Lye43gaHya0iUaWS/YFQcrT01oEbckLIDWPKfvA6pg2bZ4kMZ4=,iv:AydyWMVcjTQu+7Fzw5vtqkvTTeQEMt8y6snESoLUDE8=,tag:SoKklNqa9nH9G6d9+PnolQ==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
lastmodified: "2026-10-26T04:03:16Z"
|
||||
mac: ENC[AES256_GCM,data:gxu4WcJEuFgjtWCfAjose8R8a566/OLrXoiJmKfcvVfTFDTdGM7I4gzQVvpjEfra5Hjyt7kvLlaV2K5bKJ0k6NXbbmgrqAqEOEcPg+cnPy1FtQGAYOo3VzgKqrpLOCs/7wh4YR23XHHsjQlHAcP8BmhrqnpHXR9FMZObrKjnQj8=,iv:KJZfw0n2DOtdZHeVDk6TrpO8q57yD+xdEVt9+hM0D4Q=,tag:rHQaMjvQwaB8Am/AlTKq1g==,type:str]
|
||||
version: 3.13.2
|
||||
---
|
||||
apiVersion: ENC[AES256_GCM,data:Mdg=,iv:u/JjNJfdyz7ehk8lHZRh1u9zkNuufHosyXv10Gm+T6E=,tag:pkgZVJid6kpGuy61+oEMlQ==,type:str]
|
||||
kind: ENC[AES256_GCM,data:/kWhhqyz,iv:DymezGpBHmKT3BM/CcUKEGIWZw/JXNutReSKfhfmoD4=,tag:DnA+316Z+0eqBWN8YLyv1A==,type:str]
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ENC[AES256_GCM,data:XEQHsQqTgwukYw==,iv:wTw2eoooDA9EfB2df3jzb0IWUdtLSwcuKzx8yDBaihQ=,tag:oncXt2gM1k6Qppafpu/D1w==,type:str]
|
||||
namespace: ENC[AES256_GCM,data:jpUzI7BmZA==,iv:PQrfm67la6RJCRq0H7qpiY8n2hPUjcaK6gmJ/zPQW30=,tag:bp/3GYbRpYOIub5nT0zz/A==,type:str]
|
||||
type: ENC[AES256_GCM,data:fndXB4Y1,iv:EXOe0day1ScxvZ7ozn1QyHgaZgnvlPNmvSQG74aw7lM=,tag:quPmKvFTjRqtZoVUEoQAHQ==,type:str]
|
||||
name: minio-oidc
|
||||
namespace: storage
|
||||
type: Opaque
|
||||
stringData:
|
||||
MINIO_IDENTITY_OPENID_CLIENT_SECRET: ENC[AES256_GCM,data:IYBfNW19cTo8WkCGT4zuCljzi2bkbYWJ8DY/yedR2MJNqEO+bMVvjx0OqSqbPp6rnwGyMQOhBN0gXPbEVRDBGg==,iv:I1aGIikbHTKvh//4Inhp7UoNfhjlIXSpcyP4RqljvqM=,tag:Zh3Vh5DQYAsTtiW59D0/QA==,type:str]
|
||||
MINIO_IDENTITY_OPENID_CLIENT_SECRET: ENC[AES256_GCM,data:eFfwW2ZnV+o3/6XHjUvyFsDbaIAdosY2PXOnRdN4Ie49YDIjfJPe+Mg1FsbgoEHEjuB33hfxesb4TWOJQfW8Og==,iv:+kRr9A3auts1XHgZWa+t5b2m4lF0x9dVhiRpB17fzv4=,tag:tH/jszFzLQdqORGr9vfTGw==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtUVJFQ1ErNmkzd1N6b29t
|
||||
L0VWR2tlcEIxeG1CR1hXRkw2cEt0Q2crMWlVClg2UlNhUzdJZGpLZjJtYlAvbk16
|
||||
eHk0ZHZiNzVRTWhyQzlieTU5Y3QraGsKLS0tIHA5UVhvUXhNSDk0QlV0R2RzR1dP
|
||||
azVEQzgvSFNwak9TcW1USFNOb3RvSkUKNLuW3h3cHDSSCZGe9Vyiv5m+wXihtCYX
|
||||
oJtbRK+kJF8oOUo4emI+SLGrCsWq5cG2wN/wgP+ChR1zUgRgfq5oOA==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2SStVR0lpOWs0VnN6dkdp
|
||||
RktMUmNRR2diTUtiVk9tRk1wSjVTMnRtVlJBCkk1S0dwNUNHb25RQlZ4ZFRocisv
|
||||
aS9kbmQydWVmN1AxU3VvbFZsS2tmTHcKLS0tICtRY0ZKeFhCTUdaNW5PSFRNZ2dG
|
||||
OERIZ0xMbHBvSVNyaGxCVFpqRzlxa1EKbrjfk/CFlTSyJYgTzQ67W3JyR1fwv8VF
|
||||
dxGeAgLY4EQrf+2PbODtazRFreS+sl7dxq+rKMOVZTsjGHakkqHy9A==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
lastmodified: "2026-08-12T18:02:01Z"
|
||||
mac: ENC[AES256_GCM,data:hQfimKuhfiIiZQAyNDZQR25HZ5biU+kaWig3ElVPiqMY3B0LrmKyOEs7ittd3j3meUkPEFSmm1yYSXcdCuf6w7cwSbEiL5DG8t7QKPbnx/aRdyHAJW+1VC0e3Lye43gaHya0iUaWS/YFQcrT01oEbckLIDWPKfvA6pg2bZ4kMZ4=,iv:AydyWMVcjTQu+7Fzw5vtqkvTTeQEMt8y6snESoLUDE8=,tag:SoKklNqa9nH9G6d9+PnolQ==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
lastmodified: "2026-10-26T04:03:16Z"
|
||||
mac: ENC[AES256_GCM,data:gxu4WcJEuFgjtWCfAjose8R8a566/OLrXoiJmKfcvVfTFDTdGM7I4gzQVvpjEfra5Hjyt7kvLlaV2K5bKJ0k6NXbbmgrqAqEOEcPg+cnPy1FtQGAYOo3VzgKqrpLOCs/7wh4YR23XHHsjQlHAcP8BmhrqnpHXR9FMZObrKjnQj8=,iv:KJZfw0n2DOtdZHeVDk6TrpO8q57yD+xdEVt9+hM0D4Q=,tag:rHQaMjvQwaB8Am/AlTKq1g==,type:str]
|
||||
version: 3.13.2
|
||||
---
|
||||
apiVersion: ENC[AES256_GCM,data:Mdg=,iv:u/JjNJfdyz7ehk8lHZRh1u9zkNuufHosyXv10Gm+T6E=,tag:pkgZVJid6kpGuy61+oEMlQ==,type:str]
|
||||
kind: ENC[AES256_GCM,data:/kWhhqyz,iv:DymezGpBHmKT3BM/CcUKEGIWZw/JXNutReSKfhfmoD4=,tag:DnA+316Z+0eqBWN8YLyv1A==,type:str]
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ENC[AES256_GCM,data:rYX7nN6SYoG5HycaNNrprDM=,iv:4uX7+Sl1UzgsImBH+qm9p8DlOfJu0i0W3bpoE7cFxXg=,tag:RROCnYBVKVwPBNSGgV30FQ==,type:str]
|
||||
namespace: ENC[AES256_GCM,data:jpUzI7BmZA==,iv:PQrfm67la6RJCRq0H7qpiY8n2hPUjcaK6gmJ/zPQW30=,tag:bp/3GYbRpYOIub5nT0zz/A==,type:str]
|
||||
type: ENC[AES256_GCM,data:fndXB4Y1,iv:EXOe0day1ScxvZ7ozn1QyHgaZgnvlPNmvSQG74aw7lM=,tag:quPmKvFTjRqtZoVUEoQAHQ==,type:str]
|
||||
name: minio-user-ollama
|
||||
namespace: storage
|
||||
type: Opaque
|
||||
stringData:
|
||||
CONSOLE_ACCESS_KEY: ENC[AES256_GCM,data:FhXl6yqTIzHsbJO6P96KQrA=,iv:gGTaSKxk721GwMQWWyymqUPZlGLidrBzWCWdRtnlOr0=,tag:IaVl8GQqPstBuUjD4N6JTQ==,type:str]
|
||||
CONSOLE_SECRET_KEY: ENC[AES256_GCM,data:s22GOXY4B/m+S7tgDw8P2gU2F15vdn1tqAp53FWzFTI=,iv:GXBhHQ8YiaVjRRMw2yG4FxdulVOuOurkLXXC0u/SFRA=,tag:pvHcD9+4qaxnvhOSkNxoHA==,type:str]
|
||||
CONSOLE_ACCESS_KEY: ENC[AES256_GCM,data:Zxr/Nj0SUKSJbBD1cFjt3GE=,iv:r/B9ULn68hsXdH8uRQkKAoQTJIJIYKMoJuWOZfxS2I8=,tag:Xo4i+IVxuqrp+qDpYQysPw==,type:str]
|
||||
CONSOLE_SECRET_KEY: ENC[AES256_GCM,data:PoIDNZ+Xs6BdhGm10y1HdL5jy47EVHfHHIJhGy/KX6Q=,iv:voAv32GoTKi9s9/4MQy2yT6i9VY4/igA5XXzehwUqRQ=,tag:DmPyk5biVvlxHjeyDyrLbQ==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtUVJFQ1ErNmkzd1N6b29t
|
||||
L0VWR2tlcEIxeG1CR1hXRkw2cEt0Q2crMWlVClg2UlNhUzdJZGpLZjJtYlAvbk16
|
||||
eHk0ZHZiNzVRTWhyQzlieTU5Y3QraGsKLS0tIHA5UVhvUXhNSDk0QlV0R2RzR1dP
|
||||
azVEQzgvSFNwak9TcW1USFNOb3RvSkUKNLuW3h3cHDSSCZGe9Vyiv5m+wXihtCYX
|
||||
oJtbRK+kJF8oOUo4emI+SLGrCsWq5cG2wN/wgP+ChR1zUgRgfq5oOA==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2SStVR0lpOWs0VnN6dkdp
|
||||
RktMUmNRR2diTUtiVk9tRk1wSjVTMnRtVlJBCkk1S0dwNUNHb25RQlZ4ZFRocisv
|
||||
aS9kbmQydWVmN1AxU3VvbFZsS2tmTHcKLS0tICtRY0ZKeFhCTUdaNW5PSFRNZ2dG
|
||||
OERIZ0xMbHBvSVNyaGxCVFpqRzlxa1EKbrjfk/CFlTSyJYgTzQ67W3JyR1fwv8VF
|
||||
dxGeAgLY4EQrf+2PbODtazRFreS+sl7dxq+rKMOVZTsjGHakkqHy9A==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
lastmodified: "2026-08-12T18:02:01Z"
|
||||
mac: ENC[AES256_GCM,data:hQfimKuhfiIiZQAyNDZQR25HZ5biU+kaWig3ElVPiqMY3B0LrmKyOEs7ittd3j3meUkPEFSmm1yYSXcdCuf6w7cwSbEiL5DG8t7QKPbnx/aRdyHAJW+1VC0e3Lye43gaHya0iUaWS/YFQcrT01oEbckLIDWPKfvA6pg2bZ4kMZ4=,iv:AydyWMVcjTQu+7Fzw5vtqkvTTeQEMt8y6snESoLUDE8=,tag:SoKklNqa9nH9G6d9+PnolQ==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
lastmodified: "2026-10-26T04:03:16Z"
|
||||
mac: ENC[AES256_GCM,data:gxu4WcJEuFgjtWCfAjose8R8a566/OLrXoiJmKfcvVfTFDTdGM7I4gzQVvpjEfra5Hjyt7kvLlaV2K5bKJ0k6NXbbmgrqAqEOEcPg+cnPy1FtQGAYOo3VzgKqrpLOCs/7wh4YR23XHHsjQlHAcP8BmhrqnpHXR9FMZObrKjnQj8=,iv:KJZfw0n2DOtdZHeVDk6TrpO8q57yD+xdEVt9+hM0D4Q=,tag:rHQaMjvQwaB8Am/AlTKq1g==,type:str]
|
||||
version: 3.13.2
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
type: Opaque
|
||||
stringData:
|
||||
PAPERLESS_API_TOKEN: ENC[AES256_GCM,data:wlBC85ep6zioWLhfhNczkKfTMR8tmciN+4r3of8GpQoJmKyE2r7pOg==,iv:Rem/UJl5wUNi8G+uEV7f2eCLabaY26sZ7+AsKYiOZ4I=,tag:bGS1fu13Mwsrr+5NUEzzLw==,type:str]
|
||||
LLM_AUTH_CLIENT_SECRET: ENC[AES256_GCM,data:co7FfmRXKgrz9fNgR10KtlHL/iG4F5WKJgjcKem1H5hF5FviVi4+CQNekA==,iv:ADtBHiI6BtrkuH1iIfKV3I5odNLMQe5y/9GGTJrPNN0=,tag:j6wX6zO5VhKoLzdr7BQZCw==,type:str]
|
||||
LLM_AUTH_CLIENT_SECRET: ENC[AES256_GCM,data:OEZqGgJ4cpMsWr/PJH50lsQKvk6sjVNPCFcZP3yQ/7nvlKP3giPlLmW9XgWr1ELq2hwWDs61+f3fAyENt4ln+Q==,iv:W0Tii/nvgm9ZMIEtfzgO5pj4Li+6fpxfd3zFtQhj874=,tag:tsM/fGeduMOEfMZsDZ6lrQ==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
@@ -19,6 +19,6 @@ sops:
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
lastmodified: "2026-09-14T14:19:39Z"
|
||||
mac: ENC[AES256_GCM,data:jDdmiFg6uB1za7sAjO1CnGHdRPyUnmWCMJxqHrE3BO82pNbtm8yLOf+W6CfFKAjfZYqEE2mdKhABm7zeJgJlub3eXJbAirAFrp5jrbfHw0qsFo85Gj/BsrSDB401Aky6PxvRomz66FyX5zDDXIGAMEMlyNEadMplTfrZQQ9hQWM=,iv:UTSF72ofUyWs1LXGKQwBP60hK64UxhqTFM8QTVozGoo=,tag:OKivMDAqwWgrX+kNjvQrPg==,type:str]
|
||||
lastmodified: "2026-10-26T04:03:16Z"
|
||||
mac: ENC[AES256_GCM,data:gOrqy2fPX+77303L+E1EypaTU8mdUXYjbRZnTIJtoRpZhEmHsVvS/Ho8bVcl5KiTPN7UoFNmGyDz8fpyQ/rLTbeCiUYmyxvH7ym9DT4oWvRxITpOqBxz83pWv1BB++HJvqNQ5FllqpnAz60/bAVyxOdOXk4J3lDnuJibNOpxhW8=,iv:suV68u1Uiejl0NNJ3WlAHshgdrSL+aTwUxUBeMXlfWs=,tag:5wug4NeDZ+qclvFGPiEviQ==,type:str]
|
||||
version: 3.13.2
|
||||
|
||||
@@ -11,15 +11,13 @@ files:
|
||||
- agent-pod-ssh-key.enc.yaml
|
||||
- authentik-secrets.enc.yaml
|
||||
- cloudflare-secrets.enc.yaml
|
||||
- cloudflared-credentials.enc.yaml
|
||||
- forgejo-registry-pat-api.enc.yaml
|
||||
- forgejo-registry-pull.enc.yaml
|
||||
- forgejo-runner-token.enc.yaml
|
||||
- forgejo-secrets.enc.yaml
|
||||
- grafana-oidc-secrets.enc.yaml
|
||||
- grafana-secrets.enc.yaml
|
||||
- homarr-auth-oidc.enc.yaml
|
||||
- homarr-db-encryption.enc.yaml
|
||||
- homarr-secrets.enc.yaml
|
||||
- homelab-ca-secrets.enc.yaml
|
||||
- loki-secrets.enc.yaml
|
||||
- minio-secrets.enc.yaml
|
||||
@@ -38,3 +36,8 @@ files:
|
||||
- pi-sandbox-secrets.enc.yaml
|
||||
- etcd-defrag-creds.enc.yaml
|
||||
- zitadel-masterkey.enc.yaml
|
||||
# Login UI RSA keypair, adopted from the chart-generated Secret so ArgoCD
|
||||
# owns it. Without this, the chart re-generates the keypair on every render,
|
||||
# which changes checksum/secret-login-service-key and leaves both Zitadel
|
||||
# Deployments permanently OutOfSync.
|
||||
- zitadel-login-service-key.enc.yaml
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: talos-repair-config
|
||||
namespace: cicd
|
||||
type: Opaque
|
||||
stringData:
|
||||
config: ENC[AES256_GCM,data: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,iv:a4NHhErZolhOuzNCUU51GQiCfCT8GzZ3GQ24s7fkuyU=,tag:w+GrXmaYWADTrBxMko3xtg==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjQmk5aE1GN2dpVlREZU1s
|
||||
N3V5ZUQweWxMaVo3Y1VjdzZTYW96eFBYZGxvCnNJcjVXKzI4VnliTWFXQy9iSy9k
|
||||
eEZmWlVPQzhZK09NZGowZUVXc1BoZTQKLS0tIHIrMEpTM0lMU0w0eG02aUxJY2VZ
|
||||
aUQ0cmkvK0UwdzhqcDJwM1RDcnpEU0UKQRiTa9/yNrqlkIdlNeaIDzIKa/xvx5nW
|
||||
7wTNfjq2M3go0DMnaVjJA3e15JZBOpOs0QP9z9ydKF/FS4PeCIEFyQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
lastmodified: "2026-10-07T04:27:33Z"
|
||||
mac: ENC[AES256_GCM,data:CxZdlGJdIFFTwaeeS9LW2XDDgvUmSsBeBKtFYyDM1JliS8BHtgEv48yuCWxP5sR3SaYtcgNSIQ55zUa89jM6dQHqvjK+1jSYu1xzFWPOlxUu8gVSlU0QLS4GU1RChXuPpTUOjxzn4ymZukGgclZk9iuMvI0/U+nGD8nvs8k0zpM=,iv:MGZ7YsOsjelJPjLYWtALOrM6r8Vd1HgbU/PKL4c3r3k=,tag:dIE38TCobESY3j56NVEvIw==,type:str]
|
||||
version: 3.13.2
|
||||
@@ -0,0 +1,24 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: zitadel-login-service-key
|
||||
namespace: iam
|
||||
type: kubernetes.io/tls
|
||||
data:
|
||||
tls.crt: ENC[AES256_GCM,data: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,iv:PLsjmrTwjM7IUS7ZBSyVvJfAJzCSUtU+UAvHbSwoaVk=,tag:pUQwbHoZgfHW4CpPKoULAw==,type:str]
|
||||
tls.key: ENC[AES256_GCM,data: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,iv:PbRgjgm5ERWlAkkWc7As8eIx8e0rjTUkQ7XlDBrEUlU=,tag:yh2/JuHu5I+dWlxVAmkkXA==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByOGNaakZoV3lhemhMSVVF
|
||||
RklKbEsxeVFMWWdwQ3ZqR0lhRHlPM1dWT3h3Cnk2RnZUNnplVEwvNW9qMExsSUlt
|
||||
VVVXejMrcDRRTEdtT1krdThqWThLWWcKLS0tIEVZZEVBWFZyVi84S1puRHdtMWR1
|
||||
cDBHc1J5aHFpQkxSSEx2VDRjZ1RsdXcKG5MgnUBpFmlNGSkxBijXp7m492ujyqsC
|
||||
TbTfU8SWJwXhio7V+zDbXG+JW4R51wr9F9Sj47DCw7b64nbsD3N5TQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
lastmodified: "2026-10-26T02:15:51Z"
|
||||
mac: ENC[AES256_GCM,data:+2eGERwwzeK5JE9H6qhsu+tskR3Y9c7DecVGopfG8Ggq5SHQBfYyvz//IU+MGCOb6nIyP3JPf9+9IDS2DthVcw+RLtviQkN1TeWuUTDbxBxHdCoUv9zepwU7+d0qUMrdUK0N9QQSRZ4TpiwceViDeMuhPF/vTmADP9QWwid/qwk=,iv:nuJo37Dh8MAf+qI/Ty2jsW0IheNhgmZ4l0Va8pJG6+4=,tag:vCpifXvYF1/QU7XExc6raQ==,type:str]
|
||||
version: 3.13.2
|
||||
@@ -5,19 +5,19 @@ metadata:
|
||||
namespace: iam
|
||||
type: Opaque
|
||||
stringData:
|
||||
masterkey: ENC[AES256_GCM,data:dGp0wSCUJORP6W5PHLKYaQEqrZsgiAwU1GqcuvuZV+g=,iv:Wwt6E/XXwsoVEQxe4gG1m5oJLOcNUXngftADQvZQepw=,tag:1Vget5gs+t455U9zLavdwg==,type:str]
|
||||
masterkey: ENC[AES256_GCM,data:Da543Jt48o6p3f6/AbZRsjChz3NexPJoi/mC/LB6jUI=,iv:irS0n76tZ6QzoQMm0dWrvbw+SPfXz4h13jQb/Bjfgwg=,tag:tfbgaAdxmPIjleB1OXLn0Q==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBld0tUWHNWem13cDlxRG14
|
||||
QWRlTjA5SzZnS2pBd2M0WWkzQWxKZW9HZWdJCk00QWJJcWZFM1ZTSUlReUlGZG4z
|
||||
RFBSUWRlVUx2NGN2OFAwazBYRTVVNkEKLS0tIHpSSW9IWHhNa0d3SmEydXU4akVj
|
||||
MGJkN2U4WmtLbVFMZm55U1hEZElZMlUKKV9twnC9GC7MSmNLxEBHbPl4RN5Hsd+z
|
||||
NViCKxUoQAB8apn2XVHZcDnyfKNfr1P3aSa3IIHyVY3prgo1OFDpdw==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOZld2VG9NaFhIVWpBcGpO
|
||||
SmVwNm9FT2wrQmFKb0RGQmY4TCtITjFEZjBjCmNuNXNJYTdsZE44cDNzR2hiNTdy
|
||||
QWlVdzBtdjd0cSsxUjQxMzNyblhWTkkKLS0tIHBLT2x1MDBGdjE1eHFOdHVGa1NO
|
||||
ZGM1K2lDTksraFdPeFdSZ1JyU1VtcjAKgTjjhr7u4YRuQVZQ7kAa1clJ0Rzy10hQ
|
||||
Umcd4/YStyXHZjsJJpFSpOFdP5cZscGo3xBgpXtxAscKTS1Nw4derg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
lastmodified: "2026-10-24T08:57:27Z"
|
||||
mac: ENC[AES256_GCM,data:hXDAddVmnlf/yinY2c2Q8yfsjqi6n9cRbEbwykqYScd6mDPfdF7BriTiu9K+CV/IvIT4zcMTZbhBeQ/vTCAh0TZDp1lOVglwtNH/imgagyzPmcCvbCIkZKwF5vwWQIGHcDI6pk4/zVQkqt7zKTf8+Qj0pNh5UrAjVeEFE7cuJkQ=,iv:oIQvROFGbiESmMTthHOJiNM+Gy5BCfM5XtDWbszOPmc=,tag:AaYrJrwXuKD7w0p+TTa7FA==,type:str]
|
||||
lastmodified: "2026-10-25T01:24:21Z"
|
||||
mac: ENC[AES256_GCM,data:QFVk7KLsMLCAMH/PHVGCRyfU3k2JiO8AE3jJXmhnlUB4C8MYCTyOWQbnTFXozCMOr0kp+hAt3dYn/e23SWhyvJ7BgVLRb5QF1py704Dr46atVoLUvwHpFI7dyGK/LDTxqiM40RXAnDtWUJlTkVCzKdSFiV8CokIoByKWwxFPayM=,iv:t7HWMcpQl/DJ8Rp51Du7b5gURZO+C+FodsZSJuy5jBc=,tag:qEXSS4S0Y5cvn7T6EjakhA==,type:str]
|
||||
version: 3.13.2
|
||||
|
||||
@@ -271,3 +271,40 @@ data:
|
||||
NDKi7/Gxha790QN3TD/U7p9GcEtfXKv8KISKfxnfD9o9Yx6pWOXcgvGefQkpXuRm
|
||||
kGzDM+aY/VKs5Et5/RyKavBDihuOcumLsmoRg/bFoB/xpeURTyiehUdu
|
||||
-----END CERTIFICATE-----
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: homelab-ca
|
||||
namespace: cloudflared
|
||||
data:
|
||||
ca.crt: |
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFBjCCAu6gAwIBAgIJAKoBPdKPDL5AMA0GCSqGSIb3DQEBCwUAMDAxEzARBgNV
|
||||
BAMMCmhvbWVsYWItY2ExGTAXBgNVBAoMEHJpb3RwaWFvLmhvbWVsYWIwHhcNMjYw
|
||||
ODEyMjA1NDQzWhcNMzYwODA5MjA1NDQzWjAwMRMwEQYDVQQDDApob21lbGFiLWNh
|
||||
MRkwFwYDVQQKDBByaW90cGlhby5ob21lbGFiMIICIjANBgkqhkiG9w0BAQEFAAOC
|
||||
Ag8AMIICCgKCAgEAzw796vZRFOBc/orUu9J6rv91A3SNLJsqz/VuetwAXCaVSlK5
|
||||
kL/RaBKKkcXToCC5avwix6W21FnwLUW4R8QlQdin8+XEHpx2LY5R4GmSvaCbU7wm
|
||||
YwYgVGQVv8KlgcMbz/pF09vXU29cHUHwlkN+BWVGAxVGWxam94j1pBzV0dOhhRw2
|
||||
q5sZYUUrPO5l775od2FMMnMzoRFugxDvJof0p3fAChNAIiFhP95lrcb2YsTwK+a2
|
||||
6mk3AZc0gMtmgVsH+zryGFcAmWLixq41ye/H23ktj2bjEHCr9mIDpuSOKLyLBzrg
|
||||
js/HqdxvwkZkkxwXft0Vi7GN0/LGHMdPrDE499pJelAHbxUpBoOk8sfXSNnfESI8
|
||||
v20LybATGrKXFlOhMKshY1a3JLAoZTaW0xkG4PM4HT7lK/ovr/NLF6kdP7HxJFFQ
|
||||
+4WjySiwbjusNobpTaQWVwCjD3Imf+2feAqJeX3z+P3EhxfrmvEojLOdUiiPcjMn
|
||||
CFl9u2EpSlax53umSkcBQJGfFKM17fZzhtakjqhCTzAfXXlxZy4hsHDtfzRcfaoB
|
||||
Zz/PiJ0TtJSC7TvxZZoPpGexQdoeT7kJEksTvGi5OSfJD7HaKe5RQSZGgpz7ATB+
|
||||
YMBKbjhMXVu+TAdcO1qZRN0UCncrliN61ki9F0tE8kNgpS9biZ6kheoJMZUCAwEA
|
||||
AaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcN
|
||||
AQELBQADggIBAFN1At11d/+Ue6cEjCklnRQpLuD5agXaE+pi5oHNfnggSlO8UjUj
|
||||
lDzfnmxi3IAI6HruzDARM9J95qJboUxWOAEmK9SNnZNS/1b0GyikP3YcF1gIFqtW
|
||||
6SgmVfC1cVUFKGt/+QQzZxhOLuoydv9Ozu12nxsTW/WY2t+cpeL+1wrsM22RA6W/
|
||||
xV/bzYbwzCUT9v4tMZLZHk5CkXztVFXSJyW/8w9rVZdUGZM+mwwn81PLSE0v3s/2
|
||||
wJH8eUqGl73i3Xgn7CVanN4cJYMGYVVlRD6Qr6muBN4Vh0ETY1PrjzsZ6XsV6vk9
|
||||
glMbQnErYMZHZIDtnVdaBt60MecUD9O4eLeip0SE6bVRAucKBXA9xNMilMQEL8/X
|
||||
FBMAxXl10rowBlbX2oaFSOM80eLTla4a3gSL7+0vua3C4Dtv2bLo8ZjCyqXwOJeb
|
||||
N85cqcVZStz3qlgQN1nG2qGE3BS6scOs7+mUGe9CmS8acJV++PKm8CtmLH6/ibjV
|
||||
tAc/6+Vg/keo0kty7H7L6oQd+gKpuaJ2I+wKYe4l9UwHi9zEb8YjzGB4n2AtsoWI
|
||||
NDKi7/Gxha790QN3TD/U7p9GcEtfXKv8KISKfxnfD9o9Yx6pWOXcgvGefQkpXuRm
|
||||
kGzDM+aY/VKs5Et5/RyKavBDihuOcumLsmoRg/bFoB/xpeURTyiehUdu
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -49,30 +49,6 @@ spec:
|
||||
port:
|
||||
number: 3100
|
||||
|
||||
---
|
||||
# ── Authentik ─────────────────────────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: authentik
|
||||
namespace: iam
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: authentik.riotpiao.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: authentik-server
|
||||
port:
|
||||
number: 80
|
||||
|
||||
---
|
||||
# ── Vault ─────────────────────────────────────────────────────────────────────
|
||||
apiVersion: networking.k8s.io/v1
|
||||
@@ -299,28 +275,31 @@ spec:
|
||||
name: temporal-web
|
||||
port:
|
||||
number: 8080
|
||||
|
||||
---
|
||||
# NOTE: api.riotpiao.com is deliberately NOT here. Its namespace `api` is
|
||||
# created in wave 7, and this Application syncs in wave 1 — an Ingress into a
|
||||
# namespace that doesn't exist yet would fail and mark this whole app
|
||||
# SyncFailed. It lives in k8s/apps/api/ingress.yaml, synced by the api-gw
|
||||
# Application.
|
||||
# ── Authentik (identity provider) ─────────────────────────────────────────────
|
||||
# The chart's ingress is disabled in k8s/infra/iam/authentik-values.yaml; the
|
||||
# rule lives here so ingress-config owns every hostname. TLS terminates at
|
||||
# nginx via the wildcard default-ssl-certificate, so the backend stays HTTP.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: homarr
|
||||
namespace: dashboard
|
||||
name: authentik
|
||||
namespace: iam
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "16m"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: homarr.riotpiao.com
|
||||
- host: authentik.riotpiao.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: homarr
|
||||
name: authentik-server
|
||||
port:
|
||||
number: 7575
|
||||
|
||||
number: 80
|
||||
|
||||
@@ -138,12 +138,15 @@ configs:
|
||||
# cicd — automation-only; apiKey (token) for the CD pipeline, no UI login.
|
||||
accounts.rock: apiKey,login
|
||||
accounts.cicd: apiKey
|
||||
# Authentik OIDC. clientSecret pulled from the argocd `oidc-secret` Secret
|
||||
# (created by authentik-provision). The groups claim drives RBAC below.
|
||||
# Zitadel OIDC. clientSecret pulled from the argocd `oidc-secret` Secret.
|
||||
# The groups claim drives RBAC below.
|
||||
oidc.config: |
|
||||
name: Authentik
|
||||
issuer: https://authentik.riotpiao.com/application/o/argocd/
|
||||
clientID: argocd
|
||||
name: Zitadel
|
||||
issuer: https://zitadel.riotpiao.com
|
||||
# MUST be quoted: an unquoted 18-digit number is parsed as a float by the
|
||||
# YAML parser and ArgoCD rejects it ("cannot unmarshal number into ...
|
||||
# clientID of type string"). Same class of bug as Grafana's client_id.
|
||||
clientID: "396757801210282367"
|
||||
clientSecret: $oidc-secret:client-secret
|
||||
requestedScopes:
|
||||
- openid
|
||||
@@ -158,7 +161,7 @@ configs:
|
||||
server.insecure: false
|
||||
|
||||
# RBAC. local `admin` + `rock` + the `cicd` pipeline account all get role:admin;
|
||||
# the Authentik `homelab-admins` group (rock is a member) maps to admin so SSO
|
||||
# the Zitadel `homelab-admins` role (rock is a member) maps to admin so SSO
|
||||
# logins are admin too.
|
||||
rbac:
|
||||
policy.default: role:readonly
|
||||
|
||||
@@ -81,6 +81,15 @@ server:
|
||||
- envName: VAULT_UNSEAL_KEY_3
|
||||
secretName: vault-unseal-keys
|
||||
secretKey: key3
|
||||
# Zitadel OIDC client credentials for the Vault UI login method. Consumed by
|
||||
# the postStart hook below, which enables auth/oidc via the Vault API after
|
||||
# unsealing. Vault auth methods cannot be declared in HCL, only via API/CLI.
|
||||
- envName: OIDC_CLIENT_ID
|
||||
secretName: vault-oidc-creds
|
||||
secretKey: OIDC_CLIENT_ID
|
||||
- envName: OIDC_CLIENT_SECRET
|
||||
secretName: vault-oidc-creds
|
||||
secretKey: OIDC_CLIENT_SECRET
|
||||
|
||||
# ── Auto-unseal ─────────────────────────────────────────────────────────────
|
||||
# Vault starts sealed after every pod restart and can't serve requests until
|
||||
@@ -97,6 +106,28 @@ server:
|
||||
vault operator unseal "$VAULT_UNSEAL_KEY_2" || true
|
||||
vault operator unseal "$VAULT_UNSEAL_KEY_3" || true
|
||||
|
||||
# ── Zitadel OIDC login method ────────────────────────────────────────
|
||||
# Vault auth methods are API-driven: they cannot be declared in HCL.
|
||||
# Enable auth/oidc and write its config on every pod start. Both calls
|
||||
# are idempotent (auth enable returns 400 "already enabled", which we
|
||||
# tolerate; the config write always succeeds and overwrites).
|
||||
vault auth enable oidc 2>/dev/null || true
|
||||
vault write auth/oidc/config \
|
||||
oidc_discovery_url="https://zitadel.riotpiao.com" \
|
||||
oidc_client_id="$OIDC_CLIENT_ID" \
|
||||
oidc_client_secret="$OIDC_CLIENT_SECRET" \
|
||||
default_role="default" 2>/dev/null || true
|
||||
# The role binds the Zitadel client_id as audience and whitelists the UI
|
||||
# callback URL. Policy `default` gives a logged-in operator standard
|
||||
# read access; admin actions still require a token with root/sudo.
|
||||
vault write auth/oidc/role/default \
|
||||
bound_audiences="$OIDC_CLIENT_ID" \
|
||||
allowed_redirect_uris="https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback" \
|
||||
allowed_redirect_uris="http://localhost:8200/ui/vault/auth/oidc/oidc/callback" \
|
||||
user_claim="preferred_username" \
|
||||
oidc_scopes="openid,profile,email,groups" \
|
||||
policies="default" 2>/dev/null || true
|
||||
|
||||
# ── Vault config (HCL) ──────────────────────────────────────────────────────
|
||||
standalone:
|
||||
enabled: true
|
||||
|
||||
@@ -32,6 +32,15 @@ zitadel:
|
||||
TLS:
|
||||
Enabled: false
|
||||
|
||||
# First boot is COMPLETE (2026-10-26): the instance, the system org and the
|
||||
# `iam-admin` machine key all exist. Skip stops the setup hook from running
|
||||
# the first-instance step again. Without it, every sync re-runs the
|
||||
# machinekeyWriter, which silently rotates the iam-admin key Terraform
|
||||
# reads. ZITADEL objects are declared by scripts/iam/zitadel-provision.py.
|
||||
# Never set this back to false.
|
||||
FirstInstance:
|
||||
Skip: true
|
||||
|
||||
# Rendered into a Secret by the chart; read by pods and init jobs alike.
|
||||
masterkeySecretName: zitadel-masterkey
|
||||
|
||||
@@ -39,6 +48,20 @@ zitadel:
|
||||
requests: { memory: "512Mi", cpu: "250m" }
|
||||
limits: { memory: "2Gi", cpu: "1" }
|
||||
|
||||
# Best practice: run as non-root, drop all capabilities, and refuse
|
||||
# privilege escalation. The chart's image already supports a non-root UID.
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
# FirstInstance defaults stay for the FIRST sync: the chart creates org
|
||||
# `zitadel` plus the `iam-admin` machine user and its machine-key Secret.
|
||||
# Terraform consumes that key to declare projects, roles, OIDC apps and the
|
||||
@@ -84,16 +107,64 @@ postgresql:
|
||||
# CNPG already created the database, the `app` user and its grants, so the init
|
||||
# job runs only the ZITADEL-internal step. Running the full default command
|
||||
# would try to create a database and a superuser it does not own.
|
||||
#
|
||||
# enabled: false after the first successful boot (2026-10-26). The schema is in
|
||||
# place; the init hook has nothing left to do and re-running it on every sync
|
||||
# only adds a PreSync failure surface. Re-enable it for a deliberate schema
|
||||
# upgrade, then set it back to false.
|
||||
initJob:
|
||||
enabled: false
|
||||
command: "zitadel"
|
||||
activeDeadlineSeconds: 600
|
||||
|
||||
# The setup job runs the full first-instance creation: schema migrations, the
|
||||
# system org, the iam-admin machine key, and 13 projection replays. 20 minutes
|
||||
# leaves room for a cold database without masking a genuine hang.
|
||||
#
|
||||
# DONE, 2026-10-26 — first boot completed. The annotations are moved from
|
||||
# pre-install/pre-upgrade to post-delete because a PreSync setup run is not
|
||||
# harmless after boot:
|
||||
# - it re-runs every repeatable migration on every sync;
|
||||
# - interrupting one (a sync aborted, or a human deleting the Job) leaves a
|
||||
# `system.migration.started` event with no matching `done`, and ZITADEL then
|
||||
# waits forever on `migration already started`. That is exactly how this
|
||||
# instance deadlocked on projections.projects4 and later on
|
||||
# repeatable_delete_stale_org_fields.
|
||||
# A post-delete hook is never executed by ArgoCD sync, so setup runs only on
|
||||
# first install. Re-enable it deliberately (and only with initJob too) for a
|
||||
# schema upgrade, then revert.
|
||||
setupJob:
|
||||
activeDeadlineSeconds: 1200
|
||||
annotations:
|
||||
helm.sh/hook: post-delete
|
||||
helm.sh/hook-delete-policy: hook-succeeded
|
||||
helm.sh/hook-weight: "2"
|
||||
|
||||
# Two API replicas: the read path survives one node drain, and both share the
|
||||
# same masterkey and database. The login UI stays at one replica — a session
|
||||
# cookie is not shared, so a second pod buys nothing.
|
||||
replicaCount: 2
|
||||
revisionHistoryLimit: 3
|
||||
|
||||
# Best practice: a PDB guarantees at least one API pod stays available during
|
||||
# voluntary disruptions (node drain, rolling upgrade). Without it a drain can
|
||||
# take both replicas down at once. minAvailable 1 is correct for 2 replicas;
|
||||
# minAvailable 2 would block every drain forever.
|
||||
pdb:
|
||||
enabled: true
|
||||
minAvailable: 1
|
||||
|
||||
# Best practice: spread replicas across nodes so a single node failure cannot
|
||||
# take both API pods. Anti-affinity alone only prefers separation; topology
|
||||
# spread enforces the failure-domain budget.
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: zitadel
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
@@ -101,10 +172,31 @@ ingress:
|
||||
|
||||
login:
|
||||
enabled: true
|
||||
# ksops owns this Secret (k8s/argocd/secrets/zitadel-login-service-key.enc.yaml,
|
||||
# rendered by the sops-secrets Application). Pointing the chart at it stops the
|
||||
# chart from auto-generating an RSA keypair every render, which changed
|
||||
# checksum/secret-login-service-key and kept both Deployments permanently
|
||||
# OutOfSync. The keypair must stay RSA: the login container signs JWTs (RS256).
|
||||
loginServiceKeySecretName: zitadel-login-service-key
|
||||
replicaCount: 1
|
||||
revisionHistoryLimit: 3
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
# Best practice: the login UI is the public attack surface. Harden the pod
|
||||
# even at one replica. The chart has no login PDB key; a single replica has
|
||||
# nothing to protect with minAvailable.
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
# Chart-owned ServiceMonitor. The cluster's Prometheus runs with
|
||||
# serviceMonitorSelectorNilUsesHelmValues=false, so the release label is not
|
||||
|
||||
@@ -60,28 +60,28 @@ grafana.ini:
|
||||
database:
|
||||
wal: true
|
||||
|
||||
# ── Authentik OIDC (generic OAuth2) ───────────────────────────────────────
|
||||
# ── ZITADEL OIDC (generic OAuth2) ────────────────────────────────────────
|
||||
# Grafana v10+ supports OIDC auto-discovery; we wire it manually here because
|
||||
# Authentik's discovery endpoint is internal-only (no external DNS for iam.svc).
|
||||
# ZITADEL's discovery endpoint is internal-only (no external DNS for iam.svc).
|
||||
# All URLs use the external hostname so CoreDNS rewrites them to
|
||||
# authentik-server.iam.svc — this keeps the Host header correct so Authentik
|
||||
# zitadel.iam.svc — this keeps the Host header correct so ZITADEL
|
||||
# doesn't return localhost redirects in its token responses.
|
||||
#
|
||||
# role_attribute_path: JMESPath expression evaluated against the userinfo
|
||||
# response. akadmin gets GrafanaAdmin (server admin, can impersonate);
|
||||
# homelab-admins members get Admin (org admin); everyone else Viewer.
|
||||
# homelab-admins role members get Admin (org admin); everyone else Viewer.
|
||||
auth.generic_oauth:
|
||||
enabled: true
|
||||
name: Authentik
|
||||
name: Zitadel
|
||||
allow_sign_up: true # Allow sync to create/update users from OAuth claims
|
||||
client_id: grafana
|
||||
client_id: "396757801965191169"
|
||||
scopes: openid email profile groups
|
||||
auth_url: https://authentik.riotpiao.com/application/o/authorize/
|
||||
token_url: https://authentik.riotpiao.com/application/o/token/
|
||||
api_url: https://authentik.riotpiao.com/application/o/userinfo/
|
||||
auth_url: https://zitadel.riotpiao.com/oauth/v2/authorize
|
||||
token_url: https://zitadel.riotpiao.com/oauth/v2/token
|
||||
api_url: https://zitadel.riotpiao.com/oidc/v1/userinfo
|
||||
# Read identity straight from the userinfo/id_token claims. Without these,
|
||||
# Grafana falls back to the GitHub-style "<api_url>/emails" call, which
|
||||
# Authentik doesn't serve — request 404s with "Error getting email address"
|
||||
# ZITADEL doesn't serve — request 404s with "Error getting email address"
|
||||
# and the whole OAuth login fails.
|
||||
email_attribute_path: email
|
||||
login_attribute_path: preferred_username # Use preferred_username — OpenID standard claim, always present
|
||||
@@ -91,10 +91,10 @@ grafana.ini:
|
||||
use_pkce: false
|
||||
use_refresh_token: false
|
||||
skip_org_role_sync: false # Sync org roles from OAuth groups
|
||||
tls_skip_verify_insecure: true # Authentik uses self-signed cert; verify in prod
|
||||
tls_skip_verify_insecure: true # In-cluster cert; verify in prod
|
||||
|
||||
# GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET is injected from the grafana-oidc K8s
|
||||
# Secret (created by k8s/talos-iam/setup_talos_iam.sh). envFromSecret mounts
|
||||
# Secret (k8s/argocd/secrets/grafana-oidc-secrets.enc.yaml). envFromSecret mounts
|
||||
# every key in that secret as an environment variable — keeps secrets out of
|
||||
# values files and out of git.
|
||||
envFromSecret: grafana-oidc
|
||||
|
||||
@@ -59,6 +59,7 @@ spec:
|
||||
mountPath: /shared
|
||||
containers:
|
||||
- name: provision
|
||||
# bitnamilegacy/minio-client:2025.7.21-debian-12-r3 replaces the dead quay.io/minio/mc image
|
||||
image: docker.io/bitnamilegacy/minio-client:2025.7.21-debian-12-r3@sha256:73bd39f7899a0cef12b8dd5df13aa93a3ed1aaa44236542442e9ac76819ac158
|
||||
volumeMounts:
|
||||
- name: shared
|
||||
|
||||
@@ -103,17 +103,17 @@ spec:
|
||||
- https://minio-api.riotpiao.com
|
||||
console: https://minio.riotpiao.com
|
||||
|
||||
# ── OIDC via Authentik (server-side env, valid in v2 schema) ────────────────
|
||||
# Use in-cluster URL for config fetch (pod→authentik); browser redirects use
|
||||
# public URLs embedded in the OIDC metadata response (issuer stays public).
|
||||
# ── OIDC via ZITADEL (server-side env, valid in v2 schema) ──────────────────
|
||||
# Use external URL for config fetch (hairpins through ingress-nginx); browser
|
||||
# redirects use public URLs embedded in the OIDC metadata response.
|
||||
env:
|
||||
- name: MINIO_IDENTITY_OPENID_CONFIG_URL
|
||||
# Must use external URL — well-known response contains external issuer/jwks_uri.
|
||||
# MinIO validates issuer in JWT matches well-known issuer. Internal URL = mismatch.
|
||||
# Hairpins through ingress-nginx but stays in-cluster.
|
||||
value: "https://authentik.riotpiao.com/application/o/minio/.well-known/openid-configuration"
|
||||
value: "https://zitadel.riotpiao.com/.well-known/openid-configuration"
|
||||
- name: MINIO_IDENTITY_OPENID_CLIENT_ID
|
||||
value: "minio"
|
||||
value: "396757803257036801"
|
||||
- name: MINIO_IDENTITY_OPENID_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -126,7 +126,7 @@ spec:
|
||||
- name: MINIO_IDENTITY_OPENID_REDIRECT_URI
|
||||
value: "https://minio.riotpiao.com/oauth_callback"
|
||||
- name: MINIO_IDENTITY_OPENID_DISPLAY_NAME
|
||||
value: "Authentik"
|
||||
value: "Zitadel"
|
||||
- name: MINIO_IDENTITY_OPENID_SCOPES
|
||||
value: "openid,profile,email,groups"
|
||||
- name: MINIO_BROWSER_LOGIN_ANIMATION
|
||||
|
||||
@@ -43,8 +43,8 @@ serviceMonitor:
|
||||
url: https://grafana.riotpiao.com/
|
||||
- name: loki
|
||||
url: https://loki.riotpiao.com/ready
|
||||
- name: authentik
|
||||
url: https://authentik.riotpiao.com/-/health/ready/
|
||||
- name: zitadel
|
||||
url: https://zitadel.riotpiao.com/debug/ready
|
||||
- name: vault
|
||||
url: https://vault.riotpiao.com/v1/sys/health
|
||||
- name: minio-console
|
||||
|
||||
@@ -55,7 +55,10 @@ alertmanager:
|
||||
prometheus:
|
||||
prometheusSpec:
|
||||
retention: 30d
|
||||
retentionSize: "95GB"
|
||||
# Must stay under the PVC size below (25Gi). At 95GB the limit never
|
||||
# triggered, the volume filled, and Prometheus crashlooped with
|
||||
# "no space left on device" writing /prometheus/queries.active.
|
||||
retentionSize: "18GB"
|
||||
|
||||
# scrapeTimeout MUST be <= scrapeInterval or the operator refuses to generate
|
||||
# the Prometheus config ("scrapeTimeout greater than scrapeInterval") and no
|
||||
|
||||
@@ -31,29 +31,3 @@ roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: oidc-bootstrap
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: oidc-bootstrap-restart-apiserver-v2
|
||||
namespace: kube-system
|
||||
spec:
|
||||
backoffLimit: 4
|
||||
ttlSecondsAfterFinished: 7200
|
||||
template:
|
||||
spec:
|
||||
serviceAccountName: oidc-bootstrap
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: restart
|
||||
image: docker.io/curlimages/curl:8.22.0
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -u
|
||||
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
|
||||
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
for pod in kube-apiserver-talos-cp-1 kube-apiserver-talos-cp-2 kube-apiserver-talos-cp-3; do
|
||||
curl -sS --max-time 15 --cacert "$CA" -H "Authorization: Bearer $TOKEN" -X DELETE "https://kubernetes.default.svc/api/v1/namespaces/kube-system/pods/$pod?gracePeriodSeconds=0" -o /dev/null -w "$pod -> HTTP %{http_code}\n" || echo "$pod -> request failed"
|
||||
done
|
||||
echo "deletes requested; kubelet recreates each static pod in ~20-40s"
|
||||
@@ -1,141 +0,0 @@
|
||||
# One-shot: restart the control-plane API servers so their OIDC authenticator initialises.
|
||||
#
|
||||
# Two earlier mechanisms failed and are documented so nobody repeats them:
|
||||
# 1. deleting the mirror pod does NOT restart a static pod (deletes returned 200, no restart);
|
||||
# 2. matching /proc/*/cmdline for 'kube-apiserver' also matched this script's own cmdline,
|
||||
# so every pod SIGTERMed itself (exit 143) before reaching the real process.
|
||||
# This version matches /proc/<pid>/comm (the process name) and skips \$\$, so self-kill is
|
||||
# impossible. hostPID + privileged are permitted in the 'cicd' namespace. Delays stagger the
|
||||
# three control planes by 2 minutes each, so only one API server is down at a time.
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: oidc-restart-apiserver-cp1-v2
|
||||
namespace: cicd
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
spec:
|
||||
hostPID: true
|
||||
restartPolicy: Never
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: talos-cp-1
|
||||
tolerations:
|
||||
- key: node-role.kubernetes.io/control-plane
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
- key: node-role.kubernetes.io/master
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: kill
|
||||
image: docker.io/library/busybox:1.36
|
||||
securityContext:
|
||||
privileged: true
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
echo "sleeping 10s before restarting the API server on talos-cp-1"
|
||||
sleep 10
|
||||
self=$$
|
||||
killed=0
|
||||
for d in /proc/[0-9]*; do
|
||||
pid=${d#/proc/}
|
||||
[ "$pid" = "$self" ] && continue
|
||||
comm=$(cat "$d/comm" 2>/dev/null)
|
||||
[ "$comm" = "kube-apiserver" ] || continue
|
||||
echo "SIGTERM -> pid $pid"
|
||||
kill -TERM "$pid" 2>/dev/null && killed=$((killed+1))
|
||||
done
|
||||
echo "signalled $killed API server process(es); kubelet recreates the static pod"
|
||||
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: oidc-restart-apiserver-cp2-v2
|
||||
namespace: cicd
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
spec:
|
||||
hostPID: true
|
||||
restartPolicy: Never
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: talos-cp-2
|
||||
tolerations:
|
||||
- key: node-role.kubernetes.io/control-plane
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
- key: node-role.kubernetes.io/master
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: kill
|
||||
image: docker.io/library/busybox:1.36
|
||||
securityContext:
|
||||
privileged: true
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
echo "sleeping 120s before restarting the API server on talos-cp-2"
|
||||
sleep 120
|
||||
self=$$
|
||||
killed=0
|
||||
for d in /proc/[0-9]*; do
|
||||
pid=${d#/proc/}
|
||||
[ "$pid" = "$self" ] && continue
|
||||
comm=$(cat "$d/comm" 2>/dev/null)
|
||||
[ "$comm" = "kube-apiserver" ] || continue
|
||||
echo "SIGTERM -> pid $pid"
|
||||
kill -TERM "$pid" 2>/dev/null && killed=$((killed+1))
|
||||
done
|
||||
echo "signalled $killed API server process(es); kubelet recreates the static pod"
|
||||
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: oidc-restart-apiserver-cp3-v2
|
||||
namespace: cicd
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
spec:
|
||||
hostPID: true
|
||||
restartPolicy: Never
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: talos-cp-3
|
||||
tolerations:
|
||||
- key: node-role.kubernetes.io/control-plane
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
- key: node-role.kubernetes.io/master
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: kill
|
||||
image: docker.io/library/busybox:1.36
|
||||
securityContext:
|
||||
privileged: true
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
echo "sleeping 240s before restarting the API server on talos-cp-3"
|
||||
sleep 240
|
||||
self=$$
|
||||
killed=0
|
||||
for d in /proc/[0-9]*; do
|
||||
pid=${d#/proc/}
|
||||
[ "$pid" = "$self" ] && continue
|
||||
comm=$(cat "$d/comm" 2>/dev/null)
|
||||
[ "$comm" = "kube-apiserver" ] || continue
|
||||
echo "SIGTERM -> pid $pid"
|
||||
kill -TERM "$pid" 2>/dev/null && killed=$((killed+1))
|
||||
done
|
||||
echo "signalled $killed API server process(es); kubelet recreates the static pod"
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Scoped operator access for homarr-admins: the homarr Deployment + app
|
||||
# secrets only. homarr-oidc, auth-oidc-secret, db-encryption stay excluded
|
||||
# (security-managed, not app config). Inert until kube-apiserver's OIDC
|
||||
# wiring lands.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: homarr-operator
|
||||
namespace: dashboard
|
||||
rules:
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
resourceNames: ["homarr"]
|
||||
verbs: ["get", "list", "watch", "update", "patch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames: ["homarr-secrets"]
|
||||
verbs: ["get", "list", "watch", "update", "patch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: homarr-admins-binding
|
||||
namespace: dashboard
|
||||
subjects:
|
||||
- kind: Group
|
||||
name: "oidc:homarr-admins"
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: homarr-operator
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
@@ -10,7 +10,6 @@ resources:
|
||||
- grafana-operator-role.yaml
|
||||
- minio-operator-role.yaml
|
||||
- forgejo-operator-role.yaml
|
||||
- homarr-operator-role.yaml
|
||||
- portainer-operator-role.yaml
|
||||
- kmsvc-operator-role.yaml
|
||||
- temporal-operator-role.yaml
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
# Stable secret name: the job mounts talos-repair-config by name, so the generated
|
||||
# Secret must not get a hash suffix (same convention as k8s/argocd/secrets).
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
# ksops decrypts talosconfig-secret.enc.yaml into the Secret the job mounts.
|
||||
# Kept local to this app on purpose: no edits to the shared secrets generator.
|
||||
generators:
|
||||
- secret-generator.yaml
|
||||
resources:
|
||||
- restart-kubelet.yaml
|
||||
@@ -0,0 +1,57 @@
|
||||
# One-shot repair: the kubelets on cp-1, cp-2 and worker-1 stopped posting node status at
|
||||
# 04:11-04:14 UTC and never reconnected, so those nodes are NotReady/unreachable and every
|
||||
# pod on them (Cilium included) shows NotReady. This job runs on the one healthy node (cp-3),
|
||||
# talks to the Talos API on the LAN, and uses Talos' endpoint->node proxying to reach the
|
||||
# other three: it reports the kubelet service state, then restarts it.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: talos-restart-stalled-kubelets
|
||||
namespace: cicd
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
ttlSecondsAfterFinished: 86400
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: talos-cp-3
|
||||
tolerations:
|
||||
- key: node-role.kubernetes.io/control-plane
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: talosctl
|
||||
image: docker.io/curlimages/curl:8.22.0
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -u
|
||||
echo "fetching talosctl v1.13.3 (matches the node version)"
|
||||
curl -sSL -o /tmp/talosctl \
|
||||
https://github.com/siderolabs/talos/releases/download/v1.13.3/talosctl-linux-amd64 \
|
||||
|| { echo "DOWNLOAD FAILED"; exit 1; }
|
||||
chmod +x /tmp/talosctl
|
||||
T="/tmp/talosctl --talosconfig /talos/config -e 192.168.1.162"
|
||||
NODES="192.168.1.166 192.168.1.214 192.168.1.223"
|
||||
echo "=== BEFORE: kubelet service state ==="
|
||||
for n in $NODES; do echo "--- $n"; $T -n $n service kubelet 2>&1 || true; done
|
||||
echo "=== kernel log tail (last 15 lines per node) ==="
|
||||
for n in $NODES; do echo "--- $n"; $T -n $n dmesg --tail 15 2>&1 | tail -15 || true; done
|
||||
echo "=== ACTION: restart kubelet ==="
|
||||
for n in $NODES; do echo "--- restarting $n"; $T -n $n restart kubelet 2>&1 || true; sleep 20; done
|
||||
sleep 60
|
||||
echo "=== AFTER ==="
|
||||
for n in $NODES; do echo "--- $n"; $T -n $n service kubelet 2>&1 || true; done
|
||||
volumeMounts:
|
||||
- name: talos
|
||||
mountPath: /talos
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: talos
|
||||
secret:
|
||||
secretName: talos-repair-config
|
||||
defaultMode: 0444
|
||||
items:
|
||||
- key: config
|
||||
path: config
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: viaduct.ai/v1
|
||||
kind: ksops
|
||||
metadata:
|
||||
name: talos-repair-secret-generator
|
||||
annotations:
|
||||
config.kubernetes.io/function: |
|
||||
exec:
|
||||
path: ksops
|
||||
files:
|
||||
- talosconfig-secret.enc.yaml
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: talos-repair-config
|
||||
namespace: cicd
|
||||
type: Opaque
|
||||
stringData:
|
||||
config: ENC[AES256_GCM,data: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,iv:a4NHhErZolhOuzNCUU51GQiCfCT8GzZ3GQ24s7fkuyU=,tag:w+GrXmaYWADTrBxMko3xtg==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjQmk5aE1GN2dpVlREZU1s
|
||||
N3V5ZUQweWxMaVo3Y1VjdzZTYW96eFBYZGxvCnNJcjVXKzI4VnliTWFXQy9iSy9k
|
||||
eEZmWlVPQzhZK09NZGowZUVXc1BoZTQKLS0tIHIrMEpTM0lMU0w0eG02aUxJY2VZ
|
||||
aUQ0cmkvK0UwdzhqcDJwM1RDcnpEU0UKQRiTa9/yNrqlkIdlNeaIDzIKa/xvx5nW
|
||||
7wTNfjq2M3go0DMnaVjJA3e15JZBOpOs0QP9z9ydKF/FS4PeCIEFyQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
lastmodified: "2026-10-07T04:27:33Z"
|
||||
mac: ENC[AES256_GCM,data:CxZdlGJdIFFTwaeeS9LW2XDDgvUmSsBeBKtFYyDM1JliS8BHtgEv48yuCWxP5sR3SaYtcgNSIQ55zUa89jM6dQHqvjK+1jSYu1xzFWPOlxUu8gVSlU0QLS4GU1RChXuPpTUOjxzn4ymZukGgclZk9iuMvI0/U+nGD8nvs8k0zpM=,iv:MGZ7YsOsjelJPjLYWtALOrM6r8Vd1HgbU/PKL4c3r3k=,tag:dIE38TCobESY3j56NVEvIw==,type:str]
|
||||
version: 3.13.2
|
||||
@@ -351,11 +351,6 @@ OAuth2_PROVIDERS = {
|
||||
"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",
|
||||
|
||||
@@ -53,7 +53,7 @@ generate_secret() {
|
||||
|
||||
declare -A NEW_SECRETS
|
||||
|
||||
for svc in api-gw minio poimen paperless grafana argocd forgejo homarr immich vault portfolio-agent memory-agent local-llm; do
|
||||
for svc in api-gw minio poimen paperless grafana argocd forgejo immich vault portfolio-agent memory-agent local-llm; do
|
||||
NEW_SECRETS[$svc]=$(generate_secret)
|
||||
log " $svc: ${NEW_SECRETS[$svc]:0:15}..."
|
||||
done
|
||||
@@ -70,7 +70,6 @@ declare -A PROVIDER_PKS=(
|
||||
[grafana]=6
|
||||
[argocd]=7
|
||||
[forgejo]=8
|
||||
[homarr]=9
|
||||
[immich]=10
|
||||
[vault]=11
|
||||
)
|
||||
@@ -203,7 +202,7 @@ fi
|
||||
log ""
|
||||
log "=== Summary ==="
|
||||
log "Rotated 13 credentials:"
|
||||
log " OAuth2 Providers: api-gw, minio, poimen, paperless, grafana, argocd, forgejo, homarr, immich, vault"
|
||||
log " OAuth2 Providers: api-gw, minio, poimen, paperless, grafana, argocd, forgejo, immich, vault"
|
||||
log " Service Accounts: portfolio-agent, memory-agent, local-llm"
|
||||
|
||||
log ""
|
||||
|
||||
@@ -6,7 +6,7 @@ One command replaces the manual sequence the Authentik script
|
||||
fixed order and every phase is safe to re-run:
|
||||
|
||||
keys extract the iam-admin machine key and PAT from namespace iam
|
||||
apply terraform apply terraform/zitadel (project, roles, apps, machines)
|
||||
apply create project, roles and OIDC apps through the ZITADEL API
|
||||
secrets write generated client ids/secrets back into the SOPS files
|
||||
users import one human user, converting the Django password hash
|
||||
verify check discovery, client count and the groups-claim gate
|
||||
@@ -43,13 +43,82 @@ from pathlib import Path
|
||||
import yaml
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
TF_DIR = REPO / "terraform" / "zitadel"
|
||||
SOPS_DIR = REPO / "k8s" / "argocd" / "secrets"
|
||||
SOPS_CONFIG = REPO / ".sops.yaml"
|
||||
CONF_DIR = Path(os.environ.get("ZITADEL_CONF_DIR", Path.home() / ".config" / "zitadel"))
|
||||
STATE_FILE = CONF_DIR / "state.json"
|
||||
NAMESPACE = os.environ.get("ZITADEL_NAMESPACE", "iam")
|
||||
BASE_URL = os.environ.get("ZITADEL_BASE_URL", "https://zitadel.riotpiao.com")
|
||||
|
||||
# ── desired ZITADEL state ──────────────────────────────────────────────────
|
||||
# Declared here, applied through the API by the `apply` phase. This replaces the
|
||||
# retired terraform/zitadel root module: same objects, no state file to drift and
|
||||
# no second writer.
|
||||
PROJECT_NAME = os.environ.get("ZITADEL_PROJECT_NAME", "homelab")
|
||||
PROJECT_GROUP = "homelab"
|
||||
|
||||
# Role keys EQUAL the Authentik group names, so the `groups` claim keeps working
|
||||
# for every consumer that maps on it (kube-apiserver RBAC, MinIO policies,
|
||||
# Grafana roles).
|
||||
ROLES = {
|
||||
"homelab-admins": "Cluster administrators with full access",
|
||||
"minio-admins": "MinIO administrators",
|
||||
"minio-photos": "Photos bucket (Immich) access",
|
||||
"minio-documents": "Documents bucket (Paperless) access",
|
||||
"minio-backups": "Backups bucket read-only access",
|
||||
"poimen-admins": "Poimen memory administrators",
|
||||
"poimen-devs": "Dev and staging projects access",
|
||||
"poimen-prod-readonly": "Production projects read-only access",
|
||||
"paperless-admins": "Paperless administrators",
|
||||
"paperless-finance": "Finance documents",
|
||||
"paperless-legal": "Legal documents",
|
||||
"paperless-hr": "HR documents",
|
||||
"grafana-admins": "Grafana administrators",
|
||||
"grafana-editors": "Grafana dashboard editors",
|
||||
"grafana-viewers": "Grafana dashboard viewers",
|
||||
"sqs-users": "SQS/Temporal queue read access",
|
||||
"sqs-writers": "SQS/Temporal queue read/write access",
|
||||
"s3-users": "S3 read access",
|
||||
"s3-writers": "S3 read/write access",
|
||||
}
|
||||
|
||||
_WEB = {"app_type_id": "OIDC_APP_TYPE_WEB", "auth_method_id": "OIDC_AUTH_METHOD_TYPE_BASIC"}
|
||||
_NATIVE = {"app_type_id": "OIDC_APP_TYPE_NATIVE", "auth_method_id": "OIDC_AUTH_METHOD_TYPE_NONE"}
|
||||
_PKCE = {"app_type_id": "OIDC_APP_TYPE_USER_AGENT", "auth_method_id": "OIDC_AUTH_METHOD_TYPE_NONE"}
|
||||
|
||||
OIDC_APPS = {
|
||||
"api-gw": {**_WEB, "redirect_uris": [
|
||||
"http://localhost:3000/callback", "https://api.riotpiao.com/callback"]},
|
||||
"argocd": {**_WEB, "redirect_uris": [
|
||||
"http://localhost:8080/auth/callback",
|
||||
"https://argocd.riotpiao.com/auth/callback"]},
|
||||
"forgejo": {**_WEB, "redirect_uris": [
|
||||
"http://localhost:3000/user/oauth2/authentik/callback",
|
||||
"https://forgejo.riotpiao.com/user/oauth2/authentik/callback"]},
|
||||
"grafana": {**_WEB, "redirect_uris": [
|
||||
"http://localhost:3000/login/generic_oauth",
|
||||
"https://grafana.riotpiao.com/login/generic_oauth"]},
|
||||
"immich": {**_WEB, "app_type_id": "OIDC_APP_TYPE_NATIVE", "redirect_uris": [
|
||||
"app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login",
|
||||
"https://img.riotpiao.com/user/oauth2/callback"]},
|
||||
"kubernetes": {**_PKCE, "dev_mode": True, "redirect_uris": [
|
||||
"http://localhost:8000", "http://localhost:18000"]},
|
||||
"minio": {**_WEB, "redirect_uris": [
|
||||
"http://localhost:9000/auth/sso/oauth2/code",
|
||||
"https://minio.riotpiao.com/auth/sso/oauth2/code"]},
|
||||
"paperless": {**_WEB, "redirect_uris": [
|
||||
"http://localhost:8000/accounts/oidc/authentik/login/callback/",
|
||||
"https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"]},
|
||||
"poimen": {**_WEB, "redirect_uris": [
|
||||
"http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"]},
|
||||
"queue-sqs": {**_WEB, "redirect_uris": [
|
||||
"http://localhost:8080/callback", "https://queue.riotpiao.com/callback"]},
|
||||
"vault": {**_WEB, "redirect_uris": [
|
||||
"http://localhost:8200/ui/vault/auth/oidc/oidc/callback",
|
||||
"https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"]},
|
||||
}
|
||||
|
||||
|
||||
_TEMP_FILES: set[str] = set()
|
||||
|
||||
|
||||
@@ -155,6 +224,13 @@ def curl_json(method: str, url: str, token: str | None = None, body: dict | None
|
||||
cfg = [f'url = "{esc(url)}"', f'request = "{method}"', "silent", "show-error",
|
||||
f"max-time = {timeout}", 'write-out = "\\n%{http_code}"',
|
||||
'header = "Content-Type: application/json"']
|
||||
# Reaching the instance by name needs either public DNS through the tunnel
|
||||
# or a pinned address. ZITADEL picks the instance from the Host header, so a
|
||||
# plain port-forward fails with "Instance not found". Set this to bypass DNS:
|
||||
# ZITADEL_CURL_RESOLVE=zitadel.riotpiao.com:443:192.168.1.160
|
||||
pinned = os.environ.get("ZITADEL_CURL_RESOLVE", "").strip()
|
||||
if pinned:
|
||||
cfg.append(f'resolve = "{esc(pinned)}"')
|
||||
if token:
|
||||
cfg.append(f'header = "Authorization: Bearer {esc(token)}"')
|
||||
if body is not None:
|
||||
@@ -179,27 +255,99 @@ def phase_keys(_args: argparse.Namespace) -> int:
|
||||
|
||||
# ── phase: apply ───────────────────────────────────────────────────────────
|
||||
|
||||
def phase_apply(args: argparse.Namespace) -> int:
|
||||
need("terraform")
|
||||
if not args.org_id:
|
||||
die("apply requires --org-id (ZITADEL console -> Organization settings -> Resource ID)")
|
||||
key = CONF_DIR / "iam-admin.json"
|
||||
if not key.exists():
|
||||
die(f"{key} missing — run the keys phase first")
|
||||
env = dict(os.environ, ZITADEL_JWT_PROFILE_FILE=str(key))
|
||||
def _api(method: str, path: str, token: str, body: dict | None = None,
|
||||
timeout: int = 30) -> tuple[int, dict]:
|
||||
code, payload = curl_json(method, f"{BASE_URL}{path}", token=token, body=body, timeout=timeout)
|
||||
try:
|
||||
return code, (json.loads(payload) if payload.strip() else {})
|
||||
except json.JSONDecodeError:
|
||||
return code, {"_raw": payload}
|
||||
|
||||
for cmd in (["init", "-input=false"], ["fmt", "-check", "-recursive"]):
|
||||
cp = run(["terraform", f"-chdir={TF_DIR}", *cmd], env=env)
|
||||
if cp.returncode != 0:
|
||||
die(f"terraform {' '.join(cmd)} failed: {cp.stderr.strip() or cp.stdout.strip()}")
|
||||
|
||||
cp = run(["terraform", f"-chdir={TF_DIR}", "apply", "-input=false", "-auto-approve",
|
||||
f"-var=org_id={args.org_id}"], env=env)
|
||||
sys.stdout.write(cp.stdout)
|
||||
sys.stderr.write(cp.stderr)
|
||||
if cp.returncode != 0:
|
||||
die("terraform apply failed — no SOPS value was written. Objects created before the "
|
||||
"failure stay in ZITADEL; re-run apply once the cause is fixed")
|
||||
def _ensure_project(token: str, name: str) -> str:
|
||||
code, d = _api("POST", "/management/v1/projects/_search", token, {})
|
||||
for row in d.get("result") or []:
|
||||
if row.get("name") == name:
|
||||
return row["id"]
|
||||
code, d = _api("POST", "/management/v1/projects", token, {"name": name})
|
||||
if code not in (200, 201) or not d.get("id"):
|
||||
die(f"create project {name} failed: {code} {d}")
|
||||
return d["id"]
|
||||
|
||||
|
||||
def _ensure_roles(token: str, project_id: str, roles: dict[str, str]) -> None:
|
||||
code, d = _api("POST", f"/management/v1/projects/{project_id}/roles/_search", token, {})
|
||||
have = {r.get("roleKey") for r in (d.get("result") or [])}
|
||||
for key, display in roles.items():
|
||||
if key in have:
|
||||
continue
|
||||
code, d = _api("POST", f"/management/v1/projects/{project_id}/roles", token,
|
||||
{"roleKey": key, "displayName": display, "group": PROJECT_GROUP})
|
||||
if code not in (200, 201):
|
||||
die(f"create role {key} failed: {code} {d}")
|
||||
|
||||
|
||||
def _ensure_oidc_apps(token: str, project_id: str, apps: dict[str, dict]) -> tuple[dict, dict]:
|
||||
code, d = _api("POST", f"/management/v1/projects/{project_id}/apps/_search", token, {})
|
||||
existing = {a.get("name"): a for a in (d.get("result") or [])}
|
||||
ids: dict[str, str] = {}
|
||||
secrets: dict[str, str] = {}
|
||||
for name, spec in apps.items():
|
||||
row = existing.get(name)
|
||||
if row:
|
||||
ids[name] = (row.get("oidcConfig") or {}).get("clientId", "")
|
||||
app_id = row.get("id")
|
||||
else:
|
||||
body = {
|
||||
"name": name,
|
||||
"redirectUris": spec["redirect_uris"],
|
||||
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
|
||||
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE",
|
||||
"OIDC_GRANT_TYPE_REFRESH_TOKEN"],
|
||||
"appType": spec.get("app_type_id", "OIDC_APP_TYPE_WEB"),
|
||||
"authMethodType": spec.get("auth_method_id", "OIDC_AUTH_METHOD_TYPE_BASIC"),
|
||||
"devMode": bool(spec.get("dev_mode")),
|
||||
"accessTokenType": "OIDC_TOKEN_TYPE_BEARER",
|
||||
"version": "OIDC_VERSION_1_0",
|
||||
"idTokenRoleAssertion": True,
|
||||
}
|
||||
code, d = _api("POST", f"/management/v1/projects/{project_id}/apps/oidc", token, body)
|
||||
if code not in (200, 201) or not d.get("appId"):
|
||||
die(f"create app {name} failed: {code} {d}")
|
||||
app_id = d["appId"]
|
||||
ids[name] = d.get("clientId", "")
|
||||
if spec.get("auth_method_id") == "OIDC_AUTH_METHOD_TYPE_NONE":
|
||||
continue
|
||||
code, d = _api("POST",
|
||||
f"/management/v1/projects/{project_id}/apps/{app_id}"
|
||||
"/oidc_config/_generate_client_secret", token, {})
|
||||
if code in (200, 201) and d.get("clientSecret"):
|
||||
secrets[name] = d["clientSecret"]
|
||||
return ids, secrets
|
||||
|
||||
|
||||
def phase_apply(_args: argparse.Namespace) -> int:
|
||||
"""Declare the ZITADEL objects through the API. No Terraform, no state drift."""
|
||||
token = bootstrap_token()
|
||||
if not token:
|
||||
die("no iam-admin PAT — run the keys phase first")
|
||||
|
||||
project_id = _ensure_project(token, PROJECT_NAME)
|
||||
print(f"project : {PROJECT_NAME} ({project_id})")
|
||||
_ensure_roles(token, project_id, ROLES)
|
||||
print(f"roles : {len(ROLES)} ensured")
|
||||
ids, secrets = _ensure_oidc_apps(token, project_id, OIDC_APPS)
|
||||
print(f"oidc apps : {len(ids)} ensured, {len(secrets)} secrets issued")
|
||||
|
||||
state = {
|
||||
"project_id": {"value": project_id},
|
||||
"client_ids": {"value": ids},
|
||||
"client_secrets": {"value": secrets},
|
||||
"machine_keys": {"value": {}},
|
||||
"groups_claim_enabled": {"value": False},
|
||||
}
|
||||
write_private(STATE_FILE, json.dumps(state, indent=2, sort_keys=True).encode())
|
||||
print(f"state -> {STATE_FILE} (0600)")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -213,7 +361,7 @@ SECRET_MAP = {
|
||||
"GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET", "secret")],
|
||||
"minio": [("minio-secrets.enc.yaml", "minio-oidc",
|
||||
"MINIO_IDENTITY_OPENID_CLIENT_SECRET", "secret")],
|
||||
"paperless-ai-agent": [("paperless-ai-secrets.enc.yaml", "paperless-ai-config",
|
||||
"paperless": [("paperless-ai-secrets.enc.yaml", "paperless-ai-config",
|
||||
"LLM_AUTH_CLIENT_SECRET", "secret")],
|
||||
}
|
||||
|
||||
@@ -224,30 +372,21 @@ CLIENT_ID_MAP = {
|
||||
"grafana": ("k8s/infra/logging/grafana-values.yaml",
|
||||
r"(?m)^(\s*client_id:\s*)\S+\s*$", r"\g<1>{value}"),
|
||||
"minio": ("k8s/infra/minio/minio-tenant.yaml",
|
||||
r"(?m)^(\s*value:\s*)\"minio\"\s*$", r'\g<1>"{value}"'),
|
||||
"paperless-ai-agent": ("k8s/apps/paperless/paperless-ai.yaml",
|
||||
r"client_id=paperless-ai-agent", "client_id={value}"),
|
||||
r'(?m)^(\s*value:\s*)"(?:minio|\d+)"\s*$', r'\g<1>"{value}"'),
|
||||
"paperless": ("k8s/apps/paperless/paperless-ai.yaml",
|
||||
r"client_id=(?:paperless-ai-agent|\d+)", "client_id={value}"),
|
||||
}
|
||||
|
||||
# Secrets that a live workload mounts but no SOPS file in git defines. The phase
|
||||
# cannot re-point them safely, so it names them.
|
||||
MANUAL_ONLY = {
|
||||
"homarr": "Secret homarr-oidc (keys client-id, client-secret) has no SOPS file in git — "
|
||||
"adopt it first, then add it to SECRET_MAP. The repo also carries an orphan "
|
||||
"homarr-auth-oidc.enc.yaml that nothing consumes.",
|
||||
}
|
||||
MANUAL_ONLY: dict[str, str] = {}
|
||||
|
||||
|
||||
def terraform_outputs() -> dict:
|
||||
need("terraform")
|
||||
key = CONF_DIR / "iam-admin.json"
|
||||
if not key.exists():
|
||||
die(f"{key} missing — run the keys phase first")
|
||||
env = dict(os.environ, ZITADEL_JWT_PROFILE_FILE=str(key))
|
||||
cp = run(["terraform", f"-chdir={TF_DIR}", "output", "-json"], env=env)
|
||||
if cp.returncode != 0:
|
||||
die(f"terraform output failed: {cp.stderr.strip()}")
|
||||
return json.loads(cp.stdout or "{}")
|
||||
def state_outputs() -> dict:
|
||||
"""Read the object map written by the apply phase."""
|
||||
if not STATE_FILE.exists():
|
||||
die(f"{STATE_FILE} missing — run the apply phase first")
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
|
||||
|
||||
def sops_env() -> dict:
|
||||
@@ -345,13 +484,13 @@ def update_client_id(client: str, value: str, dry_run: bool, apply_config: bool)
|
||||
|
||||
def phase_secrets(args: argparse.Namespace) -> int:
|
||||
need("sops")
|
||||
out = terraform_outputs()
|
||||
out = state_outputs()
|
||||
ids = out.get("client_ids", {}).get("value", {})
|
||||
secrets = out.get("client_secrets", {}).get("value", {})
|
||||
machine_keys = out.get("machine_keys", {}).get("value", {})
|
||||
|
||||
if not ids and not machine_keys:
|
||||
die("terraform has no ZITADEL outputs yet — run the apply phase first")
|
||||
die("no ZITADEL objects recorded yet — run the apply phase first")
|
||||
|
||||
print("[1/3] client secrets into SOPS")
|
||||
for client, entries in SECRET_MAP.items():
|
||||
@@ -438,7 +577,7 @@ def phase_verify(_args: argparse.Namespace) -> int:
|
||||
if code != 200:
|
||||
die("discovery endpoint is not 200 — the instance or the ingress is not ready")
|
||||
|
||||
out = terraform_outputs()
|
||||
out = state_outputs()
|
||||
ids = out.get("client_ids", {}).get("value", {})
|
||||
print(f"client apps : {len(ids)}")
|
||||
enabled = bool(out.get("groups_claim_enabled", {}).get("value"))
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Cloudflare side of the edge.
|
||||
#
|
||||
# The tunnel itself is remotely managed today: its token lives in
|
||||
# k8s/apps/cloudflared and the public hostnames are configured in the Zero
|
||||
# Trust dashboard. Terraform can own the DNS records without owning the tunnel,
|
||||
# which is the safe half: a record change is reversible, a tunnel config rewrite
|
||||
# removes every hostname the plan does not list.
|
||||
|
||||
locals {
|
||||
# CNAME target of every remotely-managed tunnel.
|
||||
tunnel_target = "${var.tunnel_id}.cfargotunnel.com"
|
||||
}
|
||||
|
||||
resource "cloudflare_dns_record" "tunnel" {
|
||||
for_each = toset(var.cloudflare_tunnel_hostnames)
|
||||
|
||||
zone_id = var.cloudflare_zone_id
|
||||
name = each.value
|
||||
type = "CNAME"
|
||||
content = local.tunnel_target
|
||||
proxied = var.cloudflare_dns_proxied
|
||||
ttl = var.cloudflare_dns_ttl
|
||||
comment = "cloudflared tunnel; managed by terraform/edge"
|
||||
|
||||
# A proxied CNAME must point at the tunnel target. A CNAME to any other
|
||||
# hostname breaks the tunnel mapping.
|
||||
lifecycle {
|
||||
precondition {
|
||||
condition = var.cloudflare_dns_proxied
|
||||
error_message = "Tunnel CNAMEs must be proxied; an unproxied record bypasses the tunnel."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Optional: take the tunnel configuration into Terraform. Off by default.
|
||||
# Applying it REPLACES the whole ingress list. Import first:
|
||||
# terraform import cloudflare_zero_trust_tunnel_cloudflared_config.this \
|
||||
# <account_id>/<tunnel_id>
|
||||
resource "cloudflare_zero_trust_tunnel_cloudflared_config" "this" {
|
||||
count = var.manage_tunnel_config ? 1 : 0
|
||||
|
||||
account_id = var.cloudflare_account_id
|
||||
tunnel_id = var.tunnel_id
|
||||
|
||||
config = {
|
||||
ingress = concat(
|
||||
[for host, service in var.tunnel_ingress : { hostname = host, service = service }],
|
||||
[{ service = "http_status:404" }],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
output "cloudflare_records" {
|
||||
description = "Hostnames now managed in the Cloudflare zone."
|
||||
value = sort(keys(cloudflare_dns_record.tunnel))
|
||||
}
|
||||
|
||||
output "tunnel_config_managed" {
|
||||
description = "Whether Terraform owns the tunnel ingress list. False means the dashboard still does."
|
||||
value = var.manage_tunnel_config
|
||||
}
|
||||
|
||||
output "relay_public_ip" {
|
||||
description = "Public address of the Tencent relay. Point the .cn DNS at it, or let the module do it."
|
||||
value = tencentcloud_instance.relay.public_ip
|
||||
}
|
||||
|
||||
output "china_hostnames" {
|
||||
description = "Fully qualified China hostnames served by the relay."
|
||||
value = [for host in var.china_hostnames : "${host}.${var.china_domain}"]
|
||||
}
|
||||
|
||||
output "wireguard_peer" {
|
||||
description = "Peer stanza for the homelab side. Add it to the cluster's wg0.conf; the relay already trusts this public key."
|
||||
value = <<-EOT
|
||||
[Peer]
|
||||
PublicKey = <relay public key; see /etc/wireguard/wg0.conf on the relay>
|
||||
Endpoint = ${tencentcloud_instance.relay.public_ip}:${var.wireguard_port}
|
||||
AllowedIPs = ${var.wireguard_relay_address}
|
||||
PersistentKeepalive = 25
|
||||
EOT
|
||||
}
|
||||
|
||||
output "next_manual_steps" {
|
||||
description = "What this module cannot do for you."
|
||||
value = [
|
||||
"Confirm the ICP filing for ${var.china_domain} if the relay is in a mainland region.",
|
||||
"Issue a certificate for the China hostnames on the relay (Caddy ACME needs the DNS-01 challenge for a .cn domain).",
|
||||
"Run a WireGuard peer in the cluster that dials ${tencentcloud_instance.relay.public_ip}:${var.wireguard_port}.",
|
||||
"Add the .cn origins to the OIDC client redirect URIs before any China user logs in.",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# terraform/edge — the China-facing edge, plus DNS ownership for the existing zone
|
||||
#
|
||||
# Purpose: give users in mainland China a short, stable path to the homelab
|
||||
# without touching the Cloudflare path that serves everyone else.
|
||||
#
|
||||
# China user -> <service>.<china_domain> -> Tencent CVM (this module)
|
||||
# -> WireGuard tunnel -> ingress-nginx (192.168.1.160) -> service
|
||||
#
|
||||
# Everyone else -> Cloudflare -> cloudflared tunnel -> same ingress (unchanged)
|
||||
#
|
||||
# The homelab still opens no inbound port: the tunnel is established outbound
|
||||
# from the cluster to the CVM.
|
||||
#
|
||||
# Credentials come from the environment, never from a file in git:
|
||||
# export CLOUDFLARE_API_TOKEN=...
|
||||
# export TENCENTCLOUD_SECRET_ID=...
|
||||
# export TENCENTCLOUD_SECRET_KEY=...
|
||||
# terraform -chdir=terraform/edge init
|
||||
# terraform -chdir=terraform/edge plan -var china_domain=example.cn ...
|
||||
#
|
||||
# State is local and git-ignored. It carries no
|
||||
# credential today, but it will once InstanceData or certificates are added, so
|
||||
# treat it as a secret and move it to MinIO before a second operator applies.
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "5.28.1"
|
||||
}
|
||||
tencentcloud = {
|
||||
source = "tencentcloudstack/tencentcloud"
|
||||
version = "1.83.38"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "cloudflare" {
|
||||
api_token = var.cloudflare_api_token
|
||||
}
|
||||
|
||||
provider "tencentcloud" {
|
||||
secret_id = var.tencent_secret_id
|
||||
secret_key = var.tencent_secret_key
|
||||
region = var.tencent_region
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Rendered by terraform/edge. Do not edit on the instance.
|
||||
#
|
||||
# Caddy terminates TLS for the .cn hostnames and forwards over the WireGuard
|
||||
# tunnel to ingress-nginx. flush_interval -1 disables response buffering so
|
||||
# Server-Sent Events reach the client chunk-by-chunk.
|
||||
{
|
||||
email ${acme_email}
|
||||
admin off
|
||||
}
|
||||
|
||||
${hosts} {
|
||||
reverse_proxy ${ingress_upstream} {
|
||||
flush_interval -1
|
||||
}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
# The relay: one small CVM in Tencent Cloud that terminates TLS for the .cn
|
||||
# domain and forwards over WireGuard to the homelab ingress.
|
||||
#
|
||||
# Why a relay and not Cloudflare: mainland clients reach a Tencent edge far
|
||||
# faster than a Cloudflare edge, and Cloudflare's China network is an
|
||||
# Enterprise product. The homelab keeps zero inbound exposure because the
|
||||
# tunnel is dialled outbound from the cluster.
|
||||
#
|
||||
# ICP FILING: a mainland resource serving HTTP/HTTPS for a domain requires an
|
||||
# ICP filing naming that domain. Without it this instance accepts the
|
||||
# connection and the mainland network resets the HTTP layer. Put the relay in a
|
||||
# non-mainland region (ap-hongkong, ap-singapore) when no filing exists.
|
||||
|
||||
resource "tencentcloud_vpc" "edge" {
|
||||
name = "${replace(var.china_domain, ".", "_")}_edge"
|
||||
cidr_block = var.edge_vpc_cidr
|
||||
}
|
||||
|
||||
resource "tencentcloud_subnet" "edge" {
|
||||
name = "${replace(var.china_domain, ".", "_")}_edge"
|
||||
vpc_id = tencentcloud_vpc.edge.id
|
||||
availability_zone = var.tencent_availability_zone
|
||||
cidr_block = var.edge_subnet_cidr
|
||||
}
|
||||
|
||||
resource "tencentcloud_key_pair" "edge" {
|
||||
key_name = "${replace(var.china_domain, ".", "_")}_edge"
|
||||
public_key = var.relay_ssh_public_key
|
||||
}
|
||||
|
||||
resource "tencentcloud_security_group" "edge" {
|
||||
name = "${replace(var.china_domain, ".", "_")}_edge"
|
||||
description = "TLS + WireGuard for the ${var.china_domain} relay; SSH from the operator only"
|
||||
}
|
||||
|
||||
resource "tencentcloud_security_group_rule" "ssh" {
|
||||
security_group_id = tencentcloud_security_group.edge.id
|
||||
type = "ingress"
|
||||
ip_protocol = "tcp"
|
||||
port_range = "22"
|
||||
cidr_ip = var.relay_admin_cidr
|
||||
policy = "accept"
|
||||
}
|
||||
|
||||
resource "tencentcloud_security_group_rule" "tls" {
|
||||
security_group_id = tencentcloud_security_group.edge.id
|
||||
type = "ingress"
|
||||
ip_protocol = "tcp"
|
||||
port_range = "80,443"
|
||||
cidr_ip = "0.0.0.0/0"
|
||||
policy = "accept"
|
||||
}
|
||||
|
||||
resource "tencentcloud_security_group_rule" "wireguard" {
|
||||
security_group_id = tencentcloud_security_group.edge.id
|
||||
type = "ingress"
|
||||
ip_protocol = "udp"
|
||||
port_range = tostring(var.wireguard_port)
|
||||
# The homelab dials outbound and has no static public address, so this is
|
||||
# 0.0.0.0/0 by default. WireGuard authenticates every packet by public key,
|
||||
# and an unauthenticated packet is dropped before it reaches the tunnel.
|
||||
# Narrow this to the homelab egress CIDR when that address is stable.
|
||||
cidr_ip = var.wireguard_allowed_cidr
|
||||
policy = "accept"
|
||||
}
|
||||
|
||||
# cloud-init: WireGuard listener, forwarding sysctls, and Caddy for TLS.
|
||||
# The relay never holds application credentials; it forwards bytes.
|
||||
#
|
||||
# NOTE ON THE PRIVATE KEY: user_data is visible in the instance metadata and in
|
||||
# Terraform state. That is a known limitation of bootstrapping WireGuard this
|
||||
# way. The argument is wrapped in sensitive() to keep it out of plan/apply
|
||||
# output, but anyone with console or state access can read it. Rotate the key
|
||||
# if the state or the instance metadata is ever exposed.
|
||||
locals {
|
||||
china_fqdns = [for host in var.china_hostnames : "${host}.${var.china_domain}"]
|
||||
|
||||
caddyfile = templatefile("${path.module}/templates/Caddyfile.tpl", {
|
||||
acme_email = var.acme_email
|
||||
hosts = join(", ", local.china_fqdns)
|
||||
ingress_upstream = var.ingress_upstream
|
||||
})
|
||||
|
||||
relay_user_data = <<-EOT
|
||||
#cloud-config
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
manage_etc_hosts: true
|
||||
timezone: Asia/Shanghai
|
||||
packages: [wireguard, wireguard-tools, caddy]
|
||||
write_files:
|
||||
- path: /etc/wireguard/wg0.conf
|
||||
permissions: "0600"
|
||||
content: |
|
||||
[Interface]
|
||||
Address = ${var.wireguard_relay_address}
|
||||
ListenPort = ${var.wireguard_port}
|
||||
PrivateKey = ${var.wireguard_relay_private_key}
|
||||
# Masquerade forwarded traffic so the homelab peer only has to accept
|
||||
# packets sourced from the relay's tunnel address. Without this the
|
||||
# homelab peer's AllowedIPs would drop the original client source IP.
|
||||
PostUp = iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE
|
||||
PostDown = iptables -t nat -D POSTROUTING -o wg0 -j MASQUERADE
|
||||
[Peer]
|
||||
PublicKey = ${var.wireguard_homelab_public_key}
|
||||
AllowedIPs = ${var.wireguard_homelab_allowed_ips}
|
||||
PersistentKeepalive = 25
|
||||
- path: /etc/sysctl.d/99-wireguard.conf
|
||||
content: |
|
||||
net.ipv4.ip_forward = 1
|
||||
- path: /etc/caddy/Caddyfile
|
||||
permissions: "0644"
|
||||
content: ${jsonencode(local.caddyfile)}
|
||||
runcmd:
|
||||
- sysctl --system
|
||||
- systemctl enable --now wg-quick@wg0
|
||||
- caddy validate --config /etc/caddy/Caddyfile
|
||||
- systemctl enable --now caddy
|
||||
- systemctl restart caddy
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "tencentcloud_instance" "relay" {
|
||||
instance_name = "${replace(var.china_domain, ".", "_")}_edge"
|
||||
availability_zone = var.tencent_availability_zone
|
||||
image_id = var.relay_image_id
|
||||
instance_type = var.relay_instance_type
|
||||
|
||||
vpc_id = tencentcloud_vpc.edge.id
|
||||
subnet_id = tencentcloud_subnet.edge.id
|
||||
security_groups = [tencentcloud_security_group.edge.id]
|
||||
key_ids = [tencentcloud_key_pair.edge.id]
|
||||
allocate_public_ip = true
|
||||
|
||||
# Outbound only needs a few Mbps; inbound TLS is the same order.
|
||||
internet_max_bandwidth_out = var.relay_internet_max_bandwidth_out
|
||||
system_disk_type = var.relay_system_disk_type
|
||||
system_disk_size = var.relay_system_disk_size
|
||||
|
||||
# The private key is embedded in this bootstrap script. sensitive() keeps it
|
||||
# out of CLI output; it does not remove it from state or instance metadata.
|
||||
user_data = sensitive(local.relay_user_data)
|
||||
|
||||
tags = {
|
||||
role = "china-edge"
|
||||
domain = var.china_domain
|
||||
}
|
||||
|
||||
# Accidental `terraform destroy` would take the only China-facing edge down.
|
||||
# Remove this block deliberately when a replacement path exists.
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
# DNS for the .cn domain lives at DNSPod (Tencent). Each subdomain resolves to
|
||||
# the relay's public address, and the relay proxies to the homelab over the
|
||||
# tunnel. The "中国境内" line is the default line; no separate line records.
|
||||
resource "tencentcloud_dnspod_record" "china" {
|
||||
for_each = toset(var.china_hostnames)
|
||||
|
||||
domain = var.china_domain
|
||||
sub_domain = each.value
|
||||
record_type = "A"
|
||||
record_line = var.china_dns_record_line
|
||||
value = tencentcloud_instance.relay.public_ip
|
||||
ttl = var.china_dns_ttl
|
||||
remark = "China edge relay; origin is the homelab ingress over WireGuard"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Copy to terraform.tfvars.local (git-ignored) and fill in.
|
||||
# Secrets never belong in a committed file: pass them as environment variables
|
||||
# instead — TF_VAR_wireguard_relay_private_key, CLOUDFLARE_API_TOKEN,
|
||||
# TENCENTCLOUD_SECRET_ID, TENCENTCLOUD_SECRET_KEY.
|
||||
|
||||
# ── Cloudflare (existing zone) ───────────────────────────────────────────────
|
||||
cloudflare_zone_id = "REPLACE_WITH_ZONE_ID"
|
||||
tunnel_id = "REPLACE_WITH_TUNNEL_UUID"
|
||||
|
||||
# Adopt existing records with `terraform import` before the first apply, or the
|
||||
# create call collides with the hand-made record of the same name:
|
||||
# terraform import 'cloudflare_dns_record.tunnel["zitadel"]' \
|
||||
# <zone_id>/<record_id>
|
||||
#
|
||||
# A tunnel CNAME is only half of the path. The tunnel's Public Hostname list
|
||||
# (Zero Trust -> Networks -> Tunnels -> Public Hostname) must also map the name
|
||||
# to https://<name>.riotpiao.com, or Cloudflare answers 404 from the tunnel even
|
||||
# though DNS resolves.
|
||||
cloudflare_tunnel_hostnames = ["paperless", "img", "poimen", "zitadel"]
|
||||
|
||||
# Leave false until the tunnel config import is understood. True makes
|
||||
# Terraform replace the entire ingress list with tunnel_ingress.
|
||||
manage_tunnel_config = false
|
||||
tunnel_ingress = {}
|
||||
|
||||
# ── China edge (Tencent Cloud) ───────────────────────────────────────────────
|
||||
china_domain = "REPLACE.example.cn"
|
||||
china_hostnames = ["paperless", "img"]
|
||||
|
||||
# Mainland regions require an ICP filing for the domain. Use ap-hongkong or
|
||||
# ap-singapore when no filing exists.
|
||||
tencent_region = "ap-guangzhou"
|
||||
tencent_availability_zone = "ap-guangzhou-6"
|
||||
relay_instance_type = "S5.SMALL2"
|
||||
relay_image_id = "REPLACE_WITH_IMAGE_ID"
|
||||
relay_ssh_public_key = "ssh-ed25519 AAAA... operator@laptop"
|
||||
relay_admin_cidr = "REPLACE_WITH_OPERATOR_CIDR"
|
||||
|
||||
# ── WireGuard between the relay and the cluster ──────────────────────────────
|
||||
# The relay private key comes from `wg genkey`. Keep it in the environment, or
|
||||
# in a SOPS file mounted for the run. It is not written by this module.
|
||||
wireguard_port = 51820
|
||||
wireguard_homelab_public_key = "REPLACE_WITH_HOMELAB_WG_PUBKEY"
|
||||
wireguard_relay_address = "10.88.0.1/24"
|
||||
wireguard_homelab_address = "10.88.0.2/24"
|
||||
@@ -0,0 +1,227 @@
|
||||
variable "cloudflare_api_token" {
|
||||
description = "Cloudflare API token with Zone:DNS:Edit and, for tunnel config, Account:Cloudflare Tunnel:Edit."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "cloudflare_zone_id" {
|
||||
description = "Zone ID of the existing public zone (riotpiao.com)."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "cloudflare_account_id" {
|
||||
description = "Account ID. Required only when manage_tunnel_config is true."
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "tunnel_id" {
|
||||
description = "UUID of the cloudflared tunnel that the DNS records point at."
|
||||
type = string
|
||||
}
|
||||
|
||||
# The dashboard remains the source of truth for the remotely-managed tunnel
|
||||
# today. Setting this true hands that config to Terraform, which then rewrites
|
||||
# the whole ingress list: every hostname not listed in tunnel_ingress is
|
||||
# removed from the tunnel. Import first and read the plan before applying.
|
||||
variable "manage_tunnel_config" {
|
||||
description = "Take ownership of the remotely-managed tunnel configuration."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "tunnel_ingress" {
|
||||
description = "hostname => origin service, used only when manage_tunnel_config is true."
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
# ── DNS records managed in the Cloudflare zone ───────────────────────────────
|
||||
# These names already exist, created by hand in the dashboard. Adopt them with
|
||||
# `terraform import` before the first apply, or Terraform will try to create a
|
||||
# second record with the same name and Cloudflare will reject it.
|
||||
variable "cloudflare_tunnel_hostnames" {
|
||||
description = "Hostnames in the Cloudflare zone that must CNAME to the tunnel."
|
||||
type = list(string)
|
||||
default = []
|
||||
|
||||
validation {
|
||||
condition = alltrue([for h in var.cloudflare_tunnel_hostnames : can(regex("^[a-zA-Z0-9._-]+$", h))])
|
||||
error_message = "cloudflare_tunnel_hostnames entries must be DNS labels or relative names (letters, digits, dot, underscore, hyphen)."
|
||||
}
|
||||
}
|
||||
|
||||
variable "cloudflare_dns_ttl" {
|
||||
description = "TTL for tunnel CNAME records. 1 means automatic, which is required when proxied is true."
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "cloudflare_dns_proxied" {
|
||||
description = "Whether tunnel CNAME records are proxied through Cloudflare. Must stay true for a tunnel origin."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
# ── China edge ───────────────────────────────────────────────────────────────
|
||||
variable "china_domain" {
|
||||
description = "The .cn domain served from Tencent Cloud, without a trailing dot."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "china_hostnames" {
|
||||
description = "Subdomains of china_domain that point at the Tencent relay."
|
||||
type = list(string)
|
||||
default = ["paperless", "img"]
|
||||
}
|
||||
|
||||
variable "tencent_secret_id" {
|
||||
description = "Tencent Cloud SecretId. Prefer TENCENTCLOUD_SECRET_ID in the environment."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "tencent_secret_key" {
|
||||
description = "Tencent Cloud SecretKey. Prefer TENCENTCLOUD_SECRET_KEY in the environment."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "tencent_region" {
|
||||
description = "Tencent Cloud region for the relay. Mainland regions need an ICP filing for the domain."
|
||||
type = string
|
||||
default = "ap-guangzhou"
|
||||
}
|
||||
|
||||
variable "tencent_availability_zone" {
|
||||
description = "Availability zone inside tencent_region."
|
||||
type = string
|
||||
default = "ap-guangzhou-6"
|
||||
}
|
||||
|
||||
variable "relay_instance_type" {
|
||||
description = "CVM spec for the relay. A relay only terminates TLS and forwards bytes."
|
||||
type = string
|
||||
default = "S5.SMALL2"
|
||||
}
|
||||
|
||||
variable "relay_image_id" {
|
||||
description = "CVM image ID. Find one with: tencentcloud images --image-name 'Ubuntu 24.04' (or the console)."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "relay_ssh_public_key" {
|
||||
description = "Public key injected into the relay for break-glass SSH. Never the private key."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "relay_admin_cidr" {
|
||||
description = "CIDR allowed to reach SSH and WireGuard on the relay. Keep it to the operator, not 0.0.0.0/0."
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = var.relay_admin_cidr != "0.0.0.0/0" && var.relay_admin_cidr != "::/0"
|
||||
error_message = "relay_admin_cidr must not be the whole internet. Set the operator's egress CIDR."
|
||||
}
|
||||
}
|
||||
|
||||
variable "wireguard_port" {
|
||||
description = "UDP port of the WireGuard listener on the relay."
|
||||
type = number
|
||||
default = 51820
|
||||
}
|
||||
|
||||
variable "wireguard_allowed_cidr" {
|
||||
description = "CIDR allowed to reach the WireGuard UDP port. The homelab dials outbound, so this is often 0.0.0.0/0; WireGuard authenticates by key. Restrict it to the homelab egress CIDR when that address is static."
|
||||
type = string
|
||||
default = "0.0.0.0/0"
|
||||
}
|
||||
|
||||
variable "wireguard_relay_private_key" {
|
||||
description = "WireGuard private key of the relay side. Generate with `wg genkey`; pass via TF_VAR_wireguard_relay_private_key."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "wireguard_homelab_public_key" {
|
||||
description = "WireGuard public key of the homelab peer that dials the relay."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "wireguard_relay_address" {
|
||||
description = "Tunnel address of the relay side."
|
||||
type = string
|
||||
default = "10.88.0.1/24"
|
||||
}
|
||||
|
||||
variable "wireguard_homelab_address" {
|
||||
description = "Tunnel address of the homelab side."
|
||||
type = string
|
||||
default = "10.88.0.2/24"
|
||||
}
|
||||
|
||||
# ── Relay networking / sizing ───────────────────────────────────────────────
|
||||
variable "edge_vpc_cidr" {
|
||||
description = "CIDR of the relay VPC."
|
||||
type = string
|
||||
default = "10.66.0.0/16"
|
||||
}
|
||||
|
||||
variable "edge_subnet_cidr" {
|
||||
description = "CIDR of the relay subnet."
|
||||
type = string
|
||||
default = "10.66.1.0/24"
|
||||
}
|
||||
|
||||
variable "relay_internet_max_bandwidth_out" {
|
||||
description = "Peak outbound bandwidth in Mbps. The relay only terminates TLS and forwards bytes."
|
||||
type = number
|
||||
default = 20
|
||||
}
|
||||
|
||||
variable "relay_system_disk_type" {
|
||||
description = "System disk type for the relay."
|
||||
type = string
|
||||
default = "CLOUD_PREMIUM"
|
||||
}
|
||||
|
||||
variable "relay_system_disk_size" {
|
||||
description = "System disk size in GB for the relay."
|
||||
type = number
|
||||
default = 50
|
||||
}
|
||||
|
||||
# ── DNS records ──────────────────────────────────────────────────────────────
|
||||
variable "china_dns_ttl" {
|
||||
description = "TTL in seconds for the .cn A records."
|
||||
type = number
|
||||
default = 600
|
||||
}
|
||||
|
||||
variable "china_dns_record_line" {
|
||||
description = "DNSPod line for the .cn A records. Use 默认 for the default line."
|
||||
type = string
|
||||
default = "默认"
|
||||
}
|
||||
|
||||
# ── Caddy reverse proxy ──────────────────────────────────────────────────────
|
||||
variable "wireguard_homelab_allowed_ips" {
|
||||
description = "AllowedIPs for the homelab peer. Must include the homelab LAN so Caddy can reach ingress-nginx over the tunnel."
|
||||
type = string
|
||||
default = "10.88.0.2/32,192.168.1.0/24"
|
||||
}
|
||||
|
||||
variable "ingress_upstream" {
|
||||
description = "Upstream URL Caddy proxies to over WireGuard (the homelab ingress-nginx)."
|
||||
type = string
|
||||
default = "http://192.168.1.160:80"
|
||||
}
|
||||
|
||||
variable "acme_email" {
|
||||
description = "Contact email for Caddy ACME (Let's Encrypt)."
|
||||
type = string
|
||||
default = "[email protected]"
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
# RECOVERED from live cluster state via talosctl get machineconfig.
|
||||
# Regenerated after the original tfvars.local was lost/corrupted.
|
||||
|
||||
cluster_id = "Rtc4g2av9EP0mOdxA4M0-QQitzHLWICz-rLBNfrOjgw="
|
||||
cluster_secret = "8E0CAeylAPKmmUEQmIGmHQAhQf+8c7NUf43CdrQZ+vg="
|
||||
bootstrap_token = "b2q7lh.9w7hwgrulrd65gr3"
|
||||
machine_token = "hq9wlf.96l9z46efd79jtr2"
|
||||
machine_ca_crt = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJQekNCOHFBREFnRUNBaEVBMkUwRWZqbG41L1J3eTVjMVJmNkNSakFGQmdNclpYQXdFREVPTUF3R0ExVUUKQ2hNRmRHRnNiM013SGhjTk1qWXdOekE1TURVd056VTRXaGNOTXpZd056QTJNRFV3TnpVNFdqQVFNUTR3REFZRApWUVFLRXdWMFlXeHZjekFxTUFVR0F5dGxjQU1oQUNwR3NQZkVHdEU4anVmNG9JTkNHNnlCQmN6aURFaEpLbDV3CnJib0hSR0tUbzJFd1h6QU9CZ05WSFE4QkFmOEVCQU1DQW9Rd0hRWURWUjBsQkJZd0ZBWUlLd1lCQlFVSEF3RUcKQ0NzR0FRVUZCd01DTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3SFFZRFZSME9CQllFRkdmaExZUUdaOGYzd3lZZAo0SFI3KzlONnZKQXJNQVVHQXl0bGNBTkJBQ1ZQVlAwcGYxMkdUakYvSHJMZmcwZHBLemdBMlE1OGR5Y0paY0ZvClQvNDdPQk8ra29VZm11dXNjVVNNQnBXS0VuWHNYMFNocmNab3hQd1FHM3diblFFPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="
|
||||
machine_ca_key = "LS0tLS1CRUdJTiBFRDI1NTE5IFBSSVZBVEUgS0VZLS0tLS0KTUM0Q0FRQXdCUVlESzJWd0JDSUVJSlR4MXRqZEVOTTg1cGNRRFR0WWRtMFd0QXNBd0tzL1VESlZLN0ZqYU5uUgotLS0tLUVORCBFRDI1NTE5IFBSSVZBVEUgS0VZLS0tLS0K"
|
||||
kubernetes_ca_crt = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJpVENDQVRDZ0F3SUJBZ0lSQUtwT1ZaK29CRzljNHFycEpwaUc4TkV3Q2dZSUtvWkl6ajBFQXdJd0ZURVQKTUJFR0ExVUVDaE1LYTNWaVpYSnVaWFJsY3pBZUZ3MHlOakEzTURrd05UQTNOVGhhRncwek5qQTNNRFl3TlRBMwpOVGhhTUJVeEV6QVJCZ05WQkFvVENtdDFZbVZ5Ym1WMFpYTXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CCkJ3TkNBQVQ1VjJvNkliMUpRZkcza3lCTTBCeTRkd2FLbnlGY2tVdjNVem9yOG1Ba3RaN2JrT2ZKZDlmL2VmcVkKYXBzUFpLV1RHQnYxM3JZbFdvOGtuU3ZnNm83Um8yRXdYekFPQmdOVkhROEJBZjhFQkFNQ0FvUXdIUVlEVlIwbApCQll3RkFZSUt3WUJCUVVIQXdFR0NDc0dBUVVGQndNQ01BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0hRWURWUjBPCkJCWUVGQ1dPZVJUVHdnN2tnNVNWMG9raFQwY0VDNGwzTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSURURCtNYXUKb2JXdkp6UkNMVEdJaHJHc1daalFnalVmRWl2MnhCTXB6RnNVQWlCQzNNWkYwMjVqVHk4Uno2YjI5czVJQmpkNgpZQTVWd0kvNVFieXlwSGFXQlE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="
|
||||
kubernetes_ca_key = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUUrSEdPcUJJYXpJMzdqNmJJUlA1emVxeXEwZzhBU2xZeGplZUlJcEpIcXBvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFK1ZkcU9pRzlTVUh4dDVNZ1ROQWN1SGNHaXA4aFhKRkw5MU02Sy9KZ0pMV2UyNURueVhmWAovM242bUdxYkQyU2xreGdiOWQ2MkpWcVBKSjByNE9xTzBRPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo="
|
||||
etcd_ca_crt = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJmVENDQVNTZ0F3SUJBZ0lSQVAyaHkwdUhvTm5vSzQyZE9LTk1nU2t3Q2dZSUtvWkl6ajBFQXdJd0R6RU4KTUFzR0ExVUVDaE1FWlhSalpEQWVGdzB5TmpBM01Ea3dOVEEzTlRoYUZ3MHpOakEzTURZd05UQTNOVGhhTUE4eApEVEFMQmdOVkJBb1RCR1YwWTJRd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DQUFRVGlKYUJpSEJPCmJha3JuL0dVdkUxVFd5czRVUkVzVGtVNEM4OG1EdWZYQURjV0NnN2RTRzc0QjkzOGFwSWgybGc4UDhKRDFFRUoKN0RkU1lQVG5zYWh1bzJFd1h6QU9CZ05WSFE4QkFmOEVCQU1DQW9Rd0hRWURWUjBsQkJZd0ZBWUlLd1lCQlFVSApBd0VHQ0NzR0FRVUZCd01DTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3SFFZRFZSME9CQllFRkliYTBLaEhmM3hhClBQNk1hb3dESUNWVXBLdDVNQW9HQ0NxR1NNNDlCQU1DQTBjQU1FUUNJRzZMYUJGeGgvcys2WXpGdVlwaUxUWlYKS2prOGh5UVNvMmVTZTJTUy81MG5BaUI0a0RNWnR4b1UzTitHc3BEOHVEbXFrNlhxbzZoeE0vbG0yU21OMzlPNAovdz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"
|
||||
etcd_ca_key = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUUwZ3JiMk0yblA4c1hxWjVhd3NzNThwbUdvb1FlSWg3a3RoWVlxNzhZZThvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFRTRpV2dZaHdUbTJwSzUveGxMeE5VMXNyT0ZFUkxFNUZPQXZQSmc3bjF3QTNGZ29PM1VodQorQWZkL0dxU0lkcFlQRC9DUTlSQkNldzNVbUQwNTdHb2JnPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo="
|
||||
aggregator_ca_crt = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJZVENDQVFhZ0F3SUJBZ0lSQUpxeFI3alBzcmhzRUp2cmtEWndYR3N3Q2dZSUtvWkl6ajBFQXdJd0FEQWUKRncweU5qQTNNRGt3TlRBM05UaGFGdzB6TmpBM01EWXdOVEEzTlRoYU1BQXdXVEFUQmdjcWhrak9QUUlCQmdncQpoa2pPUFFNQkJ3TkNBQVFBWWlieHY1ZDVFRGdTbWhlRHlhYjJLZGh4ZFZnZ3lQc2pNcjBET0JMVmxtQmpMcXFqClpNSkk2d1ZmV2hkUjBRVGxVdEFLZDJvREJZLy9TeDBzQi9qY28yRXdYekFPQmdOVkhROEJBZjhFQkFNQ0FvUXcKSFFZRFZSMGxCQll3RkFZSUt3WUJCUVVIQXdFR0NDc0dBUVVGQndNQ01BOEdBMVVkRXdFQi93UUZNQU1CQWY4dwpIUVlEVlIwT0JCWUVGSTkrWm1EYVBybDhrTnhXUUxOT3ErTmVzeEh0TUFvR0NDcUdTTTQ5QkFNQ0Ewa0FNRVlDCklRRC9DRUZ2SUVHMW9qcmRZRVUzUldmTklLV1FwVXlZQWJ1ZGE0T0ZWQS9yOUFJaEFPS1VOZFF6bUk2WVZOdzgKeklDejlLblRxazQrT3dvV3RjNVl5SmlKR29zUgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="
|
||||
aggregator_ca_key = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUFoOEwycXFLbUxoYkpmczJ2M3ZRWXBFZHF6Ri9hTGZhSmoraEZRazdPY2NvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFQUdJbThiK1hlUkE0RXBvWGc4bW05aW5ZY1hWWUlNajdJeks5QXpnUzFaWmdZeTZxbzJUQwpTT3NGWDFvWFVkRUU1VkxRQ25kcUF3V1AvMHNkTEFmNDNBPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo="
|
||||
service_account_key = "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKSndJQkFBS0NBZ0VBd3NNZU40eE1YQkZ0VVE2dVZ2NEZFU1dNT2ZLc1RKdmxHa0ptVG1URjJIazg1SlJvCkhuQU1nMEkvMEJiMGFUQTJ3MjlkSEUrdUN6dFd5eVVqTzV5eWdJSDQ0Q2RtVnNHVzVua1Z0TmFGNzNpcFllbjYKaG11bmRoWDY1VndqOHpVUmFYZUxtUEc0Y2d4dk5SOXdGYUZXS0ZZdzQzdGtlWjg5S0F5QTNibjFXN3JHTloyOQovcXRYWEcrQnFSSlVuUzhXQmt4dkErUktyRE1OVHF4bjVGOEl1VkpTWkw5bDVEd3lSUnNqMjVMZVdRZUFYenl6CllWcmFKZmVubSt6L3hLZjhLdHFZMSs5U1pwbkZsS2N6TGlWVzg4WkJ5TnR0KytYZjB4N3FSL2lQOHhRQXV0ckIKV1pTbytoa01FRjV5YU0wQWdxcG5mbDBsUmhsM0I0UUx3NHkyTUpoTEpyQ1ltQjl1QllLWU5oRE5NOXk0Z1lrZgpacmdEdm9NdGpsSUJUUHRyVjMyemxYN05Od3d5UjdXOXp5ZlBSVjEvbjhpMk9KR0szeittdGNCR2Y0TS8veDFsClJRNEhzWWhDUVJRaE8vQWp2U2QvQlhFRnB3dEhsb1VYRU5nQ2lPSW00MnFrZTNWb3lLbjJ2Q3g2QlhodlJVZDAKUHgvK0JpM0d4RmRoa2Uxa0doelJLQTdySGhEMVBkVGsyM04wUXV3WHNNVDZRWXVrRDdPZG5KdFZtZVN4TkxqcwptdVpybmpwRzZsaHRDSmdvMGdVeXBYQ2RlWGVnZ0dIc0JCMks4NjIrdThVa0dwMk9IQUhGeldsdDF5L2VXTW9PCmhneWx1SEVLcjkzUU5uNDZsTGtZTDViWjlVTUZ0NXBMRFVrSFQxNkV5dTh0V21ET2ZoQ3Z3M1lmRWY4Q0F3RUEKQVFLQ0FnQUdSbUlSV1JoV3VRc0VHd3g3NmdoQXdxeHZhNFdvbkRjMzd0Njc5Tnc0K3NMKy9GY1ViL2kvTytHeApjeVBoeGJkbCtZOE82L1JJRVZ2ZEJLL0xhbU9INTJnYzFMZ2o0RzNidEJnQ2NRejBwN2NSWEFnQno3TWdCMXBECmpJSHVBbzR5anpMMHRRa0R4Nm5IbE9FNEdUQWM4WlgycGxHWTU0d0JYOUhCRXc0NEs5N1orR0NZTlc0Rm9PUVYKRGUyaStOTGxWZzRYbW9IYlpYT3V6cmcwTCttb2l1SHp0QVQwNHdtZGwxL0M0Y3IvSkZJNi8wb3FQMUthK1kweApaV1BpTXFWWnZoeEJqTWpqWEYzMHlhUkkvdFA3MjYzZjZrM3pXVGNxWnFzV3NZZjF4WFcyajNpK1NaOWVHM043CmpZZHpIL085d2Y2K29BS2s3UW9jT0dGbXBnQnlwdm9JbytlSFdjZlpZNFNXNloyeTRacUl0M2xTSWFHMEtmQjEKUnVrSVBtMlYzNDNlc3Azdy9TV2liRVU2elhvd01sWjlZc0dPalY1MTZDQmJZK0lQcmY2RXY0UFhDWjlnUERxcgpnUFFIZ0lFS294aUdYK2dENG9pYWdUMWVVSk1iYWZkaEkzSHJMWk9YbUpBZU40RHpOZXR1SGRsTk13YnpZZHN5CnpyMllSMkhqNTZydk9hcWZZUFJKV1NmS3ozRlhQdDNDMHJRVmNRekVqV1ZZbm9MZG9yR3FkaU1uMyt1NzF2RW4KSnluZksvN3VRSDY5VTU3NGNKK3FjOFNtNTlPeHBnN1RoODljMUZTeSszTk03bTE2czlJcjQvcG94Q2pWaHNWeQpKRmN2cHE4UTMwek9CQk0waUxyblNKcVRXaXJDRU8xNHlRY0VLbFBVd2VGUWdJK05pUUtDQVFFQTMwMWNrTnZuCmpaRHduRXFQaWp4SlBDSUNEeHcxYWtnVTZUNUpMVkt5SUFnK3BYNHlPZDZtendvV2hpWE5oVUQ5eWxPL21lTEsKSDRPUXVzeWdJUDdkOVNKZi9ZVTV5a1RZaVZLMUdzM1loZXovRmhRQXdBQUg4UmdBdW8rZU9UTUcyT0IxM2loLworQlFYL1lrOG9VR1M0YlpsZWNGWG5pSTZ5S0g1MUJUQ3g0ZTZZcStvdGMrMFc2RzBRVHVBT3JWcjdXWklGY1htClVDdElReXRJN2s2UVd6SXU0K3dnVnU3OUdrUEJTMHNBaXhzU0ppYXRTNzk1TjlhYjYwRzNDeVFWZDZWajJaVmEKWGpCR1oyQXREalpEWS9MMDlZQTlRVUxDbWVqT2hYVFlGNkJuNnRkYmVjeVlZTHdNS2MzWEhqNEt2U2ZaQ01HTwpvUXROdnFoUFFlSWpxUUtDQVFFQTMwZnFCSThDK2QwdXJlM1JhNFFRdUlzUC9xVTMyejIzMUdzbTZOOXlQd0hUClM4ZzlrS0ZDYzhMVGlmWFdnajhIRWxTVjVLSXJseERwZEVWQ1pEM05qdy9GVDdHcHV5SVplN1E3VlhiNld3MnYKY1I0M1FkdUQvOEc4ZkdlZGkrZ1BnKzlzeURCQXFIVzFGSHl1RmVaWGNsVTF1cndOV3V3TlFKUnZHczFoK1NuQQpXVmJxd1ZUL01GMmYxTjlBaFN0alhkM0Nscm1rb0o4ek05YW0zVWg5VUprQ2xSZi9mZVRONXZndE1odS9NNGtGCmVQYUlBMTVqT3h1cEFMN3ptVVVxZ0h1NE9yMm5mYTVNNHMzWTR6TVRYYm9VZGNVT09oRTRzU0J2bGlaak5KL3QKV2pSTmJmUWhxbWRBTTNZZjFGUC8vRW9CYTBPejBxMHNXci92cEhDUlp3S0NBUUFxMlkySnZxa1FZVi9LbmdRdApZcVFyQmR1ZlNxcDFXcCtvb21zb1oxWUhENDMxOCtGdmVXcEpFSWFCOTM4WXN3QUFjMUd4RmZQeldDdk5yTGFOCm5scTVUMzljQnRTd0c4WHhsQTFzdDFOMVg2VVRkNE10Vk5ReFQ0blVRdnI1dnZEeGJTRXhJRlJ1Sm16MEdnR28KY0F6ZmcwQzF2SVF6dEIzVG9rRnVrUTFQZkp3bms4MnNGYzltUmdGeEF4bjRLaGdyMWhTL0dOcTVSNVQyVHJnUQpBc053dkpDQzdDeklnZFBQMW5DaElpTllqamxOV042b1NuWFlZVFpLVHJIeFVWdE5PaytPMFRvbUdOMXB1T3JzCmJ6MC9VTC93M0VyazJ3cTh2Zy9qVENpclgveVE5QUo1dk9rQXB4VXVjSEYzUERDVFc3SXFHL3Bpck9pZVRXM28KRnAwQkFvSUJBRjBxa3JsSU8wT3JTUmtHRE1aQ0d3QUY5cXlZb0EvNVZzVnAySmgrOUJyYVZpSmU4V0Z5Q0ZwcApSdjlmOXh2dDFMT1BXK1JFenMrQUhRbUpCTVR6RE56UEJkUFZIQytiY09xdkw3cmZwR050K0hESTNPRzhDUDRsCkJ0TWFJU0VKdWIraG5kQ0NZZGhwRlIveFRtcVE3SmdtZWY3ckROK05jNUlvM1p0ZmE2d2VBY2JGZjdzZ0RrTk8KTGEwVFlzYXViZzN5eElsRCtTK1VmamI1TUROUlZnalZiOEJxZlE4NDg3bVdnTFZSNHB4TVpsNHM4R0FIZUh4bgpkRU45YWdQZ1duVzJLZzlJcDZUSG9BbGJQMDYrTnl4NndxTEprTUFtQTNQVlJ2cHVGaU1WUUdMTlJDbkhIbTBPCkhEbmM1amNndmNXMTA1WEFjRDVPU0IydHpQN2VnYTBDZ2dFQUZhTHJxMDJ6M2tXaW1iVzdoNi9Pby9YRHdjZ2YKSmdXMDYyYWdWUlpEMjM3Mm4valZSL25kMnFybmwxTFdWaXpzWW91b3hvY2N6NUwvQ2h3MktCOC9Rc1Zxai9KOApOUWh1ZDJ2ZUxjQlUvVzNseVhENnJpajJZMythNzRvM1A4b1psOGdDQmJEck1jajNsMHRZMXRWbm9nOWo0eHQ3Ckp3UXA3djNXb3ArSFVuRWVjbGpscTdlN1VuTGNwZlRDOE5PUmdxbjBGSUtCbXdFNlpJQnQxUzB6NmxSKytJdVMKQk1oTTZTSkZmeUFOVVdjTCt2cHJoRzBjWUZmcUYzajlPZ1dXRnBGTHFWQmdSTlZBVDRoM0U1Ymh4L0lsMTVHMAo2USs2aWlkRElqMEhNNFAzcjhja2lRUlVPdFI1R2NVS1RYZGh1TjkwMjZGRTdEckRvSURVZ0F5dGVnPT0KLS0tLS1FTkQgUlNBIFBSSVZBVEUgS0VZLS0tLS0K"
|
||||
secretbox_encryption_secret = "RLkR4RmeaLmH/abyK3eYf6q22umJ/byLhheVCnh/yrA="
|
||||
|
||||
controlplane_configs = {
|
||||
"talos-cp-1" = {
|
||||
hostname = "talos-cp-1"
|
||||
lan_ip = "192.168.1.166"
|
||||
public_ip = "https://k8s-api.riotpiao.com"
|
||||
lan_subnet = "192.168.1.0/24"
|
||||
lan_gateway = "192.168.1.254"
|
||||
install_disk = "/dev/nvme0n1"
|
||||
longhorn_disks = []
|
||||
zone = "az-a"
|
||||
allow_scheduling = true
|
||||
cloudflare_talos_sans = []
|
||||
cloudflare_apiserver_sans = []
|
||||
},
|
||||
"talos-cp-2" = {
|
||||
hostname = "talos-cp-2"
|
||||
lan_ip = "192.168.1.214"
|
||||
public_ip = "https://k8s-api.riotpiao.com"
|
||||
lan_subnet = "192.168.1.0/24"
|
||||
lan_gateway = "192.168.1.254"
|
||||
install_disk = "/dev/disk/by-id/wwn-0x644a842029bd3f002720989b07d7143d"
|
||||
longhorn_disks = [
|
||||
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b60f4375d97062", mountpoint = "/var/lib/longhorn-disk1" },
|
||||
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b5ffeb8bb8b47f", mountpoint = "/var/lib/longhorn-disk2" },
|
||||
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b6000c8dabb266", mountpoint = "/var/lib/longhorn-disk3" },
|
||||
# /var/lib/longhorn-disk4 (wwn-0x6b083fe0c5782700321370dc23fd765b) was
|
||||
# retired 2026-10-12: the 2TB PERC virtual disk failed with continuous
|
||||
# SCSI I/O errors, hung its XFS filesystem, and wedged Longhorn replica
|
||||
# rebuilds on talos-cp-2 with unkillable D-state processes. Do not
|
||||
# re-add it.
|
||||
]
|
||||
zone = "az-b"
|
||||
allow_scheduling = true
|
||||
cloudflare_talos_sans = []
|
||||
cloudflare_apiserver_sans = []
|
||||
},
|
||||
"talos-cp-3" = {
|
||||
hostname = "talos-cp-3"
|
||||
lan_ip = "192.168.1.162"
|
||||
public_ip = "https://k8s-api.riotpiao.com"
|
||||
lan_subnet = "192.168.1.0/24"
|
||||
lan_gateway = "192.168.1.254"
|
||||
install_disk = "/dev/nvme0n1"
|
||||
longhorn_disks = [
|
||||
{ device = "/dev/disk/by-id/usb-Seagate_One_Touch_w_PW_00000000NABV3H34-0:0", mountpoint = "/var/lib/longhorn-paperless-media" },
|
||||
]
|
||||
zone = "az-c"
|
||||
allow_scheduling = true
|
||||
cloudflare_talos_sans = []
|
||||
cloudflare_apiserver_sans = []
|
||||
},
|
||||
}
|
||||
|
||||
worker_configs = {
|
||||
"worker-1" = {
|
||||
hostname = "worker-1"
|
||||
lan_ip = "192.168.1.223"
|
||||
lan_subnet = "192.168.1.0/24"
|
||||
lan_gateway = "192.168.1.254"
|
||||
install_disk = "/dev/nvme0n1"
|
||||
network_interface = "enp28s0f0np0"
|
||||
zone = "az-a"
|
||||
gpu_count = 4
|
||||
node_labels = {}
|
||||
node_taints = []
|
||||
factory_image = "factory.talos.dev/metal-installer/0a2153a6dc099a371bf2f63d6c3c22d275c876bf6302dd154c5813072924cb3f:v1.13.3"
|
||||
swap_size = "64GiB"
|
||||
ephemeral_max_size = "700GiB"
|
||||
extra_disks = []
|
||||
}
|
||||
}
|
||||
|
||||
cluster_config = {
|
||||
controlplane_ip = "192.168.1.166"
|
||||
pod_subnets = ["10.244.0.0/16"]
|
||||
service_subnets = ["10.96.0.0/12"]
|
||||
dns_servers = ["8.8.8.8", "1.1.1.1"]
|
||||
dns_domain = "cluster.local"
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
# The groups claim — the single load-bearing decision of the migration.
|
||||
#
|
||||
# ZITADEL does not issue a `groups` claim. Authentik did, and these consumers
|
||||
# read it: kube-apiserver RBAC (`--oidc-groups-claim=groups` plus 11 RoleBindings
|
||||
# on oidc:<group>), MinIO (`MINIO_IDENTITY_OPENID_CLAIM_NAME=groups`), Grafana
|
||||
# role mapping, and every service that filters on a group name.
|
||||
#
|
||||
# Plan §1.3 Option A keeps those consumers unchanged by synthesizing the claim.
|
||||
# Option B refactors all of them. This module takes responsibility only for the
|
||||
# ZITADEL side of Option A: the action resource and its trigger.
|
||||
#
|
||||
# The script body is NOT shipped by default. The go/no-go gate (§1.3, §1.5 task
|
||||
# 1.3) is: prove on the live instance that the action runtime can read project
|
||||
# roles plus user metadata and write both `groups` and the per-agent claims,
|
||||
# for a human token AND a machine-user token. Until that proof exists, enabling
|
||||
# this resource would move every consumer onto an unverified claim source:
|
||||
#
|
||||
# terraform apply -var enable_groups_claim_action=true \
|
||||
# -var groups_claim_script=./groups-claim.js
|
||||
#
|
||||
# After it applies, verify with a real token exchange before moving a consumer:
|
||||
#
|
||||
# curl -s -X POST https://zitadel.riotpiao.com/oauth/v2/token \
|
||||
# -d grant_type=refresh_token -d refresh_token=<t> \
|
||||
# -d client_id=kubernetes | jq -R 'split(".")[1] | @base64d | fromjson'
|
||||
#
|
||||
# The decoded access token must contain `"groups":["homelab-admins", ...]`.
|
||||
|
||||
resource "zitadel_action" "groups_claim" {
|
||||
count = var.enable_groups_claim_action ? 1 : 0
|
||||
|
||||
org_id = data.zitadel_org.default.id
|
||||
name = "homelab-groups-claim"
|
||||
script = file(var.groups_claim_script)
|
||||
timeout = "5s"
|
||||
allowed_to_fail = false
|
||||
}
|
||||
|
||||
# Both triggers are needed. The access-token trigger fills the JWT that
|
||||
# Kubernetes and MinIO read. The userinfo trigger fills the response that
|
||||
# Grafana, paperless and Forgejo read.
|
||||
resource "zitadel_trigger_actions" "groups_claim_access_token" {
|
||||
count = var.enable_groups_claim_action ? 1 : 0
|
||||
|
||||
org_id = data.zitadel_org.default.id
|
||||
flow_type = "FLOW_TYPE_CUSTOMISE_TOKEN"
|
||||
trigger_type = "TRIGGER_TYPE_PRE_ACCESS_TOKEN_CREATION"
|
||||
action_ids = [zitadel_action.groups_claim[0].id]
|
||||
}
|
||||
|
||||
resource "zitadel_trigger_actions" "groups_claim_userinfo" {
|
||||
count = var.enable_groups_claim_action ? 1 : 0
|
||||
|
||||
org_id = data.zitadel_org.default.id
|
||||
flow_type = "FLOW_TYPE_CUSTOMISE_TOKEN"
|
||||
trigger_type = "TRIGGER_TYPE_PRE_USERINFO_CREATION"
|
||||
action_ids = [zitadel_action.groups_claim[0].id]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
# API applications — the machine-token surface.
|
||||
#
|
||||
# ZITADEL keeps two different objects apart: an OIDC application is for a
|
||||
# browser flow, and an API application is what a client_credentials / JWT-profile
|
||||
# caller presents. Authentik modelled both with one provider, so the plan §1.1
|
||||
# row 1 ("OIDC app + machine user") needs one of each here.
|
||||
#
|
||||
# The gateway signs its token request with the machine key from machines.tf, so
|
||||
# the auth method is PRIVATE_KEY_JWT. No secret is stored for it.
|
||||
|
||||
resource "zitadel_application_api" "machine" {
|
||||
for_each = { for name, spec in local.machine_users : name => spec if contains(keys(var.api_app_consumers), name) }
|
||||
|
||||
org_id = data.zitadel_org.default.id
|
||||
project_id = zitadel_project_v2.homelab.id
|
||||
name = "${each.key}-machine"
|
||||
auth_method_type = "API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT"
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
# OIDC applications — one per consumer that logs a browser in.
|
||||
#
|
||||
# Client IDs: the plan §3.2 wants every client_id byte-identical after the move,
|
||||
# so that a consumer change is only the issuer URL plus the secret.
|
||||
#
|
||||
# THAT GOAL IS NOT ACHIEVABLE HERE, verified twice on 2026-10-24:
|
||||
# - ZITADEL v4.19.2 CreateOIDCApplicationRequest has no client_id field, and
|
||||
# CreateOIDCApplicationResponse documents the value as "generated"
|
||||
# (proto/zitadel/app/v2beta/app_service.proto:540-650).
|
||||
# - zitadel provider 3.8.7 exposes no settable application id: `id` on
|
||||
# `zitadel_application_v2` is not a valid argument (terraform validate
|
||||
# rejects it), and `client_id` on `zitadel_application_oidc` is computed.
|
||||
# Consequence: every consumer takes a NEW client_id as well as a new secret.
|
||||
# Budget the extra config change per consumer — this is not the one-line
|
||||
# re-point the plan assumed.
|
||||
#
|
||||
# The only way to keep the old value is to create each app through the v2 REST
|
||||
# API with an explicit body id, then import it:
|
||||
# POST /v2/projects/<project_id>/apps
|
||||
# {"id":"<old-client-id>","name":"<old-client-id>","oidcRequest":{...}}
|
||||
# Not implemented here: it would put app creation outside Terraform state.
|
||||
#
|
||||
# Secrets are generated by ZITADEL, never written here. `terraform output -json
|
||||
# client_ids` and `client_secrets` feed the SOPS re-point (§3.2, §6.9). Do not
|
||||
# commit the tfstate: it carries those values.
|
||||
|
||||
locals {
|
||||
oidc_clients = {
|
||||
# Gateway. Browser sessions for the console, plus a machine path
|
||||
# (machines.tf) for callers that need client_credentials.
|
||||
"api-gw" = {
|
||||
redirect_uris = ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"]
|
||||
app_type = "WEB"
|
||||
auth_method = "BASIC"
|
||||
dev_mode = false
|
||||
}
|
||||
"argocd" = {
|
||||
redirect_uris = ["http://localhost:8080/auth/callback", "https://argocd.riotpiao.com/auth/callback"]
|
||||
app_type = "WEB"
|
||||
auth_method = "BASIC"
|
||||
dev_mode = false
|
||||
}
|
||||
"forgejo" = {
|
||||
redirect_uris = ["http://localhost:3000/user/oauth2/authentik/callback", "https://forgejo.riotpiao.com/user/oauth2/authentik/callback"]
|
||||
app_type = "WEB"
|
||||
auth_method = "BASIC"
|
||||
dev_mode = false
|
||||
}
|
||||
"grafana" = {
|
||||
redirect_uris = ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"]
|
||||
app_type = "WEB"
|
||||
auth_method = "BASIC"
|
||||
dev_mode = false
|
||||
}
|
||||
"homarr" = {
|
||||
redirect_uris = ["http://localhost:7575/auth/callback", "https://homarr.riotpiao.com/auth/callback"]
|
||||
app_type = "WEB"
|
||||
auth_method = "BASIC"
|
||||
dev_mode = false
|
||||
}
|
||||
"immich" = {
|
||||
redirect_uris = ["app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login", "https://img.riotpiao.com/user/oauth2/callback"]
|
||||
app_type = "NATIVE"
|
||||
auth_method = "BASIC"
|
||||
dev_mode = false
|
||||
}
|
||||
"kubernetes" = {
|
||||
redirect_uris = ["http://localhost:8000", "http://localhost:18000"]
|
||||
app_type = "WEB"
|
||||
auth_method = "NONE"
|
||||
dev_mode = true
|
||||
}
|
||||
"minio" = {
|
||||
redirect_uris = ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"]
|
||||
app_type = "WEB"
|
||||
auth_method = "BASIC"
|
||||
dev_mode = false
|
||||
}
|
||||
"paperless" = {
|
||||
redirect_uris = ["http://localhost:8000/accounts/oidc/authentik/login/callback/", "https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"]
|
||||
app_type = "WEB"
|
||||
auth_method = "BASIC"
|
||||
dev_mode = false
|
||||
}
|
||||
"poimen" = {
|
||||
redirect_uris = ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"]
|
||||
app_type = "WEB"
|
||||
auth_method = "BASIC"
|
||||
dev_mode = false
|
||||
}
|
||||
"queue-sqs" = {
|
||||
redirect_uris = ["http://localhost:8080/callback", "https://queue.riotpiao.com/callback"]
|
||||
app_type = "WEB"
|
||||
auth_method = "BASIC"
|
||||
dev_mode = false
|
||||
}
|
||||
"vault" = {
|
||||
redirect_uris = ["http://localhost:8200/ui/vault/auth/oidc/oidc/callback", "https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"]
|
||||
app_type = "WEB"
|
||||
auth_method = "BASIC"
|
||||
dev_mode = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "zitadel_application_v2" "oidc" {
|
||||
for_each = local.oidc_clients
|
||||
|
||||
org_id = data.zitadel_org.default.id
|
||||
project_id = zitadel_project_v2.homelab.id
|
||||
name = each.key
|
||||
|
||||
oidc {
|
||||
redirect_uris = each.value.redirect_uris
|
||||
response_types = ["OIDC_RESPONSE_TYPE_CODE"]
|
||||
grant_types = [
|
||||
"OIDC_GRANT_TYPE_AUTHORIZATION_CODE",
|
||||
"OIDC_GRANT_TYPE_REFRESH_TOKEN",
|
||||
]
|
||||
app_type = "OIDC_APP_TYPE_${each.value.app_type}"
|
||||
auth_method_type = "OIDC_AUTH_METHOD_TYPE_${each.value.auth_method}"
|
||||
version = "OIDC_VERSION_1_0"
|
||||
|
||||
# JWT access tokens: Kubernetes RBAC, MinIO policy mapping and the Go
|
||||
# gateway all read claims, and an opaque bearer token carries none.
|
||||
access_token_type = "OIDC_TOKEN_TYPE_JWT"
|
||||
|
||||
# Assert roles without the client having to request the scope. Every
|
||||
# consumer here maps roles or groups to local permissions.
|
||||
access_token_role_assertion = true
|
||||
id_token_role_assertion = true
|
||||
id_token_userinfo_assertion = true
|
||||
|
||||
dev_mode = each.value.dev_mode
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
# Machine users and their project grants (plan §1.4, §5.3).
|
||||
#
|
||||
# Authentik gave each agent an app password or a client_credentials grant built
|
||||
# on a user password. ZITADEL has no user password on a machine user: it issues
|
||||
# a JWT profile key (`KEY_TYPE_JSON`) that the SDK signs its token request with.
|
||||
# That removes the password path entirely, which is the point — see §5.3.
|
||||
#
|
||||
# Per-agent claim values are copied from `SERVICE_ACCOUNTS` in
|
||||
# scripts/iam/provision-rbac.py. They live in user metadata as JSON strings,
|
||||
# because the groups-claim action (actions.tf) is the only thing that turns them
|
||||
# into token claims. Without that action a consumer sees roles but not
|
||||
# `minio_buckets`, `authorized_models`, `sqs_queues`, `memory_projects`,
|
||||
# `memory_visibility` or `paperless_doctypes`.
|
||||
#
|
||||
# Keys are generated per machine user and land in `terraform output -json
|
||||
# machine_keys` (sensitive). Feed each one to the owning workload as a mounted
|
||||
# file, never as an environment variable dumps-able through `ps`.
|
||||
|
||||
locals {
|
||||
machine_users = {
|
||||
"memory-agent" = {
|
||||
description = "Memory service agent"
|
||||
project_roles = ["poimen-admins"]
|
||||
authenticated_by = "poimen memory (Rust)"
|
||||
metadata = {
|
||||
roles = jsonencode(["llm:inference", "memory:read", "memory:write", "notification:write", "sessions:read", "sessions:write"])
|
||||
memory_projects = jsonencode(["*"])
|
||||
memory_visibility = "private"
|
||||
authorized_models = jsonencode(["reasoning", "ornith:35b", "qwen2.5:3b"])
|
||||
}
|
||||
}
|
||||
"paperless-ai-agent" = {
|
||||
description = "Paperless AI plugin (auto-tagging, entity extraction)"
|
||||
project_roles = ["poimen-devs"]
|
||||
authenticated_by = "paperless-ai"
|
||||
metadata = {
|
||||
roles = jsonencode(["llm:inference", "memory:write", "paperless:admin", "notification:write"])
|
||||
minio_buckets = jsonencode(["paperless"])
|
||||
paperless_doctypes = jsonencode(["*"])
|
||||
memory_projects = jsonencode(["*"])
|
||||
authorized_models = jsonencode(["reasoning", "qwen2.5:3b"])
|
||||
}
|
||||
}
|
||||
"portfolio-agent" = {
|
||||
description = "Portfolio service agent"
|
||||
project_roles = ["poimen-prod-readonly"]
|
||||
authenticated_by = "portfolio service"
|
||||
metadata = {
|
||||
roles = jsonencode(["llm:inference", "memory:read"])
|
||||
memory_projects = jsonencode(["homelab", "portfolio"])
|
||||
memory_visibility = "public"
|
||||
authorized_models = jsonencode(["ornith:35b"])
|
||||
minio_buckets = jsonencode(["backups"])
|
||||
}
|
||||
}
|
||||
"temporal-worker-agent" = {
|
||||
description = "Temporal workflow worker"
|
||||
project_roles = ["poimen-admins"]
|
||||
authenticated_by = "Temporal worker"
|
||||
metadata = {
|
||||
roles = jsonencode(["llm:inference", "workflow:execute", "memory:read", "memory:write", "queue:send", "notification:write"])
|
||||
memory_projects = jsonencode(["*"])
|
||||
sqs_queues = jsonencode(["*"])
|
||||
authorized_models = jsonencode(["reasoning", "ornith:35b", "qwen2.5:3b"])
|
||||
}
|
||||
}
|
||||
# The gateway is the one consumer that used client_credentials on a machine
|
||||
# token, so it needs both the OIDC app (clients.tf) and a machine user
|
||||
# (here) plus its API application (api_apps.tf). Its capability check reads
|
||||
# the `roles` claim, which arrives as metadata.
|
||||
"api-gw" = {
|
||||
description = "Go LLM gateway machine identity"
|
||||
project_roles = []
|
||||
authenticated_by = "api-gw"
|
||||
metadata = {
|
||||
roles = jsonencode(["llm:inference"])
|
||||
}
|
||||
}
|
||||
# pi-worker has no anchor in git (plan §1.1: "not in git") and no group in
|
||||
# the Authentik provisioning script. It gets a machine user and no project
|
||||
# role until the owner names the consumer configuration that holds its old
|
||||
# credentials. Creating a role grant blind would widen access, not preserve it.
|
||||
"pi-worker" = {
|
||||
description = "pi sandbox worker (no git anchor for its consumer config)"
|
||||
project_roles = []
|
||||
authenticated_by = "pi sandbox worker"
|
||||
metadata = {
|
||||
roles = jsonencode([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
machine_metadata = merge([
|
||||
for user, spec in local.machine_users : {
|
||||
for key, value in spec.metadata : "${user}/${key}" => {
|
||||
user = user
|
||||
key = key
|
||||
value = value
|
||||
}
|
||||
}
|
||||
]...)
|
||||
}
|
||||
|
||||
resource "zitadel_machine_user" "agent" {
|
||||
for_each = local.machine_users
|
||||
|
||||
org_id = data.zitadel_org.default.id
|
||||
user_name = each.key
|
||||
name = each.key
|
||||
description = "${each.value.description} — consumer: ${each.value.authenticated_by}"
|
||||
with_secret = false
|
||||
}
|
||||
|
||||
resource "zitadel_machine_key" "agent" {
|
||||
for_each = local.machine_users
|
||||
|
||||
org_id = data.zitadel_org.default.id
|
||||
user_id = zitadel_machine_user.agent[each.key].id
|
||||
key_type = "KEY_TYPE_JSON"
|
||||
expiration_date = var.machine_key_expiry
|
||||
}
|
||||
|
||||
resource "zitadel_project_member" "agent" {
|
||||
for_each = { for user, spec in local.machine_users : user => spec if length(spec.project_roles) > 0 }
|
||||
|
||||
org_id = data.zitadel_org.default.id
|
||||
project_id = zitadel_project_v2.homelab.id
|
||||
user_id = zitadel_machine_user.agent[each.key].id
|
||||
roles = each.value.project_roles
|
||||
}
|
||||
|
||||
resource "zitadel_user_metadata" "agent_claim" {
|
||||
for_each = local.machine_metadata
|
||||
|
||||
org_id = data.zitadel_org.default.id
|
||||
user_id = zitadel_machine_user.agent[each.value.user].id
|
||||
key = each.value.key
|
||||
value = each.value.value
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
# Outputs feed two things: the SOPS re-point of consumer secrets (§3.2, §6.9)
|
||||
# and the machine-key handoff to each agent workload (§5.3). Every value that can
|
||||
# authenticate a client is marked sensitive, so `terraform output` prints a
|
||||
# placeholder until you ask for it explicitly.
|
||||
|
||||
output "org_id" {
|
||||
description = "Organization that owns the project, roles and applications."
|
||||
value = data.zitadel_org.default.id
|
||||
}
|
||||
|
||||
output "project_id" {
|
||||
description = "Project ID, which ZITADEL puts in the access-token `aud` claim."
|
||||
value = zitadel_project_v2.homelab.id
|
||||
}
|
||||
|
||||
output "client_ids" {
|
||||
description = "client_id per consumer. Compare against the Authentik values: any difference means the consumer config must change too."
|
||||
value = { for name, app in zitadel_application_v2.oidc : name => app.oidc[0].client_id }
|
||||
}
|
||||
|
||||
output "client_secrets" {
|
||||
description = "Generated client secret per consumer. Write these into the existing SOPS files; never into git."
|
||||
value = { for name, app in zitadel_application_v2.oidc : name => app.oidc[0].client_secret }
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "api_app_client_ids" {
|
||||
description = "client_id of each API application (machine-token surface)."
|
||||
value = { for name, app in zitadel_application_api.machine : name => app.client_id }
|
||||
}
|
||||
|
||||
output "machine_user_ids" {
|
||||
description = "ZITADEL user ID per machine user."
|
||||
value = { for name, user in zitadel_machine_user.agent : name => user.id }
|
||||
}
|
||||
|
||||
output "machine_keys" {
|
||||
description = "JWT profile key JSON per machine user. Mount as a file; do not pass through the environment."
|
||||
value = { for name, key in zitadel_machine_key.agent : name => key.key_details }
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "groups_claim_enabled" {
|
||||
description = "Whether the groups-claim action and its triggers exist. False means Option A is not yet in place and no consumer may move."
|
||||
value = var.enable_groups_claim_action
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
# Project, roles and default-org lookup.
|
||||
#
|
||||
# ZITADEL has no group object. The plan §1.3 maps every Authentik group to a
|
||||
# project role, and the groups-claim action (actions.tf) reconstructs the
|
||||
# `groups` claim from those role keys. Role keys therefore EQUAL the existing
|
||||
# Authentik group names, including the hyphens, so that the resulting claim is
|
||||
# byte-identical and no consumer or RBAC file changes. The v4.19.2 API allows
|
||||
# role keys of 1-200 characters with no character-set restriction (proto
|
||||
# zitadel/project/v2/project_service.proto, AddProjectRole.role_key).
|
||||
|
||||
data "zitadel_org" "default" {
|
||||
id = var.org_id
|
||||
}
|
||||
|
||||
locals {
|
||||
# Single source: the 19 groups of scripts/iam/provision-rbac.py GROUPS.
|
||||
# Descriptions are carried over so the console reads the same as before.
|
||||
roles = {
|
||||
"homelab-admins" = "Cluster administrators with full access"
|
||||
"minio-admins" = "MinIO administrators"
|
||||
"minio-photos" = "Photos bucket (Immich) access"
|
||||
"minio-documents" = "Documents bucket (Paperless) access"
|
||||
"minio-backups" = "Backups bucket read-only access"
|
||||
"poimen-admins" = "Poimen memory administrators"
|
||||
"poimen-devs" = "Dev and staging projects access"
|
||||
"poimen-prod-readonly" = "Production projects read-only access"
|
||||
"paperless-admins" = "Paperless administrators"
|
||||
"paperless-finance" = "Finance documents"
|
||||
"paperless-legal" = "Legal documents"
|
||||
"paperless-hr" = "HR documents"
|
||||
"grafana-admins" = "Grafana administrators"
|
||||
"grafana-editors" = "Grafana dashboard editors"
|
||||
"grafana-viewers" = "Grafana dashboard viewers"
|
||||
"sqs-users" = "SQS/Temporal queue read access"
|
||||
"sqs-writers" = "SQS/Temporal queue read/write access"
|
||||
"s3-users" = "S3 read access"
|
||||
"s3-writers" = "S3 read/write access"
|
||||
}
|
||||
}
|
||||
|
||||
resource "zitadel_project_v2" "homelab" {
|
||||
org_id = data.zitadel_org.default.id
|
||||
name = var.project_name
|
||||
project_id = var.project_id
|
||||
project_role_check = false
|
||||
|
||||
# Roles must reach the tokens. Every consumer authorizes on them:
|
||||
# kube-apiserver RBAC reads `groups`, MinIO reads bucket policies, Grafana
|
||||
# reads grafana_org_role. Role assertion on the ID token is what most OIDC
|
||||
# clients inspect.
|
||||
project_role_assertion = true
|
||||
}
|
||||
|
||||
resource "zitadel_project_role" "groups" {
|
||||
for_each = local.roles
|
||||
|
||||
org_id = data.zitadel_org.default.id
|
||||
project_id = zitadel_project_v2.homelab.id
|
||||
role_key = each.key
|
||||
display_name = each.key
|
||||
group = "homelab"
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
# terraform/zitadel — identity configuration for the ZITADEL instance
|
||||
#
|
||||
# Scope: this root module declares the objects INSIDE ZITADEL (org, project,
|
||||
# roles, OIDC applications, machine users, grants). It does NOT deploy ZITADEL
|
||||
# itself — that is ArgoCD's job (`k8s/argocd/apps/30-security.yaml`, values in
|
||||
# `k8s/infra/iam/zitadel-values.yaml`).
|
||||
#
|
||||
# Migration plan: ~/workplace/homelab-doc/ZITADEL_MIGRATION.html (outside git).
|
||||
# This file implements §1.3 (roles), §1.4 (machine users), §2.4 (grants) and
|
||||
# §6.2 (Terraform provider route). The claim-injection action of §1.3 lives in
|
||||
# `actions.tf` and stays disabled until the live go/no-go gate proves it.
|
||||
#
|
||||
# Authentication to ZITADEL uses the `iam-admin` machine key that the chart's
|
||||
# setup job writes to the `iam-admin` Secret in namespace iam:
|
||||
#
|
||||
# kubectl -n iam get secret iam-admin -o jsonpath='{.data.iam-admin\.json}' \
|
||||
# | base64 -d > ~/.config/zitadel/iam-admin.json
|
||||
# chmod 600 ~/.config/zitadel/iam-admin.json
|
||||
#
|
||||
# export ZITADEL_JWT_PROFILE_FILE=$HOME/.config/zitadel/iam-admin.json
|
||||
# terraform -chdir=terraform/zitadel init
|
||||
# terraform -chdir=terraform/zitadel plan
|
||||
#
|
||||
# State is LOCAL and git-ignored (`terraform/*.tfstate` in .gitignore). That is
|
||||
# a deliberate first step, not the final answer: §8 risk 8 of the plan names
|
||||
# state drift as a blast-radius item. Move the state to MinIO before a second
|
||||
# operator ever runs `apply`. No secret is written to state except the generated
|
||||
# client secrets and machine keys, which are marked sensitive — they still land
|
||||
# in the state file, so treat that file as a secret.
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
zitadel = {
|
||||
source = "zitadel/zitadel"
|
||||
version = "3.8.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "zitadel" {
|
||||
domain = var.zitadel_domain
|
||||
port = var.zitadel_port
|
||||
|
||||
# JWT profile of the iam-admin machine user. Preferred over a PAT: it is
|
||||
# short-lived per call and never sits in the environment as a bearer token.
|
||||
# Set the path through ZITADEL_JWT_PROFILE_FILE.
|
||||
jwt_profile_file = var.jwt_profile_file
|
||||
|
||||
# The instance serves plain HTTP behind ingress-nginx, which terminates TLS.
|
||||
# Turn this on only if the provider must call the pod directly.
|
||||
insecure = var.zitadel_insecure
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
variable "zitadel_domain" {
|
||||
description = "External domain of the ZITADEL instance, without scheme."
|
||||
type = string
|
||||
default = "zitadel.riotpiao.com"
|
||||
}
|
||||
|
||||
variable "zitadel_port" {
|
||||
description = "Port for the ZITADEL API. 443 behind ingress, 8080 for a direct pod port-forward."
|
||||
type = string
|
||||
default = "443"
|
||||
}
|
||||
|
||||
variable "zitadel_insecure" {
|
||||
description = "Call the API over plain HTTP (set true only when port-forwarding the pod)."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "jwt_profile_file" {
|
||||
description = "Path to the iam-admin machine key JSON extracted from the iam-admin Secret."
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "org_id" {
|
||||
description = <<-EOT
|
||||
ID of the ZITADEL organization that holds the project. Read it from the
|
||||
console: https://zitadel.riotpiao.com -> Organization settings -> Resource
|
||||
ID. The chart's setup job creates the default org; this module never creates
|
||||
one, because creating a second org would split the role and grant space.
|
||||
EOT
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "project_id" {
|
||||
description = <<-EOT
|
||||
Stable project ID. ZITADEL puts the project ID in the access token `aud`
|
||||
claim, so pinning it keeps audience checks readable across a rebuild.
|
||||
Every consumer that validates `aud` against a client ID must be re-pointed
|
||||
to this value — see the plan §5.2.
|
||||
EOT
|
||||
type = string
|
||||
default = "homelab"
|
||||
}
|
||||
|
||||
variable "project_name" {
|
||||
description = "Display name of the project that carries every role and application."
|
||||
type = string
|
||||
default = "homelab"
|
||||
}
|
||||
|
||||
variable "machine_key_expiry" {
|
||||
description = "Expiry of every machine key, RFC3339. Matches the chart's default iam-admin key window; rotate by changing this value and re-applying."
|
||||
type = string
|
||||
default = "2029-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
variable "api_app_consumers" {
|
||||
description = <<-EOT
|
||||
Machine users that also need an API application (the client_credentials
|
||||
replacement). Only the gateway does: every other agent signs a JWT with its
|
||||
machine key and does not need a client_credentials grant.
|
||||
EOT
|
||||
type = map(string)
|
||||
default = { api-gw = "Go LLM gateway" }
|
||||
}
|
||||
|
||||
variable "enable_groups_claim_action" {
|
||||
description = <<-EOT
|
||||
Create the Actions v1 script and its trigger that synthesizes the `groups`
|
||||
claim. Keep this false until the live go/no-go gate passes: the claim is
|
||||
load-bearing for kube-apiserver RBAC, MinIO policies, Grafana role mapping
|
||||
and every service that reads `groups`. Turning it on with an unverified
|
||||
script can break every consumer at once.
|
||||
EOT
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "groups_claim_script" {
|
||||
description = <<-EOT
|
||||
Path to the JavaScript body for the groups-claim action, read only when
|
||||
enable_groups_claim_action is true. The body is deliberately not shipped
|
||||
blind: the ZITADEL v1 Actions runtime API must be confirmed against the live
|
||||
instance before a script is committed, because a wrong call silently emits
|
||||
no claim instead of failing loudly.
|
||||
EOT
|
||||
type = string
|
||||
default = null
|
||||
|
||||
validation {
|
||||
# try() is required: HCL does not short-circuit `||`, so fileexists(null)
|
||||
# would fail the whole plan even with the action disabled.
|
||||
condition = try(fileexists(var.groups_claim_script), true)
|
||||
error_message = "groups_claim_script must name an existing file when it is set."
|
||||
}
|
||||
|
||||
# Enabling the action without a script would reach file(null) inside actions.tf.
|
||||
validation {
|
||||
condition = !var.enable_groups_claim_action || var.groups_claim_script != null
|
||||
error_message = "enable_groups_claim_action requires groups_claim_script."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user