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:
2025-09-11 23:47:27 +02:00
parent dc16b7d21c
commit a552ea175d
19 changed files with 457 additions and 16 deletions

View 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

View File

@@ -0,0 +1,2 @@
parent_dns_proxied: false
parent_dns_domains: []

View 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,
}

View 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: []

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_PARENT_HOSTS_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_parent_hosts is not defined

View 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
)
}}

View File

@@ -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

View 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 Lets 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)

View 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: []

View File

@@ -1,5 +1,4 @@
---
- name: "Validate CLOUDFLARE_API_TOKEN"
fail:
msg: >
@@ -22,3 +21,12 @@
label: "{{ item.0 }} → {{ item.1.tag }}"
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"
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

View File

@@ -0,0 +1,4 @@
- block:
- include_tasks: 01_core.yml
when: DNS_PROVIDER == 'cloudflare'
when: run_once_sys_svc_dns is not defined

View File

@@ -6,9 +6,7 @@
- name: create nginx letsencrypt config file
template:
src: "letsencrypt.conf.j2"
dest: "{{NGINX.DIRECTORIES.HTTP.GLOBAL}}letsencrypt.conf"
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

View File

@@ -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

View 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()