mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-09-14 06:17:14 +02: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