spf53 0.1.0__tar.gz
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-0.1.0/.github/dependabot.yml +13 -0
- spf53-0.1.0/.github/workflows/ci.yml +32 -0
- spf53-0.1.0/.github/workflows/release.yml +46 -0
- spf53-0.1.0/.gitignore +11 -0
- spf53-0.1.0/LICENSE +21 -0
- spf53-0.1.0/PKG-INFO +270 -0
- spf53-0.1.0/README.md +242 -0
- spf53-0.1.0/docs/superpowers/specs/2026-07-20-spf53-design.md +316 -0
- spf53-0.1.0/pyproject.toml +55 -0
- spf53-0.1.0/src/spf53/__init__.py +3 -0
- spf53-0.1.0/src/spf53/chunker.py +151 -0
- spf53-0.1.0/src/spf53/cli.py +131 -0
- spf53-0.1.0/src/spf53/config.py +153 -0
- spf53-0.1.0/src/spf53/core.py +189 -0
- spf53-0.1.0/src/spf53/deploy.py +343 -0
- spf53-0.1.0/src/spf53/guards.py +39 -0
- spf53-0.1.0/src/spf53/lambda_handler.py +24 -0
- spf53-0.1.0/src/spf53/notify.py +31 -0
- spf53-0.1.0/src/spf53/resolver.py +268 -0
- spf53-0.1.0/src/spf53/route53.py +91 -0
- spf53-0.1.0/src/spf53/ssm.py +21 -0
- spf53-0.1.0/tests/test_chunker.py +144 -0
- spf53-0.1.0/tests/test_cli.py +124 -0
- spf53-0.1.0/tests/test_config.py +278 -0
- spf53-0.1.0/tests/test_core.py +320 -0
- spf53-0.1.0/tests/test_deploy.py +329 -0
- spf53-0.1.0/tests/test_guards.py +61 -0
- spf53-0.1.0/tests/test_notify.py +77 -0
- spf53-0.1.0/tests/test_resolver.py +366 -0
- spf53-0.1.0/tests/test_route53.py +300 -0
- spf53-0.1.0/tests/test_ssm.py +92 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
18
|
+
uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
|
|
22
|
+
- name: Install spf53 with dev dependencies
|
|
23
|
+
run: pip install -e .[dev]
|
|
24
|
+
|
|
25
|
+
- name: ruff check
|
|
26
|
+
run: ruff check .
|
|
27
|
+
|
|
28
|
+
- name: ruff format --check
|
|
29
|
+
run: ruff format --check .
|
|
30
|
+
|
|
31
|
+
- name: pytest
|
|
32
|
+
run: pytest
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
|
|
13
|
+
- name: Set up Python
|
|
14
|
+
uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.13"
|
|
17
|
+
|
|
18
|
+
- name: Install build tooling
|
|
19
|
+
run: pip install build
|
|
20
|
+
|
|
21
|
+
- name: Build sdist and wheel
|
|
22
|
+
run: python -m build
|
|
23
|
+
|
|
24
|
+
- name: Upload dist artifacts
|
|
25
|
+
uses: actions/upload-artifact@v4
|
|
26
|
+
with:
|
|
27
|
+
name: dist
|
|
28
|
+
path: dist/
|
|
29
|
+
|
|
30
|
+
publish:
|
|
31
|
+
needs: build
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
environment:
|
|
34
|
+
name: pypi
|
|
35
|
+
url: https://pypi.org/project/spf53/
|
|
36
|
+
permissions:
|
|
37
|
+
id-token: write
|
|
38
|
+
steps:
|
|
39
|
+
- name: Download dist artifacts
|
|
40
|
+
uses: actions/download-artifact@v4
|
|
41
|
+
with:
|
|
42
|
+
name: dist
|
|
43
|
+
path: dist/
|
|
44
|
+
|
|
45
|
+
- name: Publish to PyPI
|
|
46
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
spf53-0.1.0/.gitignore
ADDED
spf53-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hannah Lyons
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
spf53-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spf53
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-hosted SPF flattener for Route53 — reapply your SPF automatically
|
|
5
|
+
Project-URL: Homepage, https://github.com/babyhuey/spf53
|
|
6
|
+
Project-URL: Issues, https://github.com/babyhuey/spf53/issues
|
|
7
|
+
Author: Hannah Lyons
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: dmarc,dns,email,flattening,route53,spf
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Communications :: Email
|
|
18
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: boto3>=1.34
|
|
21
|
+
Requires-Dist: dnspython>=2.6
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: moto[awslambda,events,iam,route53,sns,ssm]>=5.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# spf53
|
|
30
|
+
|
|
31
|
+
**spf53 — reapply your SPF automatically.**
|
|
32
|
+
|
|
33
|
+
A small, open-source, self-hosted SPF flattener for Amazon Route53. It
|
|
34
|
+
resolves your providers' `include:` records, flattens them into `ip4`/`ip6`
|
|
35
|
+
mechanisms, and republishes them as a chain of TXT records on a schedule —
|
|
36
|
+
so your SPF record never silently drifts past the RFC 7208 10-DNS-lookup
|
|
37
|
+
limit.
|
|
38
|
+
|
|
39
|
+
## What and why
|
|
40
|
+
|
|
41
|
+
SPF records have a hard limit: mail receivers stop evaluating after 10 DNS
|
|
42
|
+
lookups (`include:`, `a`, `mx`, `ptr`, `exists:`, plus nested lookups inside
|
|
43
|
+
those). Once you add a handful of providers — Google Workspace, a marketing
|
|
44
|
+
platform, a transactional-email service, Salesforce — it's easy to blow past
|
|
45
|
+
that limit, and the failure mode is silent: some receivers just stop
|
|
46
|
+
validating your mail as authenticated.
|
|
47
|
+
|
|
48
|
+
The standard fix is "flattening": resolve every `include:` down to the
|
|
49
|
+
concrete IP ranges behind it, and publish those instead. The catch is that
|
|
50
|
+
providers' IP ranges *change*. A one-time flattened record goes stale the
|
|
51
|
+
moment a provider rotates infrastructure — this is exactly why
|
|
52
|
+
[dmarcian discontinued their SPF flattening product](https://dmarcian.com/):
|
|
53
|
+
static flattening without ongoing maintenance is a liability, not a fix.
|
|
54
|
+
Hosted flattening services solve the staleness problem, but they add a paid
|
|
55
|
+
third-party dependency sitting in your mail-authentication path.
|
|
56
|
+
|
|
57
|
+
spf53 is the middle ground: a small tool you run yourself, on a schedule,
|
|
58
|
+
that re-flattens automatically, refuses to publish a change that looks
|
|
59
|
+
dangerous, and tells you when something needs attention.
|
|
60
|
+
|
|
61
|
+
Every run (every hour by default): resolve providers → flatten to IPs →
|
|
62
|
+
diff against what's live in Route53 → guard-check the diff → apply as one
|
|
63
|
+
atomic change → notify over SNS. No diff, no writes, no noise.
|
|
64
|
+
|
|
65
|
+
## How it works
|
|
66
|
+
|
|
67
|
+
spf53 never touches your domain's apex TXT record — that's where site
|
|
68
|
+
verification records and other unrelated TXT data usually live, and it's
|
|
69
|
+
where *you* keep control of the top-level SPF policy. Instead, spf53 owns a
|
|
70
|
+
chain of records under `_spf53-1`, `_spf53-2`, … and you point your apex at
|
|
71
|
+
the first one, once, by hand:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
example.com TXT "v=spf1 include:_spf53-1.example.com ~all"
|
|
75
|
+
^ you set this once; spf53 never touches it
|
|
76
|
+
|
|
77
|
+
_spf53-1.example.com TXT "v=spf1 exists:%{i}._spf.mta.salesforce.com
|
|
78
|
+
ip4:203.0.113.0/24 include:_spf53-2.example.com"
|
|
79
|
+
|
|
80
|
+
_spf53-2.example.com TXT "v=spf1 ip4:198.51.100.0/24 ip6:2001:db8::/32 ~all"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Passthrough mechanisms (things spf53 can't or shouldn't flatten, like
|
|
84
|
+
macro-based `exists:` rules) go verbatim at the front of the first chunk.
|
|
85
|
+
Every chunk after that is packed with `ip4`/`ip6` ranges up to the TXT
|
|
86
|
+
record size limit; every chunk except the last ends with
|
|
87
|
+
`include:_spf53-N+1.<domain>`, and the last ends with your policy (`~all`
|
|
88
|
+
or `-all`). If a provider shrinks and the chain gets shorter, the now-unused
|
|
89
|
+
trailing `_spf53-N` records are deleted in the same change batch — nothing
|
|
90
|
+
is left dangling.
|
|
91
|
+
|
|
92
|
+
`spf53 plan` always prints the apex record it expects to see and warns
|
|
93
|
+
(never errors) if your live apex doesn't reference `_spf53-1.<domain>`.
|
|
94
|
+
|
|
95
|
+
## Quickstart
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install spf53
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Write a config file:
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
# spf53.yaml
|
|
105
|
+
sns_topic_arn: arn:aws:sns:us-east-1:123456789012:spf53-alerts # optional
|
|
106
|
+
resolver_ips: ["1.1.1.1", "8.8.8.8"] # optional, defaults shown
|
|
107
|
+
|
|
108
|
+
domains:
|
|
109
|
+
- name: example.com
|
|
110
|
+
hosted_zone_id: Z123EXAMPLE
|
|
111
|
+
policy: "~all"
|
|
112
|
+
max_shrink_pct: 30
|
|
113
|
+
passthrough:
|
|
114
|
+
- "exists:%{i}._spf.mta.salesforce.com"
|
|
115
|
+
includes:
|
|
116
|
+
- _spf.google.com
|
|
117
|
+
- amazonses.com
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
See what spf53 would publish, with no AWS changes:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
spf53 plan -c spf53.yaml
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Bootstrap the scheduled Lambda (SNS topic, SSM config, IAM role, Lambda
|
|
127
|
+
function, EventBridge schedule — all created or updated idempotently):
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
spf53 deploy -c spf53.yaml --create-topic spf53-alerts
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
From then on, spf53 runs itself every hour. To trigger a one-off run
|
|
134
|
+
against the config already deployed to SSM:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
spf53 apply
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Config reference
|
|
141
|
+
|
|
142
|
+
The config is a single YAML document, either a local file (`-c/--config`)
|
|
143
|
+
or the value of an SSM parameter (`--ssm-param`, default `/spf53/config`).
|
|
144
|
+
|
|
145
|
+
| Key | Required | Default | Description |
|
|
146
|
+
|---|---|---|---|
|
|
147
|
+
| `domains` | yes | — | List of domain blocks (see below). |
|
|
148
|
+
| `sns_topic_arn` | no | none | Where alerts are published. No alerts if absent. |
|
|
149
|
+
| `resolver_ips` | no | `["1.1.1.1", "8.8.8.8"]` | DNS resolvers used for flattening, queried alternately with retries. |
|
|
150
|
+
|
|
151
|
+
Each entry in `domains`:
|
|
152
|
+
|
|
153
|
+
| Key | Required | Default | Description |
|
|
154
|
+
|---|---|---|---|
|
|
155
|
+
| `name` | yes | — | The domain whose SPF is being flattened. |
|
|
156
|
+
| `hosted_zone_id` | yes | — | Route53 hosted zone ID for this domain. No zone auto-discovery. |
|
|
157
|
+
| `includes` | yes | — | Provider `include:` targets to recursively resolve and flatten. |
|
|
158
|
+
| `passthrough` | no | `[]` | Mechanisms copied verbatim, never flattened, placed first in chunk 1. |
|
|
159
|
+
| `policy` | no | `"~all"` | SPF policy for the last chunk; must be `"~all"` or `"-all"`. |
|
|
160
|
+
| `max_shrink_pct` | no | `30` | Guard threshold — see below. |
|
|
161
|
+
|
|
162
|
+
## Safety guards
|
|
163
|
+
|
|
164
|
+
spf53 will refuse to publish (and sends an SNS alert instead) when:
|
|
165
|
+
|
|
166
|
+
- **The newly resolved set is empty.** An empty result almost always means
|
|
167
|
+
a resolution problem, not a legitimate zero-IP provider.
|
|
168
|
+
- **The address count shrank by more than `max_shrink_pct`** compared to
|
|
169
|
+
what's currently live. A shrink at or under the threshold is allowed
|
|
170
|
+
through; anything larger is treated as a signal that something's wrong
|
|
171
|
+
(a provider outage, a resolver problem) rather than an intended change.
|
|
172
|
+
On the very first run, there's no live baseline, so this check passes as
|
|
173
|
+
long as the new set is non-empty.
|
|
174
|
+
- **Resolution fails outright** (NXDOMAIN, timeout, missing SPF record, a
|
|
175
|
+
nesting depth over 10 `include:`s deep) for a domain. That domain is
|
|
176
|
+
skipped and alerted on; other domains in the same config still run.
|
|
177
|
+
|
|
178
|
+
`spf53 apply --force` overrides a guard refusal for that run. Guard
|
|
179
|
+
refusals and resolution errors always trigger an SNS notification when a
|
|
180
|
+
topic is configured.
|
|
181
|
+
|
|
182
|
+
spf53 also warns (in `plan` output and in SNS messages, never as a hard
|
|
183
|
+
error) when the total SPF lookup cost — chain length, plus 1 for the apex
|
|
184
|
+
`include:`, plus any DNS-querying passthrough mechanisms (`exists:`,
|
|
185
|
+
`include:`, `a`, `mx`, `ptr`) — exceeds 9, since that's one lookup away from
|
|
186
|
+
the RFC 7208 limit.
|
|
187
|
+
|
|
188
|
+
## CLI reference
|
|
189
|
+
|
|
190
|
+
| Command | Flags | Behavior |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| `spf53 plan` | `[-c FILE \| --ssm-param NAME]` | Prints the per-domain diff, lookup cost, and expected apex record. Exit `0` if nothing would change, `2` if changes are pending, `1` on error. |
|
|
193
|
+
| `spf53 apply` | `[-c FILE \| --ssm-param NAME] [--force]` | Applies the flattened records. Exit `0` on success (including no-op), `1` on error or guard refusal. |
|
|
194
|
+
| `spf53 deploy` | `-c FILE [--schedule "rate(1 hour)"] [--create-topic NAME] [--param-name NAME] [--function-name spf53] [--region REGION] [--dry-run]` | Idempotently bootstraps the SNS topic, SSM config, IAM role, Lambda function, and EventBridge schedule. `--dry-run` prints the planned actions without making any AWS calls. |
|
|
195
|
+
|
|
196
|
+
If neither `-c/--config` nor `--ssm-param` is given, `plan` and `apply`
|
|
197
|
+
read from SSM parameter `/spf53/config`.
|
|
198
|
+
|
|
199
|
+
## IAM permissions
|
|
200
|
+
|
|
201
|
+
Two separate sets of permissions are involved:
|
|
202
|
+
|
|
203
|
+
**The credentials you run `spf53 deploy` with** need enough access to
|
|
204
|
+
create/update the pieces spf53 manages:
|
|
205
|
+
`iam:CreateRole`, `iam:GetRole`, `iam:UpdateAssumeRolePolicy`,
|
|
206
|
+
`iam:PutRolePolicy`, `iam:PassRole` (for the `spf53-lambda` role),
|
|
207
|
+
`lambda:GetFunction`, `lambda:CreateFunction`, `lambda:UpdateFunctionCode`,
|
|
208
|
+
`lambda:UpdateFunctionConfiguration`, `lambda:AddPermission`,
|
|
209
|
+
`events:PutRule`, `events:PutTargets`, `ssm:PutParameter`,
|
|
210
|
+
`sns:CreateTopic` (only if using `--create-topic`), and
|
|
211
|
+
`sts:GetCallerIdentity`.
|
|
212
|
+
|
|
213
|
+
**The Lambda's own execution role** (`spf53-lambda`, created by `deploy`) is
|
|
214
|
+
scoped tightly to what the running tool actually needs — nothing more:
|
|
215
|
+
|
|
216
|
+
- `route53:ChangeResourceRecordSets` and `route53:ListResourceRecordSets`,
|
|
217
|
+
scoped to the hosted zone(s) named in your config.
|
|
218
|
+
- `ssm:GetParameter`, scoped to the config parameter.
|
|
219
|
+
- `sns:Publish`, scoped to the configured alert topic (if any).
|
|
220
|
+
- `logs:CreateLogGroup`, `logs:CreateLogStream`, `logs:PutLogEvents` for its
|
|
221
|
+
own CloudWatch log group.
|
|
222
|
+
|
|
223
|
+
## FAQ
|
|
224
|
+
|
|
225
|
+
**Why not just use SPF macros instead of flattening?**
|
|
226
|
+
Macros (`%{i}`, `exists:` tricks) aren't universally implemented by mail
|
|
227
|
+
receivers, and they don't solve the underlying problem: an `include:` still
|
|
228
|
+
costs a lookup no matter how it's expressed. spf53 supports macro-based
|
|
229
|
+
mechanisms you can't flatten (e.g. Salesforce's
|
|
230
|
+
`exists:%{i}._spf.mta.salesforce.com`) via `passthrough`, copied through
|
|
231
|
+
verbatim.
|
|
232
|
+
|
|
233
|
+
**Why isn't boto3 bundled in the Lambda deployment package?**
|
|
234
|
+
The Lambda Python runtime already ships boto3, so bundling it again would
|
|
235
|
+
just bloat the deployment zip for no benefit. `spf53 deploy` only packages
|
|
236
|
+
`dnspython`, `pyyaml`, and the spf53 package itself, pinned to the pure-Python
|
|
237
|
+
wheel versions installed in the environment you're deploying from — so the
|
|
238
|
+
zip matches what you tested, regardless of the Lambda runtime's own Python
|
|
239
|
+
version.
|
|
240
|
+
|
|
241
|
+
**How do I add or remove a provider?**
|
|
242
|
+
Edit the `includes` (or `passthrough`) list in your config and re-apply —
|
|
243
|
+
either `spf53 deploy -c spf53.yaml` again (which pushes the updated config
|
|
244
|
+
to SSM as part of its bootstrap) or push the file and run `spf53 apply -c`
|
|
245
|
+
directly. The next scheduled Lambda run will also pick up whatever is
|
|
246
|
+
currently in SSM.
|
|
247
|
+
|
|
248
|
+
**What happens when a provider changes its IPs?**
|
|
249
|
+
Nothing you have to do. On the next scheduled run, spf53 re-resolves the
|
|
250
|
+
provider's records, gets the new IPs, diffs them against what's live, and —
|
|
251
|
+
provided the change passes the safety guards — publishes the update and
|
|
252
|
+
sends an SNS notification. If the change looks like a problem (e.g. the
|
|
253
|
+
provider's records suddenly resolve to almost nothing), spf53 refuses and
|
|
254
|
+
alerts instead of publishing.
|
|
255
|
+
|
|
256
|
+
## Releasing
|
|
257
|
+
|
|
258
|
+
Releases publish to PyPI automatically via GitHub Actions
|
|
259
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC —
|
|
260
|
+
no stored API tokens). To cut a release:
|
|
261
|
+
|
|
262
|
+
1. Bump `version` in `pyproject.toml` and commit.
|
|
263
|
+
2. Tag it (`git tag vX.Y.Z && git push origin vX.Y.Z`) and publish a
|
|
264
|
+
[GitHub Release](https://github.com/babyhuey/spf53/releases) from that tag.
|
|
265
|
+
3. The `Release` workflow builds the sdist/wheel and publishes to PyPI —
|
|
266
|
+
watch the [Actions tab](https://github.com/babyhuey/spf53/actions) for status.
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
MIT — see [LICENSE](LICENSE).
|
spf53-0.1.0/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# spf53
|
|
2
|
+
|
|
3
|
+
**spf53 — reapply your SPF automatically.**
|
|
4
|
+
|
|
5
|
+
A small, open-source, self-hosted SPF flattener for Amazon Route53. It
|
|
6
|
+
resolves your providers' `include:` records, flattens them into `ip4`/`ip6`
|
|
7
|
+
mechanisms, and republishes them as a chain of TXT records on a schedule —
|
|
8
|
+
so your SPF record never silently drifts past the RFC 7208 10-DNS-lookup
|
|
9
|
+
limit.
|
|
10
|
+
|
|
11
|
+
## What and why
|
|
12
|
+
|
|
13
|
+
SPF records have a hard limit: mail receivers stop evaluating after 10 DNS
|
|
14
|
+
lookups (`include:`, `a`, `mx`, `ptr`, `exists:`, plus nested lookups inside
|
|
15
|
+
those). Once you add a handful of providers — Google Workspace, a marketing
|
|
16
|
+
platform, a transactional-email service, Salesforce — it's easy to blow past
|
|
17
|
+
that limit, and the failure mode is silent: some receivers just stop
|
|
18
|
+
validating your mail as authenticated.
|
|
19
|
+
|
|
20
|
+
The standard fix is "flattening": resolve every `include:` down to the
|
|
21
|
+
concrete IP ranges behind it, and publish those instead. The catch is that
|
|
22
|
+
providers' IP ranges *change*. A one-time flattened record goes stale the
|
|
23
|
+
moment a provider rotates infrastructure — this is exactly why
|
|
24
|
+
[dmarcian discontinued their SPF flattening product](https://dmarcian.com/):
|
|
25
|
+
static flattening without ongoing maintenance is a liability, not a fix.
|
|
26
|
+
Hosted flattening services solve the staleness problem, but they add a paid
|
|
27
|
+
third-party dependency sitting in your mail-authentication path.
|
|
28
|
+
|
|
29
|
+
spf53 is the middle ground: a small tool you run yourself, on a schedule,
|
|
30
|
+
that re-flattens automatically, refuses to publish a change that looks
|
|
31
|
+
dangerous, and tells you when something needs attention.
|
|
32
|
+
|
|
33
|
+
Every run (every hour by default): resolve providers → flatten to IPs →
|
|
34
|
+
diff against what's live in Route53 → guard-check the diff → apply as one
|
|
35
|
+
atomic change → notify over SNS. No diff, no writes, no noise.
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
spf53 never touches your domain's apex TXT record — that's where site
|
|
40
|
+
verification records and other unrelated TXT data usually live, and it's
|
|
41
|
+
where *you* keep control of the top-level SPF policy. Instead, spf53 owns a
|
|
42
|
+
chain of records under `_spf53-1`, `_spf53-2`, … and you point your apex at
|
|
43
|
+
the first one, once, by hand:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
example.com TXT "v=spf1 include:_spf53-1.example.com ~all"
|
|
47
|
+
^ you set this once; spf53 never touches it
|
|
48
|
+
|
|
49
|
+
_spf53-1.example.com TXT "v=spf1 exists:%{i}._spf.mta.salesforce.com
|
|
50
|
+
ip4:203.0.113.0/24 include:_spf53-2.example.com"
|
|
51
|
+
|
|
52
|
+
_spf53-2.example.com TXT "v=spf1 ip4:198.51.100.0/24 ip6:2001:db8::/32 ~all"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Passthrough mechanisms (things spf53 can't or shouldn't flatten, like
|
|
56
|
+
macro-based `exists:` rules) go verbatim at the front of the first chunk.
|
|
57
|
+
Every chunk after that is packed with `ip4`/`ip6` ranges up to the TXT
|
|
58
|
+
record size limit; every chunk except the last ends with
|
|
59
|
+
`include:_spf53-N+1.<domain>`, and the last ends with your policy (`~all`
|
|
60
|
+
or `-all`). If a provider shrinks and the chain gets shorter, the now-unused
|
|
61
|
+
trailing `_spf53-N` records are deleted in the same change batch — nothing
|
|
62
|
+
is left dangling.
|
|
63
|
+
|
|
64
|
+
`spf53 plan` always prints the apex record it expects to see and warns
|
|
65
|
+
(never errors) if your live apex doesn't reference `_spf53-1.<domain>`.
|
|
66
|
+
|
|
67
|
+
## Quickstart
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install spf53
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Write a config file:
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
# spf53.yaml
|
|
77
|
+
sns_topic_arn: arn:aws:sns:us-east-1:123456789012:spf53-alerts # optional
|
|
78
|
+
resolver_ips: ["1.1.1.1", "8.8.8.8"] # optional, defaults shown
|
|
79
|
+
|
|
80
|
+
domains:
|
|
81
|
+
- name: example.com
|
|
82
|
+
hosted_zone_id: Z123EXAMPLE
|
|
83
|
+
policy: "~all"
|
|
84
|
+
max_shrink_pct: 30
|
|
85
|
+
passthrough:
|
|
86
|
+
- "exists:%{i}._spf.mta.salesforce.com"
|
|
87
|
+
includes:
|
|
88
|
+
- _spf.google.com
|
|
89
|
+
- amazonses.com
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
See what spf53 would publish, with no AWS changes:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
spf53 plan -c spf53.yaml
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Bootstrap the scheduled Lambda (SNS topic, SSM config, IAM role, Lambda
|
|
99
|
+
function, EventBridge schedule — all created or updated idempotently):
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
spf53 deploy -c spf53.yaml --create-topic spf53-alerts
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
From then on, spf53 runs itself every hour. To trigger a one-off run
|
|
106
|
+
against the config already deployed to SSM:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
spf53 apply
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Config reference
|
|
113
|
+
|
|
114
|
+
The config is a single YAML document, either a local file (`-c/--config`)
|
|
115
|
+
or the value of an SSM parameter (`--ssm-param`, default `/spf53/config`).
|
|
116
|
+
|
|
117
|
+
| Key | Required | Default | Description |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| `domains` | yes | — | List of domain blocks (see below). |
|
|
120
|
+
| `sns_topic_arn` | no | none | Where alerts are published. No alerts if absent. |
|
|
121
|
+
| `resolver_ips` | no | `["1.1.1.1", "8.8.8.8"]` | DNS resolvers used for flattening, queried alternately with retries. |
|
|
122
|
+
|
|
123
|
+
Each entry in `domains`:
|
|
124
|
+
|
|
125
|
+
| Key | Required | Default | Description |
|
|
126
|
+
|---|---|---|---|
|
|
127
|
+
| `name` | yes | — | The domain whose SPF is being flattened. |
|
|
128
|
+
| `hosted_zone_id` | yes | — | Route53 hosted zone ID for this domain. No zone auto-discovery. |
|
|
129
|
+
| `includes` | yes | — | Provider `include:` targets to recursively resolve and flatten. |
|
|
130
|
+
| `passthrough` | no | `[]` | Mechanisms copied verbatim, never flattened, placed first in chunk 1. |
|
|
131
|
+
| `policy` | no | `"~all"` | SPF policy for the last chunk; must be `"~all"` or `"-all"`. |
|
|
132
|
+
| `max_shrink_pct` | no | `30` | Guard threshold — see below. |
|
|
133
|
+
|
|
134
|
+
## Safety guards
|
|
135
|
+
|
|
136
|
+
spf53 will refuse to publish (and sends an SNS alert instead) when:
|
|
137
|
+
|
|
138
|
+
- **The newly resolved set is empty.** An empty result almost always means
|
|
139
|
+
a resolution problem, not a legitimate zero-IP provider.
|
|
140
|
+
- **The address count shrank by more than `max_shrink_pct`** compared to
|
|
141
|
+
what's currently live. A shrink at or under the threshold is allowed
|
|
142
|
+
through; anything larger is treated as a signal that something's wrong
|
|
143
|
+
(a provider outage, a resolver problem) rather than an intended change.
|
|
144
|
+
On the very first run, there's no live baseline, so this check passes as
|
|
145
|
+
long as the new set is non-empty.
|
|
146
|
+
- **Resolution fails outright** (NXDOMAIN, timeout, missing SPF record, a
|
|
147
|
+
nesting depth over 10 `include:`s deep) for a domain. That domain is
|
|
148
|
+
skipped and alerted on; other domains in the same config still run.
|
|
149
|
+
|
|
150
|
+
`spf53 apply --force` overrides a guard refusal for that run. Guard
|
|
151
|
+
refusals and resolution errors always trigger an SNS notification when a
|
|
152
|
+
topic is configured.
|
|
153
|
+
|
|
154
|
+
spf53 also warns (in `plan` output and in SNS messages, never as a hard
|
|
155
|
+
error) when the total SPF lookup cost — chain length, plus 1 for the apex
|
|
156
|
+
`include:`, plus any DNS-querying passthrough mechanisms (`exists:`,
|
|
157
|
+
`include:`, `a`, `mx`, `ptr`) — exceeds 9, since that's one lookup away from
|
|
158
|
+
the RFC 7208 limit.
|
|
159
|
+
|
|
160
|
+
## CLI reference
|
|
161
|
+
|
|
162
|
+
| Command | Flags | Behavior |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `spf53 plan` | `[-c FILE \| --ssm-param NAME]` | Prints the per-domain diff, lookup cost, and expected apex record. Exit `0` if nothing would change, `2` if changes are pending, `1` on error. |
|
|
165
|
+
| `spf53 apply` | `[-c FILE \| --ssm-param NAME] [--force]` | Applies the flattened records. Exit `0` on success (including no-op), `1` on error or guard refusal. |
|
|
166
|
+
| `spf53 deploy` | `-c FILE [--schedule "rate(1 hour)"] [--create-topic NAME] [--param-name NAME] [--function-name spf53] [--region REGION] [--dry-run]` | Idempotently bootstraps the SNS topic, SSM config, IAM role, Lambda function, and EventBridge schedule. `--dry-run` prints the planned actions without making any AWS calls. |
|
|
167
|
+
|
|
168
|
+
If neither `-c/--config` nor `--ssm-param` is given, `plan` and `apply`
|
|
169
|
+
read from SSM parameter `/spf53/config`.
|
|
170
|
+
|
|
171
|
+
## IAM permissions
|
|
172
|
+
|
|
173
|
+
Two separate sets of permissions are involved:
|
|
174
|
+
|
|
175
|
+
**The credentials you run `spf53 deploy` with** need enough access to
|
|
176
|
+
create/update the pieces spf53 manages:
|
|
177
|
+
`iam:CreateRole`, `iam:GetRole`, `iam:UpdateAssumeRolePolicy`,
|
|
178
|
+
`iam:PutRolePolicy`, `iam:PassRole` (for the `spf53-lambda` role),
|
|
179
|
+
`lambda:GetFunction`, `lambda:CreateFunction`, `lambda:UpdateFunctionCode`,
|
|
180
|
+
`lambda:UpdateFunctionConfiguration`, `lambda:AddPermission`,
|
|
181
|
+
`events:PutRule`, `events:PutTargets`, `ssm:PutParameter`,
|
|
182
|
+
`sns:CreateTopic` (only if using `--create-topic`), and
|
|
183
|
+
`sts:GetCallerIdentity`.
|
|
184
|
+
|
|
185
|
+
**The Lambda's own execution role** (`spf53-lambda`, created by `deploy`) is
|
|
186
|
+
scoped tightly to what the running tool actually needs — nothing more:
|
|
187
|
+
|
|
188
|
+
- `route53:ChangeResourceRecordSets` and `route53:ListResourceRecordSets`,
|
|
189
|
+
scoped to the hosted zone(s) named in your config.
|
|
190
|
+
- `ssm:GetParameter`, scoped to the config parameter.
|
|
191
|
+
- `sns:Publish`, scoped to the configured alert topic (if any).
|
|
192
|
+
- `logs:CreateLogGroup`, `logs:CreateLogStream`, `logs:PutLogEvents` for its
|
|
193
|
+
own CloudWatch log group.
|
|
194
|
+
|
|
195
|
+
## FAQ
|
|
196
|
+
|
|
197
|
+
**Why not just use SPF macros instead of flattening?**
|
|
198
|
+
Macros (`%{i}`, `exists:` tricks) aren't universally implemented by mail
|
|
199
|
+
receivers, and they don't solve the underlying problem: an `include:` still
|
|
200
|
+
costs a lookup no matter how it's expressed. spf53 supports macro-based
|
|
201
|
+
mechanisms you can't flatten (e.g. Salesforce's
|
|
202
|
+
`exists:%{i}._spf.mta.salesforce.com`) via `passthrough`, copied through
|
|
203
|
+
verbatim.
|
|
204
|
+
|
|
205
|
+
**Why isn't boto3 bundled in the Lambda deployment package?**
|
|
206
|
+
The Lambda Python runtime already ships boto3, so bundling it again would
|
|
207
|
+
just bloat the deployment zip for no benefit. `spf53 deploy` only packages
|
|
208
|
+
`dnspython`, `pyyaml`, and the spf53 package itself, pinned to the pure-Python
|
|
209
|
+
wheel versions installed in the environment you're deploying from — so the
|
|
210
|
+
zip matches what you tested, regardless of the Lambda runtime's own Python
|
|
211
|
+
version.
|
|
212
|
+
|
|
213
|
+
**How do I add or remove a provider?**
|
|
214
|
+
Edit the `includes` (or `passthrough`) list in your config and re-apply —
|
|
215
|
+
either `spf53 deploy -c spf53.yaml` again (which pushes the updated config
|
|
216
|
+
to SSM as part of its bootstrap) or push the file and run `spf53 apply -c`
|
|
217
|
+
directly. The next scheduled Lambda run will also pick up whatever is
|
|
218
|
+
currently in SSM.
|
|
219
|
+
|
|
220
|
+
**What happens when a provider changes its IPs?**
|
|
221
|
+
Nothing you have to do. On the next scheduled run, spf53 re-resolves the
|
|
222
|
+
provider's records, gets the new IPs, diffs them against what's live, and —
|
|
223
|
+
provided the change passes the safety guards — publishes the update and
|
|
224
|
+
sends an SNS notification. If the change looks like a problem (e.g. the
|
|
225
|
+
provider's records suddenly resolve to almost nothing), spf53 refuses and
|
|
226
|
+
alerts instead of publishing.
|
|
227
|
+
|
|
228
|
+
## Releasing
|
|
229
|
+
|
|
230
|
+
Releases publish to PyPI automatically via GitHub Actions
|
|
231
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC —
|
|
232
|
+
no stored API tokens). To cut a release:
|
|
233
|
+
|
|
234
|
+
1. Bump `version` in `pyproject.toml` and commit.
|
|
235
|
+
2. Tag it (`git tag vX.Y.Z && git push origin vX.Y.Z`) and publish a
|
|
236
|
+
[GitHub Release](https://github.com/babyhuey/spf53/releases) from that tag.
|
|
237
|
+
3. The `Release` workflow builds the sdist/wheel and publishes to PyPI —
|
|
238
|
+
watch the [Actions tab](https://github.com/babyhuey/spf53/actions) for status.
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
MIT — see [LICENSE](LICENSE).
|