secubox-openwrt/package/secubox/secubox-app-dns-provider/files/usr/sbin/dnsctl
CyberMind-FR c6fb79ed3b feat: Add unified backup manager, custom mail server, DNS subdomain generator
New packages:
- secubox-app-backup: Unified backup for LXC containers, UCI config, services
- luci-app-backup: KISS dashboard with container list and backup history
- secubox-app-mailserver: Custom Postfix+Dovecot in LXC with mesh backup

Enhanced dnsctl with:
- generate: Auto-create subdomain A records
- suggest: Name suggestions by category
- mail-setup: MX, SPF, DMARC record creation
- dkim-add: DKIM TXT record management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:40:32 +01:00

598 lines
16 KiB
Bash

#!/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 <TYPE> <subdomain> <target> [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 <TYPE> <subdomain>"
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 <fqdn>"
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 <TYPE> [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 <TYPE> <subdomain> <target> [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 <service> [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 <name>'"
;;
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 '<dkim-public-key>'"
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] <public_key>"
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 <domain>"
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 <command> [options]
Commands:
list List all DNS records in zone
add <TYPE> <sub> <target> Create DNS record (A, AAAA, CNAME, TXT, MX)
update <TYPE> <sub> <target> Update existing DNS record
get <TYPE> [subdomain] Get specific record value
rm <TYPE> <subdomain> Remove DNS record
sync Sync HAProxy vhosts → DNS A records
verify <fqdn> 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 <service> [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] <pubkey> Add DKIM TXT record
SSL:
acme-dns01 <domain> 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