nas-traefik Ansible Role Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use dev-flow:subagent-driven-development (recommended) or dev-flow:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Codify the NAS’s TrueNAS community-catalog Traefik app as an idempotent Ansible role (nas-traefik) so a NAS rebuild reproduces it from Git, sourcing the Cloudflare DNS-01 token from Vault and scoping the insecure dashboard off the 0.0.0.0 wildcard.
Architecture: A midclt-driven reconcile role modeled on ansible/roles/nas-otel-collector (control-node Vault reads via community.hashi_vault.vault_kv2_get; app.query → app.create/app.update --job). Unlike nas-otel (a custom_app with a compose string), Traefik is a catalog app (train=community, app=traefik, version=1.0.15), so the payload is a declarative values object built from role defaults + the Vault-sourced token. Bead: hl-383r. Pairs with hl-uegs item 3 (:444 hardening).
Tech Stack: Ansible (FQCN modules, no_log), TrueNAS 26 middleware API (midclt call app.* --job), HashiCorp Vault (KV v2 + optionally a dedicated Cloudflare API token), scripts/nas-playbook.sh (Vault-key SSH wrapper).
Decisions (resolve before/within implementation)
Section titled “Decisions (resolve before/within implementation)”D1 — Cloudflare token source (SECURITY). The app needs CF_DNS_API_TOKEN for ACME DNS-01. Two options:
- (Recommended) Dedicated DNS-scoped token at
secret/fzymgc-house/infrastructure/nas/traefik-cf-dns-token(fieldtoken), provisioned viatf/cloudflare(acloudflare_api_tokenscoped toZone:DNS:Editonfzymgc.house) + written to Vault viatf/vault. Least privilege; a NAS compromise can’t touch the whole CF account. - (Fallback) Reuse
secret/fzymgc-house/infrastructure/cloudflare/bootstrap-token(fieldtoken) — the broad account token the TF cloudflare provider uses. Simpler, but widens blast radius and couples the NAS app to the bootstrap credential.
The role reads from a configurable var (nas_traefik_cf_token_vault_path / _property), so the choice is a defaults change, not a code change. Task 1 provisions the dedicated token (separate tf/cloudflare + tf/vault PR/apply); if deferred, set the var to the bootstrap path and file a follow-up bead.
D2 — :444 dashboard hardening (hl-uegs item 3). Currently api_port.host_ips: [] (binds 0.0.0.0:444) with --api.insecure=true — an unauthenticated HTTP dashboard on the whole LAN. This role sets api_port.host_ips: ["192.168.20.201"] so the dashboard is reachable only on the Traefik VIP, not .200/all interfaces. (A stronger follow-up — route the dashboard through web-secure + forward-auth and drop api.insecure — is out of scope here and tracked separately.)
File Structure
Section titled “File Structure”ansible/roles/nas-traefik/defaults/main.yml— all declarative values (catalog coordinates, network, storage, run_as, resources, ACME args, Vault token path). One responsibility: the desired app spec.ansible/roles/nas-traefik/meta/main.yml— role metadata (galaxy_info, no deps), mirrors nas-otel-collector.ansible/roles/nas-traefik/tasks/main.yml— the reconcile: read token from Vault → buildvalues→app.query→app.create(absent) /app.update(drift).ansible/nas-playbook.yml— add anas-traefik-tagged role invocation (targetsnas).ansible/CLAUDE.md— add the role to the Roles Reference table.docs/operations/nas.md— add a Tag-map row + a short “Traefik app” note.tf/cloudflare/nas_traefik_token.tf— (Task 1, D1) mint the dedicated CF DNS token and write it to Vault, co-located in one module (pernas_email.tf, because asensitivecomputed value can’t cross workspaces).tf/vault/policy-terraform-workspaces.tf— (Task 1) grantterraform-cloudflare-adminwrite on the new KV path (applies first).
Task 0: Confirm the catalog app.create/app.update values schema
Section titled “Task 0: Confirm the catalog app.create/app.update values schema”Files: none (investigation; findings inform Task 3).
- Step 1: Dump the live values shape
Run (via the Vault-key wrapper pattern):
ssh nas-automation@192.168.20.200 \ 'sudo midclt call app.config traefik' | python3 -m json.toolExpected: JSON containing top-level TZ, network (api_port, entry_points), storage, run_as, resources, labels, traefik (api_insecure, dashboard, additional_args, additional_envs). The user-editable subset (everything except ix_context, ix_volumes, ix_certificates, release_name) is the values object.
- Step 2: Confirm the create/update call signatures
Run:
ssh nas-automation@192.168.20.200 'sudo midclt call app.create' 2>&1 | headssh nas-automation@192.168.20.200 'sudo midclt call app.update' 2>&1 | headExpected: an Error: … missing … required message that names the accepted params. Confirm app.create accepts {app_name, catalog_app, train, version, values} and app.update accepts (app_name, {values}). If the names differ, adjust Task 3’s payload keys accordingly — this is the one schema assumption in the plan.
- Step 3: Record findings
Append to bead: bd note hl-383r "app.create/update schema: <verified keys>".
Task 1: Provision a dedicated Cloudflare DNS token in Vault (D1)
Section titled “Task 1: Provision a dedicated Cloudflare DNS token in Vault (D1)”Files:
- Create:
tf/cloudflare/nas_traefik_token.tf(mint the token + co-located Vault write) - Modify:
tf/vault/policy-terraform-workspaces.tf(grantterraform-cloudflare-adminwrite on the new KV path — applies first)
Two workspaces, ordered:
tf/vault(main-cluster-vault) applies first (the KV-path grant), thentf/cloudflare(main-cluster-cloudflare) mints + writes. The token mint and thevault_kv_secret_v2write live in the same module (tf/cloudflare), exactly likenas_email.tf— asensitivecomputed token value cannot cross workspaces. If deferring, skip to Task 2 and setnas_traefik_cf_token_vault_pathto the bootstrap path (D1 fallback), then file a follow-up bead.
- Step 1: Grant the Vault KV path (
tf/vault, applies first)
In tf/vault/policy-terraform-workspaces.tf, add paired secret/data/… + secret/metadata/… write capability for fzymgc-house/infrastructure/nas/traefik-cf-dns-token to the terraform-cloudflare-admin policy — copy the existing infrastructure/nas/cloudflare-email grant as the template. Without it, the tf/cloudflare KV write is denied.
- Step 2: Mint the token + write to Vault (
tf/cloudflare)
Create tf/cloudflare/nas_traefik_token.tf. All referenced data sources/vars already exist in the module (data.cloudflare_account_api_token_permission_groups_list.all in api-token-permission-groups.tf, data.cloudflare_zone.fzymgc_house in terraform.tf, var.cloudflare_account_id):
# SPDX-License-Identifier: MIT# terraform: language=hcl# nas_traefik_token.tf - Dedicated Cloudflare DNS token for the NAS Traefik app's# ACME DNS-01 challenge. Least-privilege vs the account-wide bootstrap token:# scoped to DNS edit on the fzymgc.house zone. Read control-node-side by the# nas-traefik Ansible role. Modeled on nas_email.tf. [hl-383r]## PREREQUISITE: the bootstrap token (ephemeral.vault_kv_secret_v2.cloudflare_bootstrap_token)# already manages all account DNS, so it HOLDS "DNS Write" and can grant it — no 403# (unlike the email tokens, whose group the bootstrap lacked). The tf/vault KV-path# grant (Step 1) MUST apply first or the vault write is denied.locals { # Resolve the exact permission-group id by display name from the full account # list. A wrong/drifted name yields [] -> "index 0 out of range" at plan. dns_write_permission_group = [ for pg in data.cloudflare_account_api_token_permission_groups_list.all.result : pg.id if pg.name == "DNS Write" ][0]}
# Account-owned (cloudflare_account_token, NOT cloudflare_api_token — survives the# bootstrap user leaving), scoped to the fzymgc.house ZONE resource so the token# can only edit DNS in that one zone.resource "cloudflare_account_token" "nas_traefik_dns" { account_id = var.cloudflare_account_id name = "nas-traefik-acme-dns"
policies = [{ effect = "allow" permission_groups = [ { id = local.dns_write_permission_group }, ] resources = jsonencode({ "com.cloudflare.api.account.zone.${data.cloudflare_zone.fzymgc_house.id}" = "*" }) }]}
# Credential -> Vault (co-located; read by ansible/roles/nas-traefik over the# operator's Vault session, not consumed by any TFC workspace or ESO).resource "vault_kv_secret_v2" "nas_traefik_cf_dns" { mount = "secret" name = "fzymgc-house/infrastructure/nas/traefik-cf-dns-token"
data_json = jsonencode({ token = cloudflare_account_token.nas_traefik_dns.value })
custom_metadata { max_versions = 10 data = { managed_by = "terraform" purpose = "nas-traefik-acme-dns01" } }}Confirm
"DNS Write"is the exact account permission-group display name (the[0]fails loudly at plan if not). If the bootstrap token turns out NOT to hold DNS edit, add “Zone / DNS : Edit” to it in the Cloudflare dashboard (same prerequisite class asnas_email.tf).
- Step 3: Plan/verify
Run: terraform -chdir=tf/cloudflare plan (with a Vault session). Expect cloudflare_account_token.nas_traefik_dns + vault_kv_secret_v2.nas_traefik_cf_dns created (2 add). Do not apply locally; merge triggers TFC (tf/vault first, then tf/cloudflare).
- Step 4: Commit (VCS per
references/vcs-preamble.md).
Task 2: Role scaffold — defaults + meta
Section titled “Task 2: Role scaffold — defaults + meta”Files:
- Create:
ansible/roles/nas-traefik/defaults/main.yml - Create:
ansible/roles/nas-traefik/meta/main.yml - Step 1: Write
defaults/main.yml
# SPDX-License-Identifier: MIT-0# code: language=ansible---nas_traefik_app_name: traefiknas_traefik_catalog_app: traefiknas_traefik_train: communitynas_traefik_version: "1.0.15" # traefik v3.7.5
# Cloudflare DNS-01 token (control-node Vault read; D1). Point at the dedicated# scoped token; fall back to the bootstrap token only if Task 1 is deferred.nas_traefik_cf_token_vault_path: fzymgc-house/infrastructure/nas/traefik-cf-dns-tokennas_traefik_cf_token_property: token
# VIP the app publishes on (the NAS-apps reverse-proxy alias on bond0).nas_traefik_vip_v4: "192.168.20.201"nas_traefik_vip_v6: "fddb:f665:73f7:1::201"nas_traefik_acme_email: "nas-admin@fzymgc.house"
# On-box drift marker: the desired (non-secret) values are rendered here, and the# reconcile gates app.update on this file changing — so re-runs with an unchanged# spec report `ok`. Mirrors nas-otel-collector's compose drift-marker pattern.nas_traefik_state_dir: /mnt/apps/traefiknas_traefik_marker_path: "{{ nas_traefik_state_dir }}/desired-values.json"
# Desired declarative values (the app.update `values` object minus the secret,# which is merged in at runtime). Mirrors `midclt call app.config traefik`.nas_traefik_values: TZ: America/New_York labels: [] run_as: { user: 568, group: 568 } resources: { limits: { cpus: 2, memory: 4096 } } network: host_network: false # Hardened: bind the insecure dashboard to the VIP only (was host_ips: [] # == 0.0.0.0, unauth on the whole LAN). [hl-uegs item 3]. IPv4-only is # intentional (more restrictive than the dual-stack data entrypoints below). # The container-internal ping:55555 healthcheck is NOT a user-editable network # field (it lives in the catalog template's healthcheck, not `values`), so it # is correctly absent here; Task 5 Step 3 confirms it survives the reconcile. api_port: { bind_mode: published, port_number: 444, host_ips: ["192.168.20.201"] } entry_points: - { entrypoint_name: web, bind_mode: published, port_number: 80, protocol: tcp, host_ips: ["192.168.20.201", "fddb:f665:73f7:1::201"] } - { entrypoint_name: web-secure, bind_mode: published, port_number: 443, protocol: tcp, host_ips: ["192.168.20.201", "fddb:f665:73f7:1::201"] } networks: - { name: nas-internal, containers: [{ name: traefik, config: { gw_priority: 2, priority: 2, aliases: [] } }] } - { name: nas-public, containers: [{ name: traefik, config: { gw_priority: 1, priority: 1, aliases: [] } }] } storage: acme: { type: ix_volume, ix_volume_config: { dataset_name: acme, acl_enable: false } } config: { type: ix_volume, ix_volume_config: { dataset_name: config, acl_enable: false } } additional_storage: [] traefik: api_insecure: true dashboard: true additional_args: - { flag: "--certificatesresolvers.cloudflare.acme.email", value: "nas-admin@fzymgc.house" } - { flag: "--certificatesresolvers.cloudflare.acme.dnschallenge.provider", value: "cloudflare" } - { flag: "--certificatesresolvers.cloudflare.acme.dnschallenge.propagation.disablechecks", value: "true" } - { flag: "--certificatesresolvers.cloudflare.acme.dnschallenge.propagation.delayBeforeChecks", value: "30s" } - { flag: "--providers.docker.exposedbydefault", value: "false" } # additional_envs is injected at runtime (Task 3) with the Vault token.-
Step 2: Write
meta/main.yml(copy the shape ofansible/roles/nas-otel-collector/meta/main.yml; no dependencies; galaxy_info role_namenas_traefik). -
Step 3: Commit.
Task 3: Reconcile task — Vault read, build values, create/update
Section titled “Task 3: Reconcile task — Vault read, build values, create/update”Files:
-
Create:
ansible/roles/nas-traefik/tasks/main.yml -
Step 1: Write
tasks/main.yml
# SPDX-License-Identifier: MIT-0# code: language=ansible---# Traefik as a TrueNAS COMMUNITY-CATALOG app (train=community, app=traefik).# Unlike nas-otel (custom_app), the payload is a declarative `values` object.# The Cloudflare DNS-01 token is read on the CONTROL NODE (no on-box vault-agent)# and merged into values.traefik.additional_envs. midclt calls use argv + --job.- name: Read Cloudflare DNS token from Vault ansible.builtin.set_fact: nas_traefik_cf_token: >- {{ lookup('community.hashi_vault.vault_kv2_get', nas_traefik_cf_token_vault_path, ca_cert=vault_ca_cert_bundle).data.data[nas_traefik_cf_token_property] }} no_log: true
- name: Compose the full values object (inject the secret env) ansible.builtin.set_fact: nas_traefik_full_values: >- {{ nas_traefik_values | combine({ 'traefik': nas_traefik_values.traefik | combine({ 'additional_envs': [ {'name': 'CF_DNS_API_TOKEN', 'value': nas_traefik_cf_token} ] }) }, recursive=True) }} no_log: true
- name: Ensure state dir for the drift marker ansible.builtin.file: path: "{{ nas_traefik_state_dir }}" state: directory mode: "0750" become: true
- name: Render desired-values drift marker (NON-SECRET spec only) ansible.builtin.copy: content: "{{ nas_traefik_values | to_nice_json }}\n" dest: "{{ nas_traefik_marker_path }}" mode: "0640" become: true register: nas_traefik_marker
- name: Query existing app ansible.builtin.command: argv: [midclt, call, app.query, '[["name","=","{{ nas_traefik_app_name }}"]]'] become: true changed_when: false check_mode: false register: nas_traefik_app_q
- name: Create catalog app (absent) ansible.builtin.command: argv: - midclt - call - --job - app.create - >- {{ { "app_name": nas_traefik_app_name, "catalog_app": nas_traefik_catalog_app, "train": nas_traefik_train, "version": nas_traefik_version, "values": nas_traefik_full_values } | to_json }} become: true when: (nas_traefik_app_q.stdout | from_json) | length == 0 changed_when: true no_log: true
- name: Update catalog app (present) — only when the desired spec changed ansible.builtin.command: argv: - midclt - call - --job - app.update - "{{ nas_traefik_app_name }}" - "{{ {'values': nas_traefik_full_values} | to_json }}" become: true # Real idempotency gate (mirrors nas-otel-collector's marker-change gate): update # only when the app exists AND our desired spec changed, so a re-run with an # unchanged spec reports `ok`, not `changed`. Token-only rotation does not touch # the marker — force a refresh with `-e nas_traefik_force=true`. when: - (nas_traefik_app_q.stdout | from_json) | length > 0 - (nas_traefik_marker is changed) or (nas_traefik_force | default(false) | bool) changed_when: true no_log: true-
Step 2: Adjust payload keys to match Task 0’s verified schema if they differ (e.g.
catalog_app/train/versionnaming). -
Step 3: Commit.
Task 4: Wire into the playbook + tag
Section titled “Task 4: Wire into the playbook + tag”Files:
-
Modify:
ansible/nas-playbook.yml -
Step 1: Add the role invocation
Locate the nas-targeted play in ansible/nas-playbook.yml (same play the other nas-* roles run under) and add:
- role: nas-traefik tags: [nas-traefik](Match the exact list/mapping style already used in that file — confirm by reading it first.)
- Step 2: Syntax check
Run: ansible-playbook -i ansible/inventory ansible/nas-playbook.yml --syntax-check
Expected: no errors.
- Step 3: Commit.
Task 5: Verify (dry-run → apply → observe)
Section titled “Task 5: Verify (dry-run → apply → observe)”Files: none (verification is the “test” for infra).
- Step 1: Dry-run (the failing/first test)
Run: scripts/nas-playbook.sh --tags nas-traefik --check --diff
Expected: the Vault read + query tasks run; create/update shows as would-change (or ok if values already match). No secret in output (no_log). Fix any template/schema errors before applying.
- Step 2: Apply
Run: scripts/nas-playbook.sh --tags nas-traefik
Expected: changed on the create/update task, no failures.
- Step 2b: Idempotency (re-run == green, PASS/FAIL)
Run: scripts/nas-playbook.sh --tags nas-traefik again.
Expected (PASS): recap shows changed=0 for the update task — the drift marker is unchanged, so the gated app.update is skipped (ok). A changed here is a FAIL (the marker or gate is wrong), not a “note it”.
- Step 3: Observe live state
ssh nas-automation@192.168.20.200 'sudo docker inspect -f "{{range \$p,\$c := .HostConfig.PortBindings}}{{\$p}} {{range \$c}}{{.HostIp}}:{{.HostPort}} {{end}}{{end}}" ix-traefik-traefik-1'Expected: 80/tcp+443/tcp on 192.168.20.201, and 444/tcp now on 192.168.20.201 (not 0.0.0.0) — confirms the hardening.
- Step 4: Cert sanity
Run: echo | openssl s_client -connect 192.168.20.201:443 -servername s3.nas.fzymgc.house 2>/dev/null | openssl x509 -noout -subject -issuer
Expected: a valid Let’s Encrypt cert for s3.nas.fzymgc.house (Traefik still routing after the reconcile).
- Step 5: Confirm
.200:444is closed
Run: echo | openssl s_client -connect 192.168.20.200:444 2>&1 | head -3
Expected: connection refused / no listener (dashboard no longer on the UI IP).
Task 6: Docs + role reference
Section titled “Task 6: Docs + role reference”Files:
- Modify:
ansible/CLAUDE.md(Roles Reference table) - Modify:
docs/operations/nas.md(Tag map + a short Traefik-app note) - Step 1: Add to
ansible/CLAUDE.mdRoles Reference:
| `nas-traefik` | Reconcile the TrueNAS community-catalog Traefik app (NAS-apps reverse-proxy VIP .201; ACME DNS-01 via Vault-sourced Cloudflare token; :444 dashboard bound to the VIP) | `nas` |-
Step 2: Add a
nas-traefikrow to the Tag map indocs/operations/nas.mdand a 2-3 line note (catalog app, values in role defaults, CF token from Vault,.201VIP). -
Step 3: Commit.
Verification summary
Section titled “Verification summary”--check --diffclean, then apply green, then idempotent re-run (no failures).docker inspect:444/tcpbound to.201(hardening applied),80/443still on.201.s3.nas.fzymgc.house:443still serves a valid LE cert (routing intact)..200:444refuses (dashboard off the UI IP).- No secret in any transcript (
no_logon every token-touching task).