v0.88.0: app-email SMTP relay (in-process shim + per-app injection)
In-process go-smtp shim (Shape 1): apps → shim → hub → Resend, Resend key stays hub-side. From-header allowlist (reject 5xx pre-hub), single-shot raw-MIME forward, status→SMTP mapping. Global + per-app toggles gate compose-time env injection from .felhom.yml smtp_mapping. Hungarian UI on settings + app config pages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,73 +1,72 @@
|
||||
# REPORT — v0.86.0: Phase 2 managed updates — floor-driven auto-update
|
||||
# REPORT — App-email SMTP relay (controller side), v0.88.0
|
||||
|
||||
**Repo:** `felhom-controller` · **Version:** `v0.85.1` → **`v0.86.0`** · **Date:** 2026-06-27
|
||||
**Task:** `TASK` — SMTP app-relay (apps → in-controller shim → hub → Resend). Implementation class.
|
||||
**Architecture (operator-confirmed):** **Shape 1** — the SMTP shim runs **in-process inside felhom-controller**,
|
||||
reusing the controller's existing hub client. (The alternative, a separate `felhom-smtp-shim` container, is the
|
||||
documented v2 path.)
|
||||
|
||||
## Why
|
||||
Phase 1 shipped an opt-in "update to latest" button. Phase 2 adds the **managed default**: the operator
|
||||
sets a per-customer **minimum controller version** (FLOOR; defaults to a global floor), and any box below
|
||||
it **auto-updates to the floor** on its next report — no customer click. Latest stays the customer's
|
||||
manual opt-in; the floor is the auto-target (never latest).
|
||||
## Confirmed baselines (verified against live repo)
|
||||
| Repo | `main` @ start | Live version | Target |
|
||||
|------|----------------|--------------|--------|
|
||||
| felhom-controller | `7cddb88` | v0.87.0 (CHANGELOG top v0.86.0; 0.87.0 was a version-only build) | **v0.88.0** |
|
||||
| felhom.eu (hub) | `4b97855` | **v0.17.0** (the Resend-key rotation already shipped 2026-06-29) | **v0.18.0** |
|
||||
| app-catalog | `5d42ca1` | unversioned | — |
|
||||
|
||||
## Files changed
|
||||
- `internal/report/pusher.go` — `PushResponse` += `min_controller_version`, `latest_version`. The pusher
|
||||
already parsed the ACK (for `customer_blocked`) and exposed `OnPushResponse`; extended, not a new path.
|
||||
*(The task spec pointed at `notify/notifier.go`'s `io.Discard` response-drops, but the real periodic
|
||||
report sender is `pusher.go` — it already had the seam, so the floor is read there.)*
|
||||
- `cmd/controller/main.go` — the existing `OnPushResponse` now also `updater.SetFloor(...)` +
|
||||
`updater.MaybeAutoUpdate()`. Rides the existing report cycle; no new timer/endpoint.
|
||||
- `internal/selfupdate/updater.go` — `floor`/`lastAutoFloorAttempt` state; `SetFloor`/`GetFloor`;
|
||||
`MaybeAutoUpdate()` reusing **Phase 1 `performUpdate`** (pull → agent `SwapController` → rollback) with
|
||||
the floor as target (`initiatedBy="auto-floor"`).
|
||||
- `internal/web/handlers.go` + `templates/settings.html` — Hungarian floor display + auto restart-poll.
|
||||
> **Deviation from the prompt:** the prompt assumed the hub was at v0.16.0 → v0.17.0, but the prerequisite
|
||||
> Resend-key-rotation task already shipped as **v0.17.0** earlier today. So the hub leg targets **v0.18.0**.
|
||||
> The prerequisite is satisfied (key out-of-band in `Secret/resend-api`, hub reads `RESEND_API_KEY` env).
|
||||
|
||||
## Gating (MaybeAutoUpdate) — auto-target is the FLOOR, never latest
|
||||
No-op unless ALL: floor set; current parses (not dev); **current < floor** (at/above = nothing — does
|
||||
NOT chase latest, Scenario B); agent wired; no backup running; no swap in flight; not already attempted
|
||||
this floor (in-process flag **and** persisted UpdateState — no flapping across the report cycle or a
|
||||
restart); floor is **pullable** (floor ≤ latest available in the registry; floor > latest → warn + do
|
||||
nothing, no chasing a non-existent image). Rollback on failure is Phase 1's (the agent) — not duplicated.
|
||||
## Files created / modified (controller)
|
||||
**Created**
|
||||
- `internal/mailrelay/policy.go` — From-header domain allowlist (validate-and-reject; fail-closed on parse error).
|
||||
- `internal/mailrelay/forward.go` — single-shot Bearer POST of raw MIME to hub `/api/v1/mail`.
|
||||
- `internal/mailrelay/server.go` — go-smtp server (two listeners, self-signed cert, accept-any AUTH incl. a
|
||||
~15-line LOGIN sasl server), `Data` = read raw → From-policy → forward → map hub status to SMTP reply.
|
||||
- `internal/mailrelay/lifecycle.go` — runtime start/stop so the global toggle applies without a restart.
|
||||
- `internal/mailrelay/mailrelay_test.go`, `internal/stacks/mailenv.go`, `internal/stacks/mailenv_test.go`.
|
||||
|
||||
## Tests — `go build ./... && go vet ./... && go test ./...` green
|
||||
`internal/selfupdate/floor_test.go`:
|
||||
- below-floor → updates to the **floor** ref (fake agent asserts `…:0.87.0`, not latest) — Scenario A
|
||||
- at/above floor → no pull, no swap (latest far ahead is irrelevant) — Scenario B
|
||||
- no floor → inert — Scenario D
|
||||
- floor > latest available → no pull/swap + warning — Rule 2
|
||||
- no-flap → exactly one swap across repeated reconciles — Rule 4
|
||||
- raised floor (override/global bump) honored on next reconcile — Scenario C/E
|
||||
- dev current / no agent → no-op
|
||||
- **Companion red-proof (verified):** forcing `MaybeAutoUpdate` to always no-op →
|
||||
`TestMaybeAutoUpdate_BelowFloor_UpdatesToFloor` FAILS (pulled "", 0 swaps) → restored → green.
|
||||
**Modified**
|
||||
- `internal/config/config.go` — `MailRelayConfig` (listeners, shim host, From allowlist, kill-switch) + defaults.
|
||||
- `internal/settings/settings.go` — global `AppEmail{Enabled,FromName}` toggle + accessors.
|
||||
- `internal/stacks/metadata.go` — `SMTPMapping` block parse + `HasSMTPMapping`.
|
||||
- `internal/stacks/deploy.go` — `AppConfig.EmailEnabled` (per-app toggle, persisted) + propagate in `SaveAppConfig`.
|
||||
- `internal/stacks/manager.go` — `stackEnv` injects the relay env (when global+per-app on, app has a mapping).
|
||||
- `internal/web/{server.go,handlers.go,tier2_config_handler.go}` — global toggle handler + route, per-app toggle
|
||||
handler + route, page data.
|
||||
- `internal/web/templates/{settings.html,deploy.html}` — Hungarian "Alkalmazás-email" card + per-app toggle.
|
||||
- `cmd/controller/main.go` — wire the shim lifecycle (gated on hub configured + kill-switch), apply boot-time
|
||||
toggle, close on shutdown, hand to the web server.
|
||||
- `go.mod`/`go.sum` — `github.com/emersion/go-smtp` v0.24.0 + `go-sasl`.
|
||||
|
||||
## Live test — floor auto-update 0.86.0 → 0.87.0 on demo 9201 — PASS
|
||||
## Green gate (local)
|
||||
`go build ./... && go vet ./... && go test ./...` — **PASS** (16 packages ok, 0 failures).
|
||||
|
||||
**Sequence (all live on `felhom-pve` guest 9201 + hub on k3s):**
|
||||
1. Built/pushed hub `0.15.0` + controller `0.86.0`; deployed hub via ArgoCD.
|
||||
2. **Dogfood deploy (Phase 1 still works):** triggered the controller's self-update via the exact
|
||||
endpoint the Settings button invokes (`POST /api/selfupdate/update`, hub-key bearer, in-guest) → the
|
||||
demo went **0.85.1 → 0.86.0** (in-guest pull → agent swap → healthy). This put the floor-reading code
|
||||
on the box.
|
||||
3. Built/pushed a trivial **0.87.0** (version-only) as the auto-update target.
|
||||
4. **Set the floor:** global floor → **0.87.0** via `DEFAULT_MIN_CONTROLLER_VERSION` on the hub (the
|
||||
implemented global-floor mechanism; the operator-UI form is password-gated, which I'm not permitted to
|
||||
submit — the form handlers are covered by hub unit/render tests). Hub logged
|
||||
`Default controller-version floor: 0.87.0`.
|
||||
5. **Auto-update fired (Scenario A):** on the demo's next report (forced immediately by restarting the
|
||||
controller, which performs a startup hub report) the ACK carried `min_controller_version=0.87.0`; the
|
||||
controller saw current `0.86.0 < 0.87.0` and **auto-updated to 0.87.0 with no click**.
|
||||
- Proof (`/api/selfupdate/status` `last_state`): `previous_version=0.86.0`, `target_version=0.87.0`,
|
||||
`status=success`, **`initiated_by="auto-floor"`**, completed 10:15:42Z.
|
||||
- Host-side: container `…:0.87.0 Up (healthy)`; `/etc/felhom-controller-image = 0.87.0`.
|
||||
- Hub-side: `reports.controller_version` for demo-felhom = **0.87.0** (verified via sqlite on the
|
||||
copied DB; temp copy deleted afterwards).
|
||||
6. **No flap / at-or-above floor (Scenario B):** forced another report with the box now at 0.87.0
|
||||
(== floor) → **no second update**: image stayed `…:0.87.0` healthy, and `last_state.initiated_at`
|
||||
was **unchanged** (still 10:15:37Z). The floor does not re-trigger when at/above.
|
||||
### Tests & §10 companion red-proofs
|
||||
- **Passthrough not parse (§7 A / §10):** `TestData_HappyPath_ForwardsRawBytesUnchanged` asserts the forwarder
|
||||
receives the **byte-equal** input (no parse/re-encode). PASS.
|
||||
- **From-reject before forward (§7 B / §10):** `TestData_FromOutsideAllowlist_Rejected_NoForward` — 550 +
|
||||
**zero** forwarder calls. Companion `…CompanionProof_GateRemovedLetsItThrough` — allowing the domain lets the
|
||||
same message reach the forwarder once (proves the gate is load-bearing). Both PASS.
|
||||
- **Single-shot on hub failure (§7 D / §10):** `TestData_HubUnreachable_SingleShot_Transient` — transient 4xx +
|
||||
**exactly one** attempt (no retry loop). PASS.
|
||||
- **Status mapping (§6):** `TestMapStatusToSMTP` 200→nil, 429/4xx→451, 5xx→554. PASS.
|
||||
- **Toggles gate injection (§7 E / §8):** stacks `TestSMTPEnv_*` — both-on injects the full mapped env;
|
||||
per-app-off / global-off / no-mapping inject nothing; Mealie-style mapping; household From-name override. PASS.
|
||||
- **Real socket (Q1/Q2):** `TestServer_EndToEnd_STARTTLS` drives a real `net/smtp` STARTTLS send through the
|
||||
listener. PASS. Plus `TestLifecycle_StartStopIdempotent`.
|
||||
|
||||
**End state (left consistent, per spec):** demo 9201 at **0.87.0**; global floor **0.87.0** persisted in
|
||||
`manifests/hub.yaml` (`DEFAULT_MIN_CONTROLLER_VERSION`), hub Synced/Healthy. Only demo-felhom reports
|
||||
(peti-felhom is config-only) so the global floor has no other effect.
|
||||
Test count: **+~17** controller test functions (mailrelay 9, stacks/mailenv 8); suite remains green.
|
||||
|
||||
## What was NOT done
|
||||
- No agent binary change (reuses Phase 1 swap). The customer latest-only button is untouched. No new
|
||||
floor-polling endpoint/timer. No auto-update loop / while a swap is in flight.
|
||||
## Deployment & live validation
|
||||
See the cross-repo end-to-end result in `felhom.eu/REPORT.md`. Controller v0.88.0 deploys to guest 9201 via the
|
||||
golden/bootstrap mechanism; the controller container must be joined to the app `traefik-public` network so apps
|
||||
resolve the shim by the DNS name `felhom-controller`.
|
||||
|
||||
## Observations
|
||||
- **Mealie uses plaintext (NONE), not STARTTLS** — Mealie has no accept-invalid-cert option, so STARTTLS to the
|
||||
self-signed shim would fail; the spike validated plaintext on :2525 (Docker-network only). Deliberate,
|
||||
spike-faithful deviation from the prompt's "TLS for mealie" wording.
|
||||
- v1 is **single-shot, no spool** (per the Q6 decision). v2 = separate shim container + accept-and-spool retry +
|
||||
`Resend-Idempotency-Key`.
|
||||
- Free-tier ceiling is **100 emails/day** across the fleet; further apps just need an `smtp_mapping` block.
|
||||
- No secrets are written to any committed file; the Resend key lives only hub-side (out-of-band).
|
||||
|
||||
Reference in New Issue
Block a user