#!/bin/sh # SecuBox DNS Provider Controller # Programmatic DNS record management via provider APIs VERSION="1.0.0" CONFIG="dns-provider" ADAPTER_DIR="/usr/lib/secubox/dns" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' log() { echo -e "${GREEN}[DNS]${NC} $1"; } warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; } uci_get() { uci -q get ${CONFIG}.$1; } # ============================================================================ # Provider Loading # ============================================================================ load_provider() { local provider=$(uci_get main.provider) local zone=$(uci_get main.zone) if [ -z "$provider" ]; then error "No DNS provider configured. Set with: uci set dns-provider.main.provider='ovh'" exit 1 fi if [ -z "$zone" ]; then error "No DNS zone configured. Set with: uci set dns-provider.main.zone='example.com'" exit 1 fi local adapter="${ADAPTER_DIR}/${provider}.sh" if [ ! -f "$adapter" ]; then error "Unknown provider: $provider (no adapter at $adapter)" exit 1 fi . "$adapter" } get_zone() { uci_get main.zone } # ============================================================================ # Commands # ============================================================================ cmd_list() { load_provider local zone=$(get_zone) log "Listing records for $zone..." dns_list "$zone" } cmd_add() { local type="$1" subdomain="$2" target="$3" ttl="${4:-3600}" if [ -z "$type" ] || [ -z "$subdomain" ] || [ -z "$target" ]; then echo "Usage: dnsctl add [ttl]" echo " TYPE: A, AAAA, CNAME, TXT, MX, SRV" echo "" echo "Examples:" echo " dnsctl add A myservice 1.2.3.4" echo " dnsctl add CNAME www mycdn.net" echo " dnsctl add TXT _acme-challenge 'validation-token'" return 1 fi load_provider local zone=$(get_zone) log "Adding $type record: ${subdomain}.${zone} → $target (TTL: $ttl)" dns_add "$zone" "$type" "$subdomain" "$target" "$ttl" echo "" log "Record created. Verify with: dnsctl verify ${subdomain}.${zone}" } cmd_rm() { local type="$1" subdomain="$2" if [ -z "$type" ] || [ -z "$subdomain" ]; then echo "Usage: dnsctl rm " echo " dnsctl rm A myservice" return 1 fi load_provider local zone=$(get_zone) log "Removing $type record for ${subdomain}.${zone}..." dns_rm "$zone" "$type" "$subdomain" log "Record removed." } cmd_sync() { load_provider local zone=$(get_zone) log "Syncing local vhosts to DNS..." # Read HAProxy vhosts local idx=0 local synced=0 while uci -q get haproxy.@vhost[$idx] >/dev/null 2>&1; do local domain=$(uci -q get haproxy.@vhost[$idx].domain) local enabled=$(uci -q get haproxy.@vhost[$idx].enabled) if [ "$enabled" = "1" ] && [ -n "$domain" ]; then # Check if domain is within our zone if echo "$domain" | grep -q "\.${zone}$"; then local subdomain=$(echo "$domain" | sed "s/\.${zone}$//") # Get public IP local public_ip=$(curl -s --connect-timeout 5 https://ipv4.icanhazip.com 2>/dev/null | tr -d '\n') if [ -n "$public_ip" ]; then log " $subdomain → $public_ip" dns_add "$zone" "A" "$subdomain" "$public_ip" 3600 synced=$((synced + 1)) fi fi fi idx=$((idx + 1)) done log "Synced $synced record(s)." } cmd_verify() { local fqdn="$1" if [ -z "$fqdn" ]; then echo "Usage: dnsctl verify " return 1 fi log "Checking DNS propagation for $fqdn..." # Check with multiple resolvers local resolvers="1.1.1.1 8.8.8.8 9.9.9.9" local resolved=0 local failed=0 for resolver in $resolvers; do local result=$(nslookup "$fqdn" "$resolver" 2>/dev/null | grep "Address:" | tail -1 | awk '{print $2}') if [ -n "$result" ] && [ "$result" != "$resolver" ]; then echo -e " ${GREEN}$resolver${NC} → $result" resolved=$((resolved + 1)) else echo -e " ${RED}$resolver${NC} → not resolved" failed=$((failed + 1)) fi done echo "" if [ "$failed" -eq 0 ]; then log "Fully propagated ($resolved/$((resolved + failed)) resolvers)" elif [ "$resolved" -gt 0 ]; then warn "Partially propagated ($resolved/$((resolved + failed)) resolvers)" else error "Not propagated yet. DNS changes may take up to 24 hours." fi } cmd_test() { load_provider local provider=$(uci_get main.provider) log "Testing $provider credentials..." local result=$(dns_test_credentials) if [ "$result" = "ok" ]; then log "Credentials valid." else error "Credential test failed: $result" return 1 fi } cmd_status() { local provider=$(uci_get main.provider) local zone=$(uci_get main.zone) local enabled=$(uci_get main.enabled) echo "" echo "========================================" echo " DNS Provider Status v$VERSION" echo "========================================" echo "" echo " Enabled: $([ "$enabled" = "1" ] && echo -e "${GREEN}Yes${NC}" || echo -e "${RED}No${NC}")" echo " Provider: ${provider:-not set}" echo " Zone: ${zone:-not set}" if [ -n "$provider" ]; then echo "" echo "Provider Config ($provider):" case "$provider" in ovh) local endpoint=$(uci_get ovh.endpoint) local app_key=$(uci_get ovh.app_key) echo " Endpoint: ${endpoint:-ovh-eu}" echo " App Key: ${app_key:+configured}${app_key:-NOT SET}" echo " App Secret: $([ -n "$(uci_get ovh.app_secret)" ] && echo "configured" || echo "NOT SET")" echo " Consumer Key:$([ -n "$(uci_get ovh.consumer_key)" ] && echo " configured" || echo " NOT SET")" ;; gandi) echo " API Key: $([ -n "$(uci_get gandi.api_key)" ] && echo "configured" || echo "NOT SET")" ;; cloudflare) echo " API Token: $([ -n "$(uci_get cloudflare.api_token)" ] && echo "configured" || echo "NOT SET")" echo " Zone ID: $([ -n "$(uci_get cloudflare.zone_id)" ] && echo "configured" || echo "NOT SET")" ;; esac fi echo "" } # ============================================================================ # DynDNS Commands # ============================================================================ cmd_dyndns() { local subdomain="${1:-@}" local ttl="${2:-300}" load_provider local zone=$(get_zone) local provider=$(uci_get main.provider) log "DynDNS update for ${subdomain}.${zone}..." # Check if provider has dyndns support if ! type dns_dyndns >/dev/null 2>&1; then error "Provider $provider does not support DynDNS (dns_dyndns function missing)" return 1 fi local result=$(dns_dyndns "$zone" "$subdomain" "$ttl") echo "$result" if echo "$result" | grep -q '"status":"updated"'; then local new_ip=$(echo "$result" | jsonfilter -e '@.new_ip' 2>/dev/null) log "Updated ${subdomain}.${zone} → $new_ip" elif echo "$result" | grep -q '"status":"unchanged"'; then local ip=$(echo "$result" | jsonfilter -e '@.ip' 2>/dev/null) log "IP unchanged: $ip" else error "DynDNS update failed" return 1 fi } cmd_get() { local type="$1" subdomain="$2" if [ -z "$type" ]; then echo "Usage: dnsctl get [subdomain]" echo " dnsctl get A @ # Get root A record" echo " dnsctl get A www # Get www A record" return 1 fi [ -z "$subdomain" ] && subdomain="@" load_provider local zone=$(get_zone) # Check if provider has get support if type dns_get >/dev/null 2>&1; then dns_get "$zone" "$type" "$subdomain" else # Fallback to list and filter dns_list "$zone" | jsonfilter -e "@[*]" 2>/dev/null | \ grep -E "\"rrset_name\":\"$subdomain\"" | \ grep -E "\"rrset_type\":\"$type\"" fi } cmd_update() { local type="$1" subdomain="$2" target="$3" ttl="${4:-3600}" if [ -z "$type" ] || [ -z "$subdomain" ] || [ -z "$target" ]; then echo "Usage: dnsctl update [ttl]" echo " Updates existing record (use 'add' for new records)" return 1 fi load_provider local zone=$(get_zone) # Check if provider has update support if type dns_update >/dev/null 2>&1; then log "Updating $type record: ${subdomain}.${zone} → $target" dns_update "$zone" "$type" "$subdomain" "$target" "$ttl" else # Fallback: remove and add warn "Provider lacks update, using rm+add" dns_rm "$zone" "$type" "$subdomain" dns_add "$zone" "$type" "$subdomain" "$target" "$ttl" fi } cmd_domains() { load_provider local provider=$(uci_get main.provider) if type dns_domains >/dev/null 2>&1; then log "Domains in $provider account:" dns_domains else error "Provider $provider does not support domain listing" return 1 fi } # ============================================================================ # Subdomain Generator # ============================================================================ cmd_generate() { local service="$1" local prefix="${2:-}" if [ -z "$service" ]; then echo "Usage: dnsctl generate [prefix]" echo " Auto-generates unique subdomain and creates A record" echo "" echo "Examples:" echo " dnsctl generate gitea # Creates gitea.zone.tld" echo " dnsctl generate blog dev # Creates dev-blog.zone.tld" echo " dnsctl generate api prod # Creates prod-api.zone.tld" return 1 fi load_provider local zone=$(get_zone) local public_ip=$(curl -s --connect-timeout 5 https://ipv4.icanhazip.com 2>/dev/null | tr -d '\n') [ -z "$public_ip" ] && { error "Cannot detect public IP"; return 1; } # Build subdomain name local subdomain if [ -n "$prefix" ]; then subdomain="${prefix}-${service}" else subdomain="$service" fi # Check if subdomain exists local existing=$(nslookup "${subdomain}.${zone}" 8.8.8.8 2>/dev/null | grep "Address:" | tail -1) if echo "$existing" | grep -q "[0-9]"; then warn "Subdomain ${subdomain}.${zone} already exists" # Try with numeric suffix local i=2 while [ $i -lt 10 ]; do local try_sub="${subdomain}${i}" local try_check=$(nslookup "${try_sub}.${zone}" 8.8.8.8 2>/dev/null | grep "Address:" | tail -1) if ! echo "$try_check" | grep -q "[0-9]"; then subdomain="$try_sub" break fi i=$((i + 1)) done fi log "Generating subdomain: ${subdomain}.${zone}" dns_add "$zone" "A" "$subdomain" "$public_ip" 3600 echo "" echo "Created: ${subdomain}.${zone} → $public_ip" echo "" log "Verify with: dnsctl verify ${subdomain}.${zone}" } cmd_suggest() { local category="${1:-web}" echo "" echo "Subdomain suggestions for category: $category" echo "" case "$category" in web) echo " www, blog, shop, app, portal, admin, api" ;; mail) echo " mail, smtp, imap, pop, webmail, mx, mta" ;; dev) echo " git, dev, staging, test, ci, cd, build" ;; media) echo " media, cdn, stream, video, music, files" ;; iot) echo " mqtt, home, sensor, hub, iot, zwave, zigbee" ;; security) echo " vpn, tor, proxy, guard, auth, sso" ;; *) echo " Custom: use 'dnsctl generate '" ;; esac echo "" } # ============================================================================ # Mail DNS Records (MX, SPF, DKIM, DMARC) # ============================================================================ cmd_mail_setup() { local mail_host="${1:-mail}" local priority="${2:-10}" load_provider local zone=$(get_zone) local public_ip=$(curl -s --connect-timeout 5 https://ipv4.icanhazip.com 2>/dev/null | tr -d '\n') [ -z "$public_ip" ] && { error "Cannot detect public IP"; return 1; } log "Setting up mail DNS records for $zone..." # Create mail A record log " Creating A record: ${mail_host}.${zone} → $public_ip" dns_add "$zone" "A" "$mail_host" "$public_ip" 3600 # Create MX record log " Creating MX record: ${zone} → ${mail_host}.${zone} (priority $priority)" dns_add "$zone" "MX" "@" "${priority} ${mail_host}.${zone}." 3600 # Create SPF record local spf="v=spf1 mx a:${mail_host}.${zone} ~all" log " Creating SPF record: $spf" dns_add "$zone" "TXT" "@" "$spf" 3600 # Create DMARC record (relaxed policy for start) local dmarc="v=DMARC1; p=none; rua=mailto:postmaster@${zone}" log " Creating DMARC record: $dmarc" dns_add "$zone" "TXT" "_dmarc" "$dmarc" 3600 echo "" log "Mail DNS setup complete!" echo "" echo "Next steps:" echo " 1. Configure your mail server at ${mail_host}.${zone}" echo " 2. Generate DKIM keys and add TXT record:" echo " dnsctl add TXT mail._domainkey ''" echo " 3. Verify MX: dig MX ${zone}" echo " 4. Test SPF: dig TXT ${zone}" echo "" } cmd_dkim_add() { local selector="${1:-mail}" local public_key="$2" if [ -z "$public_key" ]; then echo "Usage: dnsctl dkim-add [selector] " echo " selector defaults to 'mail'" echo "" echo "Generate DKIM key pair:" echo " openssl genrsa -out dkim.private 2048" echo " openssl rsa -in dkim.private -pubout -out dkim.public" echo " # Use content between -----BEGIN/END PUBLIC KEY----- (no newlines)" return 1 fi load_provider local zone=$(get_zone) # Format DKIM record local dkim="v=DKIM1; k=rsa; p=${public_key}" log "Adding DKIM record: ${selector}._domainkey.${zone}" dns_add "$zone" "TXT" "${selector}._domainkey" "$dkim" 3600 log "DKIM record added. Verify with: dig TXT ${selector}._domainkey.${zone}" } # ============================================================================ # ACME DNS-01 Helper # ============================================================================ cmd_acme_dns01() { local domain="$1" if [ -z "$domain" ]; then echo "Usage: dnsctl acme-dns01 " echo "Issues certificate via DNS-01 challenge using configured provider." return 1 fi load_provider local provider=$(uci_get main.provider) log "Issuing certificate for $domain via DNS-01 ($provider)..." case "$provider" in ovh) export OVH_END_POINT=$(uci_get ovh.endpoint) export OVH_APPLICATION_KEY=$(uci_get ovh.app_key) export OVH_APPLICATION_SECRET=$(uci_get ovh.app_secret) export OVH_CONSUMER_KEY=$(uci_get ovh.consumer_key) acme.sh --issue -d "$domain" --dns dns_ovh ;; gandi) export GANDI_LIVEDNS_KEY=$(uci_get gandi.api_key) acme.sh --issue -d "$domain" --dns dns_gandi_livedns ;; cloudflare) export CF_Token=$(uci_get cloudflare.api_token) export CF_Zone_ID=$(uci_get cloudflare.zone_id) acme.sh --issue -d "$domain" --dns dns_cf ;; *) error "Unsupported provider for ACME DNS-01: $provider" return 1 ;; esac } # ============================================================================ # Main # ============================================================================ show_help() { cat << EOF SecuBox DNS Provider Control v$VERSION Usage: dnsctl [options] Commands: list List all DNS records in zone add Create DNS record (A, AAAA, CNAME, TXT, MX) update Update existing DNS record get [subdomain] Get specific record value rm Remove DNS record sync Sync HAProxy vhosts → DNS A records verify Check DNS propagation across resolvers test Verify provider API credentials status Show provider configuration status domains List all domains in provider account DynDNS: dyndns [subdomain] [ttl] Update A record with current WAN IP Generator: generate [prefix] Auto-generate subdomain with A record suggest [category] Show subdomain name suggestions Mail: mail-setup [host] [priority] Set up MX, SPF, DMARC records dkim-add [selector] Add DKIM TXT record SSL: acme-dns01 Issue SSL cert via DNS-01 challenge Examples: dnsctl add A gitea 1.2.3.4 dnsctl update A gitea 5.6.7.8 dnsctl get A www dnsctl dyndns @ 300 # Update root A record dnsctl dyndns api 600 # Update api.zone with WAN IP dnsctl rm A gitea dnsctl sync dnsctl verify gitea.example.com dnsctl acme-dns01 '*.example.com' EOF } case "${1:-}" in list) shift; cmd_list "$@" ;; add) shift; cmd_add "$@" ;; update) shift; cmd_update "$@" ;; get) shift; cmd_get "$@" ;; rm|remove) shift; cmd_rm "$@" ;; sync) shift; cmd_sync "$@" ;; verify) shift; cmd_verify "$@" ;; test) shift; cmd_test "$@" ;; status) shift; cmd_status "$@" ;; domains) shift; cmd_domains "$@" ;; dyndns) shift; cmd_dyndns "$@" ;; generate) shift; cmd_generate "$@" ;; suggest) shift; cmd_suggest "$@" ;; mail-setup) shift; cmd_mail_setup "$@" ;; dkim-add) shift; cmd_dkim_add "$@" ;; acme-dns01) shift; cmd_acme_dns01 "$@" ;; help|--help|-h|'') show_help ;; *) error "Unknown command: $1"; show_help >&2; exit 1 ;; esac exit 0