Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9efc9c7005 | ||
|
|
96425dd515 |
@@ -5,6 +5,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.4.2] - 2026-10-14
|
||||||
|
|
||||||
|
Release hygiene: stage 0 of the 2026-10-12 audit. Nothing the panel or the
|
||||||
|
mail path does changes; the release workflow, the tests, the dependencies and
|
||||||
|
the documents do.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **A release image says which release it is.** The e2e gate in
|
- **A release image says which release it is.** The e2e gate in
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ outbound mail will not reach the real internet without DNS, PTR, and port 25.
|
|||||||
> local image and pulls nothing:
|
> local image and pulls nothing:
|
||||||
>
|
>
|
||||||
> ```sh
|
> ```sh
|
||||||
> docker build -f build/Dockerfile --build-arg VERSION=2.4.1 \
|
> docker build -f build/Dockerfile --build-arg VERSION=2.4.2 \
|
||||||
> -t ghcr.io/mixeme/selfpost:2.4.1 .
|
> -t ghcr.io/mixeme/selfpost:2.4.2 .
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -122,7 +122,7 @@ docker run --rm -d --name selfpost-try \
|
|||||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||||
-e PANEL_COOKIE_SECURE=false \
|
-e PANEL_COOKIE_SECURE=false \
|
||||||
-v selfpost-try-data:/data \
|
-v selfpost-try-data:/data \
|
||||||
ghcr.io/mixeme/selfpost:2.4.1
|
ghcr.io/mixeme/selfpost:2.4.2
|
||||||
```
|
```
|
||||||
|
|
||||||
**Get the setup URL** (pick one):
|
**Get the setup URL** (pick one):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:2.4.1
|
image: ghcr.io/mixeme/selfpost:2.4.2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||||
|
|||||||
@@ -543,7 +543,7 @@ card, and it is not planned) are mockup-first; a migration the test server
|
|||||||
has already run is never edited — the next file is added; the published
|
has already run is never edited — the next file is added; the published
|
||||||
`1.x` tags and images are history and are not touched.
|
`1.x` tags and images are history and are not touched.
|
||||||
|
|
||||||
### Stage 0 — release hygiene (no product code) → `2.4.2`
|
### Stage 0 — release hygiene (no product code) → `2.4.2` (shipped 2026-10-14)
|
||||||
|
|
||||||
- [x] **Stamp the right version** (H1). Make the e2e build take the version from
|
- [x] **Stamp the right version** (H1). Make the e2e build take the version from
|
||||||
the environment (`VERSION: ${SELFPOST_E2E_VERSION:-e2e}` in
|
the environment (`VERSION: ${SELFPOST_E2E_VERSION:-e2e}` in
|
||||||
@@ -563,7 +563,7 @@ has already run is never edited — the next file is added; the published
|
|||||||
development.md that the image is rebuilt after each Go patch release
|
development.md that the image is rebuilt after each Go patch release
|
||||||
(`golang:1.26-bookworm` floats, so a rebuild suffices). — **Haiku**
|
(`golang:1.26-bookworm` floats, so a rebuild suffices). — **Haiku**
|
||||||
- [x] The four `staticcheck` items. — **Haiku**
|
- [x] The four `staticcheck` items. — **Haiku**
|
||||||
- [ ] **A negative e2e case passes only on the refusal it is about** (owner,
|
- [x] **A negative e2e case passes only on the refusal it is about** (owner,
|
||||||
2026-10-14; seen, not found by the audit). `negative_level1_ratelimit`
|
2026-10-14; seen, not found by the audit). `negative_level1_ratelimit`
|
||||||
sends up to sixty messages and waits for one to be refused; it once
|
sends up to sixty messages and waits for one to be refused; it once
|
||||||
passed on "limit tripped on attempt 1/60" when the connection was reset
|
passed on "limit tripped on attempt 1/60" when the connection was reset
|
||||||
@@ -573,6 +573,23 @@ has already run is never edited — the next file is added; the published
|
|||||||
came. Then every other negative case of the suite is read for the same
|
came. Then every other negative case of the suite is read for the same
|
||||||
mistake - any failure taken for the expected refusal - and held to its
|
mistake - any failure taken for the expected refusal - and held to its
|
||||||
own reply code. - **Opus**
|
own reply code. - **Opus**
|
||||||
|
*Done 2026-10-14; what the stand showed:* `waitForSMTPSReady` took a TCP
|
||||||
|
connect for readiness, and Docker's port proxy accepts before Postfix
|
||||||
|
listens - that was the reset; it now waits for the TLS greeting. Held to
|
||||||
|
anvil's `450`, the case then failed on its first message: `553 … not owned
|
||||||
|
by user l1app`, an smtpd process from before the panel's `postfix reload`
|
||||||
|
still holding the old `sender_login_maps` - so the level-1 limit had never
|
||||||
|
been the refusal the case saw. The first message now waits (at most 15 s)
|
||||||
|
for the map; the limit trips at 51/60 with `450 4.7.1 Error: too much mail
|
||||||
|
from`. In the product this is a second or so after an application is
|
||||||
|
saved in which its first message can get a permanent 553; not worth a
|
||||||
|
change (an application is configured after it is created, not within the
|
||||||
|
second). The other cases are held to `451 4.7.1` (level 2, both MAIL FROM
|
||||||
|
of one connection), `553` at RCPT (sender mismatch), `554 … Client host
|
||||||
|
rejected` at RCPT (no AUTH, and the relay case, whose comment claimed
|
||||||
|
`reject_unauth_destination` - on 465 the client restriction comes first;
|
||||||
|
port 25 holds `554 … Relay access denied`), `550 5.1.1` (port 25 unknown
|
||||||
|
recipients), any `4xx` reply (panel down).
|
||||||
|
|
||||||
### Stage 1 — mail that is shown wrong (H3, H2, M12, M13) → `2.5.0`
|
### Stage 1 — mail that is shown wrong (H3, H2, M12, M13) → `2.5.0`
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
|
|||||||
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
|
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
|
||||||
[§ Release image](#release-image)).
|
[§ Release image](#release-image)).
|
||||||
|
|
||||||
**Planned version cuts** (from pin `2.4.1`; adjust if semver changes mid-track):
|
**Planned version cuts** (from pin `2.4.2`; adjust if semver changes mid-track):
|
||||||
|
|
||||||
| Stage | ID | Cut |
|
| Stage | ID | Cut |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -130,6 +130,12 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
|
|||||||
| 17 | panel-notifications | `2.3.0` |
|
| 17 | panel-notifications | `2.3.0` |
|
||||||
| 18 | from-header-binding | `2.4.0` |
|
| 18 | from-header-binding | `2.4.0` |
|
||||||
| 19 | code audit 2026-10-12 and its plan (docs only, owner-requested cut) | `2.4.1` (PATCH) |
|
| 19 | code audit 2026-10-12 and its plan (docs only, owner-requested cut) | `2.4.1` (PATCH) |
|
||||||
|
| 20 | audit-2026-10-12 stage 0: release hygiene | `2.4.2` (PATCH) |
|
||||||
|
| 21 | audit-2026-10-12 stages 1 and 2 | `2.5.0` |
|
||||||
|
| 22 | audit-2026-10-12 stage 2b: the log tailer | `2.5.1` |
|
||||||
|
| 23 | audit-2026-10-12 stage 3 | `2.5.2` |
|
||||||
|
| 24 | audit-2026-10-12 stage 4 | `2.5.3` |
|
||||||
|
| 25 | audit-2026-10-12 stage 5 | `2.6.0` |
|
||||||
|
|
||||||
Docs-only prep (checklists in plans, no product code) uses the same per-step
|
Docs-only prep (checklists in plans, no product code) uses the same per-step
|
||||||
commit rule but **no** version cut until the next product stage ships.
|
commit rule but **no** version cut until the next product stage ships.
|
||||||
|
|||||||
+3
-3
@@ -184,14 +184,14 @@ cat ./data/setup-token
|
|||||||
#### Fixed image tag
|
#### Fixed image tag
|
||||||
|
|
||||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||||
deliberately never `:latest`. The current pin is `2.4.1`. **No 2.x image is
|
deliberately never `:latest`. The current pin is `2.4.2`. **No 2.x image is
|
||||||
published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build
|
published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build
|
||||||
it from the checkout under the pinned name, and Compose uses the local image
|
it from the checkout under the pinned name, and Compose uses the local image
|
||||||
instead of pulling:
|
instead of pulling:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -f build/Dockerfile --build-arg VERSION=2.4.1 \
|
docker build -f build/Dockerfile --build-arg VERSION=2.4.2 \
|
||||||
-t ghcr.io/mixeme/selfpost:2.4.1 .
|
-t ghcr.io/mixeme/selfpost:2.4.2 .
|
||||||
```
|
```
|
||||||
|
|
||||||
`VERSION` must equal the tag: it is the version the backup check compares
|
`VERSION` must equal the tag: it is the version the backup check compares
|
||||||
|
|||||||
+3
-2
@@ -42,14 +42,15 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
||||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||||
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | agreed (2026-10-12: stage 0 first, then 1+2; the rest after) | 6/61 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`; next stages 1+2 → `2.5.0`) | 7/61 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
||||||
|
|
||||||
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
||||||
**inbound-antispam-panel** as `2.1.0` (2026-10-12), built in the new component
|
**inbound-antispam-panel** as `2.1.0` (2026-10-12), built in the new component
|
||||||
kit — styling its two screens once instead of twice. **delivery-log-storage**
|
kit — styling its two screens once instead of twice. **delivery-log-storage**
|
||||||
shipped as `2.2.0` the same day, **panel-notifications** as `2.3.0` and
|
shipped as `2.2.0` the same day, **panel-notifications** as `2.3.0` and
|
||||||
**from-header-binding** as `2.4.0`.
|
**from-header-binding** as `2.4.0`. Stage 0 of
|
||||||
|
[audit-2026-10-12](audit-2026-10-12.md) shipped as `2.4.2` (2026-10-14).
|
||||||
Nothing is agreed after it: every remaining
|
Nothing is agreed after it: every remaining
|
||||||
item is a candidate. panel-docs shipped in
|
item is a candidate. panel-docs shipped in
|
||||||
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
|
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
|
||||||
|
|||||||
+60
-30
@@ -44,12 +44,9 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
|
|||||||
authLogin: login, authPassword: password,
|
authLogin: login, authPassword: password,
|
||||||
from: from, to: recipient, subject: "e2e " + uniqueToken("l2-second"), body: "should be rejected",
|
from: from, to: recipient, subject: "e2e " + uniqueToken("l2-second"), body: "should be rejected",
|
||||||
})
|
})
|
||||||
if second.ok() {
|
// The milter answers tempfail at MAIL FROM, which Postfix words as below.
|
||||||
t.Fatal("second message exceeding the level-2 limit was accepted, want rejected")
|
if err := second.refusedAt("MAIL", 451, "4.7.1 Service unavailable"); err != nil {
|
||||||
}
|
t.Fatalf("the second message over the level-2 limit: %v", err)
|
||||||
if second.mailErr == nil {
|
|
||||||
t.Fatalf("expected the level-2 rejection at MAIL FROM, got: dial=%v auth=%v rcpt=%v data=%v",
|
|
||||||
second.dialErr, second.authErr, second.rcptErr, second.dataErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The limit holds for every message of a connection, not for its first
|
// The limit holds for every message of a connection, not for its first
|
||||||
@@ -59,8 +56,11 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
|
|||||||
// used to arrive with no client address and was let through (found by the
|
// used to arrive with no client address and was let through (found by the
|
||||||
// technical review of 2026-10-12; the same hole let an application past its
|
// technical review of 2026-10-12; the same hole let an application past its
|
||||||
// client-address list).
|
// client-address list).
|
||||||
if errs := mailTwiceOnOneConnection(login, password, from); errs[0] == nil || errs[1] == nil {
|
errs := mailTwiceOnOneConnection(login, password, from)
|
||||||
t.Fatalf("over the level-2 limit, two MAIL FROM on one connection were answered %v and %v, want both refused", errs[0], errs[1])
|
for i, err := range errs {
|
||||||
|
if err := replyIs(err, 451, "4.7.1 Service unavailable"); err != nil {
|
||||||
|
t.Fatalf("over the level-2 limit, MAIL FROM %d of one connection: %v", i+1, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := waitFor("a rate-limited row for l2app in the send log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
|
if err := waitFor("a rate-limited row for l2app in the send log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||||
@@ -86,15 +86,14 @@ func testSenderLoginMismatch(t *testing.T, sc *scenario) {
|
|||||||
from: "someone@not-" + senderDomain, to: recipient,
|
from: "someone@not-" + senderDomain, to: recipient,
|
||||||
subject: "e2e " + uniqueToken("mismatch"), body: "should be rejected",
|
subject: "e2e " + uniqueToken("mismatch"), body: "should be rejected",
|
||||||
})
|
})
|
||||||
if res.ok() {
|
|
||||||
t.Fatal("send with a sender the application does not own was accepted, want rejected")
|
|
||||||
}
|
|
||||||
// Postfix evaluates smtpd_sender_restrictions with smtpd_delay_reject=yes
|
// Postfix evaluates smtpd_sender_restrictions with smtpd_delay_reject=yes
|
||||||
// (the default): the mismatch is detected at MAIL FROM but the reject is
|
// (the default): the mismatch is detected at MAIL FROM but the reject is
|
||||||
// only sent back at RCPT TO, so the error can land on either call here.
|
// only sent back at RCPT TO.
|
||||||
if res.mailErr == nil && res.rcptErr == nil {
|
if err := res.refusedAt("RCPT", 553, "5.7.1"); err != nil {
|
||||||
t.Fatalf("expected the mismatch rejected at MAIL or RCPT, got: dial=%v auth=%v data=%v",
|
t.Fatalf("a sender the application does not own: %v", err)
|
||||||
res.dialErr, res.authErr, res.dataErr)
|
}
|
||||||
|
if err := replyIs(res.rcptErr, 553, "Sender address rejected: not owned by user"); err != nil {
|
||||||
|
t.Fatalf("a sender the application does not own: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,24 +105,25 @@ func testNoAuthRejected(t *testing.T, sc *scenario) {
|
|||||||
from: "anyone@unregistered.e2e.test", to: recipient,
|
from: "anyone@unregistered.e2e.test", to: recipient,
|
||||||
subject: "e2e " + uniqueToken("noauth"), body: "should be rejected",
|
subject: "e2e " + uniqueToken("noauth"), body: "should be rejected",
|
||||||
})
|
})
|
||||||
if res.ok() {
|
if err := res.refusedAt("RCPT", 554, "Client host rejected: Access denied"); err != nil {
|
||||||
t.Fatal("unauthenticated send was accepted, want rejected")
|
t.Fatalf("an unauthenticated send: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testForeignRelayRejected is plan C.4 negative check 3: a direct proof that
|
// testForeignRelayRejected is plan C.4 negative check 3: a direct proof that
|
||||||
// this is not an open relay — even the exact recipient the positive path just
|
// the submission port is not an open relay — even the exact recipient the
|
||||||
// delivered to is refused without AUTH (reject_unauth_destination).
|
// positive path just delivered to is refused without AUTH. The refusal is
|
||||||
|
// smtpd_client_restrictions (permit_sasl_authenticated, reject), which on 465
|
||||||
|
// and 587 comes before reject_unauth_destination, so the reply is the same as
|
||||||
|
// check 1's; reject_unauth_destination's own "Relay access denied" is held on
|
||||||
|
// port 25 (testInboundRelay), where clients do not authenticate.
|
||||||
func testForeignRelayRejected(t *testing.T, sc *scenario) {
|
func testForeignRelayRejected(t *testing.T, sc *scenario) {
|
||||||
res := attemptSend(sendAttempt{
|
res := attemptSend(sendAttempt{
|
||||||
from: "anyone@unregistered.e2e.test", to: recipient,
|
from: "anyone@unregistered.e2e.test", to: recipient,
|
||||||
subject: "e2e " + uniqueToken("relay"), body: "should be rejected",
|
subject: "e2e " + uniqueToken("relay"), body: "should be rejected",
|
||||||
})
|
})
|
||||||
if res.ok() {
|
if err := res.refusedAt("RCPT", 554, "Client host rejected: Access denied"); err != nil {
|
||||||
t.Fatal("unauthenticated relay to an external destination was accepted, want reject_unauth_destination")
|
t.Fatalf("an unauthenticated relay to an external destination: %v", err)
|
||||||
}
|
|
||||||
if res.rcptErr == nil && res.mailErr == nil {
|
|
||||||
t.Fatalf("expected rejection at MAIL or RCPT, got: dial=%v auth=%v data=%v", res.dialErr, res.authErr, res.dataErr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,23 +172,53 @@ func testSessionSurvivesRestart(t *testing.T, sc *scenario) {
|
|||||||
// well past that count, so the result is unambiguous regardless of how much of
|
// well past that count, so the result is unambiguous regardless of how much of
|
||||||
// the shared per-IP budget earlier subtests already spent (they stay well
|
// the shared per-IP budget earlier subtests already spent (they stay well
|
||||||
// under 50 between them).
|
// under 50 between them).
|
||||||
|
//
|
||||||
|
// Only anvil's own reply counts, and only after at least one message of the
|
||||||
|
// loop went through: the case once passed on "limit tripped on attempt 1/60"
|
||||||
|
// when the connection was reset because Postfix was still starting - and,
|
||||||
|
// held to anvil's reply, it turned out the first message had always been
|
||||||
|
// refused for another reason (below).
|
||||||
func testLevel1RateLimit(t *testing.T, sc *scenario) {
|
func testLevel1RateLimit(t *testing.T, sc *scenario) {
|
||||||
login, password, err := sc.panel.addApplication(sc.domainID, "l1app", "wildcard", "")
|
login, password, err := sc.panel.addApplication(sc.domainID, "l1app", "wildcard", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("add application: %v", err)
|
t.Fatalf("add application: %v", err)
|
||||||
}
|
}
|
||||||
|
send := func() sendResult {
|
||||||
const maxAttempts = 60
|
return attemptSend(sendAttempt{
|
||||||
for i := 0; i < maxAttempts; i++ {
|
|
||||||
res := attemptSend(sendAttempt{
|
|
||||||
authLogin: login, authPassword: password,
|
authLogin: login, authPassword: password,
|
||||||
from: "l1@" + senderDomain, to: recipient,
|
from: "l1@" + senderDomain, to: recipient,
|
||||||
subject: "e2e " + uniqueToken("l1"), body: "rate limit probe",
|
subject: "e2e " + uniqueToken("l1"), body: "rate limit probe",
|
||||||
})
|
})
|
||||||
if !res.ok() {
|
}
|
||||||
|
|
||||||
|
// The panel writes sender_login_maps and reloads Postfix, but an smtpd
|
||||||
|
// process that was idle before the reload can still take the next
|
||||||
|
// connection with the old map and answer 553 for the new login (seen on
|
||||||
|
// the stand: the process that greeted waitForSMTPSReady). Wait for the map
|
||||||
|
// to take effect; any other refusal fails at once.
|
||||||
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
|
for {
|
||||||
|
res := send()
|
||||||
|
if res.ok() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if res.refusedAt("RCPT", 553, "not owned by user "+login) != nil || time.Now().After(deadline) {
|
||||||
|
t.Fatalf("the first message of %s, before any limit could trip: %v", login, res.firstErr())
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAttempts = 60
|
||||||
|
for i := 1; i < maxAttempts; i++ {
|
||||||
|
res := send()
|
||||||
|
if res.ok() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := res.refusedAt("MAIL", 450, "4.7.1 Error: too much mail from"); err != nil {
|
||||||
|
t.Fatalf("attempt %d/%d: %v", i+1, maxAttempts, err)
|
||||||
|
}
|
||||||
t.Logf("level-1 limit tripped on attempt %d/%d: %v", i+1, maxAttempts, res.firstErr())
|
t.Logf("level-1 limit tripped on attempt %d/%d: %v", i+1, maxAttempts, res.firstErr())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=50) never tripped after %d sends", maxAttempts)
|
t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=50) never tripped after %d sends", maxAttempts)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ func testDMARCReportReachesThePanel(t *testing.T, sc *scenario) {
|
|||||||
|
|
||||||
// An address nobody chose is refused before DATA, hosted form or not.
|
// An address nobody chose is refused before DATA, hosted form or not.
|
||||||
res := deliverPort25("noreply@"+reportReporter, "dmarc-reports+nobody.e2e.test@"+selfpostHostname, "Subject: x\r\n\r\nx\r\n")
|
res := deliverPort25("noreply@"+reportReporter, "dmarc-reports+nobody.e2e.test@"+selfpostHostname, "Subject: x\r\n\r\nx\r\n")
|
||||||
if res.rcptErr == nil {
|
if err := res.refusedAt("RCPT", 550, "5.1.1"); err != nil {
|
||||||
t.Fatalf("port 25 accepted a report address no domain uses (first error: %v)", res.firstErr())
|
t.Fatalf("port 25, a report address no domain uses: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
res = deliverPort25("noreply@"+reportReporter, address, reportMessage(t, address, aggregateXML(senderDomain, "e2e-report-1")))
|
res = deliverPort25("noreply@"+reportReporter, address, reportMessage(t, address, aggregateXML(senderDomain, "e2e-report-1")))
|
||||||
@@ -344,10 +344,17 @@ func testInboundRelay(t *testing.T, sc *scenario) {
|
|||||||
t.Fatalf("the Inbound log holds %d rows, want the relayed message alone:\n%s", len(rows), strings.Join(rows, "\n"))
|
t.Fatalf("the Inbound log holds %d rows, want the relayed message alone:\n%s", len(rows), strings.Join(rows, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, to := range []string{"nobody@" + inboundDomain, "someone@elsewhere.e2e.test"} {
|
for _, c := range []struct {
|
||||||
res := deliverPort25("someone@stranger.e2e.test", to, message(to, uniqueToken("refused")))
|
to string
|
||||||
if res.rcptErr == nil {
|
code int
|
||||||
t.Fatalf("port 25 accepted the recipient %s (first error: %v)", to, res.firstErr())
|
text string
|
||||||
|
}{
|
||||||
|
{"nobody@" + inboundDomain, 550, "User unknown in relay recipient table"},
|
||||||
|
{"someone@elsewhere.e2e.test", 554, "Relay access denied"},
|
||||||
|
} {
|
||||||
|
res := deliverPort25("someone@stranger.e2e.test", c.to, message(c.to, uniqueToken("refused")))
|
||||||
|
if err := res.refusedAt("RCPT", c.code, c.text); err != nil {
|
||||||
|
t.Fatalf("port 25, the recipient %s: %v", c.to, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package e2e
|
package e2e
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/textproto"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -127,8 +129,9 @@ func testPanelDownDefersMail(t *testing.T, sc *scenario) {
|
|||||||
// Temporary, and before any message is offered: Postfix answers 451 from
|
// Temporary, and before any message is offered: Postfix answers 451 from
|
||||||
// the connection on, and "454 4.3.0 Try again later" at AUTH, which is
|
// the connection on, and "454 4.3.0 Try again later" at AUTH, which is
|
||||||
// where a client that authenticates gives up.
|
// where a client that authenticates gives up.
|
||||||
if err := res.firstErr(); err == nil || !strings.HasPrefix(fmt.Sprint(err), "4") {
|
var reply *textproto.Error
|
||||||
t.Fatalf("want a temporary 4xx with the panel down, got %v", res.firstErr())
|
if err := res.firstErr(); !errors.As(err, &reply) || reply.Code/100 != 4 {
|
||||||
|
t.Fatalf("want a temporary 4xx reply with the panel down, got %v", err)
|
||||||
}
|
}
|
||||||
if err := checkContainerAlive(h); err != nil {
|
if err := checkContainerAlive(h); err != nil {
|
||||||
t.Fatalf("container did not survive the panel stopping: %v", err)
|
t.Fatalf("container did not survive the panel stopping: %v", err)
|
||||||
|
|||||||
+57
-7
@@ -2,9 +2,12 @@ package e2e
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +54,40 @@ func (r sendResult) firstErr() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refusedAt says nil only when the transaction was refused at stage
|
||||||
|
// ("connect", "AUTH", "MAIL", "RCPT" or "DATA") with the server's reply code
|
||||||
|
// and a reply text containing text. A negative case passes on its own refusal
|
||||||
|
// and nothing else: a reset or a timeout while Postfix was still starting once
|
||||||
|
// passed for the level-1 rate limit. Anything else is described for the
|
||||||
|
// failure message - where it stopped and what came.
|
||||||
|
func (r sendResult) refusedAt(stage string, code int, text string) error {
|
||||||
|
for _, s := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}{{"connect", r.dialErr}, {"AUTH", r.authErr}, {"MAIL", r.mailErr}, {"RCPT", r.rcptErr}, {"DATA", r.dataErr}} {
|
||||||
|
if s.err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.name != stage {
|
||||||
|
return fmt.Errorf("stopped at %s, not at %s: %v", s.name, stage, s.err)
|
||||||
|
}
|
||||||
|
return replyIs(s.err, code, text)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("accepted, want refused at %s with %d", stage, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replyIs says nil when err is the server's reply code with text in it.
|
||||||
|
func replyIs(err error, code int, text string) error {
|
||||||
|
var reply *textproto.Error
|
||||||
|
if !errors.As(err, &reply) {
|
||||||
|
return fmt.Errorf("no SMTP reply but %v, want %d ...%s", err, code, text)
|
||||||
|
}
|
||||||
|
if reply.Code != code || !strings.Contains(reply.Msg, text) {
|
||||||
|
return fmt.Errorf("reply %d %s, want %d ...%s", reply.Code, reply.Msg, code, text)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// attemptSend drives one SMTP transaction over the implicit-TLS port, exactly
|
// attemptSend drives one SMTP transaction over the implicit-TLS port, exactly
|
||||||
// as an application client library would: TLS connect, EHLO, optional AUTH
|
// as an application client library would: TLS connect, EHLO, optional AUTH
|
||||||
// PLAIN, MAIL/RCPT/DATA. The certificate is the stand's throwaway self-signed
|
// PLAIN, MAIL/RCPT/DATA. The certificate is the stand's throwaway self-signed
|
||||||
@@ -117,18 +154,31 @@ func attemptSend(a sendAttempt) sendResult {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForSMTPSReady polls the implicit-TLS port until a bare TCP connect
|
// waitForSMTPSReady polls the implicit-TLS port until Postfix greets over
|
||||||
// succeeds. Used after a container (re)start: the panel's HTTP port and
|
// TLS. Used after a container (re)start: the panel's HTTP port and Postfix's
|
||||||
// Postfix's smtpd come up independently (postfix-wrapper.sh additionally
|
// smtpd come up independently (postfix-wrapper.sh additionally waits on both
|
||||||
// waits on both milter sockets before starting Postfix at all), so a caller
|
// milter sockets before starting Postfix at all), so a caller that only
|
||||||
// that only waited for the panel could still race a not-yet-listening smtpd.
|
// waited for the panel could still race a not-yet-listening smtpd. A bare TCP
|
||||||
|
// connect is not enough: Docker's port proxy accepts on the host side before
|
||||||
|
// anything listens in the container and then resets the connection, which is
|
||||||
|
// how the level-1 rate-limit case once took a reset for its limit.
|
||||||
func waitForSMTPSReady(timeout time.Duration) error {
|
func waitForSMTPSReady(timeout time.Duration) error {
|
||||||
return waitFor("smtps port to accept connections", timeout, 300*time.Millisecond, func() (bool, error) {
|
return waitFor("smtps port to greet", timeout, 300*time.Millisecond, func() (bool, error) {
|
||||||
conn, err := net.DialTimeout("tcp", smtpsAddr, 2*time.Second)
|
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 2 * time.Second}, "tcp", smtpsAddr, &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
ServerName: selfpostHostname,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
c, err := smtp.NewClient(conn, selfpostHostname) // reads the 220 greeting
|
||||||
|
if err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_ = c.Quit()
|
||||||
|
_ = c.Close()
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user