Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The repository uses OctoDNS with Cloudflare as the DNS provider and YAML configu
- **Configuration**: `config/config.yaml` defines providers, processors and zone mappings. `enforce_order` with `order_mode: natural` is on.
- **DNS Records**: The zone file `patchworklabs.org.yaml` contains DNS record definitions. Every record needs an owner (an email, a Patchwork id such as `PWL7A1CE1F3CB`, or both; a GitHub team for team-owned records) in a comment on the same line as its name.
- **Scripts**: Shell scripts in `bin/` handle DNS operations. They read the zone list from `config/config.yaml` through `bin/zones`.
- **Tools**: `tools/merge_live.py` merges live Cloudflare state back into the zone files and keeps comments. `tools/check_zones.py` checks the repository rules (owner comment on every record, TTL of 120 or more, no apex NS, no `octodns-meta`, proxy only on A/AAAA/CNAME). `./bin/validate` runs it. Tests are in `tools/test_*.py`.
- **Tools**: `tools/merge_live.py` merges live Cloudflare state back into the zone files and keeps comments. `tools/check_zones.py` checks the repository rules (owner comment on every record, TTL of 120 or more, no apex NS, no `octodns-meta`, proxy only on A/AAAA/CNAME, CAA names only known CAs and an apex `issue` list includes `letsencrypt.org`). `./bin/validate` runs it. Tests are in `tools/test_*.py`.
- **Workflows**: `validate` (no secrets), `plan` (`pull_request_target`, posts the plan and checks drift), `deploy` (push to `main`), `sync-from-cloudflare` (nightly).
- **Dependencies**: Python dependencies pinned in `requirements.txt`.

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ It fails when:
- A zone in `config/config.yaml` has no zone file, or a YAML file at the
repository root is not a zone.
- One name appears twice in one file.
- A CAA record names a CA that is not in `KNOWN_CAS` in
`tools/check_zones.py`, has a tag other than `issue`, `issuewild` or
`iodef`, or has flags other than 0 or 128. An `iodef` value must start with
`mailto:` or `https://`.
- The apex has CAA `issue` records and they do not include
`letsencrypt.org`. Vercel, Traefik on alastor and the thimble VPN get their
certificates from Let's Encrypt.

### 2. Open a pull request

Expand Down
86 changes: 86 additions & 0 deletions tools/check_zones.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
6. Every zone in ``config/config.yaml`` has a zone file, and every YAML file
at the repository root belongs to a zone.
7. No record name appears twice in one file.
8. Every CAA record names a CA in ``KNOWN_CAS``, uses a known tag and flag,
and gives ``iodef`` a ``mailto:`` or ``https://`` address. If the apex has
CAA ``issue`` records, they include ``letsencrypt.org``. A typo such
as ``letsencrypt.com`` then fails here, not at the next certificate renewal.

Usage:

Expand All @@ -37,6 +41,19 @@
MIN_TTL = 120
PROXIABLE = {"A", "AAAA", "CNAME"}
META_RECORD = "octodns-meta"
# The CAs that a CAA `issue` or `issuewild` record can name. Add a CA here in
# the same pull request that first allows it in a zone file.
KNOWN_CAS = {
"letsencrypt.org", # Vercel, Traefik on alastor, the thimble VPN
"pki.goog", # Cloudflare Universal SSL
"sectigo.com", # Cloudflare backup certificates
"ssl.com", # Cloudflare Universal SSL
}
# Vercel, Traefik on alastor and the thimble VPN get certificates from it, so
# an apex CAA set without it stops their renewals.
REQUIRED_APEX_CA = "letsencrypt.org"
CAA_TAGS = {"issue", "issuewild", "iodef"}
CAA_FLAGS = {0, 128}

# A top level key: `name:`, `"name":` or `'name':`, then an optional comment.
_TOP_LEVEL = re.compile(
Expand Down Expand Up @@ -91,6 +108,58 @@ def _records(value):
return [value]


def _record_values(record):
values = record["values"] if "values" in record else record.get("value")
if values is None:
return []
return values if isinstance(values, list) else [values]


def _caa_ca(value):
"""The CA domain of a CAA issue or issuewild value, in lower case.

The parameters after `;` are dropped. DNS names do not depend on case, and
a trailing dot is the same name.
"""
return str(value).split(";")[0].strip().strip('"').rstrip(".").lower()


def _caa_errors(where, label, record):
"""Return the errors for one CAA record."""
errors = []
for caa in _record_values(record):
if not isinstance(caa, dict):
errors.append(f"{where}: `{label}` CAA value is not a mapping")
continue
tag = caa.get("tag")
value = str(caa.get("value", ""))
flags = caa.get("flags", 0)
if isinstance(flags, bool) or not isinstance(flags, int) or flags not in CAA_FLAGS:
errors.append(
f"{where}: `{label}` CAA has flags {flags!r}. Use the number 0 or 128"
)
if tag not in CAA_TAGS:
errors.append(
f"{where}: `{label}` CAA has tag `{tag}`. Use one of "
f"{', '.join(sorted(CAA_TAGS))}"
)
elif tag == "iodef":
if not value.lower().startswith(("mailto:", "https://")):
errors.append(
f"{where}: `{label}` CAA iodef `{value}` must start with "
f"mailto: or https://"
)
else:
# An empty CA (`;`) is allowed: it means that no CA can issue.
ca = _caa_ca(value)
if ca and ca not in KNOWN_CAS:
errors.append(
f"{where}: `{label}` CAA {tag} `{ca}` is not a known CA. "
f"Fix the name or add it to KNOWN_CAS in tools/check_zones.py"
)
return errors


def check_file(path):
"""Return a list of `file:line: message` strings for one zone file."""
text = path.read_text()
Expand Down Expand Up @@ -118,6 +187,7 @@ def check_file(path):
for name, value in data.items():
number = lines.get(name, 1)
label = name or "@"
apex_cas = None
if name == META_RECORD:
errors.append(
f"{path.name}:{number}: `{META_RECORD}` is written by octoDNS. "
Expand All @@ -138,13 +208,29 @@ def check_file(path):
f"{path.name}:{number}: the apex NS records belong to "
f"Cloudflare. Remove them from the zone file"
)
if rtype == "CAA":
errors.extend(_caa_errors(f"{path.name}:{number}", label, record))
if name == "":
apex_cas = (apex_cas or set()) | {
_caa_ca(caa.get("value", ""))
for caa in _record_values(record)
if isinstance(caa, dict) and caa.get("tag") == "issue"
}
octodns = record.get("octodns") or {}
proxied = (octodns.get("cloudflare") or {}).get("proxied")
if proxied and rtype not in PROXIABLE:
errors.append(
f"{path.name}:{number}: `{label}` {rtype} cannot be "
f"proxied. Only {', '.join(sorted(PROXIABLE))} can"
)
# Without an `issue` property every CA can issue (RFC 8659), so only an
# `issue` list that leaves Let's Encrypt out is a problem.
if apex_cas and REQUIRED_APEX_CA not in apex_cas:
errors.append(
f"{path.name}:{number}: the apex CAA records do not allow "
f"{REQUIRED_APEX_CA}. Vercel, Traefik on alastor and the thimble "
f"VPN could not renew their certificates"
)
return errors


Expand Down
130 changes: 130 additions & 0 deletions tools/test_check_zones.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,136 @@ def test_proxied_txt(self):
"`api` TXT cannot be proxied",
)

def caa(self, name, *entries):
"""A zone file with one CAA record of (flags, tag, value) entries."""
values = "".join(
f" - flags: {flags}\n tag: {tag}\n value: {value}\n"
for flags, tag, value in entries
)
return f"---\n{name}: # ada@patchworklabs.org\n ttl: 3600\n type: CAA\n values:\n{values}"

def test_good_caa(self):
body = self.caa(
'""',
(0, "iodef", "mailto:security@patchworklabs.org"),
(0, "issue", "letsencrypt.org"),
(0, "issue", "pki.goog"),
(128, "issuewild", "ssl.com"),
)
self.assertEqual(self.check(body), [])

def test_caa_single_value(self):
body = (
'---\n"": # ada@patchworklabs.org\n ttl: 3600\n type: CAA\n value:\n'
" flags: 0\n tag: issue\n value: letsencrypt.org\n"
)
self.assertEqual(self.check(body), [])

def test_caa_inside_a_list(self):
body = (
'---\n"": # ada@patchworklabs.org\n - ttl: 120\n type: A\n'
" value: 192.0.2.1\n - ttl: 3600\n type: CAA\n values:\n"
" - flags: 0\n tag: issue\n value: letsencrypt.com\n"
)
errors = self.check(body)
self.assertTrue(any("`letsencrypt.com` is not a known CA" in e for e in errors), errors)

def test_caa_unknown_ca(self):
errors = self.check(
self.caa('""', (0, "issue", "letsencrypt.org"), (0, "issue", "letsencrypt.com"))
)
self.assertEqual(len(errors), 1, errors)
self.assertIn("`@` CAA issue `letsencrypt.com` is not a known CA", errors[0])

def test_caa_parameters_after_the_ca(self):
body = self.caa('""', (0, "issue", '"letsencrypt.org; validationmethods=dns-01"'))
self.assertEqual(self.check(body), [])

def test_caa_unknown_issuewild_ca(self):
self.assertOneError(
self.caa("sub", (0, "issuewild", "example-ca.com")),
"`sub` CAA issuewild `example-ca.com` is not a known CA",
)

def test_caa_empty_ca_on_a_subdomain(self):
self.assertEqual(self.check(self.caa("sub", (0, "issue", '";"'))), [])

def test_caa_bad_tag(self):
self.assertOneError(
self.caa("sub", (0, "isue", "letsencrypt.org")),
"`sub` CAA has tag `isue`",
)

def test_caa_bad_flags(self):
self.assertOneError(
self.caa("sub", (1, "issue", "letsencrypt.org")),
"`sub` CAA has flags 1",
)

def test_caa_bad_iodef(self):
self.assertOneError(
self.caa("sub", (0, "iodef", "security@patchworklabs.org")),
"iodef `security@patchworklabs.org` must start with mailto: or https://",
)

def test_apex_caa_without_letsencrypt(self):
self.assertOneError(
self.caa('""', (0, "issue", "pki.goog")),
"the apex CAA records do not allow letsencrypt.org",
)

def test_apex_caa_with_only_issuewild_letsencrypt(self):
self.assertOneError(
self.caa('""', (0, "issue", "pki.goog"), (0, "issuewild", "letsencrypt.org")),
"the apex CAA records do not allow letsencrypt.org",
)

def test_apex_caa_without_issue_allows_every_ca(self):
body = self.caa(
'""',
(0, "iodef", "mailto:security@patchworklabs.org"),
(0, "issuewild", "pki.goog"),
)
self.assertEqual(self.check(body), [])

def test_caa_ca_case_and_trailing_dot(self):
body = self.caa('""', (0, "issue", "LetsEncrypt.org."), (0, "issue", "PKI.goog"))
self.assertEqual(self.check(body), [])

def test_caa_iodef_scheme_case(self):
self.assertEqual(self.check(self.caa("sub", (0, "iodef", "MAILTO:a@patchworklabs.org"))), [])

def test_caa_missing_flags(self):
body = (
"---\nsub: # ada@patchworklabs.org\n ttl: 3600\n type: CAA\n value:\n"
" tag: issue\n value: letsencrypt.org\n"
)
self.assertEqual(self.check(body), [])

def test_caa_flags_that_are_not_a_number(self):
for flags in ('"0"', "[0]", "{}", "true"):
with self.subTest(flags=flags):
self.assertOneError(
self.caa("sub", (flags, "issue", "letsencrypt.org")),
"`sub` CAA has flags",
)

def test_caa_values_as_one_string(self):
body = (
"---\nsub: # ada@patchworklabs.org\n ttl: 3600\n type: CAA\n"
" values: letsencrypt.org\n"
)
self.assertOneError(body, "`sub` CAA value is not a mapping")

def test_apex_caa_that_forbids_every_ca(self):
self.assertOneError(
self.caa('""', (0, "issue", '";"')),
"the apex CAA records do not allow letsencrypt.org",
)

def test_subdomain_caa_does_not_need_letsencrypt(self):
self.assertEqual(self.check(self.caa("sub", (0, "issue", "pki.goog"))), [])

def test_duplicate_name(self):
errors = self.check(
"---\napi: # ada@patchworklabs.org\n ttl: 300\n type: A\n value: 192.0.2.1\n\n"
Expand Down
Loading