diff --git a/roles/sys-dns-parent-hosts/README.md b/roles/sys-dns-parent-hosts/README.md deleted file mode 100644 index f9877ee5..00000000 --- a/roles/sys-dns-parent-hosts/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# sys-dns-parent-hosts - -Create Cloudflare DNS A/AAAA records only for **parent hosts** (hosts that have children), -and always include the **apex** (SLD.TLD) as a parent. - -Examples: -- c.wiki.example.com -> parent: wiki.example.com -- a.b.example.com -> parent: b.example.com -- example.com (apex) -> always included - -## Inputs -- parent_dns_domains (list[str], optional): FQDNs to evaluate. If empty, the role flattens CURRENT_PLAY_DOMAINS. -- 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. - -## Tests -Unit tests: tests/unit/roles/sys-dns-parent-hosts/filter_plugins/test_parent_dns.py -Run with: pytest -q diff --git a/roles/sys-dns-parent-hosts/defaults/main.yml b/roles/sys-dns-parent-hosts/defaults/main.yml deleted file mode 100644 index ad04df2c..00000000 --- a/roles/sys-dns-parent-hosts/defaults/main.yml +++ /dev/null @@ -1,2 +0,0 @@ -parent_dns_proxied: false -parent_dns_domains: [] diff --git a/roles/sys-dns-parent-hosts/tasks/main.yml b/roles/sys-dns-parent-hosts/tasks/main.yml deleted file mode 100644 index 6deec2f9..00000000 --- a/roles/sys-dns-parent-hosts/tasks/main.yml +++ /dev/null @@ -1,3 +0,0 @@ -- block: - - include_tasks: 01_core.yml - when: run_once_sys_dns_parent_hosts is not defined \ No newline at end of file diff --git a/roles/sys-dns-wildcards/README.md b/roles/sys-dns-wildcards/README.md new file mode 100644 index 00000000..b045f9b2 --- /dev/null +++ b/roles/sys-dns-wildcards/README.md @@ -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. diff --git a/roles/sys-dns-wildcards/defaults/main.yml b/roles/sys-dns-wildcards/defaults/main.yml new file mode 100644 index 00000000..4ea936b7 --- /dev/null +++ b/roles/sys-dns-wildcards/defaults/main.yml @@ -0,0 +1,3 @@ +parent_dns_proxied: true +parent_dns_domains: [] +parent_dns_ipv6_enabled: true diff --git a/roles/sys-dns-parent-hosts/filter_plugins/parent_dns.py b/roles/sys-dns-wildcards/filter_plugins/wildcard_dns.py similarity index 52% rename from roles/sys-dns-parent-hosts/filter_plugins/parent_dns.py rename to roles/sys-dns-wildcards/filter_plugins/wildcard_dns.py index 4f097824..8a4a86f0 100644 --- a/roles/sys-dns-parent-hosts/filter_plugins/parent_dns.py +++ b/roles/sys-dns-wildcards/filter_plugins/wildcard_dns.py @@ -25,16 +25,12 @@ def _parent_of_child(domain: str, apex: str) -> str | None: return ".".join(parts[1:]) # drop exactly the left-most label -def _flatten_domains(current_play_domains: dict) -> list[str]: +def _flatten_domains_any_structure(domains_like) -> list[str]: """ - Accept CURRENT_PLAY_DOMAINS values as: - - str - - list/tuple/set[str] - - dict -> recurse one level (values must be str or list-like[str]) + Accepts CURRENT_PLAY_DOMAINS_ALL-like structures: + - dict values: str | list/tuple/set[str] | dict (one level deeper) + Returns unique, sorted host list. """ - if not isinstance(current_play_domains, dict): - raise AnsibleFilterError("CURRENT_PLAY_DOMAINS must be a dict of {app_id: hostnames-or-structures}") - hosts: list[str] = [] def _add_any(x): @@ -53,17 +49,18 @@ def _flatten_domains(current_play_domains: dict) -> list[str]: for v in x.values(): _add_any(v) return - raise AnsibleFilterError(f"Unsupported CURRENT_PLAY_DOMAINS value type: {type(x).__name__}") + raise AnsibleFilterError(f"Unsupported value type: {type(x).__name__}") - for v in current_play_domains.values(): + 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, include_apex: bool) -> list[str]: +def _parents_from(domains: list[str], apex: str, *, min_child_depth: int) -> list[str]: _validate(apex) - parents = set([apex]) if include_apex else set() + parents = set() for d in domains: _validate(d) if not d.endswith(apex): @@ -75,21 +72,6 @@ def _parents_from(domains: list[str], apex: str, *, min_child_depth: int, includ return sorted(parents) -def _relative_names(fqdns: list[str], apex: str) -> list[str]: - """FQDN -> relative name; '' represents the apex.""" - _validate(apex) - out: list[str] = [] - for d in fqdns: - _validate(d) - if not d.endswith(apex): - continue - if d == apex: - out.append("") - else: - out.append(d[: -(len(apex) + 1)]) # strip ".apex" - return sorted(set(out)) - - def _is_global(ip: str) -> bool: try: return ipaddress.ip_address(ip).is_global @@ -97,99 +79,79 @@ def _is_global(ip: str) -> bool: return False -def _build_cf_records( - rel_parents: list[str], +def _build_wildcard_records( + parents: list[str], apex: str, *, ip4: str, ip6: str | None, - ipv6_enabled: bool, proxied: bool, - wildcard_children: bool, - apex_wildcard: bool, + ipv6_enabled: bool, ) -> list[dict]: - if not isinstance(rel_parents, list): - raise AnsibleFilterError("rel_parents must be list[str]") + 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_one(name: str, rtype: str, content: str): + def _add(name: str, rtype: str, content: str): records.append({ "zone": apex, "type": rtype, - "name": name if name else "@", + "name": name, "content": content, "proxied": bool(proxied), "ttl": 1, }) - for rel in sorted(set(rel_parents)): - # base (parent) host - _add_one(rel, "A", str(ip4)) + 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_one(rel, "AAAA", str(ip6)) - - # wildcard children under the parent - if rel and wildcard_children: - wc = f"*.{rel}" - _add_one(wc, "A", str(ip4)) - if ipv6_enabled and ip6 and _is_global(str(ip6)): - _add_one(wc, "AAAA", str(ip6)) - - # optional apex wildcard (*.example.com) - if apex_wildcard: - _add_one("*", "A", str(ip4)) - if ipv6_enabled and ip6 and _is_global(str(ip6)): - _add_one("*", "AAAA", str(ip6)) - + _add(wc, "AAAA", str(ip6)) return records -def parent_build_records( - current_play_domains: dict, +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, - include_apex: bool = True, min_child_depth: int = 2, - wildcard_children: bool = True, - include_apex_wildcard: bool = False, - ipv6_enabled: bool = False, + ipv6_enabled: bool = True, ) -> list[dict]: """ - Return Cloudflare A/AAAA records for: - - each parent host ('' == apex), - - optionally '*.parent' for wildcard children, - - optionally '*.apex'. + 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 + # Source domains if explicit_domains and len(explicit_domains) > 0: domains = sorted(set(explicit_domains)) else: - domains = _flatten_domains(current_play_domains) + domains = _flatten_domains_any_structure(current_play_domains_all) - parents = _parents_from(domains, apex, min_child_depth=min_child_depth, include_apex=include_apex) - rel_parents = _relative_names(parents, apex) - - return _build_cf_records( - rel_parents, + parents = _parents_from(domains, apex, min_child_depth=min_child_depth) + return _build_wildcard_records( + parents, apex, ip4=ip4, ip6=ip6, - ipv6_enabled=ipv6_enabled, proxied=proxied, - wildcard_children=wildcard_children, - apex_wildcard=include_apex_wildcard, + ipv6_enabled=ipv6_enabled, ) class FilterModule(object): def filters(self): return { - "parent_build_records": parent_build_records, + "wildcard_records": wildcard_records, } diff --git a/roles/sys-dns-parent-hosts/meta/main.yml b/roles/sys-dns-wildcards/meta/main.yml similarity index 62% rename from roles/sys-dns-parent-hosts/meta/main.yml rename to roles/sys-dns-wildcards/meta/main.yml index 95f9c4d9..6634e2eb 100644 --- a/roles/sys-dns-parent-hosts/meta/main.yml +++ b/roles/sys-dns-wildcards/meta/main.yml @@ -1,6 +1,6 @@ galaxy_info: author: "Kevin Veen-Birkenbach" - description: "Create Cloudflare DNS records only for parent hosts (and apex)." + 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] diff --git a/roles/sys-dns-parent-hosts/tasks/01_core.yml b/roles/sys-dns-wildcards/tasks/01_core.yml similarity index 75% rename from roles/sys-dns-parent-hosts/tasks/01_core.yml rename to roles/sys-dns-wildcards/tasks/01_core.yml index 20be7ba8..9cc4daee 100644 --- a/roles/sys-dns-parent-hosts/tasks/01_core.yml +++ b/roles/sys-dns-wildcards/tasks/01_core.yml @@ -3,7 +3,7 @@ include_role: name: sys-dns-cloudflare-records vars: - cloudflare_records: "{{ SYN_DNS_PARENT_HOSTS_RECORDS }}" + cloudflare_records: "{{ SYN_DNS_WILDCARD_RECORDS }}" when: DNS_PROVIDER == 'cloudflare' - include_tasks: utils/run_once.yml diff --git a/roles/sys-dns-wildcards/tasks/main.yml b/roles/sys-dns-wildcards/tasks/main.yml new file mode 100644 index 00000000..69b966ea --- /dev/null +++ b/roles/sys-dns-wildcards/tasks/main.yml @@ -0,0 +1,3 @@ +- block: + - include_tasks: 01_core.yml + when: run_once_sys_dns_wildcards is not defined \ No newline at end of file diff --git a/roles/sys-dns-parent-hosts/vars/main.yml b/roles/sys-dns-wildcards/vars/main.yml similarity index 69% rename from roles/sys-dns-parent-hosts/vars/main.yml rename to roles/sys-dns-wildcards/vars/main.yml index 498e9468..abaf7e67 100644 --- a/roles/sys-dns-parent-hosts/vars/main.yml +++ b/roles/sys-dns-wildcards/vars/main.yml @@ -1,12 +1,12 @@ -SYN_DNS_PARENT_HOSTS_RECORDS: >- +SYN_DNS_WILDCARD_RECORDS: >- {{ CURRENT_PLAY_DOMAINS_ALL - | parent_build_records( + | wildcard_records( PRIMARY_DOMAIN, defaults_networks.internet.ip4, (defaults_networks.internet.ip6 | default('')), parent_dns_proxied, (parent_dns_domains | default([])), - True, - 2 + 2, + parent_dns_ipv6_enabled ) - }} \ No newline at end of file + }} diff --git a/roles/sys-svc-dns/README.md b/roles/sys-svc-dns/README.md index 2ec2b5ce..2142d0a6 100644 --- a/roles/sys-svc-dns/README.md +++ b/roles/sys-svc-dns/README.md @@ -3,7 +3,7 @@ Bootstrap and maintain **DNS prerequisites** for your web stack on Cloudflare. This role validates credentials and (by default) ensures: -- **Parent host A/AAAA records** (incl. the **apex** SLD.TLD) via `sys-dns-parent-hosts` +- **Wildcard A/AAAA records** (`*.parent`) for parent hosts via `sys-dns-wildcards` (no base/apex records) - *(Optional)* **CAA** records for Let’s Encrypt (kept as a commented block you can re-enable) Runs **once per play** and is safe to include in stacks that roll out many domains. @@ -13,9 +13,9 @@ Runs **once per play** and is safe to include in stacks that roll out many domai ## What it does 1. **Validate `CLOUDFLARE_API_TOKEN`** is present (early fail if missing). -2. **Ensure parent DNS exists** (apex + “parent” FQDNs derived from children): - - Delegates to [`sys-dns-parent-hosts`](../sys-dns-parent-hosts/README.md) - - Creates A (and AAAA if enabled upstream) on the Cloudflare zone, optionally proxied. +2. **Ensure wildcard parent DNS exists** (`*.parent` derived from children): + - Delegates to [`sys-dns-wildcards`](../sys-dns-wildcards/README.md) + - Creates `A` (and `AAAA` if enabled) wildcard records on the Cloudflare zone, optionally proxied. 3. *(Optional)* **CAA records** for all base SLDs (commented in the tasks; enable if you want CAA managed here). > Parent hosts example: diff --git a/roles/sys-svc-dns/tasks/01_core.yml b/roles/sys-svc-dns/tasks/01_core.yml index b6d4e242..b7ccbbbb 100644 --- a/roles/sys-svc-dns/tasks/01_core.yml +++ b/roles/sys-svc-dns/tasks/01_core.yml @@ -22,11 +22,11 @@ async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" -- name: "Ensure parent DNS (apex + parent FQDNs) exists" +- name: "Ensure wildcard parent DNS (*.parent) exists" include_role: - name: sys-dns-parent-hosts + name: sys-dns-wildcards vars: parent_dns_proxied: false - when: run_once_sys_dns_parent_hosts is not defined + when: run_once_sys_dns_wildcards is not defined - include_tasks: utils/run_once.yml \ No newline at end of file diff --git a/tests/unit/roles/sys-dns-parent-hosts/filter_plugins/test_parent_dns.py b/tests/unit/roles/sys-dns-parent-hosts/filter_plugins/test_parent_dns.py deleted file mode 100644 index d3023b2a..00000000 --- a/tests/unit/roles/sys-dns-parent-hosts/filter_plugins/test_parent_dns.py +++ /dev/null @@ -1,131 +0,0 @@ -import os -import sys -import unittest - -# Make the filter plugin importable -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")) -FILTER_PATH = os.path.join(ROOT, "roles", "sys-dns-parent-hosts", "filter_plugins") -if FILTER_PATH not in sys.path: - sys.path.insert(0, FILTER_PATH) - -from parent_dns import parent_build_records # noqa: E402 - - -def has_record(records, rtype, name, zone): - """True if an exact (type, name, zone) record exists.""" - return any( - r.get("type") == rtype and r.get("name") == name and r.get("zone") == zone - for r in records - ) - - -def has_name_or_wildcard(records, rtype, label, zone): - """True if either