mirror of
				https://github.com/kevinveenbirkenbach/computer-playbook.git
				synced 2025-10-31 18:29:21 +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-cln-domains | ||||
|     - sys-svc-letsencrypt | ||||
|     - sys-svc-dns | ||||
|   - include_tasks: utils/run_once.yml | ||||
|   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" | ||||
|   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 }}" | ||||
|   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 | ||||
| - 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' | ||||
|     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' | ||||
| - include_tasks: utils/run_once.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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user