feat: HTTP-based Yield routing (no DNS server required)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled

- 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
This commit is contained in:
2025-12-18 14:55:59 +01:00
parent dad97f951e
commit 800379b581
6 changed files with 613 additions and 75 deletions

View File

@ -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(),

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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 "=========================================="

View File

@ -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 "=========================================="