From 800379b5815067ba117776dfac80a752684c84df Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Thu, 18 Dec 2025 14:55:59 +0100 Subject: [PATCH] feat: HTTP-based Yield routing (no DNS server required) - Updated yield_dns.py to support A-record verification (simplest method!) - A-record pointing to 46.235.147.194 is now the primary verification method - Added Nginx catch-all config for yield domains - DNS schema updated with method and actual_a fields - CoreDNS installed but Port 53 blocked by hosting provider --- backend/app/api/yield_domains.py | 4 +- backend/app/schemas/yield_domain.py | 2 + backend/app/services/dns_zone_manager.py | 255 +++++++++++++++++++++++ backend/app/services/yield_dns.py | 149 ++++++------- backend/scripts/setup_dns_server.sh | 180 ++++++++++++++++ backend/scripts/setup_yield_nginx.sh | 98 +++++++++ 6 files changed, 613 insertions(+), 75 deletions(-) create mode 100644 backend/app/services/dns_zone_manager.py create mode 100755 backend/scripts/setup_dns_server.sh create mode 100644 backend/scripts/setup_yield_nginx.sh diff --git a/backend/app/api/yield_domains.py b/backend/app/api/yield_domains.py index 8f0a857..e95a805 100644 --- a/backend/app/api/yield_domains.py +++ b/backend/app/api/yield_domains.py @@ -527,8 +527,10 @@ async def verify_domain_dns( return DNSVerificationResult( domain=domain.domain, verified=verified, + method=check.method, expected_ns=settings.yield_nameserver_list, - actual_ns=actual_ns, + actual_ns=check.actual_ns, + actual_a=check.actual_a, cname_ok=check.cname_ok if verified else False, error=error, checked_at=datetime.utcnow(), diff --git a/backend/app/schemas/yield_domain.py b/backend/app/schemas/yield_domain.py index 32ba357..3a2cedf 100644 --- a/backend/app/schemas/yield_domain.py +++ b/backend/app/schemas/yield_domain.py @@ -242,9 +242,11 @@ class DNSVerificationResult(BaseModel): """Result of DNS verification check.""" domain: str verified: bool + method: Optional[str] = None # "a_record" | "cname" | "nameserver" expected_ns: list[str] actual_ns: list[str] + actual_a: list[str] = [] # A-records found for the domain cname_ok: bool = False diff --git a/backend/app/services/dns_zone_manager.py b/backend/app/services/dns_zone_manager.py new file mode 100644 index 0000000..86f9d6e --- /dev/null +++ b/backend/app/services/dns_zone_manager.py @@ -0,0 +1,255 @@ +""" +DNS Zone Manager for Pounce Yield. + +Manages CoreDNS zone files for yield domains. +When a domain is activated for yield, we add it to the zone file. +When deactivated, we remove it. +""" + +import logging +import os +import re +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Optional + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +# CoreDNS zone file path +ZONE_FILE = Path("/opt/coredns/zones/db.yield") +SERVER_IP = "46.235.147.194" + + +def _get_serial() -> str: + """Generate zone serial in YYYYMMDDNN format.""" + today = datetime.utcnow().strftime("%Y%m%d") + # Read current serial and increment if same day + if ZONE_FILE.exists(): + content = ZONE_FILE.read_text() + match = re.search(r"(\d{8})(\d{2})\s*;\s*Serial", content) + if match: + existing_date = match.group(1) + existing_nn = int(match.group(2)) + if existing_date == today: + return f"{today}{(existing_nn + 1):02d}" + return f"{today}01" + + +def _generate_zone_file(domains: list[str]) -> str: + """Generate the complete zone file content.""" + serial = _get_serial() + + zone_content = f"""; Pounce Yield DNS Zone +; Auto-generated by dns_zone_manager.py +; Last updated: {datetime.utcnow().isoformat()}Z + +$TTL 300 +$ORIGIN yield.pounce.ch. + +@ IN SOA ns1.pounce.ch. admin.pounce.ch. ( + {serial} ; Serial (YYYYMMDDNN) + 3600 ; Refresh (1 hour) + 600 ; Retry (10 minutes) + 604800 ; Expire (1 week) + 300 ; Minimum TTL (5 minutes) + ) + +; Nameservers +@ IN NS ns1.pounce.ch. +@ IN NS ns2.pounce.ch. + +; A record for the zone apex +@ IN A {SERVER_IP} + +; Wildcard - all subdomains point to our server +* IN A {SERVER_IP} + +; ============================================ +; YIELD DOMAINS (auto-managed) +; ============================================ +""" + + for domain in sorted(set(domains)): + # Ensure domain ends with a dot (FQDN format) + fqdn = domain.lower().strip() + if not fqdn.endswith("."): + fqdn += "." + zone_content += f"{fqdn:<30} IN A {SERVER_IP}\n" + + return zone_content + + +def add_yield_domain(domain: str) -> bool: + """ + Add a domain to the yield DNS zone. + + Returns True if successful, False otherwise. + """ + if not ZONE_FILE.exists(): + logger.warning(f"Zone file not found: {ZONE_FILE}. CoreDNS may not be installed.") + return False + + try: + # Read current domains from zone file + content = ZONE_FILE.read_text() + domains = _parse_domains_from_zone(content) + + # Add new domain + domain_clean = domain.lower().strip().rstrip(".") + if domain_clean not in domains: + domains.append(domain_clean) + + # Write updated zone + new_content = _generate_zone_file(domains) + ZONE_FILE.write_text(new_content) + + # Reload CoreDNS + _reload_coredns() + + logger.info(f"Added yield domain to DNS: {domain_clean}") + else: + logger.info(f"Domain already in DNS zone: {domain_clean}") + + return True + + except Exception as e: + logger.error(f"Failed to add domain {domain} to DNS: {e}") + return False + + +def remove_yield_domain(domain: str) -> bool: + """ + Remove a domain from the yield DNS zone. + + Returns True if successful, False otherwise. + """ + if not ZONE_FILE.exists(): + logger.warning(f"Zone file not found: {ZONE_FILE}") + return False + + try: + content = ZONE_FILE.read_text() + domains = _parse_domains_from_zone(content) + + domain_clean = domain.lower().strip().rstrip(".") + if domain_clean in domains: + domains.remove(domain_clean) + + new_content = _generate_zone_file(domains) + ZONE_FILE.write_text(new_content) + + _reload_coredns() + + logger.info(f"Removed yield domain from DNS: {domain_clean}") + + return True + + except Exception as e: + logger.error(f"Failed to remove domain {domain} from DNS: {e}") + return False + + +def sync_yield_domains(domains: list[str]) -> bool: + """ + Sync all yield domains to the DNS zone. + + Replaces all existing yield domain entries with the provided list. + """ + if not ZONE_FILE.parent.exists(): + logger.warning(f"Zone directory not found: {ZONE_FILE.parent}") + return False + + try: + clean_domains = [d.lower().strip().rstrip(".") for d in domains] + new_content = _generate_zone_file(clean_domains) + + # Ensure directory exists + ZONE_FILE.parent.mkdir(parents=True, exist_ok=True) + ZONE_FILE.write_text(new_content) + + _reload_coredns() + + logger.info(f"Synced {len(clean_domains)} yield domains to DNS") + return True + + except Exception as e: + logger.error(f"Failed to sync yield domains: {e}") + return False + + +def _parse_domains_from_zone(content: str) -> list[str]: + """Extract yield domain names from zone file content.""" + domains = [] + + # Look for lines after "YIELD DOMAINS" marker + in_yield_section = False + for line in content.split("\n"): + if "YIELD DOMAINS" in line: + in_yield_section = True + continue + + if in_yield_section and line.strip() and not line.strip().startswith(";"): + # Parse: domain.tld. IN A IP + match = re.match(r"^([a-z0-9.-]+)\.\s+IN\s+A\s+", line, re.IGNORECASE) + if match: + domain = match.group(1).rstrip(".") + # Skip wildcards and pounce domains + if domain != "*" and "pounce" not in domain: + domains.append(domain) + + return domains + + +def _reload_coredns() -> bool: + """Reload CoreDNS to pick up zone changes.""" + try: + # Send SIGUSR1 to reload zone files without restart + result = subprocess.run( + ["systemctl", "reload", "coredns"], + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode != 0: + # Try restart as fallback + subprocess.run( + ["systemctl", "restart", "coredns"], + capture_output=True, + text=True, + timeout=30 + ) + return True + except Exception as e: + logger.warning(f"Could not reload CoreDNS: {e}") + return False + + +def check_coredns_status() -> dict: + """Check if CoreDNS is running and healthy.""" + status = { + "installed": ZONE_FILE.parent.exists(), + "running": False, + "zone_file_exists": ZONE_FILE.exists(), + "domain_count": 0, + } + + try: + result = subprocess.run( + ["systemctl", "is-active", "coredns"], + capture_output=True, + text=True, + timeout=5 + ) + status["running"] = result.stdout.strip() == "active" + except Exception: + pass + + if status["zone_file_exists"]: + content = ZONE_FILE.read_text() + status["domain_count"] = len(_parse_domains_from_zone(content)) + + return status diff --git a/backend/app/services/yield_dns.py b/backend/app/services/yield_dns.py index 15c4b57..d3cde49 100644 --- a/backend/app/services/yield_dns.py +++ b/backend/app/services/yield_dns.py @@ -2,8 +2,9 @@ Yield DNS verification helpers. Production-grade DNS checks for the Yield Connect flow: -- Option A (recommended): Nameserver delegation to our nameservers -- Option B (simpler): CNAME/ALIAS to a shared target +- Option A: A-record pointing directly to our server IP (simplest!) +- Option B: CNAME/ALIAS to yield.pounce.ch +- Option C: Nameserver delegation to our nameservers (requires DNS server) """ from __future__ import annotations @@ -14,11 +15,16 @@ from typing import Optional import dns.resolver +# Our server IP - domains with A-record pointing here are verified +YIELD_SERVER_IP = "46.235.147.194" + + @dataclass(frozen=True) class YieldDNSCheckResult: verified: bool - method: Optional[str] # "nameserver" | "cname" | None + method: Optional[str] # "a_record" | "cname" | "nameserver" | None actual_ns: list[str] + actual_a: list[str] cname_ok: bool error: Optional[str] @@ -37,7 +43,6 @@ def _normalize_host(host: str) -> str: def _resolve_ns(domain: str) -> list[str]: r = _resolver() answers = r.resolve(domain, "NS") - # NS answers are RRset with .target return sorted({_normalize_host(str(rr.target)) for rr in answers}) @@ -57,112 +62,108 @@ def verify_yield_dns(domain: str, expected_nameservers: list[str], cname_target: """ Verify that a domain is connected for Yield. - We accept: - - Nameserver delegation (NS contains all expected nameservers), OR - - CNAME/ALIAS to `cname_target` (either CNAME matches, or A records match target A records) + We accept (in order of simplicity): + 1. A-record pointing directly to our server IP (46.235.147.194) + 2. CNAME/ALIAS to `cname_target` (yield.pounce.ch) + 3. Nameserver delegation (NS contains all expected nameservers) """ domain = _normalize_host(domain) expected_ns = sorted({_normalize_host(ns) for ns in expected_nameservers if ns}) target = _normalize_host(cname_target) + actual_ns: list[str] = [] + actual_a: list[str] = [] if not domain: return YieldDNSCheckResult( verified=False, method=None, actual_ns=[], + actual_a=[], cname_ok=False, error="Domain is empty", ) - if not expected_ns and not target: - return YieldDNSCheckResult( - verified=False, - method=None, - actual_ns=[], - cname_ok=False, - error="Yield DNS is not configured on server", - ) - # Option A: NS delegation + # Option A: Direct A-record to our server IP (SIMPLEST!) try: - actual_ns = _resolve_ns(domain) - if expected_ns and set(expected_ns).issubset(set(actual_ns)): + actual_a = _resolve_a(domain) + if YIELD_SERVER_IP in actual_a: return YieldDNSCheckResult( verified=True, - method="nameserver", - actual_ns=actual_ns, + method="a_record", + actual_ns=[], + actual_a=actual_a, cname_ok=False, error=None, ) except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): - actual_ns = [] + pass except Exception as e: return YieldDNSCheckResult( verified=False, method=None, actual_ns=[], + actual_a=[], cname_ok=False, error=str(e), ) - # Option B: CNAME / ALIAS - if not target: - return YieldDNSCheckResult( - verified=False, - method=None, - actual_ns=actual_ns, - cname_ok=False, - error="Yield CNAME target is not configured on server", - ) + # Option B: CNAME to yield.pounce.ch + if target: + try: + cnames = _resolve_cname(domain) + if any(c == target for c in cnames): + return YieldDNSCheckResult( + verified=True, + method="cname", + actual_ns=[], + actual_a=actual_a, + cname_ok=True, + error=None, + ) + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + pass + except Exception: + pass + + # Also check if A-records match target's A-records (ALIAS/ANAME flattening) + try: + target_as = set(_resolve_a(target)) + if target_as and actual_a and set(actual_a).issubset(target_as): + return YieldDNSCheckResult( + verified=True, + method="cname", + actual_ns=[], + actual_a=actual_a, + cname_ok=True, + error=None, + ) + except Exception: + pass - # 1) Direct CNAME check (works for subdomain CNAME setups) - try: - cnames = _resolve_cname(domain) - if any(c == target for c in cnames): - return YieldDNSCheckResult( - verified=True, - method="cname", - actual_ns=actual_ns, - cname_ok=True, - error=None, - ) - except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): - pass - except Exception as e: - return YieldDNSCheckResult( - verified=False, - method=None, - actual_ns=actual_ns, - cname_ok=False, - error=str(e), - ) - - # 2) ALIAS/ANAME flattening: compare A records against target A records - try: - target_as = set(_resolve_a(target)) - domain_as = set(_resolve_a(domain)) - if target_as and domain_as and domain_as.issubset(target_as): - return YieldDNSCheckResult( - verified=True, - method="cname", - actual_ns=actual_ns, - cname_ok=True, - error=None, - ) - except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): - pass - except Exception as e: - return YieldDNSCheckResult( - verified=False, - method=None, - actual_ns=actual_ns, - cname_ok=False, - error=str(e), - ) + # Option C: NS delegation (requires DNS server on Port 53) + if expected_ns: + try: + actual_ns = _resolve_ns(domain) + if set(expected_ns).issubset(set(actual_ns)): + return YieldDNSCheckResult( + verified=True, + method="nameserver", + actual_ns=actual_ns, + actual_a=actual_a, + cname_ok=False, + error=None, + ) + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + pass + except Exception: + pass + # Not verified return YieldDNSCheckResult( verified=False, method=None, actual_ns=actual_ns, + actual_a=actual_a, cname_ok=False, error=None, ) diff --git a/backend/scripts/setup_dns_server.sh b/backend/scripts/setup_dns_server.sh new file mode 100755 index 0000000..4ce0b22 --- /dev/null +++ b/backend/scripts/setup_dns_server.sh @@ -0,0 +1,180 @@ +#!/bin/bash +# ============================================================================ +# Pounce DNS Server Setup (CoreDNS) +# ============================================================================ +# This script sets up CoreDNS as an authoritative DNS server for Yield domains. +# Users point their domains' NS records to ns1.pounce.ch and ns2.pounce.ch, +# which both resolve to this server's IP. +# +# Usage: sudo bash setup_dns_server.sh +# ============================================================================ + +set -e + +echo "==========================================" +echo "Pounce DNS Server Setup (CoreDNS)" +echo "==========================================" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo "ERROR: Please run as root (sudo)" + exit 1 +fi + +SERVER_IP="46.235.147.194" +COREDNS_VERSION="1.11.1" +COREDNS_DIR="/opt/coredns" +ZONES_DIR="/opt/coredns/zones" + +echo "[1/6] Installing dependencies..." +apt-get update -qq +apt-get install -y -qq wget curl jq + +echo "[2/6] Downloading CoreDNS ${COREDNS_VERSION}..." +mkdir -p "$COREDNS_DIR" +cd "$COREDNS_DIR" + +if [ ! -f "coredns" ]; then + wget -q "https://github.com/coredns/coredns/releases/download/v${COREDNS_VERSION}/coredns_${COREDNS_VERSION}_linux_amd64.tgz" + tar -xzf "coredns_${COREDNS_VERSION}_linux_amd64.tgz" + rm "coredns_${COREDNS_VERSION}_linux_amd64.tgz" + chmod +x coredns +fi + +echo "[3/6] Creating zone directory..." +mkdir -p "$ZONES_DIR" + +echo "[4/6] Creating CoreDNS config (Corefile)..." +cat > "$COREDNS_DIR/Corefile" << 'COREFILE' +# CoreDNS Configuration for Pounce Yield +# Serves authoritative DNS for delegated yield domains + +# Default zone - serves A record pointing to our server +. { + # Log all queries for debugging + log + + # Serve zones from files + file /opt/coredns/zones/db.yield { + reload 30s + } + + # Health check endpoint + health :8053 + + # Prometheus metrics + prometheus :9153 + + # Forward unknown queries (shouldn't happen for authoritative) + forward . 8.8.8.8 8.8.4.4 { + max_concurrent 1000 + } + + # Cache responses + cache 300 + + # Error handling + errors +} +COREFILE + +echo "[5/6] Creating initial zone file..." +cat > "$ZONES_DIR/db.yield" << ZONEFILE +; Pounce Yield DNS Zone +; This file is dynamically updated by the Pounce backend +; DO NOT EDIT MANUALLY - changes will be overwritten + +\$TTL 300 +\$ORIGIN yield.pounce.ch. + +@ IN SOA ns1.pounce.ch. admin.pounce.ch. ( + $(date +%Y%m%d)01 ; Serial (YYYYMMDDNN) + 3600 ; Refresh (1 hour) + 600 ; Retry (10 minutes) + 604800 ; Expire (1 week) + 300 ; Minimum TTL (5 minutes) + ) + +; Nameservers +@ IN NS ns1.pounce.ch. +@ IN NS ns2.pounce.ch. + +; A record for the zone apex +@ IN A ${SERVER_IP} + +; Wildcard - all subdomains point to our server +* IN A ${SERVER_IP} + +; ============================================ +; YIELD DOMAINS +; Add domains below in format: +; domainname IN A ${SERVER_IP} +; ============================================ + +; Example (uncomment to test): +; akaya.ch. IN A ${SERVER_IP} + +ZONEFILE + +echo "[6/6] Creating systemd service..." +cat > /etc/systemd/system/coredns.service << 'SERVICE' +[Unit] +Description=CoreDNS DNS Server +Documentation=https://coredns.io +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/coredns +ExecStart=/opt/coredns/coredns -conf /opt/coredns/Corefile +ExecReload=/bin/kill -SIGUSR1 $MAINPID +Restart=on-failure +RestartSec=5s +LimitNOFILE=1048576 +LimitNPROC=512 + +# Security +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +AmbientCapabilities=CAP_NET_BIND_SERVICE +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target +SERVICE + +echo "[7/6] Opening firewall port 53..." +if command -v ufw &> /dev/null; then + ufw allow 53/tcp + ufw allow 53/udp + echo "UFW: Port 53 opened" +elif command -v firewall-cmd &> /dev/null; then + firewall-cmd --permanent --add-port=53/tcp + firewall-cmd --permanent --add-port=53/udp + firewall-cmd --reload + echo "firewalld: Port 53 opened" +else + echo "WARNING: No firewall detected. Make sure port 53 is open!" +fi + +echo "[8/6] Starting CoreDNS..." +systemctl daemon-reload +systemctl enable coredns +systemctl start coredns + +echo "" +echo "==========================================" +echo "✅ CoreDNS installed and running!" +echo "==========================================" +echo "" +echo "Status: $(systemctl is-active coredns)" +echo "Config: $COREDNS_DIR/Corefile" +echo "Zones: $ZONES_DIR/db.yield" +echo "" +echo "To add a yield domain, append to $ZONES_DIR/db.yield:" +echo " akaya.ch. IN A $SERVER_IP" +echo "" +echo "Then reload: systemctl reload coredns" +echo "" +echo "Test with: dig @localhost akaya.ch" +echo "==========================================" diff --git a/backend/scripts/setup_yield_nginx.sh b/backend/scripts/setup_yield_nginx.sh new file mode 100644 index 0000000..8310a53 --- /dev/null +++ b/backend/scripts/setup_yield_nginx.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# ============================================================================ +# Pounce Yield HTTP Routing Setup +# ============================================================================ +# This sets up Nginx to catch-all domains pointing to our server +# and route them to the Pounce backend for Yield landing pages. +# +# Instead of Nameserver delegation (which requires Port 53), +# users simply set an A-record pointing to our IP. +# +# Usage: sudo bash setup_yield_nginx.sh +# ============================================================================ + +set -e + +echo "==========================================" +echo "Pounce Yield HTTP Routing Setup" +echo "==========================================" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo "ERROR: Please run as root (sudo)" + exit 1 +fi + +NGINX_CONF="/etc/nginx/sites-available/yield-catchall" +SERVER_IP="46.235.147.194" + +echo "[1/3] Creating Nginx catch-all config for Yield..." + +cat > "$NGINX_CONF" << 'NGINX' +# Pounce Yield Catch-All Server +# This catches all domains pointing to our server that aren't pounce.ch +# and routes them to the Yield routing backend. + +server { + listen 80 default_server; + listen [::]:80 default_server; + + # Catch all hostnames except pounce.ch + server_name _; + + # Skip if it's pounce.ch or www.pounce.ch + if ($host ~* ^(www\.)?pounce\.ch$) { + return 444; # Close connection, let the main server block handle it + } + + # Route all traffic to backend yield routing + location / { + # Rewrite to /api/v1/r/{hostname} + set $yield_domain $host; + + proxy_pass http://127.0.0.1:8000/api/v1/r/$yield_domain; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Yield-Domain $host; + + # Handle errors gracefully + proxy_intercept_errors on; + error_page 404 502 503 504 = @yield_fallback; + } + + # Fallback for domains not configured in Yield + location @yield_fallback { + return 302 https://pounce.ch/yield?domain=$host; + } +} +NGINX + +echo "[2/3] Enabling site and testing config..." + +# Enable the site if not already +if [ ! -f "/etc/nginx/sites-enabled/yield-catchall" ]; then + ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/yield-catchall +fi + +# Test nginx config +nginx -t + +echo "[3/3] Reloading Nginx..." +systemctl reload nginx + +echo "" +echo "==========================================" +echo "✅ Yield HTTP Routing configured!" +echo "==========================================" +echo "" +echo "How it works:" +echo "1. User sets A-record for their domain to: $SERVER_IP" +echo "2. When someone visits the domain, Nginx catches it" +echo "3. Traffic is routed to /api/v1/r/{domain}" +echo "4. Backend serves the Yield landing page" +echo "" +echo "No DNS server (Port 53) required!" +echo "=========================================="