id: dnssec-validation-and-signature-expiry-watch
namespace: company.team
description: |
Check that your DNSSEC-signed domains still validate, alert in Slack before their signatures expire,
and report DS record changes and DNSSEC being switched off. A broken DNSSEC chain makes a domain
unreachable for every user behind a validating resolver, while it still works from most office networks.
inputs:
- id: domains
type: JSON
displayName: Domains to watch
description: List of objects with a zone apex and an owner to mention.
defaults: |
[
{"domain": "cloudflare.com", "owner": "@dns-team"},
{"domain": "ietf.org", "owner": "@dns-team"},
{"domain": "example.com", "owner": "@web-team"}
]
- id: min_signature_percent
type: INT
displayName: Signature validity warning (%)
description: Alert when less than this share of the SOA signature's validity
period (inception to expiration) remains.
defaults: 15
- id: doh_url
type: STRING
displayName: DNS-over-HTTPS JSON endpoint
description: A validating resolver that answers JSON queries (Cloudflare or Google).
defaults: https://cloudflare-dns.com/dns-query
tasks:
- id: for_each_domain
type: io.kestra.plugin.core.flow.Loop
description: Check every domain independently, four at a time.
values: "{{ inputs.domains }}"
concurrencyLimit: 4
tasks:
- id: query_validated
type: io.kestra.plugin.core.http.Download
description: SOA lookup with DNSSEC validation, as users behind a validating
resolver see it.
uri: "{{ inputs.doh_url }}?name={{ fromJson(item.value).domain }}&type=SOA&do=1"
headers:
Accept: application/dns-json
- id: query_unvalidated
type: io.kestra.plugin.core.http.Download
description: The same lookup with checking disabled (`cd=1`), to tell a DNSSEC
failure from a DNS outage and to read the signature.
uri: "{{ inputs.doh_url }}?name={{ fromJson(item.value).domain
}}&type=SOA&do=1&cd=1"
headers:
Accept: application/dns-json
- id: query_ds
type: io.kestra.plugin.core.http.Download
description: DS records published in the parent zone, which link the domain into
the DNSSEC chain.
uri: "{{ inputs.doh_url }}?name={{ fromJson(item.value).domain }}&type=DS"
headers:
Accept: application/dns-json
- id: load_previous
type: io.kestra.plugin.core.kv.Get
description: DS records and findings recorded at the last run. Missing on the
first run.
key: "dnssec_watch_{{ fromJson(item.value).domain | slugify }}"
errorOnMissing: false
- id: evaluate
type: io.kestra.plugin.scripts.python.Script
description: Work out whether the domain is signed and validates, when its
signature expires, and what changed.
containerImage: python:3.12-slim
inputFiles:
validated.json: "{{ outputs.query_validated.uri }}"
unvalidated.json: "{{ outputs.query_unvalidated.uri }}"
ds.json: "{{ outputs.query_ds.uri }}"
site.json: "{{ item.value }}"
previous.json: "{{ (outputs.load_previous.value ?? {}) | toJson }}"
script: |
import json
from datetime import datetime, timezone
def load(name):
with open(name, encoding="utf-8") as f:
data = json.load(f)
return json.loads(data) if isinstance(data, str) else data
validated = load("validated.json")
unvalidated = load("unvalidated.json")
ds_answer = load("ds.json")
domain = load("site.json")["domain"].rstrip(".").lower()
previous = load("previous.json") or {}
rcodes = {0: "NOERROR", 2: "SERVFAIL", 3: "NXDOMAIN", 5: "REFUSED"}
ds = sorted(a["data"] for a in ds_answer.get("Answer", []) if a.get("type") == 43)
signed = bool(ds)
now = datetime.now(timezone.utc)
def parse_time(value):
if len(value) == 14:
return datetime.strptime(value, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
return datetime.fromtimestamp(int(value), timezone.utc)
expiry, inception, hours_left, percent_left = None, None, None, None
for a in unvalidated.get("Answer", []):
fields = a.get("data", "").split()
if a.get("type") == 46 and fields and fields[0].upper() == "SOA":
candidate = parse_time(fields[4])
if expiry is None or candidate < expiry:
expiry, inception = candidate, parse_time(fields[5])
if expiry:
hours_left = round((expiry - now).total_seconds() / 3600)
percent_left = round(100 * (expiry - now) / (expiry - inception))
findings = {}
status, unvalidated_status = validated.get("Status"), unvalidated.get("Status")
if signed and status == 2 and unvalidated_status == 0:
comment = validated.get("Comment") or []
detail = "; ".join([e.get("extra_text", "") for e in validated.get("extended_dns_errors", [])]
or ([comment] if isinstance(comment, str) else comment)) or "no detail from the resolver"
findings["validation_failing"] = (f"*critical* DNSSEC validation fails ({detail}). Users behind validating "
"resolvers (1.1.1.1, 8.8.8.8 and most ISPs) cannot resolve the domain.")
elif signed and status == 0 and not validated.get("AD"):
findings["not_validated"] = "*warning* DS records exist but answers are not validated (AD flag missing)."
elif status not in (0, None) and unvalidated_status not in (0, None):
findings["dns_failing"] = f"*critical* The SOA lookup fails even without validation ({rcodes.get(status, status)}): this is a DNS outage, not DNSSEC."
if signed and percent_left is not None and percent_left < {{ inputs.min_signature_percent }}:
findings["signature_expiry"] = (f"*critical* The SOA signature expires in {hours_left} hours ({expiry:%Y-%m-%d %H:%M} UTC), "
f"with only {percent_left}% of its validity left. Signers renew long before this, "
"so re-signing has probably stopped.")
if previous.get("signed") and not signed:
findings["event:dnssec_removed"] = "*warning* The DS records were removed: the domain is no longer DNSSEC-protected."
if previous.get("ds") and signed and ds != previous.get("ds"):
tags = lambda records: ", ".join(r.split()[0] for r in records)
findings[f"event:ds_changed:{'|'.join(ds)}"] = (f"*info* DS records changed (key tags {tags(previous['ds'])} -> {tags(ds)}). "
"Expected during a planned key rollover; otherwise check the registrar account.")
reported = previous.get("findings", {})
new = [findings[k] for k in findings if k not in reported]
resolved = [reported[k] for k in reported if k not in findings and not k.startswith("event:")]
print(f"{domain}: signed {signed}, validation {rcodes.get(status, status)} AD={validated.get('AD')}, "
f"signature expires in {hours_left} h ({percent_left}% left), {len(ds)} DS, {len(findings)} findings ({len(new)} new, {len(resolved)} resolved)")
print("::" + json.dumps({"outputs": {
"new": new,
"resolved": resolved,
"notify": bool(new or resolved),
"critical": any("*critical*" in f for f in new),
"summary": f"signed: {'yes' if signed else 'no'} | validates: {'yes' if validated.get('AD') else 'no'} | "
+ (f"signature expires in {hours_left} h ({percent_left}% of its validity left)" if hours_left is not None else "no signature"),
"state": {"signed": signed, "ds": ds, "findings": findings},
}}) + "::")
- id: if_changed
type: io.kestra.plugin.core.flow.If
description: Only post when a finding appears or is resolved.
condition: "{{ outputs.evaluate.vars.notify }}"
then:
- id: alert_owner
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post the findings with the current DNSSEC state.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
{{ outputs.evaluate.vars.critical ? ':rotating_light:' : (outputs.evaluate.vars.new | length > 0 ? ':lock:' : ':white_check_mark:') }} *DNSSEC: {{ fromJson(item.value).domain }}* (owner: {{ fromJson(item.value).owner }})
{% for f in outputs.evaluate.vars.new %}- {{ f }}
{% endfor %}{% for f in outputs.evaluate.vars.resolved %}- Resolved: {{ f }}
{% endfor %}Now: {{ outputs.evaluate.vars.summary }}
<https://dnsviz.net/d/{{ fromJson(item.value).domain }}/dnssec/|Analyze in DNSViz>
- id: remember_state
type: io.kestra.plugin.core.kv.Set
description: Store the DS records and findings for the next comparison. Runs
after the alert.
key: "dnssec_watch_{{ fromJson(item.value).domain | slugify }}"
kvType: JSON
overwrite: true
value: "{{ outputs.evaluate.vars.state | toJson }}"
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: A domain that cannot be checked must not look healthy.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: ":warning: DNSSEC watch failed in execution {{ execution.id }} ({{
flow.namespace }}.{{ flow.id }}). Open the failed loop iteration: the
DNS-over-HTTPS endpoint may be unreachable."
triggers:
- id: hourly
type: io.kestra.plugin.core.trigger.Schedule
description: Signatures can expire within hours of a signer failure, so check
every hour. Disabled until the Slack secret is set.
cron: "15 * * * *"
disabled: true