diff --git a/CLAUDE.md b/CLAUDE.md index 58e4192..b169f8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/README.md b/README.md index 047ce58..a7c6f82 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tools/check_zones.py b/tools/check_zones.py index f76cb38..806571a 100644 --- a/tools/check_zones.py +++ b/tools/check_zones.py @@ -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: @@ -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( @@ -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() @@ -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. " @@ -138,6 +208,14 @@ 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: @@ -145,6 +223,14 @@ def check_file(path): 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 diff --git a/tools/test_check_zones.py b/tools/test_check_zones.py index c1dc764..4891d23 100644 --- a/tools/test_check_zones.py +++ b/tools/test_check_zones.py @@ -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"