Merge pull request '用统一 inventory 生成 DNS 后端配置' (#81) from feat/dns-config-generator into main
Reviewed-on: #81
This commit was merged in pull request #81.
This commit is contained in:
@@ -56,6 +56,11 @@ jobs:
|
||||
rm -f \
|
||||
infrastructure/openbao/ansible/group_vars/all/vault.yml \
|
||||
infrastructure/samba-ad/ansible/group_vars/all/vault.yml
|
||||
# ansible.cfg still declares vault_password_file. Even with encrypted
|
||||
# vars removed, ansible-lint validates that the configured file exists
|
||||
# before syntax-check starts. This throwaway value decrypts nothing.
|
||||
export ANSIBLE_VAULT_PASSWORD_FILE="$RUNNER_TEMP/ansible-lint-vault-pass"
|
||||
printf '%s\n' 'ci-placeholder-not-a-production-secret' > "$ANSIBLE_VAULT_PASSWORD_FILE"
|
||||
rc=0
|
||||
for p in infrastructure/openbao infrastructure/samba-ad infrastructure/proxmox; do
|
||||
echo "::group::$p"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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())
|
||||
@@ -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 %]
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user