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.
@@ -0,0 +1,13 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ groups:
8
+ python-deps:
9
+ patterns: ["*"]
10
+ - package-ecosystem: "github-actions"
11
+ directory: "/"
12
+ schedule:
13
+ interval: "weekly"
@@ -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
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ *.zip
11
+ .idea/
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).