aws2-10 Exercise 2: Route 53 Routing, Health Checks, DNS Zone Rules and WAF Evaluation ======================================================================== Run with Windows 11, Python 3.14 (py launcher) and a venv with moto 5.2.4 (server mode on port 5000, dummy credentials test/test), boto3 1.43.112, cfn-lint 1.57.2, PyYAML, dnspython 2.9.0; iam_eval.py is the chapter 2 model copied with one fix (see exercise 1). Scratch folder C:/lwaws/c10, nothing installed globally, no Docker. VERIFICATION LEVEL: 1 (no AWS account, no domain, nothing deployed); statements about the real services come from the AWS documentation read on 2026-10-13 and are marked as such in the chapter. Outputs below are saved from the real runs of 2026-10-13. --- route53_model.py --- """Route 53 routing logic from the pages read on 2026-10-13, plus what a real DNS zone parser says about a CNAME at the apex. - weighted routing: share = weight / sum of weights; the page's two tables for a zero-weight record with and without a health check - health check aggregation: 'more than 18% of health checkers report healthy' = healthy - dnspython (a real DNS library) parsing zone text: RFC rules the alias record exists to get around""" import random from collections import Counter # ---- weighted routing ----------------------------------------------------------------------------- def answer(records): """records: list of dicts {name, weight, hc: None|True|False} (hc = has a health check and it is healthy) Returns the names Route 53 may answer with. Derived from the docs' tables: nonzero weights first; if none of them is healthy, zero-weight records; if nothing is healthy at all, the nonzero records are answered anyway (table row U,U,U).""" ok = lambda r: r["hc"] is None or r["hc"] is True nonzero = [r for r in records if r["weight"] > 0] zero = [r for r in records if r["weight"] == 0] a = [r for r in nonzero if ok(r)] if a: return [r["name"] for r in a] b = [r for r in zero if ok(r)] if b: return [r["name"] for r in b] return [r["name"] for r in nonzero] def shares(records): pool = [r for r in records if r["name"] in answer(records)] tot = sum(r["weight"] for r in pool) return {r["name"]: (r["weight"] / tot if tot else 1 / len(pool)) for r in pool} H, U = True, False # (health of record 1, 2, 3; record 3 has weight 0), expected set of answered records, from the page's tables WITH_HC = [((U, U, H), {"R3"}), ((U, U, U), {"R1", "R2"}), ((U, H, U), {"R2"}), ((H, H, U), {"R1", "R2"}), ((H, H, H), {"R1", "R2"})] WITHOUT_HC = [((H, H), {"R1", "R2"}), ((U, U), {"R3"}), ((U, H), {"R2"})] bad = 0 print("== zero-weight record WITH a health check (page table) ==") for (a, b, c), want in WITH_HC: got = set(answer([{"name": "R1", "weight": 1, "hc": a}, {"name": "R2", "weight": 1, "hc": b}, {"name": "R3", "weight": 0, "hc": c}])) bad += got != want print(f"{'ok ' if got == want else 'FAIL'} health R1,R2,R3 = {''.join('H' if x else 'U' for x in (a, b, c))} answered {sorted(got)} page: {sorted(want)}") print("== zero-weight record WITHOUT a health check (page table) ==") for (a, b), want in WITHOUT_HC: got = set(answer([{"name": "R1", "weight": 1, "hc": a}, {"name": "R2", "weight": 1, "hc": b}, {"name": "R3", "weight": 0, "hc": None}])) bad += got != want print(f"{'ok ' if got == want else 'FAIL'} health R1,R2 = {''.join('H' if x else 'U' for x in (a, b))}, R3 unchecked answered {sorted(got)} page: {sorted(want)}") print("\n== weights 1 and 255 (page example: 1/256 and 255/256) ==") recs = [{"name": "canary", "weight": 1, "hc": None}, {"name": "stable", "weight": 255, "hc": None}] sh = shares(recs) print("model shares:", {k: f"{v:.5f}" for k, v in sh.items()}, " 1/256 =", f"{1 / 256:.5f}") random.seed(53) c = Counter(random.choices(list(sh), list(sh.values()), k=200000)) print("200,000 simulated queries:", dict(c), " canary share", f"{c['canary'] / 200000:.5f}") print("weight 0 on a healthy record:", shares([{"name": "a", "weight": 0, "hc": None}, {"name": "b", "weight": 5, "hc": None}])) # ---- health checker aggregation --------------------------------------------------------------------- print("\n== 'more than 18% of health checkers report healthy' ==") def healthy(reports_healthy, checkers): return reports_healthy / checkers > 0.18 print("the page does not say how many checkers there are; for a few possible counts, the number of healthy reports needed:") for n in (5, 10, 15, 16, 20): need = next(k for k in range(n + 1) if healthy(k, n)) print(f" {n:2d} checkers: healthy once {need} of them report healthy ({need / n:.0%}); {need - 1} of {n} ({(need - 1) / n:.0%}) is unhealthy") # ---- a real DNS parser on the apex rule -------------------------------------------------------------- import dns.zone, dns.exception BASE = """$ORIGIN example.com. $TTL 300 @ IN SOA ns1.example.com. admin.example.com. 1 7200 900 1209600 300 @ IN NS ns1.example.com. ns1 IN A 192.0.2.53 www IN A 192.0.2.10 """ def parse(extra): try: dns.zone.from_text(BASE + extra, origin="example.com.", relativize=False) return "accepted" except dns.exception.DNSException as e: return f"rejected: {type(e).__name__}: {e}" except Exception as e: return f"rejected: {type(e).__name__}: {e}" print("\n== zone text parsed by dnspython", __import__("dns").version.version, "==") for label, extra in [("a plain zone", ""), ("CNAME at the apex (@ IN CNAME www.example.com.)", "@ IN CNAME www.example.com.\n"), ("CNAME next to an A record at the same name", "blog IN CNAME www.example.com.\nblog IN A 192.0.2.99\n"), ("CNAME at a subdomain on its own", "blog IN CNAME www.example.com.\n"), ("two CNAMEs for one name", "blog IN CNAME www.example.com.\nblog IN CNAME ns1.example.com.\n"), ("A record at the apex", "@ IN A 192.0.2.10\n")]: print(f" {label:54s} {parse(extra)[:120]}") print("\nfailed checks:", bad) --- py route53_model.py (saved output of the real run) --- == zero-weight record WITH a health check (page table) == ok health R1,R2,R3 = UUH answered ['R3'] page: ['R3'] ok health R1,R2,R3 = UUU answered ['R1', 'R2'] page: ['R1', 'R2'] ok health R1,R2,R3 = UHU answered ['R2'] page: ['R2'] ok health R1,R2,R3 = HHU answered ['R1', 'R2'] page: ['R1', 'R2'] ok health R1,R2,R3 = HHH answered ['R1', 'R2'] page: ['R1', 'R2'] == zero-weight record WITHOUT a health check (page table) == ok health R1,R2 = HH, R3 unchecked answered ['R1', 'R2'] page: ['R1', 'R2'] ok health R1,R2 = UU, R3 unchecked answered ['R3'] page: ['R3'] ok health R1,R2 = UH, R3 unchecked answered ['R2'] page: ['R2'] == weights 1 and 255 (page example: 1/256 and 255/256) == model shares: {'canary': '0.00391', 'stable': '0.99609'} 1/256 = 0.00391 200,000 simulated queries: {'stable': 199200, 'canary': 800} canary share 0.00400 weight 0 on a healthy record: {'b': 1.0} == 'more than 18% of health checkers report healthy' == the page does not say how many checkers there are; for a few possible counts, the number of healthy reports needed: 5 checkers: healthy once 1 of them report healthy (20%); 0 of 5 (0%) is unhealthy 10 checkers: healthy once 2 of them report healthy (20%); 1 of 10 (10%) is unhealthy 15 checkers: healthy once 3 of them report healthy (20%); 2 of 15 (13%) is unhealthy 16 checkers: healthy once 3 of them report healthy (19%); 2 of 16 (12%) is unhealthy 20 checkers: healthy once 4 of them report healthy (20%); 3 of 20 (15%) is unhealthy == zone text parsed by dnspython 2.9.0 == a plain zone accepted CNAME at the apex (@ IN CNAME www.example.com.) rejected: CNAMEAndOtherData: CNAME rdataset is not compatible with a regular data node CNAME next to an A record at the same name rejected: CNAMEAndOtherData: rdataset type is not compatible with a CNAME node CNAME at a subdomain on its own accepted two CNAMEs for one name accepted A record at the apex accepted failed checks: 0 --- waf_model.py --- """A model of AWS WAF web ACL evaluation from the pages read on 2026-10-13: 'How AWS WAF handles rule and rule group actions' - Allow and Block terminate; Count does not; no terminating action -> default action 'Setting rule priority' - lowest number first, unique inside the web ACL and inside each rule group; the page's own ordering example rate-based settings page - window 60/120/300/600 s (default 300), lowest limit 10, counts per aggregation instance, any action except Allow WCU page + rate-based page - rate-based rule = 2 WCUs base + 30 per custom aggregation key; 1,500 WCUs in the base price, 5,000 maximum pricing page (from its examples) - $5.00 per web ACL per month, $1.00 per rule (each rule group counts), $0.60 per million requests It models only these rules; 'near the limit' accuracy of real rate limiting is not modelled (the page says the limit is not exact).""" from collections import defaultdict, deque class Rule: def __init__(self, name, priority, action, match=None, rate=None, group=None): self.name, self.priority, self.action, self.match, self.rate, self.group = name, priority, action, match or (lambda r: True), rate, group class WebACL: def __init__(self, default="Allow"): self.default, self.items, self.counts, self.trail = default, [], defaultdict(int), [] self._hist = defaultdict(deque) def add(self, item): self.items.append(item) def validate(self): errs = [] pri = [i.priority for i in self.items] if len(set(pri)) != len(pri): errs.append("priorities in the web ACL must be unique") for g in self.items: if isinstance(g, Group): gp = [r.priority for r in g.rules] if len(set(gp)) != len(gp): errs.append(f"priorities inside rule group {g.name} must be unique") for r in self.flat(): if r.rate: if r.rate["limit"] < 10: errs.append(f"{r.name}: lowest rate limit is 10") if r.rate.get("window", 300) not in (60, 120, 300, 600): errs.append(f"{r.name}: window must be 60, 120, 300 or 600") if r.action == "Allow": errs.append(f"{r.name}: a rate-based rule cannot use Allow") return errs def flat(self): out = [] for it in sorted(self.items, key=lambda i: i.priority): out += sorted(it.rules, key=lambda r: r.priority) if isinstance(it, Group) else [it] return out def order(self): return [(f"{r.group}/{r.name}" if r.group else r.name) for r in self.flat()] def _rate_hit(self, r, req): key = (r.name, req.get("ip")) h = self._hist[key] t, win = req["t"], r.rate.get("window", 300) while h and h[0] <= t - win: h.popleft() h.append(t) return len(h) > r.rate["limit"] # over the limit: the rule matches and its action applies def handle(self, req): for r in self.flat(): if r.rate: if not r.match(req) or not self._rate_hit(r, req): continue elif not r.match(req): continue self.counts[r.name] += 1 if r.action == "Count" else 0 self.trail.append((req.get("id"), r.name, r.action)) if r.action in ("Allow", "Block"): return r.action return self.default class Group: def __init__(self, name, priority, rules): self.name, self.priority, self.rules = name, priority, rules for r in rules: r.group = name def wcu(rate_keys=0, scope_down=0): return 2 + 30 * rate_keys + scope_down def monthly_cost(rules_and_groups, requests_millions, web_acls=1): return web_acls * 5.00 + rules_and_groups * 1.00 + requests_millions * 0.60 if __name__ == "__main__": bad = 0 def check(label, got, want): global bad ok = got == want; bad += not ok print(f"{'ok ' if ok else 'FAIL'} {label:74s} {got!s:>60}" + ("" if ok else f" want {want!s}")) print("== the page's priority example ==") acl = WebACL() acl.add(Rule("Rule1", 0, "Count")) acl.add(Group("RuleGroupA", 100, [Rule("RuleA1", 10000, "Count"), Rule("RuleA2", 20000, "Count")])) acl.add(Rule("Rule2", 200, "Count")) acl.add(Group("RuleGroupB", 300, [Rule("RuleB1", 0, "Count"), Rule("RuleB2", 1, "Count")])) page = ["Rule1", "RuleGroupA/RuleA1", "RuleGroupA/RuleA2", "Rule2", "RuleGroupB/RuleB1", "RuleGroupB/RuleB2"] check("evaluation order", acl.order(), page) print("\n== Allow (priority 50) and Count (priority 100), the page's example ==") def run(allow_p, count_p): a = WebACL(default="Block") a.add(Rule("allow-office", allow_p, "Allow", lambda r: r["ip"].startswith("10."))) a.add(Rule("count-api", count_p, "Count", lambda r: r["path"].startswith("/api"))) res = [a.handle({"id": i, "ip": ip, "path": p, "t": 0}) for i, (ip, p) in enumerate([("10.0.0.1", "/api/x"), ("203.0.113.5", "/api/y"), ("203.0.113.6", "/home")])] return res, a.counts["count-api"] check("Allow before Count: results, count-rule hits", run(50, 100), (["Allow", "Block", "Block"], 1)) check("Count before Allow (lower number): results, count-rule hits", run(100, 50), (["Allow", "Block", "Block"], 2)) print("\n== actions and the default action ==") a = WebACL(default="Allow"); a.add(Rule("count-all", 1, "Count")); check("only a Count rule matches -> default action", a.handle({"id": 1, "ip": "1.1.1.1", "path": "/", "t": 0}), "Allow") b = WebACL(default="Block"); b.add(Rule("count-all", 1, "Count")); b.add(Rule("allow-health", 2, "Allow", lambda r: r["path"] == "/health")) check("Count then Allow: /health", b.handle({"id": 1, "ip": "1.1.1.1", "path": "/health", "t": 0}), "Allow") check("Count then Allow: anything else (default Block)", b.handle({"id": 2, "ip": "1.1.1.1", "path": "/x", "t": 0}), "Block") print("\n== validation rules from the pages ==") v = WebACL(); v.add(Rule("a", 1, "Block")); v.add(Rule("b", 1, "Block")) check("duplicate priorities in a web ACL", v.validate(), ["priorities in the web ACL must be unique"]) v = WebACL(); v.add(Rule("rl", 1, "Block", rate={"limit": 5})) check("rate limit 5", v.validate(), ["rl: lowest rate limit is 10"]) v = WebACL(); v.add(Rule("rl", 1, "Block", rate={"limit": 100, "window": 90})) check("evaluation window 90", v.validate(), ["rl: window must be 60, 120, 300 or 600"]) v = WebACL(); v.add(Rule("rl", 1, "Allow", rate={"limit": 100})) check("rate-based rule with Allow", v.validate(), ["rl: a rate-based rule cannot use Allow"]) print("\n== rate-based rule: limit 10 per 60 s per IP, 3 clients ==") r = WebACL(default="Allow"); r.add(Rule("rate", 1, "Block", rate={"limit": 10, "window": 60})) out = defaultdict(lambda: [0, 0]); i = 0 for t in range(0, 120): # 2 minutes, one tick per second for ip, per_sec in (("198.51.100.1", 1), ("198.51.100.2", 3), ("198.51.100.3", 0.1)): n = int(per_sec) if per_sec >= 1 else (1 if t % 10 == 0 else 0) for _ in range(n): i += 1 res = r.handle({"id": i, "ip": ip, "path": "/", "t": t}) out[ip][0 if res == "Allow" else 1] += 1 for ip, (ok, blk) in out.items(): print(f" {ip:14s} allowed {ok:4d} blocked {blk:4d}") print(" (1 request/s = 60 per window, above the limit of 10; the page says real counting is 'near the limit', not exact)") print("\n== capacity and price ==") check("rate-based rule, no custom keys", wcu(0), 2) check("rate-based rule with 2 custom aggregation keys", wcu(2), 62) check("5 rules/groups, 10 million requests a month: $", monthly_cost(5, 10), 16.0) check("1 web ACL, 3 rule groups + 2 rules, 100 million requests: $", monthly_cost(5, 100), 70.0) print("\nfailed checks:", bad) --- py waf_model.py (saved output of the real run) --- == the page's priority example == ok evaluation order ['Rule1', 'RuleGroupA/RuleA1', 'RuleGroupA/RuleA2', 'Rule2', 'RuleGroupB/RuleB1', 'RuleGroupB/RuleB2'] == Allow (priority 50) and Count (priority 100), the page's example == ok Allow before Count: results, count-rule hits (['Allow', 'Block', 'Block'], 1) ok Count before Allow (lower number): results, count-rule hits (['Allow', 'Block', 'Block'], 2) == actions and the default action == ok only a Count rule matches -> default action Allow ok Count then Allow: /health Allow ok Count then Allow: anything else (default Block) Block == validation rules from the pages == ok duplicate priorities in a web ACL ['priorities in the web ACL must be unique'] ok rate limit 5 ['rl: lowest rate limit is 10'] ok evaluation window 90 ['rl: window must be 60, 120, 300 or 600'] ok rate-based rule with Allow ['rl: a rate-based rule cannot use Allow'] == rate-based rule: limit 10 per 60 s per IP, 3 clients == 198.51.100.1 allowed 10 blocked 110 198.51.100.2 allowed 10 blocked 350 198.51.100.3 allowed 12 blocked 0 (1 request/s = 60 per window, above the limit of 10; the page says real counting is 'near the limit', not exact) == capacity and price == ok rate-based rule, no custom keys 2 ok rate-based rule with 2 custom aggregation keys 62 ok 5 rules/groups, 10 million requests a month: $ 16.0 ok 1 web ACL, 3 rule groups + 2 rules, 100 million requests: $ 70.0 failed checks: 0 -------------------------- Reading the result: the weighted-routing function reproduces all 8 rows of the two documented tables (it was inferred from those tables, so it is no better than they are) and the 1/256 share; dnspython rejects a CNAME beside the apex's SOA/NS and a CNAME beside an A record, accepts a lone subdomain CNAME, and also accepts two CNAMEs on one name (I do not know what Route 53 does with that). The WAF model reproduces the page's evaluation order and the Allow/Count example. Not verified: how real WAF rate limiting treats blocked requests (my model counts them, so a client that keeps sending stays blocked), and how many Route 53 health checkers exist (the table shows several assumed counts). Mistake to avoid: do not read the model's rate-limiting behaviour (blocked requests counting toward the rate) as AWS's.