Merge pull request 49: 建立 DNS source of truth
yaml / yaml (push) Successful in 20s
ansible / collection-test (push) Successful in 2m3s
ansible / lint (push) Successful in 3m17s

This commit was merged in pull request #49.
This commit is contained in:
2026-09-13 15:42:12 +00:00
14 changed files with 324 additions and 35 deletions
+22
View File
@@ -6,11 +6,13 @@ on:
branches: [main]
paths:
- 'infrastructure/**/ansible/**'
- 'infrastructure/dns/**'
- '.ansible-lint'
- '.gitea/workflows/ansible.yml'
pull_request:
paths:
- 'infrastructure/**/ansible/**'
- 'infrastructure/dns/**'
- '.ansible-lint'
- '.gitea/workflows/ansible.yml'
@@ -48,6 +50,7 @@ jobs:
- name: ansible-lint
run: |
export PATH="$HOME/.local/bin:$PATH"
export ANSIBLE_COLLECTIONS_PATH="$PWD/infrastructure/samba-ad/ansible/collections:/root/.ansible/collections"
rc=0
for p in infrastructure/openbao infrastructure/samba-ad infrastructure/proxmox; do
echo "::group::$p"
@@ -55,3 +58,22 @@ jobs:
echo "::endgroup::"
done
exit $rc
collection-test:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install ansible-core
run: |
python3 -m pip install --user --break-system-packages \
--index-url https://pypi.org/simple --quiet uv==0.11.7
export PATH="$HOME/.local/bin:$PATH"
uv tool install ansible-core --quiet
- name: Run ansible-test
working-directory: infrastructure/samba-ad/ansible/collections/ansible_collections/ddupan/homelab
run: |
export PATH="$HOME/.local/bin:$PATH"
ansible-test sanity --venv --requirements --python 3.12 --color no
ansible-test units --venv --requirements --python 3.12 --color no
+29
View File
@@ -0,0 +1,29 @@
# DNS 声明与权威边界
`records.yml` 是 homelab DNS 的唯一声明清单,但不是 DNS 服务本身。不同视图仍由最适合
它们的后端提供:
| 视图 | 权威或递归服务 | 配置方式 |
|---|---|---|
| 公网 `ddupan.top` | Cloudflare | Terraform;尚待完整导入已有记录 |
| AD `ad.ddupan.top` | Samba internal DNS | `samba_dns_record` Ansible module |
| LAN split horizon | Blocky | 尚待从 inventory 渲染或校验 |
| Kubernetes Pod split horizon | CoreDNS | 尚待从 inventory 渲染或校验 |
## 安全边界
- Samba module 只管理 `homelab_dns.samba.records` 明确列出的 RRset,不遍历或清理 zone。
- `_ldap`、`_kerberos`、域控制器 locator 等由 Samba 自动维护的记录不进入 inventory。
- 一个受管 RRset 默认使用 `exact: true`:同名同类型的额外值会被删除,但其他名称和类型
不受影响。
- DHCP 切换不属于本阶段。Blocky 仍未成为 LAN 客户端的正式 resolver。
## 分阶段接管
1. 用 Samba module 接管现有静态 A RRset,首次 check mode 应为零变更。
2. 将 Cloudflare 已有 tunnel DNS 记录导入 Terraform state。
3. 让 Blocky 与 CoreDNS 从 `split_horizon.records` 生成配置或执行 CI 一致性检查。
4. 验证公网、LAN、Pod、AD 四个视图后,再单独修改 DHCP。
当前 inventory 已明确暴露一个既有差异:`obj.ddupan.top` 在 Blocky 中存在,但 CoreDNS
尚无对应覆盖。本阶段不会偷偷修复它;后续在两个 resolver 同时接管时统一修复。
+44
View File
@@ -0,0 +1,44 @@
---
# Homelab DNS desired state. This file is the canonical inventory; individual
# backends consume only the views they own.
homelab_dns:
samba:
# Samba remains authoritative for the AD zone. Only these explicitly listed
# RRsets are reconciled; Samba-generated AD/Kerberos records are untouched.
records:
- { zone: ad.ddupan.top, name: bao, type: A, values: [192.168.10.8] }
- { zone: ad.ddupan.top, name: pve1, type: A, values: [192.168.10.4] }
- { zone: ad.ddupan.top, name: pve2, type: A, values: [192.168.10.7] }
- { zone: ad.ddupan.top, name: pve3, type: A, values: [192.168.10.9] }
- { zone: ad.ddupan.top, name: retrolab, type: A, values: [10.60.0.10] }
- { zone: ad.ddupan.top, name: netbox, type: A, values: [192.168.10.127] }
- { zone: ad.ddupan.top, name: s3, type: A, values: [192.168.10.127] }
split_horizon:
# LAN and pod resolvers should eventually render the same set from here.
# Adoption of Blocky/CoreDNS is deliberately a separate change.
records:
- { name: git.ddupan.top, type: A, values: [192.168.10.127] }
- { name: auth.ddupan.top, type: A, values: [192.168.10.127] }
- { name: obj.ddupan.top, type: A, values: [192.168.10.127] }
public:
# Names expected at Cloudflare. Terraform adoption is a separate change;
# complete RRsets here make the current ownership gap explicit.
records:
- name: auth.ddupan.top
type: CNAME
values: [ff392451-b0b1-45bb-964e-6d9372c3a9e3.cfargotunnel.com]
proxied: true
- name: git.ddupan.top
type: CNAME
values: [ff392451-b0b1-45bb-964e-6d9372c3a9e3.cfargotunnel.com]
proxied: true
- name: obj.ddupan.top
type: CNAME
values: [ff392451-b0b1-45bb-964e-6d9372c3a9e3.cfargotunnel.com]
proxied: true
- name: e5renew.ddupan.top
type: CNAME
values: [ff392451-b0b1-45bb-964e-6d9372c3a9e3.cfargotunnel.com]
proxied: true
+4
View File
@@ -54,6 +54,7 @@ second run is a no-op there and only reconciles config drift.
|---|---|
| `roles/dc_vm/` | creates the DC VM locally via libvirt + cloud-init |
| `roles/samba_ad_dc/` | provisions the DC (§1) — idempotent |
| `ansible/collections/ansible_collections/ddupan/homelab/` | repository-local collection containing the Samba DNS module and its `ansible-test` tests |
| `roles/samba_ad_dc/tasks/verify.yml` | smoke tests (`--tags verify`) |
| `roles/samba_ad_dc/tasks/legacy.yml` | opt-in retro-client protocols (§ Retro clients) |
| `roles/windows_vm/` | unattended-installs the Windows Server 2025 admin box (§2.0) |
@@ -62,6 +63,9 @@ second run is a no-op there and only reconciles config drift.
| `group_vars/all/vault.yml` | admin passwords (gitignored; encrypt with ansible-vault) |
| `inventory/hosts.yml` | DC + Windows hosts |
Static DNS records are declared once in `../../dns/records.yml`. The role manages
only those RRsets and deliberately leaves Samba-generated AD locator records alone.
---
## 0. Decisions — fill these in before touching anything
@@ -1,6 +1,7 @@
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
collections_paths = collections
host_key_checking = False
callback_result_format = yaml
nocows = True
@@ -0,0 +1,4 @@
# ddupan.homelab
Repository-local Ansible collection for homelab infrastructure modules. It is consumed
directly through the `collections_paths` setting and is not published.
@@ -0,0 +1,16 @@
---
namespace: ddupan
name: homelab
version: 0.1.0
readme: README.md
authors:
- panxiao81
description: Ansible plugins used to manage the ddupan.top homelab.
license:
- GPL-3.0-or-later
tags:
- infrastructure
- dns
repository: https://git.ddupan.top/panxiao81/homelab-infra
build_ignore:
- .git
@@ -0,0 +1,159 @@
#!/usr/bin/python
# Copyright: (c) 2026, homelab-infra contributors
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
DOCUMENTATION = r"""
---
module: samba_dns_record
short_description: Reconcile one Samba internal DNS RRset
author:
- Pan Xiao (@panxiao81)
description:
- Reconciles only the named RRset and never deletes an undeclared zone or name.
- Runs C(samba-tool dns) on a Samba AD domain controller using its machine account.
options:
server:
description: DNS server accepted by C(samba-tool dns).
type: str
required: true
zone:
description: Authoritative DNS zone.
type: str
required: true
name:
description: Record owner relative to the zone.
type: str
required: true
type:
description: DNS record type.
type: str
choices: [A, AAAA, CNAME, PTR, TXT]
required: true
values:
description: Complete desired value set for this owner and type.
type: list
elements: str
default: []
state:
description: Whether the desired RRset is present or absent.
type: str
choices: [present, absent]
default: present
exact:
description: Remove live values not present in C(values).
type: bool
default: true
"""
EXAMPLES = r"""
- name: Reconcile an A RRset
samba_dns_record:
server: dc1.ad.ddupan.top
zone: ad.ddupan.top
name: pve1
type: A
values: [192.168.10.4]
exact: true
"""
RETURN = r"""
before:
description: Values observed before reconciliation.
type: list
returned: always
after:
description: Values expected after reconciliation.
type: list
returned: always
"""
import re
from ansible.module_utils.basic import AnsibleModule
ABSENT_ERRORS = (
"WERR_DNS_ERROR_NAME_DOES_NOT_EXIST",
"WERR_DNS_ERROR_RECORD_DOES_NOT_EXIST",
"WERR_DNS_ERROR_NXDOMAIN",
)
def normalize_value(record_type, value):
value = value.strip()
if record_type in ("CNAME", "PTR"):
return value.rstrip(".").lower()
if record_type == "TXT" and len(value) >= 2 and value[0] == value[-1] == '"':
return value[1:-1]
return value.lower() if record_type == "AAAA" else value
def parse_query(stdout, record_type):
values = []
prefix = re.compile(r"^\s*%s:\s*(.*?)\s*(?:\([^)]*\))?\s*$" % re.escape(record_type))
for line in stdout.splitlines():
match = prefix.match(line)
if match:
values.append(normalize_value(record_type, match.group(1)))
return sorted(set(values))
def run(module, args, check_rc=True):
command = [module.get_bin_path("samba-tool", required=True), "dns"] + args + ["-P"]
return module.run_command(command, check_rc=check_rc)
def main():
module = AnsibleModule(
argument_spec=dict(
server=dict(type="str", required=True),
zone=dict(type="str", required=True),
name=dict(type="str", required=True),
type=dict(type="str", required=True, choices=["A", "AAAA", "CNAME", "PTR", "TXT"]),
values=dict(type="list", elements="str", default=[]),
state=dict(type="str", choices=["present", "absent"], default="present"),
exact=dict(type="bool", default=True),
),
supports_check_mode=True,
)
p = module.params
desired = sorted(set(normalize_value(p["type"], value) for value in p["values"]))
if p["state"] == "present" and not desired:
module.fail_json(msg="values must not be empty when state=present")
rc, stdout, stderr = run(
module,
["query", p["server"], p["zone"], p["name"], p["type"]],
check_rc=False,
)
if rc == 0:
current = parse_query(stdout, p["type"])
elif any(marker in stdout + stderr for marker in ABSENT_ERRORS):
current = []
else:
module.fail_json(msg="samba-tool dns query failed", rc=rc, stdout=stdout, stderr=stderr)
target = desired if p["state"] == "present" else []
additions = sorted(set(target) - set(current))
removals = sorted(set(current) - set(target)) if p["exact"] or p["state"] == "absent" else []
changed = bool(additions or removals)
if changed and not module.check_mode:
for value in removals:
run(module, ["delete", p["server"], p["zone"], p["name"], p["type"], value])
for value in additions:
run(module, ["add", p["server"], p["zone"], p["name"], p["type"], value])
after = sorted((set(current) - set(removals)) | set(additions))
module.exit_json(
changed=changed,
before=current,
after=after,
diff={"before": {p["type"]: current}, "after": {p["type"]: after}},
)
if __name__ == "__main__":
main()
@@ -0,0 +1,23 @@
import unittest
from ansible_collections.ddupan.homelab.plugins.modules import samba_dns_record
class ParseQueryTests(unittest.TestCase):
def test_parses_a_records_and_ignores_other_types(self):
output = """ Name=host, Records=3, Children=0
A: 192.168.10.4 (flags=f0, serial=1, ttl=900)
A: 192.168.10.7 (flags=f0, serial=2, ttl=900)
TXT: ignored (flags=f0, serial=3, ttl=900)
"""
self.assertEqual(
samba_dns_record.parse_query(output, "A"),
["192.168.10.4", "192.168.10.7"],
)
def test_normalizes_dns_targets(self):
output = " CNAME: Gateway.AD.DDUPAN.TOP. (flags=f0, serial=1, ttl=900)\n"
self.assertEqual(
samba_dns_record.parse_query(output, "CNAME"),
["gateway.ad.ddupan.top"],
)
@@ -10,29 +10,8 @@ samba_ad_dc_ip: "192.168.10.5"
samba_ad_dns_forwarder: "192.168.10.1"
samba_ad_reverse_zone: "10.168.192.in-addr.arpa" # reverse of 192.168.10.0/24
# Extra A records for non-domain hosts published in the AD DNS zone.
samba_ad_extra_a_records:
- { name: "bao", ip: "192.168.10.8" } # OpenBao (../openbao), not domain-joined
# Proxmox cluster nodes (../proxmox). Not domain-joined; they authenticate
# USERS against this DC rather than being members themselves.
- { name: "pve1", ip: "192.168.10.4" }
- { name: "pve2", ip: "192.168.10.7" }
- { name: "pve3", ip: "192.168.10.9" }
# Lab VMs on the SDN VNets (routed via the VyOS router, see ../../proxmox).
# These are NOT on 192.168.10.0/24, so they have no PTR in the existing
# reverse zone — forward resolution only unless a 0.60.10.in-addr.arpa zone
# is added later.
- { name: "retrolab", ip: "10.60.0.10" }
# k3s services exposed on the LAN through the Envoy gateway (../../../platform/envoy-gateway;
# Contour was retired 2026-07-25). They all point at the k3s node, which is where
# Envoy's LoadBalancer lands; the gateway routes by Host header and serves the
# *.ad.ddupan.top wildcard cert.
# Adding another such service = one more line here + an HTTPRoute, nothing else.
- { name: "netbox", ip: "192.168.10.127" } # NetBox (../../../apps/netbox)
# SeaweedFS S3. Exists so Terraform state does NOT ride the Cloudflare tunnel:
# obj.ddupan.top works, but it hairpins through the WAN, and on 2026-07-28 that
# path was blackholed for hours by a dead VPN tunnel. State must stay on the LAN.
- { name: "s3", ip: "192.168.10.127" } # SeaweedFS S3 (../../../apps/seaweedfs)
# Static records now live in ../../dns/records.yml and are reconciled as complete
# RRsets by the local samba_dns_record module.
# Support legacy clients (Win9x/NT4/2000/XP)? INSECURE — see README "Retro clients".
samba_ad_legacy_clients: false
@@ -6,6 +6,8 @@
hosts: samba_dc
become: true
gather_facts: true
vars_files:
- ../../dns/records.yml
roles:
- role: samba_ad_dc
post_tasks:
@@ -0,0 +1,14 @@
---
- name: Reconcile explicitly managed AD DNS RRsets
ddupan.homelab.samba_dns_record:
server: "{{ samba_ad_dc_ip }}"
zone: "{{ item['zone'] }}"
name: "{{ item['name'] }}"
type: "{{ item['type'] }}"
values: "{{ item['values'] }}"
state: present
exact: true
loop: "{{ homelab_dns.samba.records }}"
loop_control:
label: "{{ item['name'] }}.{{ item['zone'] }} {{ item['type'] }}"
tags: [dns]
@@ -198,20 +198,11 @@
- "'already exists' not in (kms_srv.stderr | default('')) + (kms_srv.stdout | default(''))"
no_log: true
# --- Extra A records for non-domain hosts (e.g. OpenBao) -----------------------
# --- Directory objects and explicitly managed DNS records ----------------------
- name: Service accounts and RBAC groups
ansible.builtin.import_tasks: directory_objects.yml
tags: [directory, accounts]
- name: Register extra A records in the AD DNS zone
ansible.builtin.command:
cmd: >-
samba-tool dns add {{ samba_ad_dc_ip }} {{ samba_ad_realm | lower }}
{{ item.name }} A {{ item.ip }} -P
loop: "{{ samba_ad_extra_a_records }}"
register: extra_a
changed_when: "'Record added successfully' in (extra_a.stdout | default(''))"
failed_when:
- extra_a.rc != 0
- "'already exists' not in (extra_a.stderr | default('')) + (extra_a.stdout | default(''))"
- name: Reconcile static AD DNS records
ansible.builtin.import_tasks: dns_records.yml
tags: [dns]