refactor(dns): replace sys-dns-parent-hosts with sys-dns-wildcards; emit only *.parent wildcards from CURRENT_PLAY_DOMAINS_ALL

Rename filter parent_build_records→wildcard_records; create only wildcard (*.parent) A/AAAA records (no base/apex); switch to CURRENT_PLAY_DOMAINS_ALL; update vars to SYN_DNS_WILDCARD_RECORDS; adjust role/task names, defaults, and docs; add unittest expecting *.a.b from www.a.b.example.com. See: https://chatgpt.com/share/68c35dc1-7170-800f-8fbe-772e61780597
This commit is contained in:
2025-09-12 01:40:06 +02:00
parent feee3fd71f
commit 206b3eadbc
16 changed files with 289 additions and 250 deletions

View File

@@ -0,0 +1,20 @@
# sys-dns-wildcards
Create Cloudflare DNS **wildcard** A/AAAA records (`*.parent`) only for **parent hosts** (hosts that have children).
The **apex** (SLD.TLD) is considered when computing parents, but **no base host** or `*.apex` record is created by this role.
Examples:
- c.wiki.example.com -> parent: wiki.example.com -> creates: `*.wiki.example.com`
- a.b.example.com -> parent: b.example.com -> creates: `*.b.example.com`
- example.com (apex) -> used to detect parents, but **no** `example.com` or `*.example.com` record is created
## Inputs
- parent_dns_domains (list[str], optional): FQDNs to evaluate. If empty, the role flattens CURRENT_PLAY_DOMAINS_ALL.
- PRIMARY_DOMAIN (apex), defaults_networks.internet.ip4, optional defaults_networks.internet.ip6
- Flags:
- parent_dns_enabled (bool, default: true)
- parent_dns_ipv6_enabled (bool, default: true)
- parent_dns_proxied (bool, default: false)
## Usage
- Include the role once after your constructor stage has set CURRENT_PLAY_DOMAINS_ALL.

View File

@@ -0,0 +1,3 @@
parent_dns_proxied: true
parent_dns_domains: []
parent_dns_ipv6_enabled: true

View File

@@ -0,0 +1,157 @@
from ansible.errors import AnsibleFilterError
import ipaddress
def _validate(d: str) -> None:
if not isinstance(d, str) or not d.strip() or d.startswith(".") or d.endswith(".") or ".." in d:
raise AnsibleFilterError(f"Invalid domain: {d!r}")
def _depth(domain: str, apex: str) -> int:
dl, al = domain.split("."), apex.split(".")
if not domain.endswith(apex) or len(dl) <= len(al):
return 0
return len(dl) - len(al)
def _parent_of_child(domain: str, apex: str) -> str | None:
"""For a child like a.b.example.com return b.example.com; else None (needs depth >= 2)."""
if not domain.endswith(apex):
return None
parts = domain.split(".")
apex_len = len(apex.split("."))
if len(parts) <= apex_len + 1:
return None
return ".".join(parts[1:]) # drop exactly the left-most label
def _flatten_domains_any_structure(domains_like) -> list[str]:
"""
Accepts CURRENT_PLAY_DOMAINS_ALL-like structures:
- dict values: str | list/tuple/set[str] | dict (one level deeper)
Returns unique, sorted host list.
"""
hosts: list[str] = []
def _add_any(x):
if x is None:
return
if isinstance(x, str):
hosts.append(x)
return
if isinstance(x, (list, tuple, set)):
for i in x:
if not isinstance(i, str):
raise AnsibleFilterError(f"Non-string hostname in list: {i!r}")
hosts.append(i)
return
if isinstance(x, dict):
for v in x.values():
_add_any(v)
return
raise AnsibleFilterError(f"Unsupported value type: {type(x).__name__}")
if not isinstance(domains_like, dict):
raise AnsibleFilterError("Expected a dict for CURRENT_PLAY_DOMAINS_ALL")
for v in domains_like.values():
_add_any(v)
return sorted(set(hosts))
def _parents_from(domains: list[str], apex: str, *, min_child_depth: int) -> list[str]:
_validate(apex)
parents = set()
for d in domains:
_validate(d)
if not d.endswith(apex):
continue
if _depth(d, apex) >= min_child_depth:
p = _parent_of_child(d, apex)
if p:
parents.add(p)
return sorted(parents)
def _is_global(ip: str) -> bool:
try:
return ipaddress.ip_address(ip).is_global
except Exception:
return False
def _build_wildcard_records(
parents: list[str],
apex: str,
*,
ip4: str,
ip6: str | None,
proxied: bool,
ipv6_enabled: bool,
) -> list[dict]:
if not isinstance(parents, list):
raise AnsibleFilterError("parents must be list[str]")
_validate(apex)
if not ip4:
raise AnsibleFilterError("ip4 required")
records: list[dict] = []
def _add(name: str, rtype: str, content: str):
records.append({
"zone": apex,
"type": rtype,
"name": name,
"content": content,
"proxied": bool(proxied),
"ttl": 1,
})
for p in sorted(set(parents)):
rel = p[:-len(apex)-1] if p != apex else "" # relative part; apex shouldn't produce wildcard
if not rel:
# Do NOT create *.apex here (explicitly excluded by requirement)
continue
wc = f"*.{rel}"
_add(wc, "A", str(ip4))
if ipv6_enabled and ip6 and _is_global(str(ip6)):
_add(wc, "AAAA", str(ip6))
return records
def wildcard_records(
current_play_domains_all: dict,
apex: str,
ip4: str,
ip6: str | None = None,
proxied: bool = False,
explicit_domains: list[str] | None = None,
min_child_depth: int = 2,
ipv6_enabled: bool = True,
) -> list[dict]:
"""
Build only wildcard records for parents:
for each parent 'parent.apex' -> create '*.parent' A/AAAA.
No base parent records and no '*.apex' are created.
"""
# Source domains
if explicit_domains and len(explicit_domains) > 0:
domains = sorted(set(explicit_domains))
else:
domains = _flatten_domains_any_structure(current_play_domains_all)
parents = _parents_from(domains, apex, min_child_depth=min_child_depth)
return _build_wildcard_records(
parents,
apex,
ip4=ip4,
ip6=ip6,
proxied=proxied,
ipv6_enabled=ipv6_enabled,
)
class FilterModule(object):
def filters(self):
return {
"wildcard_records": wildcard_records,
}

View File

@@ -0,0 +1,7 @@
galaxy_info:
author: "Kevin Veen-Birkenbach"
description: "Create Cloudflare wildcard DNS records (*.parent) for parent hosts; no base or *.apex records."
license: "Infinito.Nexus NonCommercial License"
min_ansible_version: "2.12"
galaxy_tags: [dns, cloudflare, automation]
dependencies: []

View File

@@ -0,0 +1,9 @@
---
- name: "Apply Cloudflare DNS for parent domains"
include_role:
name: sys-dns-cloudflare-records
vars:
cloudflare_records: "{{ SYN_DNS_WILDCARD_RECORDS }}"
when: DNS_PROVIDER == 'cloudflare'
- include_tasks: utils/run_once.yml

View File

@@ -0,0 +1,3 @@
- block:
- include_tasks: 01_core.yml
when: run_once_sys_dns_wildcards is not defined

View File

@@ -0,0 +1,12 @@
SYN_DNS_WILDCARD_RECORDS: >-
{{ CURRENT_PLAY_DOMAINS_ALL
| wildcard_records(
PRIMARY_DOMAIN,
defaults_networks.internet.ip4,
(defaults_networks.internet.ip6 | default('')),
parent_dns_proxied,
(parent_dns_domains | default([])),
2,
parent_dns_ipv6_enabled
)
}}