feat(dns): 用模板生成各后端配置
yaml / yaml (pull_request) Successful in 23s
terraform / validate (pull_request) Successful in 36s
ansible / collection-test (pull_request) Successful in 1m33s
ansible / lint (pull_request) Failing after 2m44s

This commit is contained in:
2026-09-16 18:27:09 +00:00
parent 5ba4411646
commit 5241eceb0c
11 changed files with 250 additions and 68 deletions
+33 -7
View File
@@ -5,10 +5,32 @@
| 视图 | 权威或递归服务 | 配置方式 |
|---|---|---|
| 公网 `ddupan.top` | Cloudflare | Terraform;尚待完整导入已有记录 |
| 公网 `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 渲染或校验 |
| LAN split horizon | Blocky | 由生成器维护 `customDNS.mapping` 标记块 |
| Kubernetes Pod split horizon | CoreDNS | 由生成器维护 `.server` 标记块 |
## 生成配置
安装了 `uv` 后,在仓库根目录运行:
```bash
uv run infrastructure/dns/generate.py
uv run infrastructure/dns/generate.py --check
```
脚本使用内嵌锁定版本的 PyYAML 和 Jinja2,从 `records.yml` 渲染三个目标:
- `apps/blocky/config.yml` 中带 marker 的 LAN split-horizon mapping;
- `platform/k3s/coredns-custom.yaml` 中带 marker 的 Pod split-horizon server blocks;
- `infrastructure/cloudflared/terraform/dns.generated.tf` 中已经完成 Terraform 接管的公网记录。
生成文件需要提交进 Git,以便 PR 直接审阅最终配置。CI 执行 `--check`,任何手工修改生成块、
漏跑生成器或非确定性输出都会失败。Jinja 使用 `[[ ... ]]` 作为变量定界符,避免与 CoreDNS
模板表达式 `{{ .Name }}` 冲突。
`backends` 和 `terraform.managed` 是分阶段接管开关,而不是第二份记录数据:只有已经完成
零变更接管的后端才会生成。把记录加入新的后端前,应先完成相应的 live/state 对账。
## 安全边界
@@ -21,9 +43,13 @@
## 分阶段接管
1. 用 Samba module 接管现有静态 A RRset,首次 check mode 应为零变更。
2. 将 Cloudflare 已有 tunnel DNS 记录导入 Terraform state。
3. 让 Blocky 与 CoreDNS 从 `split_horizon.records` 生成配置或执行 CI 一致性检查。
2. 将 Cloudflare 已有 tunnel DNS 记录逐条导入 Terraform state,再启用 `terraform.managed`。
3. Blocky 与 CoreDNS 已从 `split_horizon.records` 生成;通过 `backends` 分阶段扩展。
4. 验证公网、LAN、Pod、AD 四个视图后,再单独修改 DHCP。
当前 inventory 已明确暴露一个既有差异:`obj.ddupan.top` 在 Blocky 中存在,但 CoreDNS
尚无对应覆盖。本阶段不会偷偷修复它;后续在两个 resolver 同时接管时统一修复。
当前 inventory 明确保留一个既有差异:`obj.ddupan.top` 的 `backends` 只有 Blocky,CoreDNS
尚无对应覆盖。本阶段不改变线上语义;后续验证 Pod 侧入口后再加入 `coredns`。
CoreDNS split-horizon 的原因是避免集群内请求经 Cloudflare 公网绕回同一个集群。尤其 Gitea
启动时会访问 Authelia discovery URL,公网路径故障曾令其启动失败;生成块仍返回相同 LAN A
记录,并对 AAAA 返回 NOERROR/no-data。
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["Jinja2==3.1.6", "PyYAML==6.0.3"]
# ///
"""Render backend DNS configuration from records.yml."""
from __future__ import annotations
import argparse
import difflib
from pathlib import Path
import sys
import yaml
from jinja2 import Environment, FileSystemLoader, StrictUndefined
ROOT = Path(__file__).resolve().parents[2]
DNS_DIR = ROOT / "infrastructure/dns"
BEGIN = "# BEGIN GENERATED: homelab DNS ([[ target ]])"
END = "# END GENERATED: homelab DNS ([[ target ]])"
def load_inventory() -> dict:
data = yaml.safe_load((DNS_DIR / "records.yml").read_text())
try:
inventory = data["homelab_dns"]
split_records = inventory["split_horizon"]["records"]
public_records = inventory["public"]["records"]
except (KeyError, TypeError) as exc:
raise ValueError(f"invalid DNS inventory: missing {exc}") from exc
for record in split_records:
require_fields(record, "name", "type", "values", "backends")
if record["type"] != "A" or len(record["values"]) != 1:
raise ValueError(f"split record must be a single A value: {record!r}")
unknown = set(record["backends"]) - {"blocky", "coredns"}
if unknown:
raise ValueError(f"unknown split DNS backends {sorted(unknown)}")
for record in public_records:
require_fields(record, "name", "type", "values", "proxied", "terraform")
terraform = record["terraform"]
if terraform.get("managed") and not terraform.get("resource_name"):
raise ValueError(f"managed Terraform record needs resource_name: {record['name']}")
if len(record["values"]) != 1:
raise ValueError(f"Cloudflare Terraform supports one value per record: {record['name']}")
return inventory
def require_fields(record: dict, *fields: str) -> None:
missing = [field for field in fields if field not in record]
if missing:
raise ValueError(f"record missing {', '.join(missing)}: {record!r}")
def environment() -> Environment:
return Environment(
loader=FileSystemLoader(DNS_DIR / "templates"),
undefined=StrictUndefined,
autoescape=False,
keep_trailing_newline=True,
trim_blocks=True,
lstrip_blocks=True,
variable_start_string="[[",
variable_end_string="]]",
block_start_string="[%",
block_end_string="%]",
)
def marker(target: str, end: bool = False) -> str:
return (END if end else BEGIN).replace("[[ target ]]", target)
def replace_block(original: str, target: str, rendered: str) -> str:
begin = marker(target)
end = marker(target, end=True)
if original.count(begin) != 1 or original.count(end) != 1:
raise ValueError(f"expected exactly one generated block for {target}")
prefix, remainder = original.split(begin, 1)
_, suffix = remainder.split(end, 1)
indent = prefix.rsplit("\n", 1)[-1]
body = rendered.rstrip("\n")
return f"{prefix}{begin}\n{body}\n{indent}{end}{suffix}"
def outputs(inventory: dict) -> dict[Path, str]:
env = environment()
split_records = inventory["split_horizon"]["records"]
public_records = inventory["public"]["records"]
result = {}
blocky_path = ROOT / "apps/blocky/config.yml"
blocky = env.get_template("blocky.yml.j2").render(
records=[record for record in split_records if "blocky" in record["backends"]]
)
result[blocky_path] = replace_block(blocky_path.read_text(), "blocky", blocky)
coredns_path = ROOT / "platform/k3s/coredns-custom.yaml"
coredns = env.get_template("coredns.yaml.j2").render(
records=[record for record in split_records if "coredns" in record["backends"]]
)
result[coredns_path] = replace_block(coredns_path.read_text(), "coredns", coredns)
terraform_path = ROOT / "infrastructure/cloudflared/terraform/dns.generated.tf"
terraform = env.get_template("cloudflare.tf.j2").render(
records=[record for record in public_records if record["terraform"]["managed"]]
)
result[terraform_path] = terraform
return result
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true", help="fail when generated files differ")
args = parser.parse_args()
try:
rendered_outputs = outputs(load_inventory())
except (OSError, ValueError, yaml.YAMLError) as exc:
print(f"dns generation failed: {exc}", file=sys.stderr)
return 2
changed = False
for path, expected in rendered_outputs.items():
actual = path.read_text() if path.exists() else ""
if actual == expected:
continue
changed = True
if args.check:
print("".join(difflib.unified_diff(
actual.splitlines(keepends=True),
expected.splitlines(keepends=True),
fromfile=str(path.relative_to(ROOT)),
tofile=f"{path.relative_to(ROOT)} (generated)",
)))
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(expected)
print(f"rendered {path.relative_to(ROOT)}")
return 1 if args.check and changed else 0
if __name__ == "__main__":
raise SystemExit(main())
+11 -5
View File
@@ -20,12 +20,12 @@ homelab_dns:
- { zone: ad.ddupan.top, name: zot-push, 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.
# backends records the current adoption boundary. obj is deliberately not
# emitted to CoreDNS yet, preserving the current pod resolver behaviour.
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] }
- { name: git.ddupan.top, type: A, values: [192.168.10.127], backends: [blocky, coredns] }
- { name: auth.ddupan.top, type: A, values: [192.168.10.127], backends: [blocky, coredns] }
- { name: obj.ddupan.top, type: A, values: [192.168.10.127], backends: [blocky] }
public:
# Names expected at Cloudflare. Terraform adoption is a separate change;
@@ -35,15 +35,21 @@ homelab_dns:
type: CNAME
values: [ff392451-b0b1-45bb-964e-6d9372c3a9e3.cfargotunnel.com]
proxied: true
terraform:
managed: true
resource_name: auth
- name: git.ddupan.top
type: CNAME
values: [ff392451-b0b1-45bb-964e-6d9372c3a9e3.cfargotunnel.com]
proxied: true
terraform: { managed: false }
- name: obj.ddupan.top
type: CNAME
values: [ff392451-b0b1-45bb-964e-6d9372c3a9e3.cfargotunnel.com]
proxied: true
terraform: { managed: false }
- name: e5renew.ddupan.top
type: CNAME
values: [ff392451-b0b1-45bb-964e-6d9372c3a9e3.cfargotunnel.com]
proxied: true
terraform: { managed: false }
@@ -0,0 +1,3 @@
[% for record in records %]
[[ record.name ]]: [[ record['values'][0] ]]
[% endfor %]
@@ -0,0 +1,11 @@
# Generated by infrastructure/dns/generate.py. Do not edit directly.
[% for record in records %]
resource "cloudflare_dns_record" "[[ record.terraform.resource_name ]]" {
zone_id = var.zone_id
name = "[[ record.name ]]"
type = "[[ record.type ]]"
content = "[[ record['values'][0] ]]"
proxied = [[ record.proxied | lower ]]
ttl = [[ record.ttl | default(1) ]]
}
[% endfor %]
@@ -0,0 +1,12 @@
[% for record in records %]
[[ record.name | replace('.', '-') ]].server: |
[[ record.name ]]:53 {
errors
template IN A {
answer "{{ .Name }} 60 IN A [[ record['values'][0] ]]"
}
template IN AAAA {
rcode NOERROR
}
}
[% endfor %]