add cpanel postfix mail relay crap
All checks were successful
Generate README / build (push) Successful in 15s
All checks were successful
Generate README / build (push) Successful in 15s
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
27
cpanel templates/simple
Normal file
27
cpanel templates/simple
Normal file
@@ -0,0 +1,27 @@
|
||||
; cPanel %cpversion%
|
||||
; Zone file for %domain%
|
||||
$TTL %ttl%
|
||||
@ %nsttl% IN SOA %nameserver%. %rpemail%. (
|
||||
%serial% ; serial, todays date+todays
|
||||
3600 ; refresh, seconds
|
||||
1800 ; retry, seconds
|
||||
1209600 ; expire, seconds
|
||||
86400 ) ; minimum, seconds
|
||||
|
||||
%domain%. %nsttl% IN NS %nameserver%.
|
||||
%domain%. %nsttl% IN NS %nameserver2%.
|
||||
%domain%. %nsttl% IN NS %nameserver3%.
|
||||
%domain%. %nsttl% IN NS %nameserver4%.
|
||||
|
||||
%nameserverentry%. IN A %nameservera%
|
||||
%nameserverentry2%. IN A %nameservera2%
|
||||
%nameserverentry3%. IN A %nameservera3%
|
||||
%nameserverentry4%. IN A %nameservera4%
|
||||
|
||||
%domain%. IN A %ip%
|
||||
%domain%. IN AAAA %ipv6%
|
||||
|
||||
%domain%. IN MX 10 mail.%domain%.
|
||||
%domain%. IN TXT "v=spf1 ip4:50.116.60.173 -all"
|
||||
_dmarc IN TXT "v=DMARC1; p=quarantine; adkim=s; aspf=s"
|
||||
mail IN A 50.116.60.173
|
||||
31
cpanel templates/standard
Normal file
31
cpanel templates/standard
Normal file
@@ -0,0 +1,31 @@
|
||||
; cPanel %cpversion%
|
||||
; Zone file for %domain%
|
||||
$TTL %ttl%
|
||||
@ %nsttl% IN SOA %nameserver%. %rpemail%. (
|
||||
%serial% ; serial, todays date+todays
|
||||
3600 ; refresh, seconds
|
||||
1800 ; retry, seconds
|
||||
1209600 ; expire, seconds
|
||||
86400 ) ; minimum, seconds
|
||||
|
||||
%domain%. %nsttl% IN NS %nameserver%.
|
||||
%domain%. %nsttl% IN NS %nameserver2%.
|
||||
%domain%. %nsttl% IN NS %nameserver3%.
|
||||
%domain%. %nsttl% IN NS %nameserver4%.
|
||||
|
||||
%nameserverentry%. IN A %nameservera%
|
||||
%nameserverentry2%. IN A %nameservera2%
|
||||
%nameserverentry3%. IN A %nameservera3%
|
||||
%nameserverentry4%. IN A %nameservera4%
|
||||
|
||||
%domain%. IN A %ip%
|
||||
%domain%. IN AAAA %ipv6%
|
||||
ipv6 IN AAAA %ipv6%
|
||||
|
||||
%domain%. IN MX 10 mail.%domain%.
|
||||
%domain%. IN TXT "v=spf1 ip4:50.116.60.173 -all"
|
||||
_dmarc IN TXT "v=DMARC1; p=quarantine; adkim=s; aspf=s"
|
||||
|
||||
mail IN A 50.116.60.173
|
||||
www IN CNAME %domain%.
|
||||
ftp IN CNAME %domain%.
|
||||
31
cpanel templates/standardvirutalftp
Normal file
31
cpanel templates/standardvirutalftp
Normal file
@@ -0,0 +1,31 @@
|
||||
; cPanel %cpversion%
|
||||
; Zone file for %domain%
|
||||
$TTL %ttl%
|
||||
@ %nsttl% IN SOA %nameserver%. %rpemail%. (
|
||||
%serial% ; serial, todays date+todays
|
||||
3600 ; refresh, seconds
|
||||
1800 ; retry, seconds
|
||||
1209600 ; expire, seconds
|
||||
86400 ) ; minimum, seconds
|
||||
|
||||
%domain%. %nsttl% IN NS %nameserver%.
|
||||
%domain%. %nsttl% IN NS %nameserver2%.
|
||||
%domain%. %nsttl% IN NS %nameserver3%.
|
||||
%domain%. %nsttl% IN NS %nameserver4%.
|
||||
|
||||
%nameserverentry%. IN A %nameservera%
|
||||
%nameserverentry2%. IN A %nameservera2%
|
||||
%nameserverentry3%. IN A %nameservera3%
|
||||
%nameserverentry4%. IN A %nameservera4%
|
||||
|
||||
%domain%. IN A %ip%
|
||||
%domain%. IN AAAA %ipv6%
|
||||
|
||||
%domain%. IN MX 10 mail.%domain%.
|
||||
%domain%. IN TXT "v=spf1 ip4:50.116.60.173 -all"
|
||||
_dmarc IN TXT "v=DMARC1; p=quarantine; adkim=s; aspf=s"
|
||||
|
||||
mail IN A 50.116.60.173
|
||||
www IN CNAME %domain%.
|
||||
ftp IN A %ftpip%
|
||||
ftp IN AAAA %ipv6%
|
||||
579
whm_dns_dedupe_mail_records.sh
Executable file
579
whm_dns_dedupe_mail_records.sh
Executable file
@@ -0,0 +1,579 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_MAIL_IP="50.116.60.173"
|
||||
TARGET_MX_PRIORITY="10"
|
||||
TARGET_SPF="v=spf1 ip4:50.116.60.173 -all"
|
||||
TARGET_DMARC="v=dmarc1; p=quarantine; adkim=s; aspf=s"
|
||||
TARGET_TTL="300"
|
||||
|
||||
ENV_FILE=".env"
|
||||
MODE="dry-run"
|
||||
ZONE_FILTER=""
|
||||
JSON_OUT=""
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Plan or apply WHM DNS cleanup/standardization for discovered mail domains in each zone.
|
||||
|
||||
For each discovered domain (apex/addon/subdomain), enforce:
|
||||
- mail.<domain> A ${TARGET_MAIL_IP}
|
||||
- <domain> MX ${TARGET_MX_PRIORITY} mail.<domain>
|
||||
- <domain> TXT "${TARGET_SPF}"
|
||||
- _dmarc.<domain> TXT "${TARGET_DMARC}"
|
||||
|
||||
Default mode is dry-run (no changes).
|
||||
|
||||
Options:
|
||||
--env FILE Path to env file (default: .env)
|
||||
--zone DOMAIN Only process one zone
|
||||
--json-out FILE Write planned actions JSON
|
||||
--dry-run Dry-run mode (default)
|
||||
--apply Apply planned changes
|
||||
-h, --help Show this help
|
||||
|
||||
Required env vars in ENV file:
|
||||
user
|
||||
whm_api_key
|
||||
whm_api_url
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--env)
|
||||
ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--zone)
|
||||
ZONE_FILTER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--json-out)
|
||||
JSON_OUT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
MODE="dry-run"
|
||||
shift
|
||||
;;
|
||||
--apply)
|
||||
MODE="apply"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Missing required command: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Env file not found: $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
WHM_USER="${user:-}"
|
||||
WHM_API_KEY="${whm_api_key:-}"
|
||||
WHM_API_URL="${whm_api_url:-}"
|
||||
|
||||
if [[ -z "$WHM_USER" || -z "$WHM_API_KEY" || -z "$WHM_API_URL" ]]; then
|
||||
echo "Missing one or more required env vars: user, whm_api_key, whm_api_url" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WHM_BASE="${WHM_API_URL%/}"
|
||||
if [[ "$WHM_BASE" != */json-api ]]; then
|
||||
WHM_BASE="${WHM_BASE}/json-api"
|
||||
fi
|
||||
|
||||
AUTH_HEADER="Authorization: whm ${WHM_USER}:${WHM_API_KEY}"
|
||||
|
||||
whm_api_call() {
|
||||
local func="$1"
|
||||
shift || true
|
||||
|
||||
local url="${WHM_BASE}/${func}"
|
||||
local -a args
|
||||
args=(
|
||||
-fsS
|
||||
--get
|
||||
-H "$AUTH_HEADER"
|
||||
--data-urlencode "api.version=1"
|
||||
)
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
args+=(--data-urlencode "$1")
|
||||
shift
|
||||
done
|
||||
|
||||
local body
|
||||
if ! body="$(curl "${args[@]}" "$url")"; then
|
||||
echo "WHM API transport error: ${func} (${url})" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$body"
|
||||
}
|
||||
|
||||
check_api_ok() {
|
||||
local body="$1"
|
||||
local ok
|
||||
ok="$(jq -r '.metadata.result // 0' <<<"$body" 2>/dev/null || echo 0)"
|
||||
[[ "$ok" == "1" ]]
|
||||
}
|
||||
|
||||
get_zones() {
|
||||
local body
|
||||
body="$(whm_api_call listzones)" || return 1
|
||||
if ! check_api_ok "$body"; then
|
||||
echo "WHM listzones failed:" >&2
|
||||
jq -r '.metadata.reason // . | tostring' <<<"$body" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
jq -r '
|
||||
[
|
||||
.data.zone[]?
|
||||
| if type == "string" then .
|
||||
elif type == "object" then (.domain // .zone // empty)
|
||||
else empty end
|
||||
]
|
||||
| map(select(type == "string" and length > 0))
|
||||
| unique
|
||||
| .[]
|
||||
' <<<"$body"
|
||||
}
|
||||
|
||||
plan_zone_actions() {
|
||||
local zone="$1"
|
||||
local body
|
||||
body="$(whm_api_call parse_dns_zone "zone=${zone}")" || {
|
||||
echo "WARN: parse_dns_zone transport failed for ${zone}" >&2
|
||||
echo '{"zone":"'"$zone"'","actions":[]}'
|
||||
return 0
|
||||
}
|
||||
|
||||
if ! check_api_ok "$body"; then
|
||||
echo "WARN: parse_dns_zone failed for ${zone}: $(jq -r '.metadata.reason // "unknown error"' <<<"$body" 2>/dev/null || echo "unknown")" >&2
|
||||
echo '{"zone":"'"$zone"'","actions":[]}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
jq -c \
|
||||
--arg zone "$zone" \
|
||||
--arg target_mail_ip "$TARGET_MAIL_IP" \
|
||||
--arg target_mx_priority "$TARGET_MX_PRIORITY" \
|
||||
--arg target_spf "$TARGET_SPF" \
|
||||
--arg target_dmarc "$TARGET_DMARC" \
|
||||
--arg target_ttl "$TARGET_TTL" '
|
||||
def s: if . == null then "" else tostring end;
|
||||
def norm: ascii_downcase | sub("\\.$"; "");
|
||||
def b64d: try @base64d catch .;
|
||||
def canon_txt:
|
||||
s
|
||||
| gsub("^\"|\"$"; "")
|
||||
| gsub("\\s+"; " ")
|
||||
| sub("^ "; "")
|
||||
| sub(" $"; "")
|
||||
| ascii_downcase;
|
||||
def fqdn($name; $zone):
|
||||
($name | s | norm) as $n
|
||||
| ($zone | norm) as $z
|
||||
| if $n == "" then ""
|
||||
elif $n == "@" then $z
|
||||
elif ($n == $z) or ($n | endswith("." + $z)) then $n
|
||||
else $n + "." + $z
|
||||
end;
|
||||
def rec_type:
|
||||
(if (.type // "") == "record" and (.record_type // "") != "" then .record_type else .type end)
|
||||
| s | ascii_upcase;
|
||||
def rec_name: (.dname_raw // .name // .dname // "" | s);
|
||||
def rec_line:
|
||||
if (.line_index != null) then
|
||||
(.line_index
|
||||
| if type == "number" then (. + 1)
|
||||
elif type == "string" and test("^[0-9]+$") then ((tonumber) + 1)
|
||||
else -1
|
||||
end)
|
||||
else
|
||||
((.line // .Line // .linenum // -1)
|
||||
| if type == "number" then .
|
||||
elif type == "string" and test("^[0-9]+$") then tonumber
|
||||
else -1
|
||||
end)
|
||||
end;
|
||||
def rec_txt:
|
||||
(if (.data_b64 | type) == "array" and (.data_b64 | length) > 0 then
|
||||
(.data_b64 | map(tostring | b64d) | join(""))
|
||||
else
|
||||
(.txtdata // .record // .data // .value // "" | s)
|
||||
end);
|
||||
def rec_a:
|
||||
(if (.data_b64 | type) == "array" and (.data_b64 | length) > 0 then
|
||||
(.data_b64[0] | tostring | b64d)
|
||||
else
|
||||
(.address // .record // .value // "" | s)
|
||||
end)
|
||||
| norm;
|
||||
def rec_cname:
|
||||
(if (.data_b64 | type) == "array" and (.data_b64 | length) > 0 then
|
||||
(.data_b64[0] | tostring | b64d)
|
||||
else
|
||||
(.cname // .target // .record // .value // "" | s)
|
||||
end)
|
||||
| norm;
|
||||
def rec_mx_pref:
|
||||
(if (.data_b64 | type) == "array" and (.data_b64 | length) > 0 then
|
||||
(.data_b64[0] | tostring | b64d)
|
||||
else
|
||||
(.preference // .pref // .pri // .priority // "" | s)
|
||||
end);
|
||||
def rec_mx_host:
|
||||
(if (.data_b64 | type) == "array" and (.data_b64 | length) > 1 then
|
||||
(.data_b64[1] | tostring | b64d)
|
||||
else
|
||||
(.exchange // .target // .record // .value // "" | s)
|
||||
end)
|
||||
| norm;
|
||||
|
||||
def mk_add($zone; $cat; $name; $rtype; $content; $params):
|
||||
{op:"add", zone:$zone, category:$cat, name:$name, type:$rtype, content:$content, params:$params};
|
||||
def mk_rm($zone; $cat; $r):
|
||||
{op:"remove", zone:$zone, category:$cat, line:$r.line, name:$r.name, type:$r.type, content:$r.content};
|
||||
|
||||
[
|
||||
.data.payload[]?
|
||||
| . as $r
|
||||
| ($r | rec_type) as $t
|
||||
| select($t != "" and $t != "COMMENT" and $t != "CONTROL")
|
||||
| ($r | rec_name | fqdn(.; $zone)) as $n
|
||||
| select($n != "")
|
||||
| {
|
||||
line: ($r | rec_line),
|
||||
name: $n,
|
||||
type: $t,
|
||||
txt: ($r | rec_txt),
|
||||
txt_canon: ($r | rec_txt | canon_txt),
|
||||
a: ($r | rec_a),
|
||||
cname: ($r | rec_cname),
|
||||
mx_pref: ($r | rec_mx_pref),
|
||||
mx_host: ($r | rec_mx_host)
|
||||
}
|
||||
| . + {
|
||||
content:
|
||||
(if .type == "TXT" then .txt
|
||||
elif .type == "A" then .a
|
||||
elif .type == "CNAME" then .cname
|
||||
elif .type == "MX" then (.mx_pref + " " + .mx_host)
|
||||
else ""
|
||||
end)
|
||||
}
|
||||
] as $recs
|
||||
|
|
||||
# Candidate mail domains discovered from existing mail-related records.
|
||||
(
|
||||
[ $recs[] | select(.type == "MX") | .name ]
|
||||
+ [ $recs[] | select(.type == "TXT" and (.txt_canon | contains("v=spf1"))) | .name ]
|
||||
+ [ $recs[] | select(.type == "TXT" and (.name | startswith("_dmarc.")) and (.txt_canon | contains("v=dmarc1"))) | (.name | sub("^_dmarc\\."; "")) ]
|
||||
+ [ $recs[] | select((.type == "A" or .type == "CNAME") and (.name | startswith("mail."))) | (.name | sub("^mail\\."; "")) ]
|
||||
) as $domain_candidates
|
||||
|
|
||||
($zone | norm) as $z
|
||||
|
|
||||
(
|
||||
($domain_candidates | map(select(type == "string" and length > 0 and (. == $z or endswith("." + $z)))) | unique)
|
||||
+ [$z]
|
||||
| unique
|
||||
) as $domains
|
||||
|
|
||||
{
|
||||
zone: $zone,
|
||||
actions:
|
||||
(
|
||||
[
|
||||
$domains[] as $d
|
||||
| ("mail." + $d) as $mailhost
|
||||
| ("_dmarc." + $d) as $dmarc_name
|
||||
|
||||
| ($recs | map(select(.type == "A" and .name == $mailhost))) as $mail_a
|
||||
| ($recs | map(select(.type == "CNAME" and .name == $mailhost))) as $mail_cname
|
||||
| ($recs | map(select(.type == "MX" and .name == $d))) as $mx
|
||||
| ($recs | map(select(.type == "TXT" and .name == $d and (.txt_canon | contains("v=spf1"))))) as $spf
|
||||
| ($recs | map(select(.type == "TXT" and .name == $dmarc_name and (.txt_canon | contains("v=dmarc1"))))) as $dmarc
|
||||
|
||||
| ($mail_a | map(select(.a == ($target_mail_ip | norm))) | sort_by(.line) | .[0]) as $keep_mail_a
|
||||
| ($mx | map(select(.mx_pref == $target_mx_priority and .mx_host == $mailhost)) | sort_by(.line) | .[0]) as $keep_mx
|
||||
| ($spf | map(select(.txt_canon == ($target_spf | canon_txt))) | sort_by(.line) | .[0]) as $keep_spf
|
||||
| ($dmarc | map(select(.txt_canon == ($target_dmarc | canon_txt))) | sort_by(.line) | .[0]) as $keep_dmarc
|
||||
|
||||
| (
|
||||
# remove: mail A non-target or duplicate target
|
||||
[ $mail_a[] | select((.a != ($target_mail_ip | norm)) or (.line != ($keep_mail_a.line // -999999))) | mk_rm($zone; "mail_a"; .) ]
|
||||
# remove: mail CNAME always (mail host must be A)
|
||||
+ [ $mail_cname[] | mk_rm($zone; "mail_cname"; .) ]
|
||||
# remove: MX not target or duplicate target
|
||||
+ [ $mx[] | select((.line != ($keep_mx.line // -999999))) | mk_rm($zone; "mx"; .) ]
|
||||
# remove: SPF not target or duplicate target
|
||||
+ [ $spf[] | select((.line != ($keep_spf.line // -999999))) | mk_rm($zone; "spf_txt"; .) ]
|
||||
# remove: DMARC not target or duplicate target
|
||||
+ [ $dmarc[] | select((.line != ($keep_dmarc.line // -999999))) | mk_rm($zone; "dmarc_txt"; .) ]
|
||||
# add missing target mail A
|
||||
+ (if $keep_mail_a == null then
|
||||
[mk_add($zone; "mail_a"; $mailhost; "A"; $target_mail_ip; {
|
||||
zone:$zone, name:($mailhost + "."), type:"A", address:$target_mail_ip, ttl:$target_ttl
|
||||
})]
|
||||
else [] end)
|
||||
# add missing target MX
|
||||
+ (if $keep_mx == null then
|
||||
[mk_add($zone; "mx"; $d; "MX"; ($target_mx_priority + " " + $mailhost); {
|
||||
zone:$zone, name:($d + "."), type:"MX", preference:$target_mx_priority, exchange:($mailhost + "."), ttl:$target_ttl
|
||||
})]
|
||||
else [] end)
|
||||
# add missing target SPF
|
||||
+ (if $keep_spf == null then
|
||||
[mk_add($zone; "spf_txt"; $d; "TXT"; $target_spf; {
|
||||
zone:$zone, name:($d + "."), type:"TXT", txtdata:$target_spf, ttl:$target_ttl
|
||||
})]
|
||||
else [] end)
|
||||
# add missing target DMARC
|
||||
+ (if $keep_dmarc == null then
|
||||
[mk_add($zone; "dmarc_txt"; $dmarc_name; "TXT"; $target_dmarc; {
|
||||
zone:$zone, name:($dmarc_name + "."), type:"TXT", txtdata:$target_dmarc, ttl:$target_ttl
|
||||
})]
|
||||
else [] end)
|
||||
)
|
||||
] | add // []
|
||||
)
|
||||
}
|
||||
' <<<"$body"
|
||||
}
|
||||
|
||||
remove_record_line() {
|
||||
local zone="$1"
|
||||
local line="$2"
|
||||
local body
|
||||
if ! body="$(whm_api_call removezonerecord "zone=${zone}" "line=${line}")"; then
|
||||
echo "APPLY FAIL remove zone=${zone} line=${line} reason=transport_error" >&2
|
||||
return 1
|
||||
fi
|
||||
if check_api_ok "$body"; then
|
||||
echo "APPLY OK remove zone=${zone} line=${line}"
|
||||
else
|
||||
echo "APPLY FAIL remove zone=${zone} line=${line} reason=$(jq -r '.metadata.reason // "unknown"' <<<"$body" 2>/dev/null || echo "unknown")" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
add_record() {
|
||||
local zone="$1"
|
||||
local name="$2"
|
||||
local type="$3"
|
||||
local ttl="$4"
|
||||
local body
|
||||
|
||||
case "$type" in
|
||||
A)
|
||||
local address="$5"
|
||||
if ! body="$(whm_api_call addzonerecord "zone=${zone}" "name=${name}" "type=A" "address=${address}" "ttl=${ttl}")"; then
|
||||
echo "APPLY FAIL add zone=${zone} type=A name=${name} reason=transport_error" >&2
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
MX)
|
||||
local preference="$5"
|
||||
local exchange="$6"
|
||||
if ! body="$(whm_api_call addzonerecord "zone=${zone}" "name=${name}" "type=MX" "preference=${preference}" "exchange=${exchange}" "ttl=${ttl}")"; then
|
||||
echo "APPLY FAIL add zone=${zone} type=MX name=${name} reason=transport_error" >&2
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
TXT)
|
||||
local txtdata="$5"
|
||||
if ! body="$(whm_api_call addzonerecord "zone=${zone}" "name=${name}" "type=TXT" "txtdata=${txtdata}" "ttl=${ttl}")"; then
|
||||
echo "APPLY FAIL add zone=${zone} type=TXT name=${name} reason=transport_error" >&2
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported add type: $type" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if check_api_ok "$body"; then
|
||||
echo "APPLY OK add zone=${zone} type=${type} name=${name}"
|
||||
else
|
||||
echo "APPLY FAIL add zone=${zone} type=${type} name=${name} reason=$(jq -r '.metadata.reason // "unknown"' <<<"$body" 2>/dev/null || echo "unknown")" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local -a zones
|
||||
if [[ -n "$ZONE_FILTER" ]]; then
|
||||
zones=("$ZONE_FILTER")
|
||||
else
|
||||
local zone_list
|
||||
if ! zone_list="$(get_zones)"; then
|
||||
exit 1
|
||||
fi
|
||||
mapfile -t zones <<<"$zone_list"
|
||||
fi
|
||||
|
||||
if [[ ${#zones[@]} -eq 0 ]]; then
|
||||
echo "No zones found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local actions_file
|
||||
actions_file="$(mktemp)"
|
||||
trap "rm -f '$actions_file'" EXIT
|
||||
local zone
|
||||
for zone in "${zones[@]}"; do
|
||||
local plan
|
||||
plan="$(plan_zone_actions "$zone")"
|
||||
jq -c '.actions[]?' <<<"$plan" >> "$actions_file"
|
||||
done
|
||||
local actions
|
||||
if [[ -s "$actions_file" ]]; then
|
||||
actions="$(jq -sc '.' "$actions_file")"
|
||||
else
|
||||
actions='[]'
|
||||
fi
|
||||
|
||||
local total
|
||||
total="$(jq 'length' <<<"$actions")"
|
||||
|
||||
echo "Mode: $MODE"
|
||||
echo "Desired mail A IP: $TARGET_MAIL_IP"
|
||||
echo "Desired MX: $TARGET_MX_PRIORITY mail.<domain>"
|
||||
echo "Desired SPF: $TARGET_SPF"
|
||||
echo "Desired DMARC: $TARGET_DMARC"
|
||||
echo "Zones scanned: ${#zones[@]}"
|
||||
echo "Actions queued: $total"
|
||||
|
||||
if [[ "$total" -gt 0 ]]; then
|
||||
echo
|
||||
echo "Planned actions:"
|
||||
jq -r '
|
||||
sort_by(.zone, .op, .category, (.line // 0), .name)
|
||||
| .[]
|
||||
| if .op == "remove" then
|
||||
"- REMOVE zone=\(.zone) line=\(.line) category=\(.category) name=\(.name) type=\(.type) content=\(.content)"
|
||||
else
|
||||
"- ADD zone=\(.zone) category=\(.category) name=\(.name) type=\(.type) content=\(.content)"
|
||||
end
|
||||
' <<<"$actions"
|
||||
|
||||
echo
|
||||
echo "Per-zone count:"
|
||||
jq -r '
|
||||
group_by(.zone)
|
||||
| map({zone: .[0].zone, count: length})
|
||||
| sort_by(.zone)
|
||||
| .[]
|
||||
| "- \(.zone): \(.count)"
|
||||
' <<<"$actions"
|
||||
fi
|
||||
|
||||
if [[ -n "$JSON_OUT" ]]; then
|
||||
jq '.' <<<"$actions" > "$JSON_OUT"
|
||||
echo
|
||||
echo "Wrote action JSON: $JSON_OUT"
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "dry-run" ]]; then
|
||||
echo
|
||||
echo "Dry-run only. No DNS records were changed."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$total" -eq 0 ]]; then
|
||||
echo
|
||||
echo "Nothing to apply."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Applying removals first..."
|
||||
local failures=0
|
||||
|
||||
while IFS=$'\t' read -r z l; do
|
||||
if ! remove_record_line "$z" "$l"; then
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
done < <(jq -r '[.[] | select(.op == "remove")] | sort_by(.zone, .line) | reverse | .[] | [.zone, (.line|tostring)] | @tsv' <<<"$actions")
|
||||
|
||||
echo
|
||||
echo "Applying additions..."
|
||||
while IFS=$'\t' read -r z t n ttl a1 a2; do
|
||||
case "$t" in
|
||||
A)
|
||||
if ! add_record "$z" "$n" "$t" "$ttl" "$a1"; then
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
MX)
|
||||
if ! add_record "$z" "$n" "$t" "$ttl" "$a1" "$a2"; then
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
TXT)
|
||||
if ! add_record "$z" "$n" "$t" "$ttl" "$a1"; then
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "APPLY FAIL add zone=${z} reason=unsupported_type_${t}" >&2
|
||||
failures=$((failures + 1))
|
||||
;;
|
||||
esac
|
||||
done < <(jq -r '
|
||||
[.[] | select(.op == "add")]
|
||||
| sort_by(.zone, .category, .name)
|
||||
| .[]
|
||||
| [
|
||||
.zone,
|
||||
.type,
|
||||
.params.name,
|
||||
(.params.ttl // "300"),
|
||||
(.params.address // .params.preference // .params.txtdata // ""),
|
||||
(.params.exchange // "")
|
||||
]
|
||||
| @tsv
|
||||
' <<<"$actions")
|
||||
|
||||
echo
|
||||
if [[ "$failures" -gt 0 ]]; then
|
||||
echo "Apply complete with failures: $failures" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Apply complete: all queued actions processed successfully."
|
||||
trap - EXIT
|
||||
rm -f "$actions_file"
|
||||
}
|
||||
|
||||
main
|
||||
Reference in New Issue
Block a user