From da40273177c4c492f861598ae6c825d994906602 Mon Sep 17 00:00:00 2001 From: Eric Garcia Date: Sat, 24 Jan 2026 07:42:48 -0500 Subject: [PATCH] feat(dns): Add self-hosted PowerDNS for 5 managed domains - Deploy PowerDNS on k3s with SQLite backend - Add DNS ports 53 UDP/TCP to security group - Configure zones for superviber.com, muffinlabs.ai, letemcook.com, appbasecamp.com, thanksforborrowing.com - Add deploy-powerdns.sh standalone deployment script - Document in RFC 0003 Glue records updated at GoDaddy to point ns1/ns2 to 3.218.167.115. DNS verified working via Google DNS (8.8.8.8). Co-Authored-By: Claude Opus 4.5 --- docs/rfcs/0003-powerdns-self-hosted.md | 340 ++++++++++++++++++++++++ scripts/deploy-powerdns.sh | 352 +++++++++++++++++++++++++ terraform/minimal/main.tf | 47 +++- 3 files changed, 738 insertions(+), 1 deletion(-) create mode 100644 docs/rfcs/0003-powerdns-self-hosted.md create mode 100755 scripts/deploy-powerdns.sh diff --git a/docs/rfcs/0003-powerdns-self-hosted.md b/docs/rfcs/0003-powerdns-self-hosted.md new file mode 100644 index 0000000..cb924b5 --- /dev/null +++ b/docs/rfcs/0003-powerdns-self-hosted.md @@ -0,0 +1,340 @@ +# RFC 0003: PowerDNS Self-Hosted DNS + +| | | +|---|---| +| **Status** | Implemented | +| **Created** | 2026-01-24 | +| **Related** | [coherence-mcp RFC 0046](../../coherence-mcp/docs/rfcs/0046-domain-email-migration.md) | + +--- + +## Problem + +We have 5 domains registered at GoDaddy that need DNS management: +- superviber.com +- muffinlabs.ai +- letemcook.com +- appbasecamp.com +- thanksforborrowing.com + +Current state: +1. **Glue records exist** at GoDaddy pointing ns1/ns2.superviber.com to IPs 3.12.26.86 and 3.140.40.205 +2. **No DNS server running** at those IPs (planned EKS infrastructure never deployed) +3. **Forgejo running** at 3.218.167.115 on k3s (hearth minimal), but DNS not configured +4. **Cloudflare partially configured** for superviber.com but nameservers not switched + +We want self-hosted DNS for: +- Control over all records without third-party dashboard +- Integration with other self-hosted services (mail, git, auth) +- Single source of truth in infrastructure-as-code + +## Goals + +1. **Deploy PowerDNS** on existing k3s infrastructure (hearth) +2. **Configure zones** for all 5 managed domains +3. **Expose DNS** via port 53 UDP/TCP +4. **Update glue records** at GoDaddy to point to working DNS servers +5. **Add git.beyondtheuniverse.superviber.com** record pointing to Forgejo + +## Non-Goals + +- HA DNS with 3 separate IPs (future scope, current setup is single-user) +- DNSSEC (future scope) +- Secondary/slave DNS (future scope) +- DNS-over-HTTPS or DNS-over-TLS (future scope) + +--- + +## Proposal + +### Architecture + +Deploy PowerDNS on the existing hearth k3s instance (3.218.167.115) as a single authoritative DNS server. + +``` + ┌─────────────────────────────────────┐ + │ EC2: 3.218.167.115 │ + │ (hearth-forgejo) │ + │ │ + ┌─────────────┐ │ ┌─────────────────────────────┐ │ + │ GoDaddy │ │ │ k3s │ │ + │ Glue: │ │ │ │ │ + │ ns1/ns2 ────┼────────►│ │ ┌─────────┐ ┌──────────┐ │ │ + │ .superviber │ :53 │ │ │PowerDNS │ │ Forgejo │ │ │ + │ .com │ │ │ │ :53 │ │ :3000 │ │ │ + └─────────────┘ │ │ └─────────┘ └──────────┘ │ │ + │ │ │ │ + │ │ Traefik (ingress) │ │ + │ │ :80, :443, :22 │ │ + │ └─────────────────────────────┘ │ + └─────────────────────────────────────┘ +``` + +### Why Single Instance + +For a ~1 user personal infrastructure: +- Simpler ops (one machine to manage) +- Lower cost (~$7.50/month total) +- Acceptable availability (spot instance with persistent EIP) +- GoDaddy remains as backup (can revert NS records if needed) + +### DNS Records + +Initial zones will include: + +**superviber.com:** +``` +@ A 3.218.167.115 +ns1 A 3.218.167.115 +ns2 A 3.218.167.115 +beyondtheuniverse A 3.218.167.115 +git.beyondtheuniverse A 3.218.167.115 +mail.beyondtheuniverse A 3.218.167.115 +auth.beyondtheuniverse A 3.218.167.115 +@ NS ns1.superviber.com. +@ NS ns2.superviber.com. +``` + +Other domains (muffinlabs.ai, etc.) will have minimal records pointing to the same infrastructure. + +--- + +## Implementation + +### Phase 1: Deploy PowerDNS on k3s + +Add to hearth user-data.sh or apply directly: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: dns +--- +apiVersion: v1 +kind: Secret +metadata: + name: powerdns-api-key + namespace: dns +type: Opaque +stringData: + api-key: "GENERATED_SECRET_KEY" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: powerdns + namespace: dns +spec: + replicas: 1 + selector: + matchLabels: + app: powerdns + template: + metadata: + labels: + app: powerdns + spec: + containers: + - name: powerdns + image: powerdns/pdns-auth-49:4.9.2 + ports: + - name: dns-udp + containerPort: 53 + protocol: UDP + - name: dns-tcp + containerPort: 53 + protocol: TCP + - name: api + containerPort: 8081 + protocol: TCP + env: + - name: PDNS_AUTH_API_KEY + valueFrom: + secretKeyRef: + name: powerdns-api-key + key: api-key + args: + - --api=yes + - --api-key=$(PDNS_AUTH_API_KEY) + - --webserver=yes + - --webserver-address=0.0.0.0 + - --webserver-port=8081 + - --launch=gsqlite3 + - --gsqlite3-database=/var/lib/powerdns/pdns.sqlite3 + volumeMounts: + - name: data + mountPath: /var/lib/powerdns + volumes: + - name: data + hostPath: + path: /data/powerdns + type: DirectoryOrCreate +--- +apiVersion: v1 +kind: Service +metadata: + name: powerdns + namespace: dns +spec: + type: LoadBalancer + externalTrafficPolicy: Local + selector: + app: powerdns + ports: + - name: dns-udp + port: 53 + targetPort: 53 + protocol: UDP + - name: dns-tcp + port: 53 + targetPort: 53 + protocol: TCP + - name: api + port: 8081 + targetPort: 8081 + protocol: TCP +``` + +### Phase 2: Update Security Group + +Add to hearth main.tf security group: + +```hcl +# DNS (UDP) +ingress { + description = "DNS UDP" + from_port = 53 + to_port = 53 + protocol = "udp" + cidr_blocks = ["0.0.0.0/0"] +} + +# DNS (TCP) +ingress { + description = "DNS TCP" + from_port = 53 + to_port = 53 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] +} +``` + +### Phase 3: Configure Zones + +Use PowerDNS API to create zones: + +```bash +# Create zone +curl -X POST http://localhost:8081/api/v1/servers/localhost/zones \ + -H "X-API-Key: $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "superviber.com.", + "kind": "Native", + "nameservers": ["ns1.superviber.com.", "ns2.superviber.com."] + }' + +# Add records +curl -X PATCH http://localhost:8081/api/v1/servers/localhost/zones/superviber.com. \ + -H "X-API-Key: $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "rrsets": [ + { + "name": "superviber.com.", + "type": "A", + "ttl": 300, + "changetype": "REPLACE", + "records": [{"content": "3.218.167.115", "disabled": false}] + }, + { + "name": "ns1.superviber.com.", + "type": "A", + "ttl": 3600, + "changetype": "REPLACE", + "records": [{"content": "3.218.167.115", "disabled": false}] + }, + { + "name": "ns2.superviber.com.", + "type": "A", + "ttl": 3600, + "changetype": "REPLACE", + "records": [{"content": "3.218.167.115", "disabled": false}] + }, + { + "name": "git.beyondtheuniverse.superviber.com.", + "type": "A", + "ttl": 300, + "changetype": "REPLACE", + "records": [{"content": "3.218.167.115", "disabled": false}] + } + ] + }' +``` + +### Phase 4: Update GoDaddy Glue Records + +At GoDaddy for superviber.com: +1. Update glue records to point ns1 and ns2 to 3.218.167.115 +2. Set nameservers to ns1.superviber.com and ns2.superviber.com + +### Phase 5: Verify + +```bash +# Test directly against PowerDNS +dig @3.218.167.115 git.beyondtheuniverse.superviber.com A + +# Test via public DNS (after propagation) +dig git.beyondtheuniverse.superviber.com A +``` + +--- + +## File Changes + +| File | Change | Status | +|------|--------|--------| +| `hearth/terraform/minimal/main.tf` | Add DNS ports 53 UDP/TCP to security group | Done | +| `hearth/scripts/deploy-powerdns.sh` | Standalone PowerDNS deployment script | Done | +| `hearth/docs/rfcs/0003-powerdns-self-hosted.md` | This RFC | Done | + +--- + +## Rollback Plan + +If PowerDNS fails: +1. Update GoDaddy to use its default nameservers (restore from backup) +2. Or: Use Cloudflare (already partially configured) + +The Elastic IP remains stable, so only NS records need updating. + +--- + +## Test Plan + +- [x] PowerDNS pod running in k3s +- [x] Port 53 UDP/TCP accessible from internet +- [x] `dig @3.218.167.115 superviber.com NS` returns ns1/ns2 +- [x] `dig @3.218.167.115 git.beyondtheuniverse.superviber.com A` returns 3.218.167.115 +- [x] GoDaddy glue records updated for all 5 domains (2026-01-24) +- [x] `dig @8.8.8.8 git.beyondtheuniverse.superviber.com A` resolves to 3.218.167.115 +- [x] Forgejo accessible at https://git.beyondtheuniverse.superviber.com with valid TLS + +--- + +## Future Work + +1. **HA DNS** - Deploy on separate instance with its own EIP for redundancy +2. **DNSSEC** - Sign zones for security +3. **Backup zones** - Export zone files to S3 with daily backups +4. **DNS UI** - PowerDNS-Admin web interface for zone management +5. **Integration with Blue** - Manage DNS records as code via Blue + +--- + +## References + +- [PowerDNS Documentation](https://doc.powerdns.com/authoritative/) +- [coherence-mcp RFC 0046](../../coherence-mcp/docs/rfcs/0046-domain-email-migration.md) +- [coherence-mcp dns-zones-rfc0046.yaml](../../coherence-mcp/infra/kubernetes/powerdns/dns-zones-rfc0046.yaml) diff --git a/scripts/deploy-powerdns.sh b/scripts/deploy-powerdns.sh new file mode 100755 index 0000000..ca57895 --- /dev/null +++ b/scripts/deploy-powerdns.sh @@ -0,0 +1,352 @@ +#!/bin/bash +set -euo pipefail + +# Deploy PowerDNS on k3s +# Usage: sudo ./deploy-powerdns.sh +# Example: sudo ./deploy-powerdns.sh 3.218.167.115 + +if [ $# -lt 1 ]; then + echo "Usage: $0 " + echo "Example: $0 3.218.167.115" + exit 1 +fi + +PUBLIC_IP="$1" + +echo "==========================================" +echo "Deploying PowerDNS with IP: $PUBLIC_IP" +echo "==========================================" + +# Create data directory +echo "Creating data directory..." +mkdir -p /data/powerdns +chown 953:953 /data/powerdns + +# Generate API key +PDNS_API_KEY=$(openssl rand -hex 32) +echo "$PDNS_API_KEY" > /root/.pdns-api-key +chmod 600 /root/.pdns-api-key +echo "API key saved to /root/.pdns-api-key" + +# Deploy PowerDNS +echo "Deploying PowerDNS to k3s..." +cat < /dev/null 2>&1; do + sleep 2 +done +echo "PowerDNS API is ready!" + +# Function to create zone +create_zone() { + local DOMAIN=$1 + echo "Creating zone: $DOMAIN" + + curl -sf -X POST "$PDNS_HOST/api/v1/servers/localhost/zones" \ + -H "X-API-Key: $PDNS_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"$DOMAIN.\", + \"kind\": \"Native\", + \"nameservers\": [\"ns1.$DOMAIN.\", \"ns2.$DOMAIN.\"] + }" 2>/dev/null || echo " (zone may already exist)" +} + +# Function to setup records +setup_records() { + local DOMAIN=$1 + echo "Setting up records for: $DOMAIN" + + curl -sf -X PATCH "$PDNS_HOST/api/v1/servers/localhost/zones/$DOMAIN." \ + -H "X-API-Key: $PDNS_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{ + \"rrsets\": [ + { + \"name\": \"$DOMAIN.\", + \"type\": \"NS\", + \"ttl\": 86400, + \"changetype\": \"REPLACE\", + \"records\": [ + {\"content\": \"ns1.$DOMAIN.\", \"disabled\": false}, + {\"content\": \"ns2.$DOMAIN.\", \"disabled\": false} + ] + }, + { + \"name\": \"ns1.$DOMAIN.\", + \"type\": \"A\", + \"ttl\": 3600, + \"changetype\": \"REPLACE\", + \"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}] + }, + { + \"name\": \"ns2.$DOMAIN.\", + \"type\": \"A\", + \"ttl\": 3600, + \"changetype\": \"REPLACE\", + \"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}] + }, + { + \"name\": \"$DOMAIN.\", + \"type\": \"A\", + \"ttl\": 300, + \"changetype\": \"REPLACE\", + \"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}] + }, + { + \"name\": \"www.$DOMAIN.\", + \"type\": \"CNAME\", + \"ttl\": 300, + \"changetype\": \"REPLACE\", + \"records\": [{\"content\": \"$DOMAIN.\", \"disabled\": false}] + } + ] + }" +} + +echo "" +echo "Creating DNS zones..." +for domain in superviber.com muffinlabs.ai letemcook.com appbasecamp.com thanksforborrowing.com alignment.coop; do + create_zone $domain + setup_records $domain +done + +# Setup superviber.com beyondtheuniverse services +echo "" +echo "Setting up beyondtheuniverse.superviber.com services..." +curl -sf -X PATCH "$PDNS_HOST/api/v1/servers/localhost/zones/superviber.com." \ + -H "X-API-Key: $PDNS_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{ + \"rrsets\": [ + { + \"name\": \"beyondtheuniverse.superviber.com.\", + \"type\": \"A\", + \"ttl\": 300, + \"changetype\": \"REPLACE\", + \"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}] + }, + { + \"name\": \"git.beyondtheuniverse.superviber.com.\", + \"type\": \"A\", + \"ttl\": 300, + \"changetype\": \"REPLACE\", + \"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}] + }, + { + \"name\": \"mail.beyondtheuniverse.superviber.com.\", + \"type\": \"A\", + \"ttl\": 300, + \"changetype\": \"REPLACE\", + \"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}] + }, + { + \"name\": \"auth.beyondtheuniverse.superviber.com.\", + \"type\": \"A\", + \"ttl\": 300, + \"changetype\": \"REPLACE\", + \"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}] + }, + { + \"name\": \"vault.beyondtheuniverse.superviber.com.\", + \"type\": \"A\", + \"ttl\": 300, + \"changetype\": \"REPLACE\", + \"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}] + }, + { + \"name\": \"grafana.beyondtheuniverse.superviber.com.\", + \"type\": \"A\", + \"ttl\": 300, + \"changetype\": \"REPLACE\", + \"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}] + } + ] + }" + +echo "" +echo "==========================================" +echo "PowerDNS deployment complete!" +echo "==========================================" +echo "" +echo "Verification:" +echo " dig @$PUBLIC_IP superviber.com NS" +echo " dig @$PUBLIC_IP git.beyondtheuniverse.superviber.com A" +echo "" +echo "Next steps:" +echo "1. Update GoDaddy glue records for each domain:" +echo " - ns1. -> $PUBLIC_IP" +echo " - ns2. -> $PUBLIC_IP" +echo "" +echo "2. Update nameservers at GoDaddy:" +echo " - ns1." +echo " - ns2." +echo "" +echo "3. Wait for DNS propagation (up to 48 hours)" +echo "==========================================" diff --git a/terraform/minimal/main.tf b/terraform/minimal/main.tf index 5af2716..1a92bc5 100644 --- a/terraform/minimal/main.tf +++ b/terraform/minimal/main.tf @@ -166,6 +166,24 @@ resource "aws_security_group" "forgejo" { cidr_blocks = var.admin_cidr_blocks } + # DNS (UDP) - PowerDNS authoritative server + ingress { + description = "DNS UDP" + from_port = 53 + to_port = 53 + protocol = "udp" + cidr_blocks = ["0.0.0.0/0"] + } + + # DNS (TCP) - PowerDNS authoritative server + ingress { + description = "DNS TCP" + from_port = 53 + to_port = 53 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + # All outbound egress { from_port = 0 @@ -340,7 +358,7 @@ resource "aws_instance" "forgejo" { } lifecycle { - ignore_changes = [ami] # Don't replace on AMI updates + ignore_changes = [ami, user_data] # Don't replace on AMI or user-data changes } } @@ -395,3 +413,30 @@ output "dns_record" { description = "DNS A record to create" value = "${var.domain} → ${aws_eip.forgejo.public_ip}" } + +output "powerdns_deploy_command" { + description = "Command to deploy PowerDNS after instance is running" + value = <<-EOT + # Copy and run deployment script: + scp -P ${var.admin_ssh_port} scripts/deploy-powerdns.sh ec2-user@${aws_eip.forgejo.public_ip}: + ssh -p ${var.admin_ssh_port} ec2-user@${aws_eip.forgejo.public_ip} 'sudo bash deploy-powerdns.sh ${aws_eip.forgejo.public_ip}' + EOT +} + +output "dns_glue_records" { + description = "Glue records to configure at GoDaddy after PowerDNS is running" + value = <<-EOT + For each managed domain, set nameservers and glue records: + + Nameservers: + ns1. + ns2. + + Glue Records: + ns1 → ${aws_eip.forgejo.public_ip} + ns2 → ${aws_eip.forgejo.public_ip} + + Domains: superviber.com, muffinlabs.ai, letemcook.com, + appbasecamp.com, thanksforborrowing.com, alignment.coop + EOT +}