diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index d9fe225..5224dcf 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -13,12 +13,16 @@ on: paths: - '**/*.yaml' - '**/*.yml' + - 'infrastructure/dns/**' + - 'infrastructure/cloudflared/terraform/dns.generated.tf' - '.yamllint.yml' - '.gitea/workflows/lint.yml' pull_request: paths: - '**/*.yaml' - '**/*.yml' + - 'infrastructure/dns/**' + - 'infrastructure/cloudflared/terraform/dns.generated.tf' - '.yamllint.yml' - '.gitea/workflows/lint.yml' @@ -52,3 +56,8 @@ jobs: export PATH="$HOME/.local/bin:$PATH" files=$(git ls-files '*.yaml' '*.yml' | grep -vE '^apps/netboot/') yamllint -c .yamllint.yml --no-warnings -f parsable $files + + - name: Verify generated DNS configuration + run: | + export PATH="$HOME/.local/bin:$PATH" + uv run infrastructure/dns/generate.py --check diff --git a/apps/blocky/config.yml b/apps/blocky/config.yml index 6d8bc0b..6adc628 100644 --- a/apps/blocky/config.yml +++ b/apps/blocky/config.yml @@ -54,9 +54,11 @@ customDNS: # laptop's only global IPv6 belongs to tun0, so a AAAA answer would send LAN # traffic into the VPN. See CLAUDE.md. mapping: + # BEGIN GENERATED: homelab DNS (blocky) git.ddupan.top: 192.168.10.127 auth.ddupan.top: 192.168.10.127 obj.ddupan.top: 192.168.10.127 + # END GENERATED: homelab DNS (blocky) blocking: denylists: diff --git a/infrastructure/cloudflared/terraform/dns.generated.tf b/infrastructure/cloudflared/terraform/dns.generated.tf new file mode 100644 index 0000000..53f05a9 --- /dev/null +++ b/infrastructure/cloudflared/terraform/dns.generated.tf @@ -0,0 +1,9 @@ +# Generated by infrastructure/dns/generate.py. Do not edit directly. +resource "cloudflare_dns_record" "auth" { + zone_id = var.zone_id + name = "auth.ddupan.top" + type = "CNAME" + content = "ff392451-b0b1-45bb-964e-6d9372c3a9e3.cfargotunnel.com" + proxied = true + ttl = 1 +} diff --git a/infrastructure/cloudflared/terraform/main.tf b/infrastructure/cloudflared/terraform/main.tf index 6c48edb..7ee4b33 100644 --- a/infrastructure/cloudflared/terraform/main.tf +++ b/infrastructure/cloudflared/terraform/main.tf @@ -38,17 +38,6 @@ resource "cloudflare_zero_trust_tunnel_cloudflared_config" "main" { } } -# Public DNS: proxied CNAME -> the tunnel. (auth was bootstrapped with -# `cloudflared tunnel route dns`; import it into state — see README.) -resource "cloudflare_dns_record" "auth" { - zone_id = var.zone_id - name = "auth.ddupan.top" - type = "CNAME" - content = "${var.tunnel_id}.cfargotunnel.com" - proxied = true - ttl = 1 # 1 = automatic (required when proxied) -} - # DKIM for Microsoft 365 mail sent as *@ddupan.top (via the smtp-relay). CNAMEs point # at the tenant's DKIM keys; must be DNS-only (unproxied). Enable signing in Exchange # after these resolve: smtp-relay/scripts/enable-dkim.ps1. diff --git a/infrastructure/dns/README.md b/infrastructure/dns/README.md index 378aae7..b8685a7 100644 --- a/infrastructure/dns/README.md +++ b/infrastructure/dns/README.md @@ -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。 diff --git a/infrastructure/dns/generate.py b/infrastructure/dns/generate.py new file mode 100644 index 0000000..2cd9e3f --- /dev/null +++ b/infrastructure/dns/generate.py @@ -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()) diff --git a/infrastructure/dns/records.yml b/infrastructure/dns/records.yml index 442a2dd..d4245e8 100644 --- a/infrastructure/dns/records.yml +++ b/infrastructure/dns/records.yml @@ -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 } diff --git a/infrastructure/dns/templates/blocky.yml.j2 b/infrastructure/dns/templates/blocky.yml.j2 new file mode 100644 index 0000000..9dc9d7a --- /dev/null +++ b/infrastructure/dns/templates/blocky.yml.j2 @@ -0,0 +1,3 @@ +[% for record in records %] + [[ record.name ]]: [[ record['values'][0] ]] +[% endfor %] diff --git a/infrastructure/dns/templates/cloudflare.tf.j2 b/infrastructure/dns/templates/cloudflare.tf.j2 new file mode 100644 index 0000000..e28b710 --- /dev/null +++ b/infrastructure/dns/templates/cloudflare.tf.j2 @@ -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 %] diff --git a/infrastructure/dns/templates/coredns.yaml.j2 b/infrastructure/dns/templates/coredns.yaml.j2 new file mode 100644 index 0000000..9107990 --- /dev/null +++ b/infrastructure/dns/templates/coredns.yaml.j2 @@ -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 %] diff --git a/platform/k3s/coredns-custom.yaml b/platform/k3s/coredns-custom.yaml index cc1b074..0a6eb81 100644 --- a/platform/k3s/coredns-custom.yaml +++ b/platform/k3s/coredns-custom.yaml @@ -55,51 +55,9 @@ data: rcode NXDOMAIN } } - # ── split-horizon for the Authelia hostname ───────────────────────────── - # Cloudflare is authoritative for ddupan.top (the DC only holds ad.ddupan.top), - # so auth.ddupan.top publicly resolves to Cloudflare proxy IPs — 104.21.6.55 and - # 172.67.154.245. TCP/443 to BOTH fails from this network, persistently, while - # other Cloudflare IPs (104.16.132.229) connect fine. So every in-cluster - # consumer of Authelia was hairpinning out to an internet path that does not - # work, to reach a Service sitting in the same cluster. - # - # Concretely (2026-07-28): the Gitea chart runs `gitea admin auth update-oauth` - # in an INIT container, which FETCHES the OIDC discovery URL on every pod start. - # It timed out, so Gitea CrashLoopBackOff'd on any restart, and server-side - # token exchange failed for logins. This was latent — any restart would do it. - # - # Answer with the Envoy Gateway LAN address instead. The gateway terminates TLS - # with a real Let's Encrypt cert for this exact name - # (../cert-manager/certificate-auth-ddupan.yaml) and routes to the authelia - # Service (../../apps/authelia/httproute.yaml), so the name, issuer, redirect URIs and - # cookie domain are all unchanged — only the path stops leaving the LAN. - # - # A `template` block (not `hosts`) so it answers only A/AAAA-shaped queries and - # returns NOERROR/no-data rather than NXDOMAIN for anything else. - auth-ddupan-top.server: | - auth.ddupan.top:53 { - errors - template IN A { - answer "{{ .Name }} 60 IN A 192.168.10.127" - } - template IN AAAA { - rcode NOERROR - } - } - # ── split-horizon for Gitea ───────────────────────────────────────────── - # Same shape and same reason as auth.ddupan.top above: git.ddupan.top publicly - # resolves to Cloudflare, so an in-cluster client cloning from Gitea would leave - # the LAN, cross the WAN and come back down the tunnel into this same cluster. - # - # This matters for CI: an Actions runner checking out the repo, and anything - # else that clones from inside the cluster. Served by the `https-git` listener - # with a real LE cert for the name (../cert-manager/certificate-git-ddupan.yaml), - # so the clone URL is identical inside and outside — no remote needs rewriting. - # - # ⚠ This only covers PODS. LAN clients resolve via the DC, which forwards - # ddupan.top to the router — and the NEC IX has no static-host/proxy-DNS - # feature (`show dns` offers only fqdn-database). The DC therefore needs its own - # `git.ddupan.top` zone for laptops and PVE nodes to get the LAN answer. + # Split-horizon blocks are generated from ../../infrastructure/dns/records.yml. + # They keep pod traffic on the LAN while preserving public hostnames and LE certs. + # BEGIN GENERATED: homelab DNS (coredns) git-ddupan-top.server: | git.ddupan.top:53 { errors @@ -110,6 +68,17 @@ data: rcode NOERROR } } + auth-ddupan-top.server: | + auth.ddupan.top:53 { + errors + template IN A { + answer "{{ .Name }} 60 IN A 192.168.10.127" + } + template IN AAAA { + rcode NOERROR + } + } + # END GENERATED: homelab DNS (coredns) --- # NOTE: `serve_stale` could NOT be added here. #