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 +3 -0
- spf53/chunker.py +151 -0
- spf53/cli.py +131 -0
- spf53/config.py +153 -0
- spf53/core.py +189 -0
- spf53/deploy.py +343 -0
- spf53/guards.py +39 -0
- spf53/lambda_handler.py +24 -0
- spf53/notify.py +31 -0
- spf53/resolver.py +268 -0
- spf53/route53.py +91 -0
- spf53/ssm.py +21 -0
- spf53-0.1.0.dist-info/METADATA +270 -0
- spf53-0.1.0.dist-info/RECORD +17 -0
- spf53-0.1.0.dist-info/WHEEL +4 -0
- spf53-0.1.0.dist-info/entry_points.txt +2 -0
- spf53-0.1.0.dist-info/licenses/LICENSE +21 -0
spf53/__init__.py
ADDED
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)
|