spf53 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
spf53/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """spf53 — self-hosted SPF flattener for Route53."""
2
+
3
+ __version__ = "0.1.0"
spf53/chunker.py ADDED
@@ -0,0 +1,151 @@
1
+ """Pack flattened SPF mechanisms into chained ``_spf53-N.<domain>`` TXT records.
2
+
3
+ Split convention (RFC 7208 3.3): a DNS TXT record's character-strings are
4
+ concatenated by resolvers with NO separator between them, so a multi-string
5
+ record must carry its own separating space. When a record's content needs a
6
+ second string, the split always falls exactly between two mechanisms (never
7
+ mid-token): the first string carries no trailing space, and the second
8
+ string carries a single LEADING space. Concatenating the two strings then
9
+ reproduces the original "mech1 mech2 ..." content byte-for-byte.
10
+ """
11
+
12
+ from collections.abc import Sequence
13
+ from ipaddress import IPv4Network, IPv6Network
14
+
15
+ MAX_TXT_STRING = 255
16
+ MAX_STRINGS_PER_RECORD = 2
17
+ DNS_QUERYING_MECHANISMS = ("include:", "exists:", "a", "mx", "ptr")
18
+
19
+ _MAX_RECORD_CHARS = MAX_TXT_STRING * MAX_STRINGS_PER_RECORD
20
+
21
+
22
+ def build_records(
23
+ domain: str,
24
+ networks: Sequence[IPv4Network | IPv6Network],
25
+ passthrough: Sequence[str],
26
+ policy: str,
27
+ ) -> dict[str, list[str]]:
28
+ """Pack passthrough mechanisms and networks into chained TXT records.
29
+
30
+ Chunk 1 carries passthrough mechanisms first, then ip4:, then ip6:
31
+ mechanisms. Every chunk but the last ends with an ``include:`` link to
32
+ the next chunk; the last ends with ``policy``. Room for that trailing
33
+ link (or the policy) is reserved before packing each chunk's body.
34
+ """
35
+ v4 = sorted(n for n in networks if isinstance(n, IPv4Network))
36
+ v6 = sorted(n for n in networks if isinstance(n, IPv6Network))
37
+ tokens = [*passthrough, *(_render(n) for n in v4), *(_render(n) for n in v6)]
38
+
39
+ records: dict[str, list[str]] = {}
40
+ chunk_num = 1
41
+ start = 0
42
+ while True:
43
+ name = f"_spf53-{chunk_num}.{domain}"
44
+ remaining = tokens[start:]
45
+
46
+ if _fits(["v=spf1", *remaining, policy]):
47
+ content = " ".join(["v=spf1", *remaining, policy])
48
+ records[name] = _split(content)
49
+ return records
50
+
51
+ next_link = f"include:_spf53-{chunk_num + 1}.{domain}"
52
+ n = _max_fit(remaining, next_link)
53
+ if n == 0:
54
+ raise ValueError(f"mechanism too large to fit in chunk {name!r}")
55
+ content = " ".join(["v=spf1", *remaining[:n], next_link])
56
+ records[name] = _split(content)
57
+ start += n
58
+ chunk_num += 1
59
+
60
+
61
+ def lookup_cost(records: dict[str, list[str]], passthrough: Sequence[str]) -> int:
62
+ """Chain length + 1 (apex include) + DNS-querying passthrough mechanisms."""
63
+ dns_querying = sum(1 for p in passthrough if p.startswith(DNS_QUERYING_MECHANISMS))
64
+ return len(records) + 1 + dns_querying
65
+
66
+
67
+ def to_route53_value(strings: list[str]) -> str:
68
+ """Render as Route53's space-separated, double-quoted TXT value form."""
69
+ return " ".join(f'"{_escape(s)}"' for s in strings)
70
+
71
+
72
+ def from_route53_value(value: str) -> list[str]:
73
+ """Parse a Route53 TXT value back into its component strings.
74
+
75
+ Defensively unescapes ``\\"`` and ``\\\\`` inside each quoted string.
76
+ """
77
+ strings: list[str] = []
78
+ i, n = 0, len(value)
79
+ while i < n:
80
+ if value[i].isspace():
81
+ i += 1
82
+ continue
83
+ if value[i] != '"':
84
+ raise ValueError(f"malformed Route53 TXT value: {value!r}")
85
+ i += 1
86
+ chars: list[str] = []
87
+ while i < n and value[i] != '"':
88
+ if value[i] == "\\" and i + 1 < n:
89
+ chars.append(value[i + 1])
90
+ i += 2
91
+ else:
92
+ chars.append(value[i])
93
+ i += 1
94
+ if i >= n:
95
+ raise ValueError(f"unterminated quoted string in Route53 TXT value: {value!r}")
96
+ i += 1 # skip closing quote
97
+ strings.append("".join(chars))
98
+ return strings
99
+
100
+
101
+ def _render(network: IPv4Network | IPv6Network) -> str:
102
+ """Render a network as an ip4:/ip6: mechanism; host routes drop the prefix."""
103
+ is_v6 = isinstance(network, IPv6Network)
104
+ label = "ip6" if is_v6 else "ip4"
105
+ host_prefixlen = 128 if is_v6 else 32
106
+ if network.prefixlen == host_prefixlen:
107
+ return f"{label}:{network.network_address}"
108
+ return f"{label}:{network}"
109
+
110
+
111
+ def _escape(s: str) -> str:
112
+ return s.replace("\\", "\\\\").replace('"', '\\"')
113
+
114
+
115
+ def _split(content: str) -> list[str]:
116
+ result = _try_split(content)
117
+ if result is None:
118
+ raise ValueError(f"content does not fit in {MAX_STRINGS_PER_RECORD} strings: {content!r}")
119
+ return result
120
+
121
+
122
+ def _try_split(content: str) -> list[str] | None:
123
+ """Split content into <= MAX_STRINGS_PER_RECORD strings, or None if it can't."""
124
+ if len(content) <= MAX_TXT_STRING:
125
+ return [content]
126
+ if len(content) > _MAX_RECORD_CHARS:
127
+ return None
128
+ split_at = None
129
+ for i, ch in enumerate(content):
130
+ if i > MAX_TXT_STRING:
131
+ break
132
+ if ch == " " and (len(content) - i) <= MAX_TXT_STRING:
133
+ split_at = i
134
+ if split_at is None:
135
+ return None
136
+ return [content[:split_at], content[split_at:]]
137
+
138
+
139
+ def _fits(tokens: list[str]) -> bool:
140
+ return _try_split(" ".join(tokens)) is not None
141
+
142
+
143
+ def _max_fit(remaining: list[str], suffix: str) -> int:
144
+ """Largest prefix of `remaining` that still fits alongside `suffix`."""
145
+ best = 0
146
+ for i in range(len(remaining) + 1):
147
+ if _fits(["v=spf1", *remaining[:i], suffix]):
148
+ best = i
149
+ else:
150
+ break
151
+ return best
spf53/cli.py ADDED
@@ -0,0 +1,131 @@
1
+ """Command-line interface for spf53: plan, apply, deploy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+
9
+ from spf53 import core, ssm
10
+ from spf53.config import Spf53Config, load_config_file
11
+
12
+
13
+ def main(argv: Sequence[str] | None = None) -> int:
14
+ parser = _build_parser()
15
+ args = parser.parse_args(argv)
16
+ return args.func(args)
17
+
18
+
19
+ def _build_parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(prog="spf53")
21
+ subparsers = parser.add_subparsers(dest="command", required=True)
22
+
23
+ plan_parser = subparsers.add_parser("plan", help="show pending SPF changes")
24
+ _add_config_args(plan_parser)
25
+ plan_parser.set_defaults(func=_cmd_plan)
26
+
27
+ apply_parser = subparsers.add_parser("apply", help="apply pending SPF changes")
28
+ _add_config_args(apply_parser)
29
+ apply_parser.add_argument(
30
+ "--force", action="store_true", help="apply changes even if a guard refuses"
31
+ )
32
+ apply_parser.set_defaults(func=_cmd_apply)
33
+
34
+ deploy_parser = subparsers.add_parser("deploy", help="deploy the spf53 Lambda")
35
+ deploy_parser.add_argument("-c", "--config", required=True, metavar="FILE")
36
+ deploy_parser.add_argument("--schedule", default="rate(1 hour)")
37
+ deploy_parser.add_argument("--create-topic", metavar="NAME")
38
+ deploy_parser.add_argument("--param-name", default=ssm.DEFAULT_PARAM)
39
+ deploy_parser.add_argument("--function-name", default="spf53")
40
+ deploy_parser.add_argument("--region")
41
+ deploy_parser.add_argument("--dry-run", action="store_true")
42
+ deploy_parser.set_defaults(func=_cmd_deploy)
43
+
44
+ return parser
45
+
46
+
47
+ def _add_config_args(parser: argparse.ArgumentParser) -> None:
48
+ group = parser.add_mutually_exclusive_group()
49
+ group.add_argument("-c", "--config", metavar="FILE", help="local YAML config file")
50
+ group.add_argument(
51
+ "--ssm-param",
52
+ metavar="NAME",
53
+ help=f"SSM parameter name (default: {ssm.DEFAULT_PARAM})",
54
+ )
55
+
56
+
57
+ def _load_config(args: argparse.Namespace) -> Spf53Config:
58
+ if args.config:
59
+ return load_config_file(args.config)
60
+ return ssm.load_config_ssm(args.ssm_param or ssm.DEFAULT_PARAM)
61
+
62
+
63
+ def _expected_apex_line(p: core.DomainPlan) -> str | None:
64
+ if not p.desired:
65
+ return None
66
+ last_name = f"_spf53-{len(p.desired)}.{p.domain}"
67
+ last_strings = p.desired.get(last_name)
68
+ if not last_strings:
69
+ return None
70
+ policy = "".join(last_strings).split()[-1]
71
+ return f"v=spf1 include:_spf53-1.{p.domain} {policy}"
72
+
73
+
74
+ def _cmd_plan(args: argparse.Namespace) -> int:
75
+ cfg = _load_config(args)
76
+ result = core.plan(cfg)
77
+
78
+ for p in result.plans:
79
+ print(f"== {p.domain} ==")
80
+ apex_line = _expected_apex_line(p)
81
+ print(f"expected apex record: {apex_line if apex_line else 'n/a'}")
82
+ cost_note = " (WARNING: exceeds 9-lookup limit)" if p.lookup_cost > 9 else ""
83
+ print(f"lookup cost: {p.lookup_cost}{cost_note}")
84
+ if p.apex_warning:
85
+ print(f"WARNING: {p.apex_warning}")
86
+ if not p.guard.ok:
87
+ print(f"guard: REFUSED - {'; '.join(p.guard.reasons)}")
88
+ if not p.has_changes:
89
+ print("changes: none")
90
+ else:
91
+ print(f"changes: {len(p.upserts)} upsert(s), {len(p.deletes)} delete(s)")
92
+ print(p.summary)
93
+ print()
94
+
95
+ for err in result.errors:
96
+ print(f"ERROR: {err}", file=sys.stderr)
97
+
98
+ if result.errors:
99
+ return 1
100
+ if any(p.has_changes for p in result.plans):
101
+ return 2
102
+ return 0
103
+
104
+
105
+ def _cmd_apply(args: argparse.Namespace) -> int:
106
+ cfg = _load_config(args)
107
+ result = core.apply(cfg, force=args.force)
108
+
109
+ refused: list[str] = []
110
+ for p in result.plans:
111
+ if not p.has_changes:
112
+ continue
113
+ if not p.guard.ok and not args.force:
114
+ refused.append(p.domain)
115
+ print(f"{p.domain}: refused - {'; '.join(p.guard.reasons)}")
116
+ else:
117
+ print(f"{p.domain}: applied")
118
+
119
+ for err in result.errors:
120
+ print(f"ERROR: {err}", file=sys.stderr)
121
+
122
+ if result.errors or refused:
123
+ return 1
124
+ return 0
125
+
126
+
127
+ def _cmd_deploy(args: argparse.Namespace) -> int:
128
+ from spf53 import deploy
129
+
130
+ result = deploy.run_deploy(args)
131
+ return result if isinstance(result, int) else 0
spf53/config.py ADDED
@@ -0,0 +1,153 @@
1
+ """Config schema and YAML parsing for spf53."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ _ALLOWED_TOP_KEYS = {"sns_topic_arn", "resolver_ips", "domains"}
11
+ _ALLOWED_DOMAIN_KEYS = {
12
+ "name",
13
+ "hosted_zone_id",
14
+ "includes",
15
+ "passthrough",
16
+ "policy",
17
+ "max_shrink_pct",
18
+ }
19
+ _VALID_POLICIES = ("~all", "-all")
20
+
21
+
22
+ class ConfigError(Exception):
23
+ """Raised when a config fails validation."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DomainConfig:
28
+ name: str
29
+ hosted_zone_id: str
30
+ includes: tuple[str, ...]
31
+ passthrough: tuple[str, ...] = ()
32
+ policy: str = "~all"
33
+ max_shrink_pct: int = 30
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Spf53Config:
38
+ domains: tuple[DomainConfig, ...]
39
+ sns_topic_arn: str | None = None
40
+ resolver_ips: tuple[str, ...] = ("1.1.1.1", "8.8.8.8")
41
+
42
+
43
+ def parse_config(yaml_text: str) -> Spf53Config:
44
+ try:
45
+ raw = yaml.safe_load(yaml_text)
46
+ except yaml.YAMLError as e:
47
+ raise ConfigError(f"invalid YAML: {e}") from e
48
+
49
+ if raw is None:
50
+ raise ConfigError("config is empty")
51
+ if not isinstance(raw, dict):
52
+ raise ConfigError(f"config must be a mapping, got {type(raw).__name__}")
53
+
54
+ unknown = set(raw) - _ALLOWED_TOP_KEYS
55
+ if unknown:
56
+ raise ConfigError(f"unknown top-level field(s): {', '.join(sorted(unknown))}")
57
+
58
+ if "domains" not in raw:
59
+ raise ConfigError("missing required field 'domains'")
60
+ domains_raw = raw["domains"]
61
+ if not isinstance(domains_raw, list) or not domains_raw:
62
+ raise ConfigError("'domains' must be a non-empty list")
63
+ domains = tuple(_parse_domain(i, d) for i, d in enumerate(domains_raw))
64
+
65
+ sns_topic_arn = raw.get("sns_topic_arn")
66
+ if sns_topic_arn is not None and not isinstance(sns_topic_arn, str):
67
+ raise ConfigError("'sns_topic_arn' must be a string")
68
+
69
+ if "resolver_ips" in raw:
70
+ resolver_ips_raw = raw["resolver_ips"]
71
+ if not isinstance(resolver_ips_raw, list) or not all(
72
+ isinstance(x, str) for x in resolver_ips_raw
73
+ ):
74
+ raise ConfigError("'resolver_ips' must be a list of strings")
75
+ if not resolver_ips_raw:
76
+ raise ConfigError("'resolver_ips' must not be empty")
77
+ resolver_ips = tuple(resolver_ips_raw)
78
+ else:
79
+ resolver_ips = ("1.1.1.1", "8.8.8.8")
80
+
81
+ return Spf53Config(domains=domains, sns_topic_arn=sns_topic_arn, resolver_ips=resolver_ips)
82
+
83
+
84
+ def _parse_domain(index: int, raw: object) -> DomainConfig:
85
+ label = f"domains[{index}]"
86
+ if not isinstance(raw, dict):
87
+ raise ConfigError(f"{label}: must be a mapping, got {type(raw).__name__}")
88
+
89
+ unknown = set(raw) - _ALLOWED_DOMAIN_KEYS
90
+ if unknown:
91
+ raise ConfigError(f"{label}: unknown field(s): {', '.join(sorted(unknown))}")
92
+
93
+ if "name" not in raw:
94
+ raise ConfigError(f"{label}: missing required field 'name'")
95
+ name = raw["name"]
96
+ if not isinstance(name, str) or not name:
97
+ raise ConfigError(f"{label}: 'name' must be a non-empty string")
98
+ # Normalize so config names match what Route53 returns (always lowercase,
99
+ # unqualified) — otherwise every diff looks like a change and the shrink
100
+ # guard never sees a matching live record.
101
+ name = name.lower()
102
+ if name.endswith("."):
103
+ name = name[:-1]
104
+ if not name:
105
+ raise ConfigError(f"{label}: 'name' must be a non-empty string")
106
+ label = f"domain '{name}'"
107
+
108
+ if "hosted_zone_id" not in raw:
109
+ raise ConfigError(f"{label}: missing required field 'hosted_zone_id'")
110
+ hosted_zone_id = raw["hosted_zone_id"]
111
+ if not isinstance(hosted_zone_id, str) or not hosted_zone_id:
112
+ raise ConfigError(f"{label}: 'hosted_zone_id' must be a non-empty string")
113
+
114
+ if "includes" not in raw:
115
+ raise ConfigError(f"{label}: missing required field 'includes'")
116
+ includes_raw = raw["includes"]
117
+ if not isinstance(includes_raw, list) or not all(isinstance(x, str) for x in includes_raw):
118
+ raise ConfigError(f"{label}: 'includes' must be a list of strings")
119
+ includes = tuple(includes_raw)
120
+
121
+ passthrough_raw = raw.get("passthrough", [])
122
+ if not isinstance(passthrough_raw, list) or not all(
123
+ isinstance(x, str) for x in passthrough_raw
124
+ ):
125
+ raise ConfigError(f"{label}: 'passthrough' must be a list of strings")
126
+ passthrough = tuple(passthrough_raw)
127
+
128
+ policy = raw.get("policy", "~all")
129
+ if policy not in _VALID_POLICIES:
130
+ raise ConfigError(f"{label}: 'policy' must be '~all' or '-all', got {policy!r}")
131
+
132
+ max_shrink_pct = raw.get("max_shrink_pct", 30)
133
+ if (
134
+ isinstance(max_shrink_pct, bool)
135
+ or not isinstance(max_shrink_pct, int)
136
+ or not 0 <= max_shrink_pct <= 100
137
+ ):
138
+ raise ConfigError(
139
+ f"{label}: 'max_shrink_pct' must be an int between 0 and 100, got {max_shrink_pct!r}"
140
+ )
141
+
142
+ return DomainConfig(
143
+ name=name,
144
+ hosted_zone_id=hosted_zone_id,
145
+ includes=includes,
146
+ passthrough=passthrough,
147
+ policy=policy,
148
+ max_shrink_pct=max_shrink_pct,
149
+ )
150
+
151
+
152
+ def load_config_file(path: str | Path) -> Spf53Config:
153
+ return parse_config(Path(path).read_text())
spf53/core.py ADDED
@@ -0,0 +1,189 @@
1
+ """Orchestration: resolve, chunk, diff against live DNS, guard-check, apply, notify."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ from collections.abc import Iterable, Sequence
7
+ from dataclasses import dataclass, field
8
+
9
+ from botocore.exceptions import ClientError
10
+
11
+ from spf53 import chunker, guards, notify, resolver, route53
12
+ from spf53.config import Spf53Config
13
+ from spf53.guards import GuardResult
14
+ from spf53.resolver import ResolutionError
15
+
16
+ IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network
17
+
18
+ _SUMMARY_LIST_CAP = 20
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class DomainPlan:
23
+ domain: str
24
+ zone_id: str
25
+ desired: dict[str, list[str]]
26
+ live: dict[str, list[str]]
27
+ upserts: dict[str, list[str]]
28
+ deletes: dict[str, list[str]]
29
+ guard: GuardResult
30
+ lookup_cost: int
31
+ apex_warning: str | None
32
+ summary: str
33
+ delete_ttls: dict[str, int] = field(default_factory=dict)
34
+
35
+ @property
36
+ def has_changes(self) -> bool:
37
+ return bool(self.upserts or self.deletes)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RunResult:
42
+ plans: tuple[DomainPlan, ...]
43
+ errors: tuple[str, ...]
44
+
45
+
46
+ def plan(cfg: Spf53Config) -> RunResult:
47
+ plans: list[DomainPlan] = []
48
+ errors: list[str] = []
49
+
50
+ for dc in cfg.domains:
51
+ try:
52
+ networks = resolver.flatten(dc.includes, cfg.resolver_ips)
53
+ except ResolutionError as exc:
54
+ errors.append(str(exc))
55
+ continue
56
+
57
+ desired = chunker.build_records(dc.name, networks, dc.passthrough, dc.policy)
58
+ live_all, live_ttls = route53.get_txt_records(dc.hosted_zone_id, dc.name)
59
+ live = {name: strings for name, strings in live_all.items() if name != dc.name}
60
+ apex_warning = _apex_warning(dc.name, live_all.get(dc.name))
61
+
62
+ upserts = {name: strings for name, strings in desired.items() if live.get(name) != strings}
63
+ deletes = {name: strings for name, strings in live.items() if name not in desired}
64
+ delete_ttls = {name: live_ttls[name] for name in deletes if name in live_ttls}
65
+
66
+ try:
67
+ live_networks = _networks_from_records(live)
68
+ except ValueError as exc:
69
+ errors.append(f"{dc.name}: {exc}")
70
+ continue
71
+ guard = guards.check_guards(live_networks, networks, dc.max_shrink_pct)
72
+
73
+ plans.append(
74
+ DomainPlan(
75
+ domain=dc.name,
76
+ zone_id=dc.hosted_zone_id,
77
+ desired=desired,
78
+ live=live,
79
+ upserts=upserts,
80
+ deletes=deletes,
81
+ guard=guard,
82
+ lookup_cost=chunker.lookup_cost(desired, dc.passthrough),
83
+ apex_warning=apex_warning,
84
+ summary=_build_summary(dc.name, live_networks, networks),
85
+ delete_ttls=delete_ttls,
86
+ )
87
+ )
88
+
89
+ return RunResult(plans=tuple(plans), errors=tuple(errors))
90
+
91
+
92
+ def apply(cfg: Spf53Config, force: bool = False) -> RunResult:
93
+ result = plan(cfg)
94
+ errors = list(result.errors)
95
+
96
+ for p in result.plans:
97
+ if not p.has_changes:
98
+ continue
99
+ if not p.guard.ok and not force:
100
+ _notify_refusal(cfg.sns_topic_arn, p)
101
+ continue
102
+ try:
103
+ route53.apply_changes(p.zone_id, p.upserts, p.deletes, delete_ttls=p.delete_ttls)
104
+ except ClientError as exc:
105
+ msg = f"{p.domain}: failed to apply Route53 changes: {exc}"
106
+ errors.append(msg)
107
+ notify.publish(cfg.sns_topic_arn, f"spf53: failed to apply changes for {p.domain}", msg)
108
+ continue
109
+ _notify_success(cfg.sns_topic_arn, p)
110
+
111
+ for err in result.errors:
112
+ notify.publish(cfg.sns_topic_arn, "spf53: SPF resolution failed", err)
113
+
114
+ return RunResult(plans=result.plans, errors=tuple(errors))
115
+
116
+
117
+ def _apex_warning(domain: str, apex_record: list[str] | None) -> str | None:
118
+ expected = f"include:_spf53-1.{domain}"
119
+ if apex_record is None:
120
+ return (
121
+ f"no apex TXT record found for {domain} — create one: "
122
+ f"v=spf1 include:_spf53-1.{domain} ~all"
123
+ )
124
+ if expected in "".join(apex_record):
125
+ return None
126
+ return (
127
+ f"live apex TXT record for {domain} does not contain '{expected}' — "
128
+ f"the apex should read: v=spf1 include:_spf53-1.{domain} <policy>"
129
+ )
130
+
131
+
132
+ def _networks_from_records(records: dict[str, list[str]]) -> list[IPNetwork]:
133
+ networks: list[IPNetwork] = []
134
+ for name, strings in records.items():
135
+ for token in "".join(strings).split():
136
+ for prefix in ("ip4:", "ip6:"):
137
+ if token.startswith(prefix):
138
+ try:
139
+ networks.append(ipaddress.ip_network(token[len(prefix) :], strict=False))
140
+ except ValueError as exc:
141
+ raise ValueError(
142
+ f"invalid {prefix} token {token!r} in live record {name!r}: {exc}"
143
+ ) from exc
144
+ break
145
+ return networks
146
+
147
+
148
+ def _sort_key(network: IPNetwork) -> tuple[int, int, int]:
149
+ return (network.version, int(network.network_address), network.prefixlen)
150
+
151
+
152
+ def _format_cidrs(networks: Iterable[IPNetwork]) -> str:
153
+ ordered = sorted(networks, key=_sort_key)
154
+ if not ordered:
155
+ return "none"
156
+ shown = [str(n) for n in ordered[:_SUMMARY_LIST_CAP]]
157
+ if len(ordered) > _SUMMARY_LIST_CAP:
158
+ shown.append(f"+{len(ordered) - _SUMMARY_LIST_CAP} more")
159
+ return ", ".join(shown)
160
+
161
+
162
+ def _build_summary(
163
+ domain: str,
164
+ live_networks: Sequence[IPNetwork],
165
+ new_networks: Sequence[IPNetwork],
166
+ ) -> str:
167
+ live_set = set(live_networks)
168
+ new_set = set(new_networks)
169
+ added = new_set - live_set
170
+ removed = live_set - new_set
171
+ return (
172
+ f"{domain}: {len(added)} CIDR(s) added, {len(removed)} CIDR(s) removed\n"
173
+ f" added: {_format_cidrs(added)}\n"
174
+ f" removed: {_format_cidrs(removed)}"
175
+ )
176
+
177
+
178
+ def _notify_refusal(topic_arn: str | None, p: DomainPlan) -> None:
179
+ reasons = "; ".join(p.guard.reasons)
180
+ notify.publish(
181
+ topic_arn,
182
+ f"spf53: refused to apply changes for {p.domain}",
183
+ f"spf53 refused to publish new SPF records for {p.domain} because the "
184
+ f"safety guard failed: {reasons}\n\n{p.summary}",
185
+ )
186
+
187
+
188
+ def _notify_success(topic_arn: str | None, p: DomainPlan) -> None:
189
+ notify.publish(topic_arn, f"spf53: applied SPF changes for {p.domain}", p.summary)