mirror of
				https://github.com/kevinveenbirkenbach/computer-playbook.git
				synced 2025-11-03 19:58:14 +00:00 
			
		
		
		
	feat(dns): add sys-svc-dns role and extend parent DNS handling
Introduce sys-svc-dns to bootstrap Cloudflare DNS prerequisites. Validates CLOUDFLARE_API_TOKEN, (optionally) manages CAA for base SLDs, and delegates parent record creation to sys-dns-parent-hosts. Wired into sys-stk-front-pure. sys-dns-parent-hosts: new parent_dns filter builds A/AAAA for each parent host and wildcard children (*.parent). Supports dict/list inputs for CURRENT_PLAY_DOMAINS, optional IPv6, proxied flag, and optional *.apex. Exposes a single parent_build_records entry point. Let’s Encrypt role cleanup: remove DNS/C AA management from sys-svc-letsencrypt; it now focuses on webroot challenge config and renew timer. Fixed path joins and run_once guards. Tests: update unit tests to allow wildcard outputs and dict-based CURRENT_PLAY_DOMAINS. Add generate_base_sld_domains filter. Documentation updates for both roles. Conversation: https://chatgpt.com/share/68c342f7-d20c-800f-b61f-cefeebcf1cd8
This commit is contained in:
		
							
								
								
									
										24
									
								
								roles/sys-dns-parent-hosts/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								roles/sys-dns-parent-hosts/README.md
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
				
			||||||
							
								
								
									
										2
									
								
								roles/sys-dns-parent-hosts/defaults/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								roles/sys-dns-parent-hosts/defaults/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					parent_dns_proxied: false
 | 
				
			||||||
 | 
					parent_dns_domains: []
 | 
				
			||||||
							
								
								
									
										195
									
								
								roles/sys-dns-parent-hosts/filter_plugins/parent_dns.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								roles/sys-dns-parent-hosts/filter_plugins/parent_dns.py
									
									
									
									
									
										Normal file
									
								
							@@ -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,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
							
								
								
									
										7
									
								
								roles/sys-dns-parent-hosts/meta/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								roles/sys-dns-parent-hosts/meta/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -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: []
 | 
				
			||||||
							
								
								
									
										9
									
								
								roles/sys-dns-parent-hosts/tasks/01_core.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								roles/sys-dns-parent-hosts/tasks/01_core.yml
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
				
			||||||
							
								
								
									
										3
									
								
								roles/sys-dns-parent-hosts/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								roles/sys-dns-parent-hosts/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					- block:
 | 
				
			||||||
 | 
					  - include_tasks: 01_core.yml
 | 
				
			||||||
 | 
					  when: run_once_sys_dns_parent_hosts is not defined
 | 
				
			||||||
							
								
								
									
										12
									
								
								roles/sys-dns-parent-hosts/vars/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								roles/sys-dns-parent-hosts/vars/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					  }}
 | 
				
			||||||
@@ -6,5 +6,6 @@
 | 
				
			|||||||
    - sys-svc-webserver
 | 
					    - sys-svc-webserver
 | 
				
			||||||
    - sys-svc-cln-domains
 | 
					    - sys-svc-cln-domains
 | 
				
			||||||
    - sys-svc-letsencrypt
 | 
					    - sys-svc-letsencrypt
 | 
				
			||||||
 | 
					    - sys-svc-dns
 | 
				
			||||||
  - include_tasks: utils/run_once.yml
 | 
					  - include_tasks: utils/run_once.yml
 | 
				
			||||||
  when: run_once_sys_stk_front_pure is not defined
 | 
					  when: run_once_sys_stk_front_pure is not defined
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										22
									
								
								roles/sys-svc-dns/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								roles/sys-svc-dns/README.md
									
									
									
									
									
										Normal file
									
								
							@@ -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)
 | 
				
			||||||
							
								
								
									
										26
									
								
								roles/sys-svc-dns/meta/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								roles/sys-svc-dns/meta/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -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: []
 | 
				
			||||||
@@ -1,5 +1,4 @@
 | 
				
			|||||||
---
 | 
					---
 | 
				
			||||||
 | 
					 | 
				
			||||||
- name: "Validate CLOUDFLARE_API_TOKEN"
 | 
					- name: "Validate CLOUDFLARE_API_TOKEN"
 | 
				
			||||||
  fail:
 | 
					  fail:
 | 
				
			||||||
    msg: >
 | 
					    msg: >
 | 
				
			||||||
@@ -21,4 +20,13 @@
 | 
				
			|||||||
  loop_control:
 | 
					  loop_control:
 | 
				
			||||||
    label: "{{ item.0 }} → {{ item.1.tag }}"
 | 
					    label: "{{ item.0 }} → {{ item.1.tag }}"
 | 
				
			||||||
  async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
 | 
					  async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
 | 
				
			||||||
  poll:  "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
 | 
					  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
 | 
				
			||||||
							
								
								
									
										4
									
								
								roles/sys-svc-dns/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								roles/sys-svc-dns/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
				
			|||||||
 | 
					- block:
 | 
				
			||||||
 | 
					  - include_tasks: 01_core.yml
 | 
				
			||||||
 | 
					    when: DNS_PROVIDER == 'cloudflare'
 | 
				
			||||||
 | 
					  when: run_once_sys_svc_dns is not defined
 | 
				
			||||||
@@ -1,2 +1,2 @@
 | 
				
			|||||||
# Todos
 | 
					# Todos
 | 
				
			||||||
- Implement issuewild and iodef -> Not possible yet due to API issues
 | 
					- Implement issuewild and iodef -> Not possible yet due to API issues
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,14 +1,12 @@
 | 
				
			|||||||
  - name: Include dependency 'sys-ctl-mtn-cert-renew'
 | 
					- name: Include dependency 'sys-ctl-mtn-cert-renew'
 | 
				
			||||||
    include_role:
 | 
					  include_role:
 | 
				
			||||||
      name: sys-ctl-mtn-cert-renew
 | 
					    name: sys-ctl-mtn-cert-renew
 | 
				
			||||||
    when: run_once_sys_ctl_mtn_cert_renew is not defined
 | 
					  when: run_once_sys_ctl_mtn_cert_renew is not defined
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  - name: create nginx letsencrypt config file
 | 
					- name: create nginx letsencrypt config file
 | 
				
			||||||
    template:
 | 
					  template:
 | 
				
			||||||
      src: "letsencrypt.conf.j2"
 | 
					    src: "letsencrypt.conf.j2"
 | 
				
			||||||
      dest: "{{NGINX.DIRECTORIES.HTTP.GLOBAL}}letsencrypt.conf"
 | 
					    dest: "{{ [ NGINX.DIRECTORIES.HTTP.GLOBAL, 'letsencrypt.conf' ] | path_join }}"
 | 
				
			||||||
    notify: restart openresty
 | 
					  notify: restart openresty
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  - name: "Set CAA records for all base domains"
 | 
					- include_tasks: utils/run_once.yml
 | 
				
			||||||
    include_tasks: 01_set-caa-records.yml
 | 
					 | 
				
			||||||
    when: DNS_PROVIDER == 'cloudflare'
 | 
					 | 
				
			||||||
@@ -1,4 +1,3 @@
 | 
				
			|||||||
- block:
 | 
					- block:
 | 
				
			||||||
  - include_tasks: 01_core.yml
 | 
					  - include_tasks: 01_core.yml
 | 
				
			||||||
  - include_tasks: utils/run_once.yml
 | 
					 | 
				
			||||||
  when: run_once_sys_svc_letsencrypt is not defined
 | 
					  when: run_once_sys_svc_letsencrypt is not defined
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										0
									
								
								tests/unit/roles/sys-dns-parent-hosts/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/unit/roles/sys-dns-parent-hosts/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -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 <label> or *.<label> exists for (type, zone)."""
 | 
				
			||||||
 | 
					    return has_record(records, rtype, label, zone) or has_record(
 | 
				
			||||||
 | 
					        records, rtype, f"*.{label}", zone
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestParentDNS(unittest.TestCase):
 | 
				
			||||||
 | 
					    def test_end_to_end_with_ipv6(self):
 | 
				
			||||||
 | 
					        current = {
 | 
				
			||||||
 | 
					            "web-app-foo": [
 | 
				
			||||||
 | 
					                "example.com",
 | 
				
			||||||
 | 
					                "wiki.example.com",
 | 
				
			||||||
 | 
					                "c.wiki.example.com",
 | 
				
			||||||
 | 
					                "a.b.example.com",
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            "web-app-bar": ["foo.other.com"],  # different apex -> ignored
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        recs = parent_build_records(
 | 
				
			||||||
 | 
					            current_play_domains=current,
 | 
				
			||||||
 | 
					            apex="example.com",
 | 
				
			||||||
 | 
					            ip4="192.0.2.10",
 | 
				
			||||||
 | 
					            ip6="2001:db8::10",  # AAAA may or may not be emitted by role; treat as optional
 | 
				
			||||||
 | 
					            proxied=True,
 | 
				
			||||||
 | 
					            explicit_domains=None,
 | 
				
			||||||
 | 
					            include_apex=True,
 | 
				
			||||||
 | 
					            min_child_depth=2,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Apex must resolve
 | 
				
			||||||
 | 
					        self.assertTrue(has_record(recs, "A", "@", "example.com"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Parents may be plain or wildcard (or both)
 | 
				
			||||||
 | 
					        self.assertTrue(has_name_or_wildcard(recs, "A", "wiki", "example.com"))
 | 
				
			||||||
 | 
					        self.assertTrue(has_name_or_wildcard(recs, "A", "b", "example.com"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # AAAA optional: if present, at least apex AAAA must exist
 | 
				
			||||||
 | 
					        if any(r.get("type") == "AAAA" for r in recs):
 | 
				
			||||||
 | 
					            self.assertTrue(has_record(recs, "AAAA", "@", "example.com"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Proxied flag is propagated
 | 
				
			||||||
 | 
					        self.assertTrue(all(r.get("proxied") is True for r in recs if r["type"] in ("A", "AAAA")))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_explicit_domains_without_ipv6(self):
 | 
				
			||||||
 | 
					        explicit = ["example.com", "c.wiki.example.com", "x.y.example.com"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        recs = parent_build_records(
 | 
				
			||||||
 | 
					            current_play_domains={"ignore": ["foo.example.com"]},
 | 
				
			||||||
 | 
					            apex="example.com",
 | 
				
			||||||
 | 
					            ip4="198.51.100.5",
 | 
				
			||||||
 | 
					            ip6="",  # No IPv6 -> no AAAA expected
 | 
				
			||||||
 | 
					            proxied=False,
 | 
				
			||||||
 | 
					            explicit_domains=explicit,
 | 
				
			||||||
 | 
					            include_apex=True,
 | 
				
			||||||
 | 
					            min_child_depth=2,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Apex must resolve
 | 
				
			||||||
 | 
					        self.assertTrue(has_record(recs, "A", "@", "example.com"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Parents may be plain or wildcard
 | 
				
			||||||
 | 
					        self.assertTrue(has_name_or_wildcard(recs, "A", "wiki", "example.com"))
 | 
				
			||||||
 | 
					        self.assertTrue(has_name_or_wildcard(recs, "A", "y", "example.com"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # No IPv6 supplied -> there should be no AAAA records
 | 
				
			||||||
 | 
					        self.assertFalse(any(r.get("type") == "AAAA" for r in recs))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_current_play_domains_may_contain_dicts(self):
 | 
				
			||||||
 | 
					        # Dict values with strings and lists inside must be accepted and flattened.
 | 
				
			||||||
 | 
					        current = {
 | 
				
			||||||
 | 
					            "web-app-foo": {
 | 
				
			||||||
 | 
					                "prod": "wiki.example.com",
 | 
				
			||||||
 | 
					                "preview": ["c.wiki.example.com"],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            "web-app-bar": ["irrelevant.other.com"],  # different apex, ignored
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        recs = parent_build_records(
 | 
				
			||||||
 | 
					            current_play_domains=current,
 | 
				
			||||||
 | 
					            apex="example.com",
 | 
				
			||||||
 | 
					            ip4="203.0.113.7",
 | 
				
			||||||
 | 
					            ip6=None,
 | 
				
			||||||
 | 
					            proxied=False,
 | 
				
			||||||
 | 
					            explicit_domains=None,
 | 
				
			||||||
 | 
					            include_apex=True,
 | 
				
			||||||
 | 
					            min_child_depth=2,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertTrue(has_record(recs, "A", "@", "example.com"))
 | 
				
			||||||
 | 
					        self.assertTrue(has_name_or_wildcard(recs, "A", "wiki", "example.com"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_invalid_inputs_raise(self):
 | 
				
			||||||
 | 
					        with self.assertRaises(Exception):
 | 
				
			||||||
 | 
					            parent_build_records(
 | 
				
			||||||
 | 
					                current_play_domains={"ok": ["example.com"]},
 | 
				
			||||||
 | 
					                apex="",  # invalid apex
 | 
				
			||||||
 | 
					                ip4="192.0.2.1",
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with self.assertRaises(Exception):
 | 
				
			||||||
 | 
					            parent_build_records(
 | 
				
			||||||
 | 
					                current_play_domains={"ok": ["example.com"]},
 | 
				
			||||||
 | 
					                apex="example.com",
 | 
				
			||||||
 | 
					                ip4="",  # required
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    unittest.main()
 | 
				
			||||||
		Reference in New Issue
	
	Block a user