diff --git a/roles/sys-dns-parent-hosts/README.md b/roles/sys-dns-parent-hosts/README.md new file mode 100644 index 00000000..f9877ee5 --- /dev/null +++ b/roles/sys-dns-parent-hosts/README.md @@ -0,0 +1,24 @@ +# 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 new file mode 100644 index 00000000..ad04df2c --- /dev/null +++ b/roles/sys-dns-parent-hosts/defaults/main.yml @@ -0,0 +1,2 @@ +parent_dns_proxied: false +parent_dns_domains: [] diff --git a/roles/sys-dns-parent-hosts/filter_plugins/parent_dns.py b/roles/sys-dns-parent-hosts/filter_plugins/parent_dns.py new file mode 100644 index 00000000..4f097824 --- /dev/null +++ b/roles/sys-dns-parent-hosts/filter_plugins/parent_dns.py @@ -0,0 +1,195 @@ +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(current_play_domains: dict) -> 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]) + """ + 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): + 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 CURRENT_PLAY_DOMAINS value type: {type(x).__name__}") + + for v in current_play_domains.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]: + _validate(apex) + parents = set([apex]) if include_apex else 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 _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 + except Exception: + return False + + +def _build_cf_records( + rel_parents: list[str], + apex: str, + *, + ip4: str, + ip6: str | None, + ipv6_enabled: bool, + proxied: bool, + wildcard_children: bool, + apex_wildcard: bool, +) -> list[dict]: + if not isinstance(rel_parents, list): + raise AnsibleFilterError("rel_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): + records.append({ + "zone": apex, + "type": rtype, + "name": name if name else "@", + "content": content, + "proxied": bool(proxied), + "ttl": 1, + }) + + for rel in sorted(set(rel_parents)): + # base (parent) host + _add_one(rel, "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)) + + return records + + +def parent_build_records( + current_play_domains: 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, +) -> list[dict]: + """ + Return Cloudflare A/AAAA records for: + - each parent host ('' == apex), + - optionally '*.parent' for wildcard children, + - optionally '*.apex'. + """ + # source domains + if explicit_domains and len(explicit_domains) > 0: + domains = sorted(set(explicit_domains)) + else: + domains = _flatten_domains(current_play_domains) + + 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, + apex, + ip4=ip4, + ip6=ip6, + ipv6_enabled=ipv6_enabled, + proxied=proxied, + wildcard_children=wildcard_children, + apex_wildcard=include_apex_wildcard, + ) + + +class FilterModule(object): + def filters(self): + return { + "parent_build_records": parent_build_records, + } diff --git a/roles/sys-dns-parent-hosts/meta/main.yml b/roles/sys-dns-parent-hosts/meta/main.yml new file mode 100644 index 00000000..95f9c4d9 --- /dev/null +++ b/roles/sys-dns-parent-hosts/meta/main.yml @@ -0,0 +1,7 @@ +galaxy_info: + author: "Kevin Veen-Birkenbach" + description: "Create Cloudflare DNS records only for parent hosts (and apex)." + license: "Infinito.Nexus NonCommercial License" + min_ansible_version: "2.12" + galaxy_tags: [dns, cloudflare, automation] +dependencies: [] diff --git a/roles/sys-dns-parent-hosts/tasks/01_core.yml b/roles/sys-dns-parent-hosts/tasks/01_core.yml new file mode 100644 index 00000000..20be7ba8 --- /dev/null +++ b/roles/sys-dns-parent-hosts/tasks/01_core.yml @@ -0,0 +1,9 @@ +--- +- name: "Apply Cloudflare DNS for parent domains" + include_role: + name: sys-dns-cloudflare-records + vars: + cloudflare_records: "{{ SYN_DNS_PARENT_HOSTS_RECORDS }}" + when: DNS_PROVIDER == 'cloudflare' + +- include_tasks: utils/run_once.yml diff --git a/roles/sys-dns-parent-hosts/tasks/main.yml b/roles/sys-dns-parent-hosts/tasks/main.yml new file mode 100644 index 00000000..6deec2f9 --- /dev/null +++ b/roles/sys-dns-parent-hosts/tasks/main.yml @@ -0,0 +1,3 @@ +- 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-parent-hosts/vars/main.yml b/roles/sys-dns-parent-hosts/vars/main.yml new file mode 100644 index 00000000..a5128a83 --- /dev/null +++ b/roles/sys-dns-parent-hosts/vars/main.yml @@ -0,0 +1,12 @@ +SYN_DNS_PARENT_HOSTS_RECORDS: >- + {{ CURRENT_PLAY_DOMAINS + | parent_build_records( + PRIMARY_DOMAIN, + defaults_networks.internet.ip4, + (defaults_networks.internet.ip6 | default('')), + parent_dns_proxied, + (parent_dns_domains | default([])), + True, + 2 + ) + }} \ No newline at end of file diff --git a/roles/sys-stk-front-pure/tasks/main.yml b/roles/sys-stk-front-pure/tasks/main.yml index edcaeae1..cde74987 100644 --- a/roles/sys-stk-front-pure/tasks/main.yml +++ b/roles/sys-stk-front-pure/tasks/main.yml @@ -6,5 +6,6 @@ - sys-svc-webserver - sys-svc-cln-domains - sys-svc-letsencrypt + - sys-svc-dns - include_tasks: utils/run_once.yml when: run_once_sys_stk_front_pure is not defined diff --git a/roles/sys-svc-dns/README.md b/roles/sys-svc-dns/README.md new file mode 100644 index 00000000..2ec2b5ce --- /dev/null +++ b/roles/sys-svc-dns/README.md @@ -0,0 +1,22 @@ +# sys-svc-dns + +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` +- *(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. + +--- + +## 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. +3. *(Optional)* **CAA records** for all base SLDs (commented in the tasks; enable if you want CAA managed here). + +> Parent hosts example: +> `c.wiki.example.com` → **parent** `wiki.example.com` (plus `example.com` apex) diff --git a/roles/sys-svc-dns/meta/main.yml b/roles/sys-svc-dns/meta/main.yml new file mode 100644 index 00000000..ffd892a7 --- /dev/null +++ b/roles/sys-svc-dns/meta/main.yml @@ -0,0 +1,26 @@ +galaxy_info: + author: "Kevin Veen-Birkenbach" + description: "Cloudflare DNS bootstrap: parent host A/AAAA (and optional CAA) — runs once per play." + license: "Infinito.Nexus NonCommercial License" + license_url: "https://s.infinito.nexus/license" + company: | + Kevin Veen-Birkenbach + Consulting & Coaching Solutions + https://www.veen.world + min_ansible_version: "2.12" + platforms: + - name: Archlinux + versions: [rolling] + galaxy_tags: + - dns + - cloudflare + - automation + - letsencrypt + - nginx + repository: "https://s.infinito.nexus/code" + issue_tracker_url: "https://s.infinito.nexus/issues" + documentation: "https://docs.infinito.nexus" + logo: + class: "fa-solid fa-cloud" + run_after: [] +dependencies: [] diff --git a/roles/sys-svc-letsencrypt/tasks/01_set-caa-records.yml b/roles/sys-svc-dns/tasks/01_core.yml similarity index 70% rename from roles/sys-svc-letsencrypt/tasks/01_set-caa-records.yml rename to roles/sys-svc-dns/tasks/01_core.yml index fd5a5589..b6d4e242 100644 --- a/roles/sys-svc-letsencrypt/tasks/01_set-caa-records.yml +++ b/roles/sys-svc-dns/tasks/01_core.yml @@ -1,5 +1,4 @@ --- - - name: "Validate CLOUDFLARE_API_TOKEN" fail: msg: > @@ -21,4 +20,13 @@ loop_control: label: "{{ item.0 }} → {{ item.1.tag }}" async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" \ No newline at end of file + poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" + +- name: "Ensure parent DNS (apex + parent FQDNs) exists" + include_role: + name: sys-dns-parent-hosts + vars: + parent_dns_proxied: false + when: run_once_sys_dns_parent_hosts is not defined + +- include_tasks: utils/run_once.yml \ No newline at end of file diff --git a/roles/sys-svc-dns/tasks/main.yml b/roles/sys-svc-dns/tasks/main.yml new file mode 100644 index 00000000..afc50f94 --- /dev/null +++ b/roles/sys-svc-dns/tasks/main.yml @@ -0,0 +1,4 @@ +- block: + - include_tasks: 01_core.yml + when: DNS_PROVIDER == 'cloudflare' + when: run_once_sys_svc_dns is not defined diff --git a/roles/sys-svc-letsencrypt/vars/main.yml b/roles/sys-svc-dns/vars/main.yml similarity index 100% rename from roles/sys-svc-letsencrypt/vars/main.yml rename to roles/sys-svc-dns/vars/main.yml diff --git a/roles/sys-svc-letsencrypt/TODO.md b/roles/sys-svc-letsencrypt/TODO.md index 05318d46..8e2a81bf 100644 --- a/roles/sys-svc-letsencrypt/TODO.md +++ b/roles/sys-svc-letsencrypt/TODO.md @@ -1,2 +1,2 @@ # Todos -- Implement issuewild and iodef -> Not possible yet due to API issues \ No newline at end of file +- Implement issuewild and iodef -> Not possible yet due to API issues diff --git a/roles/sys-svc-letsencrypt/tasks/01_core.yml b/roles/sys-svc-letsencrypt/tasks/01_core.yml index b8fcf58b..0b91570f 100644 --- a/roles/sys-svc-letsencrypt/tasks/01_core.yml +++ b/roles/sys-svc-letsencrypt/tasks/01_core.yml @@ -1,14 +1,12 @@ - - name: Include dependency 'sys-ctl-mtn-cert-renew' - include_role: - name: sys-ctl-mtn-cert-renew - when: run_once_sys_ctl_mtn_cert_renew is not defined +- name: Include dependency 'sys-ctl-mtn-cert-renew' + include_role: + name: sys-ctl-mtn-cert-renew + when: run_once_sys_ctl_mtn_cert_renew is not defined - - name: create nginx letsencrypt config file - template: - src: "letsencrypt.conf.j2" - dest: "{{NGINX.DIRECTORIES.HTTP.GLOBAL}}letsencrypt.conf" - notify: restart openresty +- name: create nginx letsencrypt config file + template: + src: "letsencrypt.conf.j2" + dest: "{{ [ NGINX.DIRECTORIES.HTTP.GLOBAL, 'letsencrypt.conf' ] | path_join }}" + notify: restart openresty - - name: "Set CAA records for all base domains" - include_tasks: 01_set-caa-records.yml - when: DNS_PROVIDER == 'cloudflare' \ No newline at end of file +- include_tasks: utils/run_once.yml \ No newline at end of file diff --git a/roles/sys-svc-letsencrypt/tasks/main.yml b/roles/sys-svc-letsencrypt/tasks/main.yml index 2dfb3f90..528296be 100644 --- a/roles/sys-svc-letsencrypt/tasks/main.yml +++ b/roles/sys-svc-letsencrypt/tasks/main.yml @@ -1,4 +1,3 @@ - block: - include_tasks: 01_core.yml - - include_tasks: utils/run_once.yml when: run_once_sys_svc_letsencrypt is not defined diff --git a/tests/unit/roles/sys-dns-parent-hosts/__init__.py b/tests/unit/roles/sys-dns-parent-hosts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/roles/sys-dns-parent-hosts/filter_plugins/__init__.py b/tests/unit/roles/sys-dns-parent-hosts/filter_plugins/__init__.py new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..d3023b2a --- /dev/null +++ b/tests/unit/roles/sys-dns-parent-hosts/filter_plugins/test_parent_dns.py @@ -0,0 +1,131 @@ +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