Restructure Pulumi into separate projects for Tailscale and Gandi DNS

- Move Tailscale ACL management to pulumi/tailscale/
- Add new Gandi DNS project at pulumi/gandi/ for eblu.me management
- Create wildcard DNS record *.ops.eblu.me pointing to indri's Tailscale IP
- Add mise tasks: dns-up, dns-preview
- Update tailnet-up/preview to use new path and add --yes flag
- Document PAT cycling process (expires every 30 days)

This enables using real DNS names (*.ops.eblu.me) that resolve to Tailscale
IPs, allowing containers to resolve services without MagicDNS dependency.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-01-25 07:59:22 -08:00
commit cdeda4856f
16 changed files with 183 additions and 3 deletions

10
mise-tasks/dns-preview Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
#MISE description="Preview DNS changes to eblu.me with Pulumi"
set -euo pipefail
GANDI_PERSONAL_ACCESS_TOKEN=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mco6ka3dc3rmw7zkg2dhia5d2m --fields pat --reveal)
export GANDI_PERSONAL_ACCESS_TOKEN
cd "$(dirname "$0")/../pulumi/gandi"
pulumi preview "$@"

10
mise-tasks/dns-up Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
#MISE description="Apply DNS changes to eblu.me with Pulumi"
set -euo pipefail
GANDI_PERSONAL_ACCESS_TOKEN=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mco6ka3dc3rmw7zkg2dhia5d2m --fields pat --reveal)
export GANDI_PERSONAL_ACCESS_TOKEN
cd "$(dirname "$0")/../pulumi/gandi"
pulumi up --yes "$@"

View file

@ -9,5 +9,5 @@ TAILSCALE_OAUTH_CLIENT_SECRET=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get w
export TAILSCALE_OAUTH_CLIENT_SECRET
export TAILSCALE_TAILNET="tail8d86e.ts.net"
cd "$(dirname "$0")/../pulumi"
cd "$(dirname "$0")/../pulumi/tailscale"
pulumi preview "$@"

View file

@ -9,5 +9,5 @@ TAILSCALE_OAUTH_CLIENT_SECRET=$(op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get w
export TAILSCALE_OAUTH_CLIENT_SECRET
export TAILSCALE_TAILNET="tail8d86e.ts.net"
cd "$(dirname "$0")/../pulumi"
pulumi up "$@"
cd "$(dirname "$0")/../pulumi/tailscale"
pulumi up --yes "$@"

1
pulumi/gandi/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.venv/

View file

@ -0,0 +1,6 @@
---
config:
blumeops-dns:domain: eblu.me
blumeops-dns:subdomain: ops
# indri's Tailscale IP - only routable within tailnet
blumeops-dns:tailscale_ip: "100.98.163.89"

7
pulumi/gandi/Pulumi.yaml Normal file
View file

@ -0,0 +1,7 @@
---
name: blumeops-dns
runtime:
name: python
options:
toolchain: uv
description: DNS configuration for eblu.me via Gandi LiveDNS

92
pulumi/gandi/README.md Normal file
View file

@ -0,0 +1,92 @@
# Gandi DNS Management
This Pulumi project manages DNS records for `eblu.me` via Gandi LiveDNS.
## What It Does
Creates DNS records that point `*.ops.eblu.me` to indri's Tailscale IP (`100.98.163.89`).
Since Tailscale IPs (100.x.x.x) are not routable on the public internet, these
DNS records effectively make services accessible only from within the tailnet,
while still using real, resolvable DNS names.
## Setup
```bash
cd pulumi/gandi
uv sync
pulumi stack select eblu-me # or: pulumi stack init eblu-me
```
## Authentication
This project requires a Gandi Personal Access Token (PAT) with LiveDNS permissions.
**The PAT expires every 30 days and must be cycled manually.**
### Cycling the PAT
1. Go to [Gandi PAT Management](https://admin.gandi.net/organizations/1db8d76a-f729-11ed-b8d1-00163e94b645/account/pat)
2. Create a new PAT:
- Name: `blumeops-pulumi` (or similar)
- Expiration: 30 days (maximum)
- Permissions: **Manage domain name technical configurations** (under Domains)
3. Update 1Password:
```bash
# Update the existing item with the new PAT value
op item edit mco6ka3dc3rmw7zkg2dhia5d2m pat="<NEW_PAT_VALUE>" --vault vg6xf6vvfmoh5hqjjhlhbeoaie
```
4. Delete the old PAT from Gandi admin console
### Running with Authentication
The mise task handles fetching the PAT from 1Password:
```bash
mise run dns-up # Preview and apply changes
mise run dns-preview # Preview only
```
Or manually:
```bash
export GANDI_PERSONAL_ACCESS_TOKEN=$(op item get mco6ka3dc3rmw7zkg2dhia5d2m --field pat --reveal --vault vg6xf6vvfmoh5hqjjhlhbeoaie)
pulumi up
```
## DNS Records Created
| Record | Type | Value | Purpose |
|--------|------|-------|---------|
| `*.ops.eblu.me` | A | 100.98.163.89 | Wildcard for all services |
| `ops.eblu.me` | A | 100.98.163.89 | Base subdomain |
## Service Hostnames
Once Caddy is configured, services will be accessible at:
- `forge.ops.eblu.me` - Forgejo git server
- `registry.ops.eblu.me` - Zot container registry
- `grafana.ops.eblu.me` - Grafana dashboards
- `argocd.ops.eblu.me` - ArgoCD
- `feed.ops.eblu.me` - Miniflux RSS reader
- `pypi.ops.eblu.me` - DevPI Python index
- `kiwix.ops.eblu.me` - Kiwix offline content
- `tesla.ops.eblu.me` - TeslaMate
- `torrent.ops.eblu.me` - Transmission
- `prometheus.ops.eblu.me` - Prometheus metrics
- `loki.ops.eblu.me` - Loki logs
## Changing the Target IP
If indri's Tailscale IP changes, update `Pulumi.eblu-me.yaml`:
```yaml
config:
blumeops-dns:tailscale_ip: "NEW_IP_HERE"
```
Then run `mise run dns-up` to apply.

49
pulumi/gandi/__main__.py Normal file
View file

@ -0,0 +1,49 @@
"""Pulumi program to manage eblu.me DNS via Gandi LiveDNS.
This program manages DNS records for blumeops infrastructure:
- Wildcard record for *.ops.eblu.me pointing to indri's Tailscale IP
- This allows services to be accessed via real DNS names while remaining
tailnet-only (Tailscale IPs are not publicly routable)
Authentication:
Set GANDI_PERSONAL_ACCESS_TOKEN environment variable.
See README.md for PAT management instructions.
"""
import pulumi
import pulumiverse_gandi as gandi
# Get configuration
config = pulumi.Config()
domain = config.require("domain") # eblu.me
subdomain = config.require("subdomain") # ops
tailscale_ip = config.require("tailscale_ip") # 100.98.163.89
# Wildcard A record for *.ops.eblu.me
# Points to indri's Tailscale IP, which is only routable within the tailnet.
# This allows containers and other systems to resolve real DNS names
# while keeping services private to the tailnet.
wildcard_record = gandi.livedns.Record(
"ops-wildcard",
zone=domain,
name=f"*.{subdomain}",
type="A",
ttl=300,
values=[tailscale_ip],
)
# Base subdomain record (ops.eblu.me) - same IP
base_record = gandi.livedns.Record(
"ops-base",
zone=domain,
name=subdomain,
type="A",
ttl=300,
values=[tailscale_ip],
)
# ============== Exports ==============
pulumi.export("domain", domain)
pulumi.export("wildcard_fqdn", f"*.{subdomain}.{domain}")
pulumi.export("base_fqdn", f"{subdomain}.{domain}")
pulumi.export("target_ip", tailscale_ip)

View file

@ -0,0 +1,5 @@
[project]
name = "blumeops-dns"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["pulumi>=3.0.0", "pulumiverse-gandi>=2.3.0"]