From 0dce806107fbedc85572c7f47d7fb77d4d2c030b Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 10 Feb 2026 15:36:13 -0800 Subject: [PATCH] Add plan and reference card for UniFi Express 7 Pulumi stack (#145) ## Summary - Rewrites the UniFi Pulumi plan doc to use filipowm/unifi Terraform provider via `pulumi package add terraform-provider` (replaces pulumiverse_unifi approach) - Adds network segmentation goals (main/guest/IoT WiFi zones) and API key auth - Creates UniFi reference card (`docs/reference/infrastructure/unifi.md`) with topology diagram - Updates all documentation indexes (plans.md, how-to.md, hosts.md, reference.md) ## What's Deferred Actual stack scaffolding (`pulumi/unifi/`), mise tasks, and `pulumi import` are blocked on switch purchase and cabling. The plan doc captures everything needed for a future execution session. ## Verification - `docs-check-links` passes (all wiki-links resolve) - `docs-check-index` passes (unifi.md referenced in reference.md) - Pre-commit hooks pass Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/145 --- .../feature-unifi-pulumi.bugfix.md | 1 + docs/changelog.d/feature-unifi-pulumi.doc.md | 1 + docs/how-to/how-to.md | 1 + docs/how-to/plans/add-unifi-pulumi-stack.md | 225 ++++++++++++++++++ docs/how-to/plans/plans.md | 1 + docs/reference/infrastructure/hosts.md | 2 +- docs/reference/infrastructure/unifi.md | 77 ++++++ docs/reference/reference.md | 1 + mise-tasks/dns-preview | 2 +- mise-tasks/dns-up | 2 +- mise-tasks/tailnet-preview | 4 +- mise-tasks/tailnet-up | 4 +- 12 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 docs/changelog.d/feature-unifi-pulumi.bugfix.md create mode 100644 docs/changelog.d/feature-unifi-pulumi.doc.md create mode 100644 docs/how-to/plans/add-unifi-pulumi-stack.md create mode 100644 docs/reference/infrastructure/unifi.md diff --git a/docs/changelog.d/feature-unifi-pulumi.bugfix.md b/docs/changelog.d/feature-unifi-pulumi.bugfix.md new file mode 100644 index 0000000..015a6ed --- /dev/null +++ b/docs/changelog.d/feature-unifi-pulumi.bugfix.md @@ -0,0 +1 @@ +Replace `op item get --fields` with `op read` in all mise tasks (tailnet-up, tailnet-preview, dns-up, dns-preview) to prevent multi-line secret corruption. diff --git a/docs/changelog.d/feature-unifi-pulumi.doc.md b/docs/changelog.d/feature-unifi-pulumi.doc.md new file mode 100644 index 0000000..8e5e5fc --- /dev/null +++ b/docs/changelog.d/feature-unifi-pulumi.doc.md @@ -0,0 +1 @@ +Add plan and reference card for UniFi Express 7 Pulumi IaC management. diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index f31f738..99248de 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -53,3 +53,4 @@ Migration and transition plans for upcoming infrastructure changes. |------|-------------| | [[plans]] | Index of all plans | | [[migrate-forgejo-from-brew]] | Transition Forgejo from Homebrew to source-built binary | +| [[add-unifi-pulumi-stack]] | Add Pulumi IaC for UniFi Express 7 | diff --git a/docs/how-to/plans/add-unifi-pulumi-stack.md b/docs/how-to/plans/add-unifi-pulumi-stack.md new file mode 100644 index 0000000..75746af --- /dev/null +++ b/docs/how-to/plans/add-unifi-pulumi-stack.md @@ -0,0 +1,225 @@ +--- +title: "Plan: Add UniFi Pulumi Stack" +tags: + - how-to + - plans + - networking + - pulumi +--- + +# Plan: Add UniFi Pulumi Stack + +> **Status:** Planned (not yet executed) +> **Blocked by:** Ethernet switch purchase and cabling + +## Background + +The UniFi Express 7 (UX7) is the home WiFi router, currently unmanaged. This plan adds a Pulumi stack (`pulumi/unifi/`) to bring it under IaC control, following the same conventions as `pulumi/tailscale/` and `pulumi/gandi/`. + +### Why IaC for the Router? + +- **Reproducibility** — WiFi networks, firewall rules, and DHCP settings are declared in code +- **Audit trail** — changes go through PR review like all other infrastructure +- **Consistency** — joins the existing Pulumi stacks for Tailscale ACLs and DNS +- **Network segmentation** — declare main/guest/IoT WiFi networks with proper firewall zones + +### Why This Is Blocked + +The UX7 has one LAN port, currently connected to [[sifaka]]. Modifying WiFi settings over WiFi would sever the management connection mid-apply. We need: + +1. **Two switches** (UniFi Switch Flex Mini recommended) daisy-chained: + - Switch A by the router: connects UX7, sifaka + - Switch B on the desk (~12ft cable): connects indri, ringtail, and optionally gilbert +2. **Cat6 Ethernet cables**: one ~12ft run between switches, plus short cables for each device + +``` +UniFi Express 7 [LAN port] + └── Switch A (by router/sifaka) + ├── sifaka (short cable) + └── ~12ft Cat6 ──→ Switch B (on desk) + ├── indri (Cat6) + ├── ringtail (Cat6) + └── (gilbert via USB-C adapter, optional) +``` + +Daisy-chaining is standard Layer 2 networking — no speed loss per device, no subnet impact. The only shared bottleneck is the 1 Gbps uplink between the two switches, which is more than adequate for homelab use. UniFi Flex Minis will appear in the UX7's controller for monitoring and eventual Pulumi management. + +## Prerequisites + +Before starting the execution session: + +- [ ] Purchase 2x UniFi Switch Flex Mini (USW-Flex-Mini) +- [ ] Purchase Cat6 cables: 1x ~12ft, 3-4x short (~3ft) +- [ ] Cable everything up and verify all devices have network connectivity +- [ ] Verify indri has an active wired Ethernet connection: `networksetup -getinfo "Ethernet"` should show an IP address +- [ ] Create an API key on the UX7 via `https://192.168.1.1` → Settings → Control Plane → API (preferred over username/password for IaC) +- [ ] Store UniFi credentials in 1Password: vault `blumeops`, item `unifi - blumeops`, fields `api_key` and `url` +- [ ] Verify Pulumi CLI version is >= v3.147.0 (`pulumi version`) + +## Provider: filipowm/unifi via `pulumi package add` + +We use `pulumi package add terraform-provider filipowm/unifi 1.0.0` to consume the [filipowm fork](https://github.com/filipowm/terraform-provider-unifi) of the UniFi Terraform provider directly from Pulumi. This approach: + +- **Generates a local Python SDK** in `./sdks/unifi/` and adds a package reference to `Pulumi.yaml` +- **Supports zone-based firewalls** — needed for main/guest/IoT WiFi segmentation and a dedicated blumeops services subnet +- **Supports API key authentication** — cleaner than username/password for IaC +- **Actively maintained** — v1.0.0, 520+ commits, not the abandoned upstream +- **Uses Pulumi Cloud state** — same as existing stacks, free tier +- **No fork/bridge maintenance** — just re-run `pulumi package add` on new provider versions + +### Why Not pulumiverse_unifi? + +The `pulumiverse_unifi` PyPI package bridges from `paultyng/terraform-provider-unifi`, which is in maintenance mode. It lacks zone-based firewalls, API key auth, and many newer resource types. The filipowm fork is the active community successor. + +## Network Segmentation Goals + +Once the stack is operational, we plan to configure these network zones: + +| Network | VLAN | Subnet | Purpose | +|---------|------|--------|---------| +| Default LAN | 1 | `192.168.1.0/24` | Main network (indri, gilbert, ringtail, sifaka) | +| Guest | TBD | `192.168.2.0/24` | Guest WiFi, internet-only | +| IoT | TBD | `192.168.3.0/24` | Smart devices, isolated from LAN | + +Zone-based firewall rules will enforce: + +- Guest → Internet only (no LAN, no IoT) +- IoT → Internet + limited LAN access (e.g., mDNS) +- LAN → full access + +These will be declared after the initial import is stable. + +## Pulumi Stack Structure + +Following the conventions of `pulumi/tailscale/` and `pulumi/gandi/`: + +``` +pulumi/unifi/ +├── Pulumi.yaml # name: blumeops-unifi, python runtime, uv toolchain +│ # includes parameterized package reference for filipowm/unifi +├── Pulumi.home-network.yaml # Stack config: router_url, site +├── pyproject.toml # Python >=3.11, pulumi>=3.0.0 +├── sdks/unifi/ # Generated Python SDK from pulumi package add +│ └── ... # (auto-generated, committed to repo) +├── __main__.py # Main program with safety guard +├── .gitignore # .venv/, __pycache__/, *.py[cod] +└── uv.lock # Generated by uv sync, committed +``` + +### Provider Configuration + +**Authentication** (via environment variables in mise tasks): + +| Variable | Value | Notes | +|----------|-------|-------| +| `UNIFI_API_KEY` | from 1Password | API key created in UX7 control plane | +| `UNIFI_API` | `https://192.168.1.1:443` | No `/api` suffix — SDK auto-discovers `/proxy/network` for UniFi OS | +| `UNIFI_INSECURE` | `true` | UX7 uses a self-signed TLS certificate | + +### Safety Guard + +The `__main__.py` must fail fast before creating any Pulumi resources if: + +1. **Wrong host** — `platform.node()` is not `indri` +2. **No wired connection** — `networksetup -getinfo "Ethernet"` shows no active Ethernet + +This prevents accidentally running the stack from gilbert over WiFi. + +## Execution Steps + +### Step 1: Create Stack Directory and Install Provider + +```fish +mkdir -p pulumi/unifi +cd pulumi/unifi +# Create Pulumi.yaml, pyproject.toml, .gitignore, __main__.py +pulumi package add terraform-provider filipowm/unifi 1.0.0 +# This generates sdks/unifi/ and updates Pulumi.yaml with package reference +pulumi install +uv sync +``` + +### Step 2: Create Stack Files + +Create `Pulumi.yaml`, `Pulumi.home-network.yaml`, `pyproject.toml`, `.gitignore`, and `__main__.py`. The main program should declare: + +- **Ethernet safety guard** (hostname + wired connection check) +- **Default LAN network** resource (corporate, `192.168.1.0/24`, DHCP) +- **WiFi WLAN** resources (commented out initially — need SSID names and IDs from the controller) +- **Exports** for router IP, network ID, subnet + +### Step 3: Create Mise Tasks + +Create `mise-tasks/unifi-preview` and `mise-tasks/unifi-up` following the pattern from `tailnet-up`/`dns-up`: + +```bash +UNIFI_API_KEY=$(op read "op://blumeops/unifi - blumeops/api_key") +export UNIFI_API="https://192.168.1.1:443" +export UNIFI_INSECURE="true" +``` + +### Step 4: Initialize Stack (on indri) + +```fish +cd pulumi/unifi +uv sync +pulumi stack init home-network +``` + +### Step 5: Import Existing Resources + +Discover resource IDs from the UniFi controller API or web UI, then import: + +```fish +# Import default network +pulumi import unifi:index/network:Network default-lan + +# Later, import WLANs +pulumi import unifi:index/wlan:Wlan home-wifi +``` + +Adjust `__main__.py` resource properties to match the actual controller state until `pulumi preview` shows no diff. + +### Step 6: Documentation Updates + +- Create `docs/reference/infrastructure/unifi.md` reference card +- Update `docs/reference/infrastructure/hosts.md` — link UniFi row to `[[unifi|Details]]` +- Update `docs/reference/reference.md` — add `[[unifi]]` to Infrastructure section +- Add changelog fragment + +### Step 7: Verify + +- `mise run unifi-preview` shows no unexpected diffs +- Pre-commit hooks pass +- `docs-check-links` and `docs-check-index` pass + +## Known Limitations + +- **macOS-specific guard** — the `networksetup` check only works on macOS, which is fine since indri is permanently macOS +- **User group ID discovery** — the provider may not expose a `get_user_group` data source. Must be discovered manually from the controller API (`/proxy/network/api/s/default/rest/usergroup`) and hardcoded + +## Future Considerations + +- **Firewall rules** — declare zone-based firewall rules after the initial import is stable +- **UnPoller** — add Prometheus metrics exporter for UniFi gear, integrates with existing Grafana stack +- **Switch management** — manage the USW-Flex-Minis via the same Pulumi stack once adopted into the UX7 controller +- **Provider updates** — re-run `pulumi package add terraform-provider filipowm/unifi ` to update + +## Reference Pattern Files + +| File | Purpose | +|------|---------| +| `pulumi/tailscale/__main__.py` | Pulumi program pattern (resources, exports, data sources) | +| `pulumi/gandi/__main__.py` | Config resolution pattern (`pulumi.Config().require()`) | +| `pulumi/tailscale/Pulumi.yaml` | Project definition pattern | +| `pulumi/gandi/Pulumi.eblu-me.yaml` | Stack config pattern | +| `mise-tasks/tailnet-up` | Mise task credential pattern (`op read`) | +| `docs/reference/infrastructure/gandi.md` | Infrastructure reference card pattern | + +## Related + +- [[hosts]] - Device inventory (UniFi Express 7) +- [[unifi]] - Reference card +- [[power]] - UPS power chain +- [[indri]] - Server connected via Cat6 Ethernet +- [[tailscale]] - Tailnet networking diff --git a/docs/how-to/plans/plans.md b/docs/how-to/plans/plans.md index d7d73ab..7e71612 100644 --- a/docs/how-to/plans/plans.md +++ b/docs/how-to/plans/plans.md @@ -14,3 +14,4 @@ Plans differ from regular how-to guides in that they describe work that has been | Plan | Status | Description | |------|--------|-------------| | [[migrate-forgejo-from-brew]] | Planned | Transition Forgejo from Homebrew to source-built binary with LaunchAgent | +| [[add-unifi-pulumi-stack]] | Planned | Add Pulumi IaC for UniFi Express 7 home network | diff --git a/docs/reference/infrastructure/hosts.md b/docs/reference/infrastructure/hosts.md index 916123b..3029b11 100644 --- a/docs/reference/infrastructure/hosts.md +++ b/docs/reference/infrastructure/hosts.md @@ -16,7 +16,7 @@ All devices connected via [Tailscale](https://login.tailscale.com/) tailnet `tai | **Gilbert** | MacBook Air M4, 2025 - Workstation | [[gilbert|Details]] | | **[[sifaka|Sifaka]]** | Synology NAS - Storage & backups | [[sifaka|Details]] | | **Mouse** | MacBook Air M2 - Allison's laptop | - | -| **UniFi** | UniFi Express 7 - Home WiFi | - | +| **UniFi** | UniFi Express 7 - Home WiFi | [[unifi|Details]] | | **Dwarf** | iPad Air - Employer-provided, off tailnet | - | ## Related diff --git a/docs/reference/infrastructure/unifi.md b/docs/reference/infrastructure/unifi.md new file mode 100644 index 0000000..12f9815 --- /dev/null +++ b/docs/reference/infrastructure/unifi.md @@ -0,0 +1,77 @@ +--- +title: UniFi +tags: + - infrastructure + - networking +--- + +# UniFi + +Home WiFi router and network controller, managed via Pulumi IaC. + +## Quick Reference + +| Property | Value | +|----------|-------| +| **Model** | UniFi Express 7 (UX7) | +| **LAN IP** | `192.168.1.1` | +| **Management URL** | `https://192.168.1.1` | +| **IaC** | `pulumi/unifi/` (planned) | +| **Stack** | `home-network` (planned) | +| **Power** | Battery-backed via UPS (see [[power]]) | + +## What It Does + +The UX7 is the home WiFi access point and network gateway. It provides: + +- WiFi (main, guest, IoT networks) +- DHCP for `192.168.1.0/24` +- Built-in UniFi controller for managing adopted devices (switches, APs) +- Firewall and traffic management + +## Network Topology + +``` +ISP Modem + └── UniFi Express 7 [WAN] + └── [LAN port] ──→ Switch A (by router/sifaka) + ├── sifaka (Synology NAS) + └── ~12ft Cat6 ──→ Switch B (on desk) + ├── indri (Mac Mini, primary server) + ├── ringtail (Raspberry Pi) + └── (gilbert via USB-C adapter, optional) +``` + +All wired devices share the `192.168.1.0/24` subnet. The two daisy-chained UniFi Switch Flex Minis provide enough ports for all devices while using the UX7's single LAN port. + +## Pulumi Configuration (Planned) + +The Pulumi program will live in `pulumi/unifi/`: + +- `__main__.py` — declares networks, WLANs, and firewall zones +- `Pulumi.home-network.yaml` — stack config (router URL, site) +- `sdks/unifi/` — generated Python SDK from `pulumi package add terraform-provider filipowm/unifi` + +Provider: [filipowm/terraform-provider-unifi](https://github.com/filipowm/terraform-provider-unifi) v1.0.0, consumed via `pulumi package add terraform-provider`. + +See [[add-unifi-pulumi-stack]] for the full implementation plan. + +## Operations + +| Task | Command | +|------|---------| +| Preview changes | `mise run unifi-preview` (planned) | +| Apply changes | `mise run unifi-up` (planned) | +| Web management | `https://192.168.1.1` | + +## Authentication + +The provider uses an API key created in the UX7 control plane (Settings → Control Plane → API). The key is stored in 1Password (`op://blumeops/unifi - blumeops/api_key`) and injected via mise task environment variables. + +## Related + +- [[add-unifi-pulumi-stack]] - Implementation plan +- [[hosts]] - Device inventory +- [[power]] - UPS power chain +- [[indri]] - Primary server (wired connection required for management) +- [[tailscale]] - Tailnet networking diff --git a/docs/reference/reference.md b/docs/reference/reference.md index f0794d5..c1bd418 100644 --- a/docs/reference/reference.md +++ b/docs/reference/reference.md @@ -46,6 +46,7 @@ Host inventory and network configuration. - [[gilbert]] - Development workstation - [[tailscale]] - ACLs, groups, tags - [[gandi]] - DNS hosting for `eblu.me` +- [[unifi]] - Home WiFi router (UniFi Express 7) - [[routing|Routing]] - DNS domains, port mappings - [[power]] - Battery-backed power chain diff --git a/mise-tasks/dns-preview b/mise-tasks/dns-preview index be7b9e0..2591640 100755 --- a/mise-tasks/dns-preview +++ b/mise-tasks/dns-preview @@ -3,7 +3,7 @@ set -euo pipefail -GANDI_PERSONAL_ACCESS_TOKEN=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mco6ka3dc3rmw7zkg2dhia5d2m --fields pat --reveal) +GANDI_PERSONAL_ACCESS_TOKEN=$(op read "op://blumeops/gandi - blumeops/pat") export GANDI_PERSONAL_ACCESS_TOKEN cd "$(dirname "$0")/../pulumi/gandi" diff --git a/mise-tasks/dns-up b/mise-tasks/dns-up index 2be5abb..55f786a 100755 --- a/mise-tasks/dns-up +++ b/mise-tasks/dns-up @@ -3,7 +3,7 @@ set -euo pipefail -GANDI_PERSONAL_ACCESS_TOKEN=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mco6ka3dc3rmw7zkg2dhia5d2m --fields pat --reveal) +GANDI_PERSONAL_ACCESS_TOKEN=$(op read "op://blumeops/gandi - blumeops/pat") export GANDI_PERSONAL_ACCESS_TOKEN cd "$(dirname "$0")/../pulumi/gandi" diff --git a/mise-tasks/tailnet-preview b/mise-tasks/tailnet-preview index 3df1369..8a39842 100755 --- a/mise-tasks/tailnet-preview +++ b/mise-tasks/tailnet-preview @@ -3,9 +3,9 @@ set -euo pipefail -TAILSCALE_OAUTH_CLIENT_ID=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_id) +TAILSCALE_OAUTH_CLIENT_ID=$(op read "op://blumeops/tailscale - blumeops/client_id") export TAILSCALE_OAUTH_CLIENT_ID -TAILSCALE_OAUTH_CLIENT_SECRET=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_secret --reveal) +TAILSCALE_OAUTH_CLIENT_SECRET=$(op read "op://blumeops/tailscale - blumeops/client_secret") export TAILSCALE_OAUTH_CLIENT_SECRET export TAILSCALE_TAILNET="tail8d86e.ts.net" diff --git a/mise-tasks/tailnet-up b/mise-tasks/tailnet-up index 882fada..7f36d93 100755 --- a/mise-tasks/tailnet-up +++ b/mise-tasks/tailnet-up @@ -3,9 +3,9 @@ set -euo pipefail -TAILSCALE_OAUTH_CLIENT_ID=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_id) +TAILSCALE_OAUTH_CLIENT_ID=$(op read "op://blumeops/tailscale - blumeops/client_id") export TAILSCALE_OAUTH_CLIENT_ID -TAILSCALE_OAUTH_CLIENT_SECRET=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get wi6bkf7bcccwfy4eu776ab4p4u --fields client_secret --reveal) +TAILSCALE_OAUTH_CLIENT_SECRET=$(op read "op://blumeops/tailscale - blumeops/client_secret") export TAILSCALE_OAUTH_CLIENT_SECRET export TAILSCALE_TAILNET="tail8d86e.ts.net"