diff --git a/.dockerignore b/.dockerignore index 3dcf8027f..6aa65db2f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,7 @@ **/node_modules **/.next **/dist +**/build **/release-dist apps/desktop/out apps/desktop/.vite @@ -16,10 +17,34 @@ apps/desktop/.vite .gitignore .github -# Secrets / local env — MUST NOT enter the build context or image -.env -.env.* -!.env.example +# Claude worktrees — full duplicate checkouts of this repo (multi-GB), each +# carrying its own tracked env files. Nothing builds from them. +.claude/worktrees + +# Secrets / local env — MUST NOT enter the build context or image. +# +# The `**/` prefixes are load-bearing: a pattern without one is matched only +# against the CONTEXT-ROOT-relative path, so the previous `.env` / `.env.*` +# covered a root-level `.env` and nothing deeper. Every per-app env file was +# therefore in the context of every image. Two consequences, both real: +# +# • `docker build -f apps/api/Dockerfile .` from a working checkout shipped +# the operator's own apps/api/.env — DB URL, auth secret, provider keys — +# into openship-api, because Dockerfile:10 is `COPY apps/ ./apps/` and the +# runtime stage copies apps/api forward. Same exposure via +# apps/dashboard/Dockerfile. CI escaped it only because a clean checkout +# has no untracked .env. +# • apps/email/client/.env.development reached the webmail builder, where +# `node` is bun and bun auto-loads it whenever NODE_ENV is unset — which is +# how GH-567 froze `http://localhost:3000` into the published client +# bundle. (Also fixed at the source in apps/email/scripts/build-release.ts; +# this is the second lock on the same door.) +# +# No Dockerfile copies a `.env*` out of the context — every env COPY is a +# `--from=builder` — so excluding them cannot break a build. +**/.env +**/.env.* +!**/.env.example # Caches + logs **/.turbo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc9b989f0..287d9f70f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,11 @@ on: pull_request: {} push: branches: [main] - # A tag is what actually ships; the e2e-docker job runs on tags (see its - # `if` gate) so a release is proven restorable before it goes out. + # Tags run typecheck + tests here for the record; nothing in THIS file gates a + # release, because workflows are not ordered against one another. The checks that + # BLOCK a publish — this same suite plus the real-daemon E2E matrix, which used to + # live in this file and only ever ran alongside the release it claimed to gate — + # are in release-gate.yml, which Release and Docker images both list in `needs:`. tags: ["v*"] workflow_dispatch: {} @@ -86,35 +89,28 @@ jobs: # script (@repo/core, @repo/adapters, @repo/db [PGlite — no external DB], # apps/api, apps/dashboard). Packages resolve to src, so no build needed. # apps/api excludes test/e2e/** here — those need a daemon and run in the - # e2e-docker job below. + # e2e-docker job in release-gate.yml, where they gate the publish. - name: Run tests run: bun run test - # Rollback and restore against a REAL Docker daemon. This is the only job that - # proves those paths work at all; every other test in the repo mocks the runtime. + # The webmail server's own suite, which nothing else runs. # - # MANUAL by design: runs only on a manual `workflow_dispatch` and on release - # tag pushes (v*) — never on main pushes or PRs, which stay fast (typecheck + - # tests). The tag run is the release-restorability gate. + # GH-220: apps/email/server/test/{sanitize,from-header,list-snippet}.test.ts had no + # runner at all. They are `bun:test` files, and the root `test` script is + # `turbo run test --filter=!@repo/email` → vitest, so turbo never reached them; the + # server is not a root workspace member either (workspaces is apps/* + packages/*, + # which matches apps/email but not its subdirectories), so its deps are not installed + # by the root install. Net effect: 35 assertions were green on someone's laptop and + # unreachable from every pipeline — including sanitize.test.ts, which pins the fix for + # the CSS url()/@import read-receipt leak (GHSA-3hcp-c4c7-6m8p) and asserts the read + # pane stays inert. That is exactly the kind of test that must not rot. # - # `RUN_DOCKER_E2E=1` is what makes it honest: without it the suite skips when no - # daemon answers, which is exactly how these cases sat green-and-unrun for months. - # With it, an unreachable daemon fails in `beforeAll` instead of reporting skipped. - # - # `fast` is every daemon-level and full-cycle case (~5 min); `heavy` is - # rollback-build-restore alone (~225s cold, and fileParallelism is off, so it - # holds the whole suite up). Both run here — see E2E_SCOPE in - # apps/api/vitest.e2e.config.ts. - e2e-docker: - name: E2E (real Docker, ${{ matrix.scope }}) - # Manual + release tags only. Never on main pushes or PRs. - if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} + # Its own job rather than a turbo target: these need `bun test` (not vitest) and a + # separate install rooted in apps/email/server. Independent and parallel, so it cannot + # slow the jobs above. + webmail-server-test: + name: Test webmail server runs-on: ubuntu-latest - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - scope: [fast, heavy] steps: - name: Checkout uses: actions/checkout@v7 @@ -128,22 +124,18 @@ jobs: uses: actions/cache@v6 with: path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }} + key: ${{ runner.os }}-bun-webmail-${{ hashFiles('apps/email/server/bun.lock') }} restore-keys: | + ${{ runner.os }}-bun-webmail- ${{ runner.os }}-bun- - - name: Install dependencies - run: bun install --frozen-lockfile + # Not --frozen-lockfile: the committed lockfile is regenerated by + # scripts/build-release.ts for the dist, so it can legitimately lag the manifest + # here. Resolving fresh is fine for a test-only install. + - name: Install webmail server dependencies + working-directory: apps/email/server + run: bun install - # Fail here rather than inside vitest, so "the runner lost Docker" is - # distinguishable at a glance from "a rollback assertion broke". - - name: Check the Docker daemon - run: | - docker info - docker version - - - name: Run real-daemon E2E - env: - RUN_DOCKER_E2E: "1" - E2E_SCOPE: ${{ matrix.scope }} - run: bun run --cwd apps/api test:e2e + - name: Run webmail server tests + working-directory: apps/email/server + run: bun test diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 6bd30611b..8a403d9c5 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -33,6 +33,13 @@ permissions: packages: write jobs: + # Typecheck + tests. `merge-images` depends on this, so a red test cannot become a + # pullable image tag. See .github/workflows/release-gate.yml for why this is a + # called workflow rather than a trigger. + gate: + name: Release gate + uses: ./.github/workflows/release-gate.yml + # Native per-arch builds (no QEMU — reuses the ubuntu-24.04-arm runner). Each # arch is pushed to GHCR BY DIGEST; merge-images assembles the manifest lists. build-images: @@ -112,9 +119,98 @@ jobs: if-no-files-found: error retention-days: 1 + # The update path itself: the PREVIOUS release's stack, with rows in its database, + # recreated onto the api this run just built — `up -d --force-recreate api`, the same + # command `openship update` issues. + # + # Positioned between the build and the manifest publish on purpose. `build-images` + # pushes by digest under no tag, so at this point the new image exists but nothing + # pulls it yet; this job pulls it BY DIGEST, which makes the thing under test the exact + # bytes `merge-images` is about to tag. Testing `:latest` after publishing it would be + # testing what operators already got. + update-e2e: + name: E2E (update from previous release) + needs: build-images + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout + # Tags, and all of them: the test resolves which release to upgrade FROM by + # walking `git tag`. A shallow clone has none, and it fails rather than guess. + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache bun install + uses: actions/cache@v6 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # This runner is amd64, so it needs the amd64 half of the build. The artifact holds + # one empty file NAMED for the digest (see "Export digest" above). + - name: Resolve the new api image digest + id: newimage + uses: actions/download-artifact@v8 + with: + name: digests-api-amd64 + path: /tmp/api-digest + + - name: Build the image reference + id: ref + run: | + set -euo pipefail + digest="$(ls /tmp/api-digest | head -1)" + if [ -z "$digest" ]; then + echo "No digest artifact — build-images did not export one for api/amd64." >&2 + exit 1 + fi + echo "image=ghcr.io/${{ github.repository_owner }}/openship-api@sha256:${digest}" >> "$GITHUB_OUTPUT" + + # Fail here rather than inside vitest, so "the runner lost Docker" is + # distinguishable at a glance from "the update broke". + - name: Check the Docker daemon + run: | + docker info + docker version + + - name: Run the update E2E + env: + RUN_DOCKER_E2E: "1" + E2E_SCOPE: update + OPENSHIP_E2E_NEW_API_IMAGE: ${{ steps.ref.outputs.image }} + OPENSHIP_E2E_IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }} + run: bun run --cwd apps/api test:e2e + merge-images: name: Publish ${{ matrix.image }} (manifest) - needs: build-images + # `gate` as well as the builds: this job is where images become PULLABLE (the + # per-arch builds above push by digest only, under no tag), so it is the point a + # failing test has to stop. `openship update` pulls these tags — an unreachable + # gate result here is the difference between a bad release sitting unreferenced + # in the registry and every operator's stack recreating onto it. + # + # Gated on the manual path too. That path never moves `:latest`, but it does + # publish tags a box can be pointed at, and the wiring stays simpler than a + # conditional that has to be right about which publishes are "only tests". + needs: [gate, build-images, update-e2e] runs-on: ubuntu-24.04 strategy: fail-fast: false diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 000000000..18d73304c --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,125 @@ +name: Release gate + +# The tests that must pass before anything ships: the unit/integration suite, and the +# real-daemon E2E matrix. If either goes red, the release does not publish. +# +# Why this exists: `Release` and `Docker images` both trigger on the same `v*.*.*` tag +# push as `CI`, and GitHub does not order workflows against one another. So up to and +# including v0.6.5, the GitHub release, the npm CLI and the GHCR images all published +# *in parallel with* the test run and completed regardless of its result — `publish` +# needed only the build jobs, and `merge-images` only `build-images`. The e2e suite +# described itself as "the release-restorability gate" while being nothing of the kind. +# Nothing in the repo could fail an upload. +# +# `workflow_call` rather than a trigger: listing a job in `needs:` is the only way one +# workflow can block on another's result. `workflow_dispatch` is kept so the E2E matrix +# can still be run on demand, which is what CI's copy of it was for. +# +# `apps/api` IS typechecked here, because nothing else in a release does it. The build +# jobs only look like they would: `build` is `tsup --format esm` with no `--dts` and +# `build-release` is a bun compile, and both strip types without checking them. A live +# example while this was being written — `packages/db/src/dump.ts` referencing a schema +# export that no longer existed — compiled clean and would have shipped. +# +# The dashboard's typecheck deliberately stays in CI and out of this gate: it is a +# grep-filtered scan that tolerates pre-existing fumadocs errors, which is not something +# a release should hang on. + +on: + workflow_call: {} + workflow_dispatch: {} + +jobs: + test: + name: Typecheck + tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + # `latest`, matching what CI has always run these suites on. Deliberately NOT + # switched to the pinned .bun-version here: these two jobs now block releases, + # and changing their runtime in the same move that made them blocking is how you + # get a gate whose first red run nobody can attribute. Worth revisiting as its + # own change — a gate arguably should be reproducible. + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache bun install + uses: actions/cache@v6 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + # Covers packages/* too: they resolve to source, so an error in @repo/db surfaces + # here. This is the only typecheck between a tag and a published image. + - name: Typecheck apps/api + run: bun run --cwd apps/api lint + + # `turbo run test` across every package with a test script. Includes the two + # migration suites in packages/db: migrate-chain (the chain applied to a + # POPULATED database, plus a self-check proving it can fail) and + # migrations-additive (the static ADD COLUMN ... NOT NULL scan). Those are the + # coverage for "the update crash-looped on migrations". + - name: Run tests + run: bun run test + + # Rollback and restore against a REAL Docker daemon. This is the only job that proves + # those paths work at all; every other test in the repo mocks the runtime. It lived in + # CI, where it ran alongside the release it claimed to gate — it is here now so a + # failure actually stops the publish. + # + # `RUN_DOCKER_E2E=1` is what makes it honest: without it the suite skips when no + # daemon answers, which is exactly how these cases sat green-and-unrun for months. + # With it, an unreachable daemon fails in `beforeAll` instead of reporting skipped. + # + # `fast` is every daemon-level and full-cycle case (~5 min); `heavy` is + # rollback-build-restore alone (~225s cold, and fileParallelism is off, so it holds + # the whole suite up). Both run here — see E2E_SCOPE in apps/api/vitest.e2e.config.ts. + e2e-docker: + name: E2E (real Docker, ${{ matrix.scope }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + scope: [fast, heavy] + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache bun install + uses: actions/cache@v6 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + # Fail here rather than inside vitest, so "the runner lost Docker" is + # distinguishable at a glance from "a rollback assertion broke". + - name: Check the Docker daemon + run: | + docker info + docker version + + - name: Run real-daemon E2E + env: + RUN_DOCKER_E2E: "1" + E2E_SCOPE: ${{ matrix.scope }} + run: bun run --cwd apps/api test:e2e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ed99399a..0099dfaff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,13 @@ permissions: contents: write jobs: + # Typecheck + tests. `publish` and `publish-npm` depend on this, so a red test + # stops the release instead of publishing alongside it. Runs on the manual + # dispatch too — that path publishes npm, which is immutable. + gate: + name: Release gate + uses: ./.github/workflows/release-gate.yml + build-openship: name: Build openship release artifact if: github.event_name == 'push' @@ -546,9 +553,15 @@ jobs: # mismatch. `always()` keeps the manual npm-only path working: on a # workflow_dispatch the `publish` job is skipped (push-only), and we publish # anyway; on a tag push we require `publish` to have succeeded. - needs: [publish] + # + # `gate` is required on BOTH paths, and is the one condition with no escape + # hatch: the tag path inherits it through `publish`, and the dispatch path — + # which skips `publish` entirely — would otherwise put an untested, + # unreplaceable version on npm. + needs: [gate, publish] if: >- always() && + needs.gate.result == 'success' && (github.event_name == 'workflow_dispatch' || needs.publish.result == 'success') # OIDC trusted publishing: npm verifies this workflow's identity instead of # a long-lived token. id-token:write lets the runner mint the OIDC token. @@ -652,7 +665,7 @@ jobs: publish: name: Publish GitHub release if: github.event_name == 'push' - needs: [build-openship, build-email, build-dashboard, build-cli-payload, build-desktop, build-desktop-macos] + needs: [gate, build-openship, build-email, build-dashboard, build-cli-payload, build-desktop, build-desktop-macos] runs-on: ubuntu-24.04 steps: - name: Checkout diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts index ee3a88aa8..885682c07 100644 --- a/apps/api/src/lib/audit.ts +++ b/apps/api/src/lib/audit.ts @@ -19,7 +19,7 @@ import type { Context } from "hono"; import { repos } from "@repo/db"; -import { resolveCallSource, type AuditSource } from "./call-source"; +import { resolveCallClientId, resolveCallSource, type AuditSource } from "./call-source"; export interface AuditContext { organizationId: string; @@ -28,6 +28,9 @@ export interface AuditContext { userAgent?: string | null; /** Where the action came in from. Filled by `auditContextFrom`. */ source?: AuditSource | null; + /** Which client of that surface — `oauth:` / `pat:`. Only + * MCP dispatch sets it; see call-source.ts. */ + sourceClientId?: string | null; } export interface AuditEventInput { @@ -39,6 +42,9 @@ export interface AuditEventInput { /** Overrides the context's source. For emitters with no request to read * (crons, Better Auth hooks, webhook deliveries). */ source?: AuditSource | null; + /** Overrides the context's client id. For the MCP endpoint itself, which knows + * the calling client before any sub-request has carried the signed header. */ + sourceClientId?: string | null; } export const audit = { @@ -56,6 +62,7 @@ export const audit = { ipAddress: ctx.ipAddress ?? null, userAgent: ctx.userAgent ?? null, source: event.source ?? ctx.source ?? null, + sourceClientId: event.sourceClientId ?? ctx.sourceClientId ?? null, }); } catch (err) { console.error("[audit] failed to record event", event.eventType, err); @@ -79,5 +86,6 @@ export function auditContextFrom( ipAddress: c.var.clientIp, userAgent: c.req.header("user-agent") ?? null, source: resolveCallSource(c), + sourceClientId: resolveCallClientId(c), }; } diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 9df7ba5d2..e2d84e811 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -175,7 +175,17 @@ export const auth = betterAuth({ sendVerificationEmail: smtpEnabled ? async ({ user, url }: { user: User; url: string; token: string }) => { const email = verifyEmailTemplate(user, url); - await sendMail({ to: user.email, ...email }); + const delivered = await sendMail({ to: user.email, ...email }); + // `requireEmailVerification` blocks sign-in until the address is confirmed, + // so a silently-dropped verification mail is an account that can never be + // used. Fail the request instead of creating one. + if (!delivered) { + throw new APIError("SERVICE_UNAVAILABLE", { + message: + "Could not send the verification email — this instance has no working " + + "email transport. Configure SMTP in Settings → Email.", + }); + } } : undefined, }, @@ -405,7 +415,17 @@ export const auth = betterAuth({ // types are not enabled, so they fall through and send nothing. if (type === "email-verification") { const tmpl = verifyOtpEmailTemplate(otp, { expiresMinutes: 10 }); +<<<<<<< HEAD await sendMail({ to: email, ...tmpl }); +======= + if (!(await sendMail({ to: email, ...tmpl }))) { + throw new APIError("SERVICE_UNAVAILABLE", { + message: + "Could not send the verification code — this instance has no working " + + "email transport. Configure SMTP in Settings → Email.", + }); + } +>>>>>>> a52e2566 (patch v0.6.6) return; } // Password reset. This replaced the link flow (`sendResetPassword` in the @@ -431,7 +451,18 @@ export const auth = betterAuth({ }); } const tmpl = resetPasswordOtpEmail(otp, { expiresMinutes: 10 }); - await sendMail({ to: email, ...tmpl }); + // Check the RESULT as well as the pre-flight above. `canSendMail()` reads a + // transport cache with a 60s TTL, so it can say yes for a config that has + // since been changed or broken — and being told to check your inbox while + // locked out is the worst place to be optimistic. + if (!(await sendMail({ to: email, ...tmpl }))) { + throw new APIError("SERVICE_UNAVAILABLE", { + message: + "Could not send the reset code — this instance has no working email " + + "transport. Configure SMTP in Settings → Email, or reset the password " + + "from the server with `openship reset-admin`.", + }); + } } }, }), @@ -532,7 +563,7 @@ export const auth = betterAuth({ const settings = await repos.instanceSettings.get(); const source = settings?.invitationMailSource === "cloud" ? "cloud" : "platform"; - await sendMail({ + const delivered = await sendMail({ to: data.email, preferSource: source, // organizationId is required by lib/mail.ts when @@ -542,6 +573,18 @@ export const auth = betterAuth({ organizationId: data.organization.id, ...email, }); + // An invite that cannot be delivered must not report success: the invitee + // has a pending row and no way to learn about it, and the inviter believes + // it went out. `sendMail` only warns on an empty chain, so this is the only + // place that can tell. Throwing surfaces it on the invite request itself. + if (!delivered) { + throw new APIError("SERVICE_UNAVAILABLE", { + message: + `Could not email the invitation to ${data.email} — this instance has ` + + `no working email transport. Configure SMTP in Settings → Email and ` + + `invite again.`, + }); + } } : undefined, diff --git a/apps/api/src/lib/call-source.ts b/apps/api/src/lib/call-source.ts index 75d4280a8..5f5c3658e 100644 --- a/apps/api/src/lib/call-source.ts +++ b/apps/api/src/lib/call-source.ts @@ -13,6 +13,10 @@ * generated at boot and never sent anywhere: only a request originating INSIDE * this process can know it. A forged header fails the check and the source is * derived from the credential instead. + * + * The dispatcher signs a second claim the same way — which CLIENT of that surface + * (`oauth:` / `pat:`) — because with two assistants connected + * under one user, "mcp" alone can't tell you which one to revoke. */ import { AsyncLocalStorage } from "node:async_hooks"; @@ -28,6 +32,21 @@ export function isAuditSource(value: unknown): value is AuditSource { } const CALL_SOURCE_HEADER = "x-openship-call-source"; +const CALL_CLIENT_HEADER = "x-openship-call-client"; + +/** + * Shape of an attributable client id: the canonical principal id the auth layer + * mints (`oauth:` / `pat:`). Bounded and character-checked + * because the value is persisted on audit_event and rendered in the audit UI — + * the nonce proves it came from us, not that we assembled it from something sane. + */ +const CLIENT_ID_PATTERN = /^(?:oauth|pat):[A-Za-z0-9_.\-]{1,128}$/; + +/** True for a well-formed source-client id. Also the query-param validator for + * the audit filter, so what can be stored and what can be filtered on agree. */ +export function isAuditClientId(value: unknown): value is string { + return typeof value === "string" && CLIENT_ID_PATTERN.test(value); +} /** * Process-local secret. Regenerated on every boot: an in-flight forged header @@ -40,21 +59,54 @@ export function internalSourceHeader(source: AuditSource): Record { + return { [CALL_CLIENT_HEADER]: `${principalId}.${PROCESS_NONCE}` }; +} + /** Constant-time, because the nonce is the whole gate: leak it and `mcp` is claimable. */ function nonceMatches(candidate: string): boolean { if (candidate.length !== PROCESS_NONCE.length) return false; return timingSafeEqual(Buffer.from(candidate), Buffer.from(PROCESS_NONCE)); } -/** The claimed source, but only if the claim came from this process. */ -function trustedClaim(c: Context): AuditSource | null { - const raw = c.req.header(CALL_SOURCE_HEADER); +/** + * The payload of a nonce-signed header, or null if the signature isn't ours. + * The nonce is hex, so the LAST dot is always the separator no matter what the + * payload contains. + */ +function signedPayload(raw: string | undefined): string | null { if (!raw) return null; const sep = raw.lastIndexOf("."); if (sep <= 0) return null; - const claimed = raw.slice(0, sep); if (!nonceMatches(raw.slice(sep + 1))) return null; - return isAuditSource(claimed) ? claimed : null; + return raw.slice(0, sep); +} + +/** The claimed source, but only if the claim came from this process. */ +function trustedClaim(c: Context): AuditSource | null { + const claimed = signedPayload(c.req.header(CALL_SOURCE_HEADER)); + return claimed && isAuditSource(claimed) ? claimed : null; +} + +/** + * Which client of the surface made this request — `oauth:` / + * `pat:` — or null when nobody trustworthy said. + * + * Deliberately NOT derived from the credential as a fallback: the surfaces that + * have one client per request (a browser, the CLI) gain nothing from the column, + * and guessing here would put a value in it that no dispatcher stands behind. + */ +export function resolveCallClientId(c: Context): string | null { + const claimed = signedPayload(c.req.header(CALL_CLIENT_HEADER)); + return claimed && CLIENT_ID_PATTERN.test(claimed) ? claimed : null; } /** diff --git a/apps/api/src/lib/domain-claims.ts b/apps/api/src/lib/domain-claims.ts new file mode 100644 index 000000000..9af337240 --- /dev/null +++ b/apps/api/src/lib/domain-claims.ts @@ -0,0 +1,72 @@ +/** + * "May this project ROUTE a hostname it does not own?" + * + * The generic question the general routing paths should be asking. + * + * `domain.owner_type` is already a general concept — `project` (the default), + * `webhook`, `mail` — so the data model has always allowed a row that belongs to + * something other than a project. What the code lacked was a matching question. Both + * general paths (`lib/routing-domains.ts` and `modules/domains/domain.service.ts`) + * instead hardcoded a call to the MAIL predicate, each with its own copy of the + * mail-specific reasoning, in the middle of logic that is otherwise subsystem-blind. + * + * That is the smell this file removes. A general path now asks one general question, + * and each subsystem that owns hostnames answers for itself. When `webhook`-owned rows + * need the same treatment it becomes an entry below, not a third `if` in a third + * general path. + * + * WHAT "TRUE" MEANS, and why it is not just tidiness: the caller registers the vhost + * and creates NO domain row. `domain.project_id` cascades on project delete, so + * stamping the routing project onto a foreign-owned row would make deleting that + * project delete the owner's record — for mail, that is the mail host's only + * certificate-renewal row, and the failure surfaces as silent TLS expiry ~90 days + * later with nothing to restore it (#566). + */ + +import type { Domain } from "@repo/db"; + +import { mailHostRoutableByProject } from "./mail-host-claim"; + +/** + * One subsystem's answer for the hostnames it owns. + * + * Contract: NEVER throw. These are consulted on the way to a conflict error, and a + * failed lookup must leave the caller's existing refusal in place rather than replace + * it with a 500. Each implementation is responsible for that. + */ +type ForeignHostClaim = ( + hostname: string, + projectId: string, + row?: Domain | null, +) => Promise; + +/** + * Every subsystem that can hand a project routing rights over a hostname it does not + * own. One entry today; the point is that adding the second one touches this list and + * nothing else. + */ +const FOREIGN_HOST_CLAIMS: ForeignHostClaim[] = [mailHostRoutableByProject]; + +/** + * True when some subsystem grants `projectId` the right to route `hostname` while + * owning no row for it. + * + * Call this BEFORE the cross-project ownership refusal and whether or not a row + * exists: a claim can legitimately apply to a hostname with no row at all (mail's + * `recordMailCertDomain` is best-effort), and inside an `if (row)` branch the caller + * would fall through and MINT a project-owned row for the very hostname the claim + * exists to protect. + * + * @param row The already-fetched domain row for this hostname, when the caller has + * one — passed through so a claim can inspect it without a second query. + */ +export async function routableWithoutOwnership( + hostname: string, + projectId: string, + row?: Domain | null, +): Promise { + for (const claim of FOREIGN_HOST_CLAIMS) { + if (await claim(hostname, projectId, row)) return true; + } + return false; +} diff --git a/apps/api/src/lib/env-reveal.ts b/apps/api/src/lib/env-reveal.ts new file mode 100644 index 000000000..15b96a8cb --- /dev/null +++ b/apps/api/src/lib/env-reveal.ts @@ -0,0 +1,56 @@ +import { AppError } from "@repo/core"; + +/** + * #336 per-key env reveal — the one place that turns "the whole env map" into + * "exactly the keys the caller named". + * + * All three reveal sources (a service row, an upload session, a container's + * `docker inspect`) hand back a full map, so without this every eye-press shipped + * every secret of that service to the browser: 32 plaintext values in the network + * response, in memory and in the devtools log to see one. + * + * `keys` is REQUIRED and non-empty on purpose — there is no request shape that + * means "give me everything". A caller must already know a key's name to see its + * value, and the audit row (`auditAfter.revealedEnvKeys`) records exactly which + * secrets were disclosed instead of an unqualified "revealed env". + */ + +/** Bound on one request. Generous for a real service, cheap abuse guard. */ +export const MAX_REVEAL_KEYS = 500; +const MAX_KEY_LENGTH = 512; + +/** Validate a client-sent `keys` list. Throws AppError(400) — the global error + * handler renders it; callers don't need their own branch. */ +export function parseRevealKeys(input: unknown): string[] { + if (!Array.isArray(input) || input.length === 0) { + throw new AppError("keys must be a non-empty array of env var names", 400); + } + if (input.length > MAX_REVEAL_KEYS) { + throw new AppError(`keys accepts at most ${MAX_REVEAL_KEYS} names per request`, 400); + } + const seen = new Set(); + for (const key of input) { + if (typeof key !== "string" || key.length === 0 || key.length > MAX_KEY_LENGTH) { + throw new AppError("keys must contain only non-empty env var names", 400); + } + seen.add(key); + } + return [...seen]; +} + +/** + * Return ONLY the requested keys that actually exist in `env`. `hasOwn` rather + * than `key in env`: `keys` is client-controlled, and `in` would happily resolve + * `constructor` / `toString` off the prototype and answer with a function. + */ +export function pickRevealed( + env: Record | null | undefined, + keys: string[], +): Record { + const revealed: Record = {}; + if (!env) return revealed; + for (const key of keys) { + if (Object.hasOwn(env, key)) revealed[key] = env[key]; + } + return revealed; +} diff --git a/apps/api/src/lib/host-channel-banner.ts b/apps/api/src/lib/host-channel-banner.ts index a28da8bf3..73b7dfb2f 100644 --- a/apps/api/src/lib/host-channel-banner.ts +++ b/apps/api/src/lib/host-channel-banner.ts @@ -28,6 +28,7 @@ const TITLES: Partial> = { unreachable: "HOST CONTROL UNREACHABLE", not_configured: "HOST CONTROL NOT CONFIGURED", key_unreadable: "HOST CONTROL KEY UNREADABLE", + auth_rejected: "HOST CONTROL KEY REFUSED", }; /** One blocked item, wrapped with a hanging indent so a long one still reads as a diff --git a/apps/api/src/lib/loopback-publish.ts b/apps/api/src/lib/loopback-publish.ts index 9dbe3f912..649714a85 100644 --- a/apps/api/src/lib/loopback-publish.ts +++ b/apps/api/src/lib/loopback-publish.ts @@ -34,3 +34,44 @@ export function withLoopbackPublish( const kept = portSpecs.filter((spec) => specContainerPort(spec) !== containerPort); return [...kept, `127.0.0.1:${hostPort}:${containerPort}`]; } + +/** + * Republish EVERY routed container port on its own pinned loopback host port. + * + * A service can own several routes (minio's console + `s3` API), and each needs a + * DISTINCT host port or the edge cannot tell them apart. + */ +export function withLoopbackPublishAll( + portSpecs: readonly string[], + /** routed container port → the loopback host port pinned for it. */ + pinned: ReadonlyMap, +): string[] { + let out = [...portSpecs]; + for (const [containerPort, hostPort] of pinned) { + out = withLoopbackPublish(out, containerPort, hostPort); + } + return out; +} + +/** + * The host port a route's upstream should dial for `port`. + * + * The pinned map is authoritative. `resultHostPort` — a single scalar read back + * off the daemon — is only meaningful for the PRIMARY routed port: applying it to + * a secondary port is what made every extra subdomain proxy to the first route's + * port (minio's `s3` host served the console). Undefined means "no host port", + * which sends the caller to container-IP addressing instead of a wrong guess. + */ +export function upstreamHostPortFor(args: { + port: number; + pinned: ReadonlyMap; + primaryPort?: number; + resultHostPort?: number | null; + sameService: boolean; +}): number | undefined { + const { port, pinned, primaryPort, resultHostPort, sameService } = args; + return ( + pinned.get(port) ?? + (sameService && port === primaryPort && resultHostPort ? resultHostPort : undefined) + ); +} diff --git a/apps/api/src/lib/mail-host-claim.ts b/apps/api/src/lib/mail-host-claim.ts new file mode 100644 index 000000000..ef63da7de --- /dev/null +++ b/apps/api/src/lib/mail-host-claim.ts @@ -0,0 +1,83 @@ +/** + * May this project route `mail.` — the mail server's own hostname? + * + * The mail install records `mail.` as a domain row with `ownerType='mail'` and + * `project_id = NULL`, purely so the renewal sweep can find the certificate + * (`recordMailCertDomain`). Nothing owns it in the project sense, and the two hijack + * guards both compare `owner.projectId !== projectId` — which `NULL` never satisfies. So + * no project could ever claim a mail-owned hostname, including the webmail the mail + * module itself deploys onto that exact host (issue #566). The install died with "already + * connected to another project"; the deploy skipped the route and left the container + * running with no vhost. + * + * Deploying webmail on `mail.` is the sanctioned configuration — same box, same + * certificate, no second DNS record, no port conflict (mail is on 25/465/587/143/993, + * webmail on 443). This is the one predicate that says so. + * + * ROUTE IT, OWN NOTHING. A caller that gets `true` registers the vhost and creates NO + * domain row: the mail row keeps `ownerType='mail', project_id=NULL`. That is not + * tidiness, it is the whole safety argument. `domain.project_id` cascades on project + * delete, so stamping this project onto the mail row would make deleting the webmail + * delete the mail host's only renewal record — silent IMAP/SMTP TLS expiry about 90 days + * later, with no hook anywhere to restore it. + * + * THE LINK IS THE AUTHORIZATION. `mail_servers.webmail_project_id` must already point at + * the claiming project; a webmail-shaped project is not enough, because "some project in + * this org that looks like a webmail" would let an ordinary service edit take over the + * mail hostname. The webmail installer therefore stamps that link BEFORE it applies the + * mail-host route (see `runWebmailInstall`), which is also what keeps the box correct: + * `startWebmailDeploy` refuses `mail.` on any server other than the mail server + * itself, so the only project that can hold the link is one deploying onto that box. + */ + +import { mailHostBaseDomain } from "@repo/core"; +import { repos } from "@repo/db"; +import type { Domain } from "@repo/db"; + +import { MAIL_DOMAIN_OWNER } from "./domain-ssl"; + +/** + * True when `hostname` is the mail host of a mail server whose webmail IS this project. + * + * Never throws: it is consulted on the way to an error, and a failed lookup must leave + * the caller's existing refusal in place rather than replace it with a 500. + * + * @param row The already-fetched domain row for this hostname, when the caller has one. + * Pass it to avoid a second query. Its absence is legitimate — `recordMailCertDomain` + * is best-effort, so the mail row can be missing, and this must still answer `true` + * there or the caller mints a project-owned row for the mail host and the next mail + * install refuses to record the certificate against it. + */ +export async function mailHostRoutableByProject( + hostname: string, + projectId: string, + row?: Domain | null, +): Promise { + try { + // `mailHostBaseDomain` (@repo/core) rather than a local `/^mail\./` strip: the mail + // host label is defined once, and the build and the parse derive from the same + // constant. A private regex here is how a prefix change makes this predicate + // silently answer `false` and webmail-on-the-mail-host stops routing with no error. + const base = mailHostBaseDomain(hostname); + if (!base) return false; + + // A row that exists must be the mail install's own. Another project's row is a real + // conflict, and a project-owned row for this host means someone already minted what + // this predicate exists to prevent — either way, not ours to route. + if (row && (row.ownerType !== MAIL_DOMAIN_OWNER || row.projectId !== null)) return false; + + const mail = await repos.mailServer.findByDomain(base); + if (!mail || mail.webmailProjectId !== projectId) return false; + + // Org authority comes from the SERVER row, never from the link alone: the link is a + // pointer the mail module writes, and a cross-org pointer must not authorize a route. + const [server, project] = await Promise.all([ + repos.server.get(mail.serverId), + repos.project.findById(projectId), + ]); + if (!server?.organizationId || !project) return false; + return server.organizationId === project.organizationId; + } catch { + return false; + } +} diff --git a/apps/api/src/lib/mail.ts b/apps/api/src/lib/mail.ts index 4863df385..10706a44b 100644 --- a/apps/api/src/lib/mail.ts +++ b/apps/api/src/lib/mail.ts @@ -399,12 +399,33 @@ async function getTransportChain( if (preferSource !== "platform" && envTransport) { chain.push({ transport: envTransport, from: envFrom, source: "env" }); } + // Last resort for the "platform" preference: env is normally dropped so a branded + // invite can't quietly go out from a generic sender — but that only holds while a + // branded sender EXISTS. On a box whose only mail config is env SMTP the chain above + // is empty, and dropping env there doesn't downgrade the sender, it loses the mail + // entirely (sendMail's empty-chain path is a silent no-op). An invite delivered from + // the generic sender beats an invite that never arrives. + if (chain.length === 0 && envTransport) { + chain.push({ transport: envTransport, from: envFrom, source: "env" }); + } return chain; } -/** Send an email. No-ops with a warning when no transport is available; fails - * over across the transport chain when a send throws. */ -export async function sendMail(opts: SendMailOptions): Promise { +/** + * Send an email. Fails over across the transport chain when a send throws. + * + * RETURNS whether anything actually accepted the message. This used to be `void`, + * and the empty-chain path still only warns rather than throwing — which is right + * for the fire-and-forget callers, but it meant a caller could not TELL. That is how + * an email notification channel ended up marked `verified` and every delivery marked + * `sent` on a box with no transport at all: the send "succeeded" because it silently + * did nothing. + * + * So: callers that must react check the boolean (see the email notification worker), + * and callers that genuinely don't care keep ignoring it exactly as before. A hard + * throw here would have changed behaviour for all eight call sites at once. + */ +export async function sendMail(opts: SendMailOptions): Promise { const preferSource = opts.preferSource ?? "auto"; // Cloud relay branch — only meaningful on a local self-hosted instance. @@ -416,7 +437,7 @@ export async function sendMail(opts: SendMailOptions): Promise { "[mail] preferSource=cloud requires organizationId - skipping email to", opts.to, ); - return; + return false; } // cloud-client is dual-side (local outbound → SaaS) with no local- // only side effects on import, so static import is fine. Cargo-cult @@ -434,8 +455,9 @@ export async function sendMail(opts: SendMailOptions): Promise { console.warn( `[mail] cloud invitation relay failed for org=${opts.organizationId}: ${result.error}`, ); + return false; } - return; + return true; } const chain = await getTransportChain(preferSource); @@ -444,7 +466,7 @@ export async function sendMail(opts: SendMailOptions): Promise { `[mail] no transport configured (preferSource=${preferSource}) - skipping email to`, opts.to, ); - return; + return false; } // Try each transport in priority order; fail over to the next on a send @@ -461,7 +483,7 @@ export async function sendMail(opts: SendMailOptions): Promise { html: opts.html, ...(opts.text ? { text: opts.text } : {}), }); - return; + return true; } catch (err) { lastErr = err; const more = i < chain.length - 1; diff --git a/apps/api/src/lib/notification-categories.ts b/apps/api/src/lib/notification-categories.ts index 7c73371b1..cd97d5f68 100644 --- a/apps/api/src/lib/notification-categories.ts +++ b/apps/api/src/lib/notification-categories.ts @@ -32,6 +32,9 @@ export const CATEGORY_GROUPS = [ { id: "jobs", label: "Jobs" }, { id: "domains", label: "Domains & SSL" }, { id: "members", label: "Members" }, + // Self-hosted-only, and dropped from `listCategories` under CLOUD_MODE — the mirror + // image of `billing` below. Placed before it so the cloud-only group stays last. + { id: "mail", label: "Mail" }, { id: "billing", label: "Billing" }, ] as const satisfies readonly { id: string; label: string }[]; @@ -211,6 +214,24 @@ export const CATEGORIES: readonly NotificationCategory[] = [ defaultEnabled: false, }, + // Self-hosted-only: fed by the mail engine, so `listCategories` drops this group + // under CLOUD_MODE — the inverse of the billing block below. It stays in the + // registry regardless, because `findCategory` supplies the title and first body + // line of every alert already stored against it. + // + // defaultEnabled MUST stay false. The dispatcher's fallback fans a default-enabled + // category to every org member's verified email channel with no opt-in, and for a + // PER-MESSAGE event that is both a flood and a mail loop: the notification mail + // would land on the same engine, inside a watched domain, and capture itself. + { + id: "mail.inbound_received", + group: "mail", + label: "Inbound email received", + description: + "A message arrived at a mailbox or domain one of your inbound rules watches.", + defaultEnabled: false, + }, + // Cloud-only: both are fed by Stripe/Oblien, so `listCategories` drops the whole // group outside CLOUD_MODE rather than showing toggles that can never fire. They // stay in the registry regardless — `findCategory` still has to render a message @@ -290,6 +311,11 @@ const EVENT_TYPE_TO_CATEGORY: Record = { "invitation.sent": "invitation.sent", "invitation.created": "invitation.sent", + // Mail (self-hosted engine). Without this entry `notification.emit` returns with no + // log and no delivery row, so the rules UI would look like it works while nothing + // is ever sent. + "mail.inbound_received": "mail.inbound_received", + // Billing "billing.payment_failed": "billing.alert", "billing.invoice_overdue": "billing.alert", diff --git a/apps/api/src/lib/notification-workers.ts b/apps/api/src/lib/notification-workers.ts index b786c1a3f..47a088a70 100644 --- a/apps/api/src/lib/notification-workers.ts +++ b/apps/api/src/lib/notification-workers.ts @@ -191,12 +191,27 @@ async function sendEmail( } const { title, body } = renderMessage(delivery); - await sendMail({ + const delivered = await sendMail({ to: config.address, subject: `[Openship] ${title}`, text: body, html: `
${escapeHtml(body)}
`, }); + // THROW when nothing could carry it. `sendMail` only warns on an empty transport + // chain, so ignoring its result meant this worker returned normally and the delivery + // was marked SENT having delivered nothing. Worse, `POST /channels/:id/test` flips a + // channel to `verified` whenever the send doesn't throw — so an instance with no SMTP + // produced a channel that looked verified and working and had never delivered once. + // + // Throwing is exactly right here: this worker's contract is "throw = retry", so the + // alert is retried and the reason lands in the delivery row's lastError, and the test + // endpoint's catch now refuses to verify the channel. + if (!delivered) { + throw new Error( + "No email transport is configured on this instance, so the message could not be " + + "sent. Configure SMTP in Settings → Email.", + ); + } } async function sendWebhook( diff --git a/apps/api/src/lib/routing-domains.ts b/apps/api/src/lib/routing-domains.ts index 78d3f79b5..d32d0cc57 100644 --- a/apps/api/src/lib/routing-domains.ts +++ b/apps/api/src/lib/routing-domains.ts @@ -8,6 +8,7 @@ import { acmeIssueLockKey, LOCAL_ACME_SCOPE, resolveSslPatch, sslIssueLockKey } import { resolveRouteRedirect } from "./domain-redirect"; import { createProvisionLock } from "./provision-lock"; import { generateToken } from "./domain-token"; +import { routableWithoutOwnership } from "./domain-claims"; export interface PlannedRouteDomain { hostname: string; @@ -629,6 +630,19 @@ export async function ensureRouteDomainRecord(opts: { // cert). Refuse loudly here, before any patch or create, so neither the update // nor the create path can claim a hostname this project doesn't own. const owner = await repos.domain.findByHostname(route.hostname); + + // A hostname owned by another SUBSYSTEM that still grants this project routing rights + // — see lib/domain-claims for who those are and why they own no project row. + // Deliberately the GENERAL question: this function is otherwise subsystem-blind, and + // the per-subsystem reasoning belongs with the claim rather than inline in the deploy + // path. A true return still registers the vhost; it only declines to record a row. + // + // Asked BEFORE the ownership branch and whether or not a row exists: a claim can apply + // to a hostname with no row at all, and inside the `owner &&` branch this would fall + // through to `findOrCreate` and MINT a project-owned row for the very host the claim + // exists to protect. + if (await routableWithoutOwnership(route.hostname, projectId, owner)) return null; + if (owner && owner.projectId !== projectId) { throw new ConflictError( `Hostname ${route.hostname} is routed by another project and cannot be claimed here.`, diff --git a/apps/api/src/lib/server-container-session.ts b/apps/api/src/lib/server-container-session.ts index 883d781ab..d616680ac 100644 --- a/apps/api/src/lib/server-container-session.ts +++ b/apps/api/src/lib/server-container-session.ts @@ -100,6 +100,61 @@ export function getActiveContainerApplySession( return null; } +/** + * A session without its transport — what a progress READER needs (steps, status, + * outcome) and nothing it must not hold (the subscriber set, the log ring, the + * done promise). + */ +export interface ContainerApplySnapshot { + id: string; + serverId: string; + component: ContainerComponent; + status: ContainerSessionStatus; + steps: ContainerStep[]; + startedAt: number; + finishedAt?: number; + result?: ContainerApplyResult; + error?: string; +} + +function snapshot(s: ContainerApplySession): ContainerApplySnapshot { + return { + id: s.id, + serverId: s.serverId, + component: s.component, + status: s.status, + steps: s.steps.map((step) => ({ ...step })), + startedAt: s.startedAt, + ...(s.finishedAt ? { finishedAt: s.finishedAt } : {}), + ...(s.result ? { result: { ...s.result } } : {}), + ...(s.error ? { error: s.error } : {}), + }; +} + +/** + * Every apply this process is running, plus the ones that JUST settled — the read + * behind a fleet-wide progress surface. + * + * One pass over the store instead of a scan per (server, component), because a + * fleet view asks about every box at once. `settledWithinMs` includes finished + * sessions for that long after the fact: the outcome is the only place a "done" + * beat can come from — the drift row clears its `behind` and its in-progress flag + * in the same write, so a reader watching the row alone can only ever see the work + * disappear, never that it succeeded. + */ +export function listContainerApplySessions(opts?: { + settledWithinMs?: number; +}): ContainerApplySnapshot[] { + const window = opts?.settledWithinMs ?? 0; + const cutoff = Date.now() - window; + const out: ContainerApplySnapshot[] = []; + for (const s of sessions.values()) { + if (s.status === "running") out.push(snapshot(s)); + else if (window > 0 && (s.finishedAt ?? 0) >= cutoff) out.push(snapshot(s)); + } + return out.sort((a, b) => a.startedAt - b.startedAt); +} + export function createContainerApplySession( serverId: string, component: ContainerComponent, diff --git a/apps/api/src/lib/startup/infra-reconcile.ts b/apps/api/src/lib/startup/infra-reconcile.ts index a7d50908b..8c2411a5e 100644 --- a/apps/api/src/lib/startup/infra-reconcile.ts +++ b/apps/api/src/lib/startup/infra-reconcile.ts @@ -19,6 +19,12 @@ * auto-apply, when `autoUpdateInfra` is on — never holds up boot. Both the * advisory (toggle off) and the auto-update (toggle on) fall out of that one * call. Self-hosted + desktop only (CLOUD_MODE never runs startup hooks). + * + * One thing here runs on EVERY boot, not once per version: clearing stale + * in-progress flags. An apply lives in this process — its session, logs and step + * model are in memory — so a flag that survived a restart describes a run that no + * longer exists, and every surface that renders it would show a permanent + * "Updating…" for a swap nobody is performing. */ import { safeErrorMessage } from "@repo/core"; @@ -32,6 +38,14 @@ export function registerInfraReconcile(): void { id: "infra:reconcile", modes: ["desktop", "selfhosted"], run: async () => { + // Every boot, before the version gate: no apply can be running yet, so any + // in-progress flag is a leftover from the process that died holding it. + await repos.serverContainerStatus + .clearAllInProgress() + .catch((err) => + console.warn(`[infra-reconcile] in-progress reset failed: ${safeErrorMessage(err)}`), + ); + const current = readApiVersion(); const settings = await repos.instanceSettings.get().catch(() => undefined); if (settings?.lastSeenVersion === current) return; diff --git a/apps/api/src/lib/startup/self-server.ts b/apps/api/src/lib/startup/self-server.ts index 622bcd72b..a8fc28d03 100644 --- a/apps/api/src/lib/startup/self-server.ts +++ b/apps/api/src/lib/startup/self-server.ts @@ -38,6 +38,7 @@ * the target list and the deploy wizard to surface — an annotation, never a gate. */ import { env } from "../../config/env"; +import { hostChannelAccount } from "@repo/core"; import type { HostChannelCode } from "@repo/adapters"; import { repos, type Server } from "@repo/db"; import { boxOwningOrgId } from "../box-org"; @@ -131,12 +132,13 @@ async function register(opts?: EnsureLocalServerOptions): Promise const organizationId = await localServerOwnerOrg(); if (!organizationId) return null; - // The account the host channel actually logs in as. The CLI writes - // OPENSHIP_HOST_SSH_USER when it provisions the container→host channel; the default - // matches `hostChannelUser()`, so a bare install with no channel agrees too. A row - // claiming `root` while the channel dials someone else is issue #489 — and since - // these fields are display-only (below), nothing else would ever correct it. - const desiredSshUser = process.env.OPENSHIP_HOST_SSH_USER?.trim() || "root"; + // The account the host channel actually logs in as. Through the SHARED resolver, not a + // local copy of the expression: the row is display-only, so nothing downstream would + // ever correct it, and a row claiming one account while the channel dials another is + // #489 and then #527 — where the operator was shown `admin@…`, went to fix it, and the + // dial went on using `root` regardless. Same function the dial uses (hostChannelUser in + // adapters), so the two cannot drift. + const desiredSshUser = hostChannelAccount(process.env); const existing = await repos.server.findLocal(organizationId); if (existing) { diff --git a/apps/api/src/middleware/error-handler.ts b/apps/api/src/middleware/error-handler.ts index 62c4bf766..2faade503 100644 --- a/apps/api/src/middleware/error-handler.ts +++ b/apps/api/src/middleware/error-handler.ts @@ -32,6 +32,12 @@ export function handleApiError(err: unknown, c: Context) { if (err instanceof AppError) { const { message, code, statusCode } = err; + // A 5xx is a SERVER fault and must leave a trace, even when it arrives as a typed + // AppError carrying its own message. `AppError`'s statusCode defaults to 500, so + // a bare `new AppError(msg)` used to answer 500 and log NOTHING — the "500 with no + // actionable information in the logs" of GH-562. 4xx stays quiet on purpose: those + // are client outcomes, and logging them turns ordinary validation into noise. + if (statusCode >= 500) console.error(`[API ERROR] ${requestTag(c)}`, err); return c.json( { error: message, code }, // 502/503 included: an AppError can legitimately mean "an upstream we @@ -50,6 +56,26 @@ export function handleApiError(err: unknown, c: Context) { return c.json({ error: "Invalid JSON body", code: "INVALID_JSON" }, 400); } - console.error("[UNHANDLED ERROR]", err); + // Log the route with it. `[UNHANDLED ERROR] Error: doveadm pw returned …` on its own + // doesn't say WHICH request produced it, which is most of the work of diagnosing a + // 500 from a log file. The response body stays deliberately generic — an unknown + // error's message can carry internals we don't hand to a client. + console.error(`[UNHANDLED ERROR] ${requestTag(c)}`, err); return c.json({ error: "Internal server error" }, 500); } + +/** + * `METHOD /path` for a log line. Query string omitted: it can carry tokens. + * + * Exported for the handlers that answer a 5xx THEMSELVES (the mail funnels), so every + * server-fault line in the log reads the same and is greppable by route. The try/catch + * below is load-bearing for those callers: a handler under test can be driven with a + * context that has no `method`/`url`, and this must not throw from inside a catch block. + */ +export function requestTag(c: Context): string { + try { + return `${c.req.method} ${new URL(c.req.url).pathname}`; + } catch { + return c.req.method; + } +} diff --git a/apps/api/src/modules/apps/app-install.service.ts b/apps/api/src/modules/apps/app-install.service.ts index d22896a9b..eebcb18cd 100644 --- a/apps/api/src/modules/apps/app-install.service.ts +++ b/apps/api/src/modules/apps/app-install.service.ts @@ -33,6 +33,7 @@ import { import { getRuntimeCatalog, getTemplateForOrg, listOrgCustomApps } from "./catalog-source"; import { repos } from "@repo/db"; import { env } from "../../config"; +import { decrypt, encrypt } from "../../lib/encryption"; import type { RequestContext } from "../../lib/request-context"; import { isLocalHostRow } from "../../lib/box-org"; import { parseServicePort } from "../../lib/deployable-service"; @@ -524,7 +525,7 @@ export async function installApp( // lets a service env embed a generated secret it can't otherwise interpolate // (e.g. a full `postgres://user:PASSWORD@db/…` connection URL). const inlineConfig = (s: string): string => - s.replace(/\{\{\s*config:([A-Za-z0-9_]+)\s*\}\}/g, (_m, k) => resolved.get(k) ?? ""); + s.replace(CONFIG_TOKEN_RE, (_m, k) => resolved.get(k) ?? ""); // Resolve template files per service. const filesByService = new Map(); @@ -566,7 +567,9 @@ export async function installApp( const rowByName = new Map(existingRows.map((s) => [s.name, s])); // Only services this call CREATED get config/secret env written. Re-writing a // generated secret onto an adopted row would rotate it (Convex's - // INSTANCE_SECRET invalidates the admin key), so an existing row keeps its own. + // INSTANCE_SECRET invalidates the admin key), so an existing row keeps its own — + // and `ensureGeneratedAppSecrets` below is what fills the gap that leaves when the + // first attempt never got as far as writing them. const createdServices = new Set(); // Seed the compose service rows — or, on an adopted draft, re-apply the chosen @@ -608,6 +611,9 @@ export async function installApp( environment: plainEnv, volumes: svc.volumes ? [...svc.volumes] : [], command: svc.command, + // Structured argv bypasses the `sh -c` wrap resolveComposeCmd applies to a + // bare `command`, which some images' entrypoints cannot survive. + commandArgv: svc.commandArgv ? [...svc.commandArgv] : undefined, restart: svc.restart, advanced: { ...(svc.healthcheck ? { healthcheck: svc.healthcheck } : {}), @@ -615,6 +621,7 @@ export async function installApp( ? { files: filesByService.get(svc.name) } : {}), ...(svc.build ? { build: resolveBuild(svc.build) } : {}), + ...(svc.stopGracePeriod ? { stopGracePeriod: svc.stopGracePeriod } : {}), }, // Routing is exactly what the operator chose — never the template's // `exposed` flag turned into a hostname. @@ -651,5 +658,223 @@ export async function installApp( } } + // AFTER the writes above, never before: `setServiceEnvVars` REPLACES a service's + // whole production scope, so anything backfilled first would be wiped. This is what + // makes the installer's guarantee unconditional rather than "if the first attempt ran + // to completion". + await ensureGeneratedAppSecrets(project.id, template); + return { kind: "template", projectId: project.id, slug: project.slug }; } + +// ─── Generated secrets: reuse-or-mint ──────────────────────────────────────── + +/** Scope the installer writes generated config values to. */ +const GENERATED_ENV_SCOPE = "production"; + +/** The substitution form `inlineConfig` uses. Shared so the two cannot drift. */ +const CONFIG_TOKEN_RE = /\{\{\s*config:([A-Za-z0-9_]+)\s*\}\}/g; + +/** + * Config keys the template SUBSTITUTES into some other string — a sibling service's + * `DATABASE_URL`, a mounted `redis.conf`, a build arg. + * + * Those copies are written once, at install, from the value resolved then. Minting a + * fresh value for such a key later would leave the env row saying A while every inlined + * copy still says B: an app that cannot reach its own database, with nothing logged. + * So for these keys a missing row is reported, never invented. + */ +function inlinedConfigKeys(template: AppTemplate): ReadonlySet { + const keys = new Set(); + const scan = (text: string | undefined | null) => { + if (!text) return; + for (const [, key] of text.matchAll(CONFIG_TOKEN_RE)) keys.add(key); + }; + // The four places `inlineConfig` is applied, and only those. + for (const svc of template.services ?? []) { + for (const value of Object.values(svc.environment ?? {})) scan(value); + scan(svc.build?.dockerfile); + for (const file of svc.build?.files ?? []) scan(file.content); + } + for (const file of template.files ?? []) scan(file.content); + return keys; +} + +/** + * Make every `generate:` config field the template declares EXIST on the project, + * minting only what is genuinely missing. + * + * `installApp` writes those values for the services one call creates, which made the + * guarantee conditional on a single uninterrupted pass: an adopted draft keeps whatever + * the first attempt wrote, and a later attempt skips the write for rows it did not + * create. Webmail then deploys a container with no `SESSION_ENCRYPTION_KEY`, which its + * image treats as fatal — a crash loop the deploy reported as success (issue #566). + * + * Idempotent and non-destructive, in that order: + * - PRESENCE is decided from the RAW stored keys, never from decrypted values. + * `decryptEnvMap` drops whatever it cannot decrypt, so after a BETTER_AUTH_SECRET + * rotation every secret would read as absent — and `mergeEnvVars` deletes before it + * inserts, which would overwrite the only copy of a database password and turn a + * recoverable misconfiguration into an unopenable volume. + * - a key the template inlines elsewhere is never minted (see above). + * - a `generateGroup` is resolved ACROSS services and only ever gets one value. Two + * fields in a group are a password that must MATCH (ghost's `ghostdb` spans + * ghost-db and ghost), so a per-service map would repair one half of the pair with a + * value the other half does not know. Where one member is already stored we reuse + * ITS value; where that copy cannot be decrypted the whole group is left alone. + * + * Returns the keys it wrote, for the caller's log. + */ +export async function ensureGeneratedAppSecrets( + projectId: string, + template: AppTemplate, +): Promise { + const generated = (template.configFields ?? []).filter((f) => f.generate); + if (generated.length === 0) return []; + + const inlined = inlinedConfigKeys(template); + const rows = await repos.service.listByProject(projectId); + const rowByName = new Map(rows.map((r) => [r.name, r])); + const secretKeysByService = new Map( + (template.services ?? []).map((s) => [s.name, new Set(s.secretEnv ?? [])] as const), + ); + + // Every service's stored scope, read before anything is decided: a group spans + // services, so what to do about one field can depend on another service's rows. + const byService = groupByService(generated); + const storedByService = new Map>(); + for (const serviceName of byService.keys()) { + const row = rowByName.get(serviceName); + if (row) { + storedByService.set( + serviceName, + await repos.project.getEnvMap(projectId, GENERATED_ENV_SCOPE, row.id), + ); + } + } + // PROJECT scope counts as present. An operator may hold a generated value at project + // level (`service_id IS NULL`), and the deploy layers service env ABOVE project env — + // so minting a service-scoped value here would SHADOW theirs and silently rotate the + // secret out from under a running app. Never mint over a value that is already in use, + // wherever it is kept. + const storedAtProject = await repos.project.getEnvMap(projectId, GENERATED_ENV_SCOPE, null); + + const { groupValue, blockedGroups } = resolveGeneratedGroups( + generated, + storedByService, + storedAtProject, + ); + + const written: string[] = []; + for (const [serviceName, fields] of byService) { + const row = rowByName.get(serviceName); + const stored = storedByService.get(serviceName); + if (!row || !stored) continue; + const upserts: { key: string; value: string; isSecret: boolean }[] = []; + + for (const field of fields) { + if (Object.hasOwn(stored, field.key) || Object.hasOwn(storedAtProject, field.key)) continue; + const group = field.generateGroup ?? field.jwtSecretGroup; + if (group && blockedGroups.has(group)) { + console.warn( + `[apps] ${template.id}: ${serviceName}.${field.key} is missing, but its group "${group}" already has a value elsewhere that cannot be read — leaving it alone rather than writing a value the rest of the group would not match.`, + ); + continue; + } + if (inlined.has(field.key)) { + console.warn( + `[apps] ${template.id}: ${serviceName}.${field.key} is missing and is inlined elsewhere in the template — not minting a value that its existing copies would contradict.`, + ); + continue; + } + const value = mintGenerated(field, groupValue); + if (!value) continue; + upserts.push({ + key: field.key, + value: encrypt(value), + isSecret: !!field.secret || !!secretKeysByService.get(serviceName)?.has(field.key), + }); + written.push(field.key); + } + + if (upserts.length > 0) { + await repos.project.mergeEnvVars(projectId, GENERATED_ENV_SCOPE, upserts, [], row.id); + } + } + + if (written.length > 0) { + console.warn( + `[apps] ${template.id}: backfilled generated config on ${projectId}: ${written.join(", ")}`, + ); + } + return written; +} + +/** + * Seed each `generateGroup` from whatever is already stored, before anything is minted. + * + * A group is one shared value across services. Three outcomes per group: + * - a stored member we can decrypt → its plaintext becomes the group's value, so the + * missing members are REPAIRED to match it instead of rotating the pair; + * - a stored member we cannot decrypt → the group is blocked, because any value we + * minted would silently disagree with the copy that is already in use; + * - nothing stored → left unset, and the first field that needs it mints one. + */ +function resolveGeneratedGroups( + fields: AppConfigField[], + storedByService: Map>, + storedAtProject: Record, +): { groupValue: Map; blockedGroups: Set } { + const groupValue = new Map(); + const blockedGroups = new Set(); + for (const field of fields) { + const group = field.generateGroup; + if (!group) continue; + const raw = storedByService.get(field.service)?.[field.key] ?? storedAtProject[field.key]; + if (!raw || groupValue.has(group)) continue; + try { + groupValue.set(group, decrypt(raw)); + } catch { + blockedGroups.add(group); + } + } + // A group whose value we recovered is not blocked, whichever member we read it from. + for (const group of groupValue.keys()) blockedGroups.delete(group); + return { groupValue, blockedGroups }; +} + +function groupByService(fields: AppConfigField[]): Map { + const out = new Map(); + for (const field of fields) { + const list = out.get(field.service) ?? []; + list.push(field); + out.set(field.service, list); + } + return out; +} + +/** + * One generated value, using the same rules as the installer's `valueFor`. + * + * `groupValue` is shared across services and may already carry a value recovered from a + * stored member, which is what makes a repaired half of a pair match the other half. A + * `generate:"jwt"` field can only be signed with a secret this map knows: with no + * recovered and no minted secret it yields "" and is left for the operator's reinstall + * rather than signed with a key nothing else has. + */ +function mintGenerated(field: AppConfigField, groupValue: Map): string { + if (field.generate === "secret") { + if (!field.generateGroup) return generateSecret(); + const existing = groupValue.get(field.generateGroup); + if (existing) return existing; + const secret = generateSecret(); + groupValue.set(field.generateGroup, secret); + return secret; + } + if (field.generate === "jwt") { + const secret = field.jwtSecretGroup ? groupValue.get(field.jwtSecretGroup) : undefined; + if (!secret || !field.jwtRole) return ""; + return signHs256Jwt(secret, field.jwtRole); + } + return ""; +} diff --git a/apps/api/src/modules/audit/audit.routes.ts b/apps/api/src/modules/audit/audit.routes.ts index c4506ec79..d9b5d4c9a 100644 --- a/apps/api/src/modules/audit/audit.routes.ts +++ b/apps/api/src/modules/audit/audit.routes.ts @@ -8,7 +8,8 @@ * * Filters on the list: `category` (expanded to its event types through the * shared taxonomy — a category is not a column), `eventType`, `actorUserId`, - * `source`, `resourceType`, `resourceId`, `from`/`to`, and `q`. + * `source`, `sourceClientId` (one MCP client, not just "an assistant"), + * `resourceType`, `resourceId`, `from`/`to`, and `q`. * * `q` is deliberately more than an `event_type LIKE`: rows store ids, so * searching "api-gateway" resolves the term against project/server/domain names @@ -35,7 +36,7 @@ import { secureRouter } from "../../lib/secure-router"; import { getRequestContext } from "../../lib/request-context"; import { checkPermissionOnResource } from "../../lib/permission"; import { audit, auditContextFrom } from "../../lib/audit"; -import { isAuditSource } from "../../lib/call-source"; +import { isAuditClientId, isAuditSource } from "../../lib/call-source"; const r = secureRouter(new Hono(), { module: "audit", basePath: "/api/audit" }); @@ -68,6 +69,7 @@ async function filtersFromQuery(c: Context, organizationId: string) { const category = c.req.query("category"); const eventType = c.req.query("eventType"); const source = c.req.query("source"); + const sourceClientId = c.req.query("sourceClientId"); const q = c.req.query("q")?.trim(); return { @@ -82,6 +84,10 @@ async function filtersFromQuery(c: Context, organizationId: string) { resourceType: c.req.query("resourceType") || undefined, resourceId: c.req.query("resourceId") || undefined, source: source && isAuditSource(source) ? source : undefined, + // Shape-checked with the same predicate the writer uses, so a filter can only + // name something the column could hold. A malformed value degrades to + // unfiltered, matching how an unknown category behaves above. + sourceClientId: isAuditClientId(sourceClientId) ? sourceClientId : undefined, from: parseDate(c.req.query("from")), to: parseDate(c.req.query("to")), q: q || undefined, @@ -145,6 +151,35 @@ async function attachResourceNames(rows: AuditRow[]): Promise` → the registered MCP + * app's name, `pat:` → the token's name. + * + * Two batched lookups at most, in parallel, same as the resource resolver above. + * An unresolvable id (client deleted, token revoked and pruned) stays nameless + * and the UI falls back to the raw id: a row attributed to something that no + * longer exists is still evidence, and dropping it would be worse. + */ +async function resolveClientNames(ids: string[]): Promise> { + const names = new Map(); + if (ids.length === 0) return names; + + const oauthIds: string[] = []; + const patIds: string[] = []; + for (const id of ids) { + if (id.startsWith("oauth:")) oauthIds.push(id.slice("oauth:".length)); + else if (id.startsWith("pat:")) patIds.push(id.slice("pat:".length)); + } + + const [apps, tokens] = await Promise.all([ + oauthIds.length ? repos.oauth.listApplicationsByClientIds(oauthIds).catch(() => []) : [], + patIds.length ? repos.personalAccessToken.listNamesByIds(patIds).catch(() => []) : [], + ]); + for (const a of apps) names.set(`oauth:${a.clientId}`, a.name); + for (const t of tokens) names.set(`pat:${t.id}`, t.name); + return names; +} + r.get("/", { tag: "audit:read" }, async (c: Context) => { const ctx = getRequestContext(c); const cursor = c.req.query("cursor"); @@ -168,9 +203,13 @@ r.get("/", { tag: "audit:read" }, async (c: Context) => { const actorIds = Array.from( new Set(result.rows.map((r) => r.actorUserId).filter((id): id is string => !!id)), ); - const [actors, resourceNames] = await Promise.all([ + const clientIds = Array.from( + new Set(result.rows.map((r) => r.sourceClientId).filter((id): id is string => !!id)), + ); + const [actors, resourceNames, clientNames] = await Promise.all([ repos.user.findManyByIds(actorIds), attachResourceNames(result.rows), + resolveClientNames(clientIds), ]); const actorById = new Map(actors.map((u) => [u.id, { id: u.id, email: u.email, name: u.name }])); @@ -181,6 +220,9 @@ r.get("/", { tag: "audit:read" }, async (c: Context) => { row.resourceType && row.resourceId ? resourceNames.get(`${row.resourceType}:${row.resourceId}`) ?? null : null, + // "Claude Desktop", not "oauth:4f2a…" — the actor a reader cares about when + // the human in the row only authorized the agent months ago. + sourceClientName: row.sourceClientId ? clientNames.get(row.sourceClientId) ?? null : null, })); if ("pageInfo" in result) { @@ -205,11 +247,14 @@ r.get("/facets", { tag: "audit:read" }, async (c: Context) => { const ctx = getRequestContext(c); const orgId = ctx.organizationId; const filters = await filtersFromQuery(c, orgId); - const { eventTypes, source, ...shared } = filters; - - const [byEventType, bySource, actorIds, settings, canManage] = await Promise.all([ - repos.auditEvent.countByEventType(orgId, { ...shared, source }), - repos.auditEvent.countBySource(orgId, { ...shared, eventTypes }), + const { eventTypes, source, sourceClientId, ...shared } = filters; + + const [byEventType, bySource, byClient, actorIds, settings, canManage] = await Promise.all([ + repos.auditEvent.countByEventType(orgId, { ...shared, source, sourceClientId }), + repos.auditEvent.countBySource(orgId, { ...shared, eventTypes, sourceClientId }), + // Counted without its own filter, like every other facet — picking one agent + // must not zero out the others and trap the filter on that choice. + repos.auditEvent.countBySourceClient(orgId, { ...shared, eventTypes, source }), repos.auditEvent.distinctActors(orgId, { from: filters.from, to: filters.to }), repos.auditSettings.get(orgId), checkPermissionOnResource(ctx, { resourceType: "audit", resourceId: "*", action: "write" }), @@ -225,7 +270,10 @@ r.get("/facets", { tag: "audit:read" }, async (c: Context) => { if (category) categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + count); } - const actors = await repos.user.findManyByIds(actorIds); + const [actors, clientNames] = await Promise.all([ + repos.user.findManyByIds(actorIds), + resolveClientNames(byClient.map((row) => row.sourceClientId)), + ]); return c.json({ total, @@ -236,6 +284,11 @@ r.get("/facets", { tag: "audit:read" }, async (c: Context) => { count: categoryCounts.get(cat.id) ?? 0, })), sources: bySource.map((row) => ({ source: row.source, count: row.count })), + clients: byClient.map((row) => ({ + id: row.sourceClientId, + name: clientNames.get(row.sourceClientId) ?? null, + count: row.count, + })), actors: actors.map((u) => ({ id: u.id, name: u.name, email: u.email, image: u.image })), settings, canManage, diff --git a/apps/api/src/modules/backups/PENDING.md b/apps/api/src/modules/backups/PENDING.md index c005bf027..bf7bbdba5 100644 --- a/apps/api/src/modules/backups/PENDING.md +++ b/apps/api/src/modules/backups/PENDING.md @@ -142,8 +142,8 @@ executor, which is precisely why #434 survived a green run: neither fake could h and neither had a container to miss. So the guarantees above are pinned by one real-daemon test, `../../test/e2e/backup-volume-roundtrip.e2e.test.ts` — capture a volume, empty the volume, restore, assert the tree and the bytes. It runs in the -opt-in suite (`bun run --cwd apps/api test:e2e`, `RUN_DOCKER_E2E=1` in CI's -`e2e-docker` job) and it is the only test that drives `backupOrchestrator.execute` +opt-in suite (`bun run --cwd apps/api test:e2e`, `RUN_DOCKER_E2E=1` in the release +gate's `e2e-docker` job) and it is the only test that drives `backupOrchestrator.execute` and `restoreOrchestrator.beginPrepare`/`apply` through real archives, a real `alpine:3` helper and a real destination on disk. It asserts the sha256 recorded at capture is the one prepare recomputes from the destination's bytes @@ -172,11 +172,13 @@ itself the regression test for the removed cast. **CI triggers, split by cost.** `e2e-docker` is a two-scope matrix (`E2E_SCOPE` in `apps/api/vitest.e2e.config.ts`): `fast` — every daemon-level and full-cycle case, -~5 min — runs on every PR and push; `heavy` — `rollback-build-restore` alone, ~225s -cold because it pulls a Node base image and runs a real build, with -`fileParallelism: false` holding the suite up meanwhile — runs on `workflow_dispatch`, -the nightly `schedule`, and `v*` tags, so a release is proven restorable before it -ships. `ci.yml` also gained a `concurrency` group (PR pushes cancel their +~5 min; `heavy` — `rollback-build-restore` alone, ~225s cold because it pulls a Node +base image and runs a real build, with `fileParallelism: false` holding the suite up +meanwhile. Both scopes live in `release-gate.yml` and run on `workflow_dispatch` and on +every publish, because `Release` and `Docker images` list that gate in `needs:` — so a +release really is proven restorable before it ships. (It was not, for as long as the job +sat in `ci.yml`: a separate workflow on the same tag push is not ordered against the +publish, so the suite finished whenever it finished, alongside the release.) `ci.yml` also gained a `concurrency` group (PR pushes cancel their predecessor; main and tags run to completion) and a Docker Hub login guarded on a secret that does not exist yet — hosted runners share outbound IPs, so anonymous pulls hit `toomanyrequests` as a function of strangers' traffic and it reads as a diff --git a/apps/api/src/modules/deployments/compose/deploy.service.ts b/apps/api/src/modules/deployments/compose/deploy.service.ts index d378b473a..7db17253c 100644 --- a/apps/api/src/modules/deployments/compose/deploy.service.ts +++ b/apps/api/src/modules/deployments/compose/deploy.service.ts @@ -24,6 +24,7 @@ import { composeNamespaceRef, ownsNetworkEndpoint, UNLIMITED_RESOURCES, + safeErrorMessage, type ComposeAdvanced, type ProxySettings, } from "@repo/core"; @@ -33,7 +34,7 @@ import { BuildLogger, DockerRuntime, allocateHostPort, - elevatedExecutor, + rootOrDegrade, resolveEnvironment, runDeployPipeline, type CommandExecutor, @@ -94,7 +95,10 @@ import { compileProjectRoutingFields } from "../../../lib/project-routing-fields import { buildCompositeRegistration, buildDomainFanoutRegistrations } from "./composite-route"; import { newerThanRestoredRelease, serviceKind } from "./project-services"; import { buildUpstreamUrl, resolveRouteStrategy } from "../../../lib/upstream-url"; -import { withLoopbackPublish } from "../../../lib/loopback-publish"; +import { + withLoopbackPublishAll, + upstreamHostPortFor, +} from "../../../lib/loopback-publish"; export interface ComposeDeployResult { /** `reconciling` when at least one service's outcome is UNKNOWN because the @@ -790,6 +794,33 @@ export async function deployComposeServices( localHost?: boolean; }, ): Promise { + // Generated app secrets, BEFORE any env is read below. A catalog app whose install died + // part-way keeps a service row with the generated values missing, and the installer only + // writes them for services it created — so webmail reached the container with no + // SESSION_ENCRYPTION_KEY and crash-looped (#566). The install path repairs that, but + // Redeploy / Start / a webhook push all arrive HERE without passing through it, and that + // Redeploy button is the natural next click after seeing a bouncing container. + // + // Idempotent (a stored value is reused, never rotated) and best-effort: a repair we + // could not make must not fail a deploy that would otherwise run. + if (project.appTemplateId) { + try { + const template = await getTemplateForOrg(project.organizationId, project.appTemplateId); + if (template) { + // Dynamic: a static import would pull the app-install service — and through it the + // whole services/auth layer — into every module that imports this one. Same reason + // mail.service.ts imports deployment-runtime lazily. + const { ensureGeneratedAppSecrets } = await import("../../apps/app-install.service"); + await ensureGeneratedAppSecrets(project.id, template); + } + } catch (err) { + logger.log( + `Could not check this app's generated secrets: ${safeErrorMessage(err)}\n`, + "warn", + ); + } + } + const services = await repos.service.listByProject(project.id); const enabled = services.filter((s) => s.enabled); @@ -1145,19 +1176,21 @@ export async function deployComposeServices( // how the edge writes its own root-owned config. Resolved lazily and cached, so // privilege detection is skipped entirely for deploys that ship no config files // and never re-run per service. + // Through the shared gate rather than an inline copy of it. This was the only + // `elevatedExecutor` call site in apps/*, and it re-derived the decision that + // `rootOrDegrade` exists to own: same sudo arm, but it also REPORTS when the login can + // neither be root nor sudo, instead of silently handing back the plain executor and + // letting the write fail later as "No such file". Still lazy and still cached, so a + // deploy that ships no config files never probes privileges. let hostConfigWriter: Promise | null = null; const resolveHostConfigWriter = (executor: CommandExecutor): Promise => { - hostConfigWriter ??= (async () => { - try { - const env = await resolveEnvironment(executor); - if (!env.isRoot && env.canSudo) return elevatedExecutor(executor); - } catch { - // Privilege probe failed — fall back to the plain executor. A root or - // already-writable target still succeeds; a locked-down one fails loudly - // at write time, exactly as it did before this guard. - } - return executor; - })(); + hostConfigWriter ??= rootOrDegrade(executor, { + purpose: "Writing generated app config files to the host", + consequence: + "A config file the app needs may be missing, so the service can start misconfigured " + + "or fail outright.", + report: (message) => logger.log(`${message}\n`, "warn"), + }); return hostConfigWriter; }; @@ -1329,12 +1362,12 @@ export async function deployComposeServices( status: "failed", error: message, }); - await repos.service.createServiceDeployment({ + await repos.service.markServiceDeploymentFailed({ deploymentId: dep.id, serviceId: svc.id, serviceName: svc.name, - status: "failure", imageRef: opts?.builtImages?.get(svc.id) ?? svc.image ?? null, + errorMessage: message, }); results.push({ serviceId: svc.id, @@ -1393,12 +1426,12 @@ export async function deployComposeServices( status: "failed", error: buildFailure, }); - await repos.service.createServiceDeployment({ + await repos.service.markServiceDeploymentFailed({ deploymentId: dep.id, serviceId: svc.id, serviceName: svc.name, - status: "failure", imageRef: svc.image ?? null, + errorMessage: buildFailure, }); results.push({ serviceId: svc.id, @@ -1428,11 +1461,11 @@ export async function deployComposeServices( status: "failed", error: message, }); - await repos.service.createServiceDeployment({ + await repos.service.markServiceDeploymentFailed({ deploymentId: dep.id, serviceId: svc.id, serviceName: svc.name, - status: "failure", + errorMessage: message, }); results.push({ serviceId: svc.id, @@ -1575,12 +1608,12 @@ export async function deployComposeServices( status: "failed", error: message, }); - await repos.service.createServiceDeployment({ + await repos.service.markServiceDeploymentFailed({ deploymentId: dep.id, serviceId: svc.id, serviceName: svc.name, - status: "failure", imageRef: image ?? null, + errorMessage: message, }); results.push({ serviceId: svc.id, serviceName: svc.name, status: "failed", error: message }); unavailableServiceNames.add(svc.name); @@ -1721,50 +1754,77 @@ export async function deployComposeServices( ); } - // loopback-port routing (compose parity, mirrors single-app): republish the - // PRIMARY routed container port on `127.0.0.1:` so the edge - // reaches it on loopback and it isn't network-exposed. We OWN the pinned - // port (reuse the carried one, else allocate avoiding this deploy's picks), - // so the route resolves to it deterministically — no reading it back from - // the daemon's ambiguous first-binding. Port-only bindings the user declared - // for direct access are preserved. Cloud handles exposure itself; bare/no- - // executor can't publish → skip (route falls back to container-IP/loopback). + // loopback-port routing (compose parity, mirrors single-app): republish EVERY + // routed container port on its OWN `127.0.0.1:` so the edge + // reaches each on loopback and none is network-exposed. We OWN the pinned + // ports (reuse the carried one for the primary, else allocate avoiding this + // deploy's picks), so each route resolves deterministically — no reading it + // back from the daemon's ambiguous first-binding. Port-only bindings the user + // declared for direct access are preserved. Cloud handles exposure itself; + // bare/no-executor can't publish → skip (route falls back to container-IP). + // + // ONE HOST PORT PER ROUTED PORT, not one per service: a service can carry + // several routes (minio's console + `s3` API, convex's API + `http`), and + // pinning only `proxyRoutes[0]` while `resolveTargetUrl` returned that single + // port for every route made each extra subdomain silently proxy to the FIRST + // route's port. minio's s3 host served the console; convex's http host served + // the 3210 API. Only the primary port is persisted (`service_deployment` + // holds one), which is why the extras are re-pinned and re-registered on + // every deploy rather than carried. const composeRouteStrategy = resolveRouteStrategy(project.routeStrategy); - const routedContainerPort = proxyRoutes[0]?.targetPort; + const routedContainerPorts = [ + ...new Set( + proxyRoutes + .map((r) => r.targetPort) + .filter((p): p is number => typeof p === "number" && p > 0), + ), + ]; + const primaryRoutedPort = routedContainerPorts[0]; + /** routed container port → the loopback host port WE pinned for it. */ + const pinnedHostPortByContainerPort = new Map(); let servicePinnedHostPort: number | undefined; if ( composeRouteStrategy === "loopback-port" && runtime.name !== "cloud" && - routedContainerPort !== undefined && + primaryRoutedPort !== undefined && // A container with no endpoint of its own publishes nothing — allocating a // host port would burn it and pin a route to an upstream that never binds. !hasNoRoutableAddress && opts?.executor ) { - const carried = previousByServiceId.get(svc.id)?.hostPort; - if (carried) { - servicePinnedHostPort = carried; - } else { - const allocation = await allocateHostPort(opts.executor, { avoid: usedHostPorts }); - servicePinnedHostPort = allocation.port; - // "Couldn't read occupancy" is not "nothing is listening" — without this the - // bind failure that follows blames Docker for an unreachable host (#490). - if (!allocation.scanned) { - logger.log( - `Couldn't read live port occupancy on the target, so ${allocation.port} for ` + - `${svc.name} avoids only ports this deploy already took. If publishing it fails ` + - `as "already allocated", check that Openship can reach this host ` + - `(Servers → this box).\n`, - "warn", - ); + for (const containerPort of routedContainerPorts) { + // Only the primary reuses the carried port: it is the one persisted, so + // it is the only one whose previous value is knowable. + const carried = + containerPort === primaryRoutedPort + ? previousByServiceId.get(svc.id)?.hostPort + : undefined; + let hostPort: number; + if (carried) { + hostPort = carried; + } else { + const allocation = await allocateHostPort(opts.executor, { avoid: usedHostPorts }); + hostPort = allocation.port; + // "Couldn't read occupancy" is not "nothing is listening" — without this the + // bind failure that follows blames Docker for an unreachable host (#490). + if (!allocation.scanned) { + logger.log( + `Couldn't read live port occupancy on the target, so ${allocation.port} for ` + + `${svc.name} avoids only ports this deploy already took. If publishing it fails ` + + `as "already allocated", check that Openship can reach this host ` + + `(Servers → this box).\n`, + "warn", + ); + } } + usedHostPorts.add(hostPort); + pinnedHostPortByContainerPort.set(containerPort, hostPort); } - usedHostPorts.add(servicePinnedHostPort); - serviceRuntimeConfig.ports = withLoopbackPublish( + serviceRuntimeConfig.ports = withLoopbackPublishAll( serviceRuntimeConfig.ports, - routedContainerPort, - servicePinnedHostPort, + pinnedHostPortByContainerPort, ); + servicePinnedHostPort = pinnedHostPortByContainerPort.get(primaryRoutedPort); } let deployedContainerId: string | undefined; @@ -1795,10 +1855,18 @@ export async function deployComposeServices( ? async (containerId, port) => { const strategy = resolveRouteStrategy(project.routeStrategy); const sameSvc = serviceResult?.containerId === containerId; - // Prefer the port WE pinned+published this deploy (deterministic); - // fall back to the port reported by the deploy result otherwise. - const hostPort = - servicePinnedHostPort ?? (sameSvc ? serviceResult?.hostPort : undefined); + // Prefer the port WE pinned+published for THIS container port + // (deterministic); fall back to the port the deploy result + // reported. That fallback is a single scalar read off the daemon, + // so it is only meaningful for the primary route — applying it to + // a secondary port is the collapse this map exists to prevent. + const hostPort = upstreamHostPortFor({ + port, + pinned: pinnedHostPortByContainerPort, + primaryPort: primaryRoutedPort, + resultHostPort: serviceResult?.hostPort, + sameService: sameSvc, + }); // loopback-port → the service's published host port; else the // container IP (cached from the deploy result when we can). if (strategy === "loopback-port" && hostPort) { @@ -2076,12 +2144,12 @@ export async function deployComposeServices( error: message, }); - await repos.service.createServiceDeployment({ + await repos.service.markServiceDeploymentFailed({ deploymentId: dep.id, serviceId: svc.id, serviceName: svc.name, - status: "failure", imageRef: image, + errorMessage: message, }); results.push({ diff --git a/apps/api/src/modules/deployments/deployment.schema.ts b/apps/api/src/modules/deployments/deployment.schema.ts index 4cc12040d..dc45f7855 100644 --- a/apps/api/src/modules/deployments/deployment.schema.ts +++ b/apps/api/src/modules/deployments/deployment.schema.ts @@ -67,6 +67,12 @@ const BuildServiceInput = Type.Object({ environment: Type.Record(Type.String(), Type.String()), volumes: Type.Array(Type.String()), command: Type.Optional(Type.String()), + // #332: the string above is a lossy join for a list command, so it was not + // possible to express `["sh","-c","a && b"]` on this route at all — and these + // entries are persisted (requestBuildAccess → syncFromCompose), so a client + // replaying its service list re-split the stored argv. The repo now keeps an + // unchanged string from disturbing argv; this lets a client be explicit. + commandArgv: Type.Optional(Type.Array(Type.String())), restart: Type.Optional(Type.String()), exposed: Type.Optional(Type.Boolean()), exposedPort: Type.Optional(Type.String()), diff --git a/apps/api/src/modules/deployments/rollback/PENDING.md b/apps/api/src/modules/deployments/rollback/PENDING.md index 8e66efc6f..62d30e079 100644 --- a/apps/api/src/modules/deployments/rollback/PENDING.md +++ b/apps/api/src/modules/deployments/rollback/PENDING.md @@ -69,8 +69,10 @@ forward-compatibility placeholder). Real-daemon E2Es under `apps/api/test/e2e/`. They run only in the opt-in suite (`bun run --cwd apps/api test:e2e`) — `bun run test` excludes the directory — and -CI runs them in the `e2e-docker` job with `RUN_DOCKER_E2E=1`, which turns "no -daemon" into a FAILURE instead of a skip. Locally the socket is resolved the way +the release gate (`release-gate.yml`) runs them in the `e2e-docker` job with +`RUN_DOCKER_E2E=1`, which turns "no daemon" into a FAILURE instead of a skip — and, +since `Release` and `Docker images` both list that gate in `needs:`, into a blocked +publish. Locally the socket is resolved the way the product resolves it (explicit → `DOCKER_HOST` → active `docker context` → `/var/run/docker.sock`), so Colima / Rancher / Podman need no env at all; a skip prints the reason. See `apps/api/test/helpers/docker-e2e.ts`. diff --git a/apps/api/src/modules/domains/domain.service.ts b/apps/api/src/modules/domains/domain.service.ts index 6dd31fce6..1fb524658 100644 --- a/apps/api/src/modules/domains/domain.service.ts +++ b/apps/api/src/modules/domains/domain.service.ts @@ -30,6 +30,7 @@ import { resolveRecords } from "../../lib/dns-resolver"; import { resolveProjectServerHost, resolveLocalServerHost, resolveInstancePublicIp, isLoopbackHost } from "../../lib/server-target"; import { reconcileProjectRoutes } from "../../lib/route-apply.service"; import { generateToken } from "../../lib/domain-token"; +import { routableWithoutOwnership } from "../../lib/domain-claims"; import { untrackedSiteFor } from "../../lib/edge-orphans.service"; import type { UntrackedEdgeSite } from "@repo/core"; import { publicEndpointHostname, resolveServicePublicEndpoints } from "../../lib/public-endpoints"; @@ -409,6 +410,15 @@ export async function ensurePendingServiceDomain(opts: { // must neither create (collision) nor touch theirs (cross-tenant write) — // surface it as a conflict (matches addDomain) instead of silently skipping. const foreign = await repos.domain.findByHostname(hostname); + + // Same general question the deploy path asks — see lib/domain-claims. A hostname + // another subsystem owns can still be routable by this project, and the claim decides + // that, not this function. Asked even when there is NO row, for the reason documented + // there. + if (await routableWithoutOwnership(hostname, opts.projectId, foreign)) { + return { created: false, domainId: null }; + } + if (foreign) { throw new ConflictError( `The domain "${hostname}" is already connected to another project.`, diff --git a/apps/api/src/modules/jobs/job.registry.ts b/apps/api/src/modules/jobs/job.registry.ts index 5fb79cca7..301fa7dfa 100644 --- a/apps/api/src/modules/jobs/job.registry.ts +++ b/apps/api/src/modules/jobs/job.registry.ts @@ -316,6 +316,44 @@ export const SYSTEM_JOB_DEFS: SystemJobDef[] = [ return { servers: r.servers, behind: r.behind, updated: r.updated }; }, }, + { + key: "mail:inbound-watch", + label: "Inbound mail rules", + // Reads each armed collector folder, matches captured mail against the operator's + // inbound rules, emits notifications, and DELETES what it handled — the deletion is + // both the cursor and the prune that keeps a second full copy of every watched + // message from accumulating under /var/vmail (a bind mount with no quota of its own). + // + // Every minute: this is the feature's latency, and a rule that says "tell me when + // support@ gets mail" is not useful at a six-hour cadence. A server with no rules + // costs one indexed query and no SSH at all. Self-hosted + desktop only — the mail + // module does not exist in the cloud runtime. + defaultCron: "* * * * *", + available: () => platform().target !== "cloud", + run: async () => { + const { runInboundWatch } = await import("../mail/inbound/watch"); + return runInboundWatch(); + }, + }, + { + key: "mail:inbound-reconcile", + label: "Inbound mail capture drift", + // Brings each engine's armed BCC rows back in line with the rules that exist. + // Separate from the one-minute read sweep above because it costs SSH per mail + // server and repairs nothing latency-sensitive: a `scope: "all"` rule picking up + // a domain added after it was written, an arm/disarm that failed mid-write, or a + // collector mailbox an operator deleted by hand. + // + // Deliberately NOT the defence against an unpruned collector filling /var/vmail — + // the write path disarms what it orphans (GH-559). This is the backstop, so a + // slow off-peak cadence is right. Off the :00/:30 marks. + defaultCron: "17 */2 * * *", + available: () => platform().target !== "cloud", + run: async () => { + const { runInboundReconcile } = await import("../mail/inbound/watch"); + return runInboundReconcile(); + }, + }, { key: "services:health-watch", label: "Container health watch", diff --git a/apps/api/src/modules/mail/admin/admin.controller.ts b/apps/api/src/modules/mail/admin/admin.controller.ts index 6bf84811d..105b6f5db 100644 --- a/apps/api/src/modules/mail/admin/admin.controller.ts +++ b/apps/api/src/modules/mail/admin/admin.controller.ts @@ -50,7 +50,7 @@ import { getMailServerStats } from "./stats.service"; import { scanDns } from "./dns-scan.service"; import { sendTestEmail, TestEmailError } from "./test-email.service"; import { AppError, isRelayProviderId, safeErrorMessage } from "@repo/core"; -import { handleApiError } from "../../../middleware/error-handler"; +import { handleApiError, requestTag } from "../../../middleware/error-handler"; import { getComponentLogs, restartAllComponents, @@ -862,5 +862,11 @@ function errorJson(c: Context, err: unknown) { // The SSH+psql layer throws plain Error for any non-shape error // (connection failure, SQL syntax, validation). 500 is the right default; // typed errors above are caught and mapped to 4xx individually. + // + // Logged HERE because we answer the response ourselves: `app.onError` only sees + // errors that were never caught, so every mail-admin 500 left the API log with + // nothing but hono's `--> … 500` (the second half of GH-562). The AppError branch + // above logs through `handleApiError`, so no path logs twice. + console.error(`[MAIL ADMIN ERROR] ${requestTag(c)}`, err); return c.json({ error: message }, 500); } diff --git a/apps/api/src/modules/mail/admin/backup-plan.ts b/apps/api/src/modules/mail/admin/backup-plan.ts index 941056ff7..c6a961816 100644 --- a/apps/api/src/modules/mail/admin/backup-plan.ts +++ b/apps/api/src/modules/mail/admin/backup-plan.ts @@ -18,7 +18,14 @@ */ import { HOST_STATE_DIR } from "@repo/adapters"; -import { HOST_AMAVIS_CONF_CANDIDATES } from "../mail-engine"; +import { + HOST_AMAVIS_CONF_CANDIDATES, + mailDaemonReloadCommand, + mailEngineCommand, + mailPgDumpToStdout, + mailPsqlFromStdin, + type MailEngineFlavor, +} from "../mail-engine"; /** The four tables that hold accounts / domains / aliases / admins. */ const ACCOUNT_TABLES = ["domain", "mailbox", "forwardings", "domain_admins"] as const; @@ -67,6 +74,7 @@ export interface MailBackupPayload { export function buildMailBackupPayload( domain: string, flags: MailBackupFlags, + flavor: MailEngineFlavor, ): MailBackupPayload { const tableArgs = ACCOUNT_TABLES.map((t) => `-t ${t}`).join(" "); const truncateList = ACCOUNT_TABLES.join(", "); @@ -77,7 +85,7 @@ export function buildMailBackupPayload( 'tmp="$(mktemp -d)"', "trap 'rm -rf \"$tmp\"' EXIT", // Accounts + auth — always. Plain-SQL data-only dump (COPY blocks). - `sudo -u postgres pg_dump -d vmail --data-only --no-owner --no-privileges ${tableArgs} > "$tmp/vmail.data.sql"`, + `${mailPgDumpToStdout(flavor, `--data-only --no-owner --no-privileges ${tableArgs}`)} > "$tmp/vmail.data.sql"`, // What's inside — read by the UI / hand-restore. `printf '%s' '${info.replace(/'/g, "'\\''")}' > "$tmp/mail-backup.json"`, flags.keys @@ -95,12 +103,16 @@ export function buildMailBackupPayload( // is a disaster-recovery archive which is silently not one. Same shape as the // amavis bug this file's own header documents. // - // `sudo -n` needs no new capability: `sudo -u postgres pg_dump` above already - // makes sudo a hard requirement, and under `set -e` a host without it fails - // there — so reaching this line means sudo works, and a `test` that says "no" - // now means genuinely absent rather than merely unreadable. That is what makes - // dropping `|| true` safe: absence still skips, and only a real failure to - // collect a secret the operator explicitly asked for stops the backup. + // The `sudo -n` reads below only distinguish "absent" from "unreadable" if sudo + // is known to WORK — otherwise a failing `test` reads as absence and skips, and + // we are back to an archive stamped `keys: true` with no keys in it. + // + // That used to be guaranteed for free: `sudo -u postgres pg_dump` ran first, so + // under `set -e` a box without sudo never reached here. Routing the dump through + // the DB sidecar for containerized engines (GH-563) removed the guarantee on + // exactly the topology most installs now use, so the probe is explicit. It is + // the whole reason dropping `|| true` on the copies is safe. + "sudo -n true", 'if sudo -n test -d /var/lib/dkim; then sudo -n cp -a /var/lib/dkim "$tmp/keys/dkim"; fi', // Whichever of the known locations this box actually uses, with the source // path recorded alongside so the restore puts it back where amavis reads it. @@ -137,8 +149,8 @@ export function buildMailBackupPayload( // stdin = the tar.zst artifact. 'zstd -d | tar -x -C "$tmp"', // Data-only restore: wipe the target's account tables, then load. - `sudo -u postgres psql -d vmail -v ON_ERROR_STOP=1 -c 'TRUNCATE ${truncateList} CASCADE;'`, - 'sudo -u postgres psql -d vmail -v ON_ERROR_STOP=1 -f "$tmp/vmail.data.sql"', + `printf '%s' 'TRUNCATE ${truncateList} CASCADE;' | ${mailPsqlFromStdin(flavor)}`, + `${mailPsqlFromStdin(flavor)} < "$tmp/vmail.data.sql"`, // DKIM keys + amavis config (if the archive carried them). 'if [ -d "$tmp/keys/dkim" ]; then mkdir -p /var/lib/dkim && cp -a "$tmp/keys/dkim/." /var/lib/dkim/ || true; fi', // Where to put it is decided by THIS box, not by the box the archive came from. @@ -176,11 +188,16 @@ export function buildMailBackupPayload( ` cp -a "$tmp/${AMAVIS_IN_ARCHIVE_LEGACY}" "$dest" || true`, `fi`, // Maildirs (if included). Ownership must be vmail:vmail for Dovecot. - 'if [ -d "$tmp/vmail1" ]; then cp -a "$tmp/vmail1" /var/vmail/ && chown -R vmail:vmail /var/vmail/vmail1 || true; fi', + // /var/vmail is a bind mount, so the COPY is a host operation either way - but the + // ownership has to be applied where the `vmail` user exists, which on a containerized + // box is the engine, not the host (GH-563). No `|| true` on the chown: maildirs left + // root-owned are maildirs Dovecot cannot read, and swallowing that produced a restore + // that reported success and served nothing. + `if [ -d "$tmp/vmail1" ]; then cp -a "$tmp/vmail1" /var/vmail/ && ${mailEngineCommand(flavor, "chown -R vmail:vmail /var/vmail/vmail1")}; fi`, // Recompute per-domain counters (app-managed, not DB triggers). - "sudo -u postgres psql -d vmail -c \"UPDATE domain d SET mailboxes=(SELECT count(*) FROM mailbox m WHERE m.domain=d.domain), aliases=(SELECT count(*) FROM forwardings f WHERE f.domain=d.domain AND f.is_alias) WHERE d.domain IS NOT NULL;\" || true", + `printf '%s' "UPDATE domain d SET mailboxes=(SELECT count(*) FROM mailbox m WHERE m.domain=d.domain), aliases=(SELECT count(*) FROM forwardings f WHERE f.domain=d.domain AND f.is_alias) WHERE d.domain IS NOT NULL;" | ${mailPsqlFromStdin(flavor)} || true`, // Reload daemons so the restored data + keys take effect. - "systemctl reload postfix dovecot 2>/dev/null || true; systemctl restart amavis 2>/dev/null || true", + `${mailDaemonReloadCommand(flavor)} || true`, ].join("\n"); return { diff --git a/apps/api/src/modules/mail/admin/components.service.ts b/apps/api/src/modules/mail/admin/components.service.ts index 76a6cb461..eadfe5426 100644 --- a/apps/api/src/modules/mail/admin/components.service.ts +++ b/apps/api/src/modules/mail/admin/components.service.ts @@ -25,22 +25,43 @@ * * Fixes applied below: * • Return the instant the supervisor accepts the job (`--no-block` on - * systemd; supervisorctl behaves that way already); the daemon cycles in - * the background and Health tab polling reflects the new state seconds later. + * systemd; supervisorctl behaves that way already), then re-probe once so the + * answer carries the daemon's OBSERVED state and not the acknowledgement — a + * daemon that FATALs on its next breath must not come back as "restarted". + * A daemon that dies later still belongs to the Health tab's 10 s poll. * • Wrap commands as `( … ; echo __EXIT=$?__ ) 2>&1`. The subshell * always exits 0 (echo succeeds), so the SSH layer always sees a * clean close. We parse the real exit code from stdout. - * • Cap remote-side execution with `timeout N` for log tails so a - * stuck journal can't tie up the SSH session. + * • Cap remote-side execution with `timeout N` for log tails so a stuck + * read can't tie up the SSH session. */ -import { safeErrorMessage } from "@repo/core"; +import { AppError, safeErrorMessage } from "@repo/core"; import { MAIL_COMPONENTS } from "../mail-health.service"; -import { mailUnitActionCommand, mailUnitLogsCommand, runMailCommand } from "../mail-engine"; +import { + mailUnitActionCommand, + mailUnitLogsRead, + mailUnitProbeCommand, + parseMailUnitProbe, + runMailCommand, + type MailEngineFlavor, + type MailUnitState, +} from "../mail-engine"; export class UnknownComponentError extends Error {} +/** + * A daemon the supervisor refused to bring up. 409, not 500: nothing here is broken + * except the daemon, and the panel offers logs + retry off the code. + */ +export class MailComponentActionError extends AppError { + constructor(message: string) { + super(message, 409, "MAIL_COMPONENT_ACTION_FAILED"); + this.name = "MailComponentActionError"; + } +} + export type ComponentAction = "restart" | "start" | "stop"; const ACTIONS: readonly ComponentAction[] = ["restart", "start", "stop"]; @@ -66,14 +87,14 @@ function resolveUnit(key: string): string { */ async function execWithExitMarker( serverId: string, - build: (flavor: "container" | "host" | "none") => string, + build: (flavor: MailEngineFlavor) => string, timeoutMs: number, -): Promise<{ output: string; code: number }> { +): Promise<{ flavor: MailEngineFlavor; output: string; code: number }> { // Wrapping subshell: real command runs, exit marker is emitted on stdout, // subshell always returns 0 so ssh2 closes cleanly. - const { output: raw } = await runMailCommand( + const { flavor, output: raw } = await runMailCommand( serverId, - (flavor) => `( ${build(flavor)} ; echo __EXIT=$?__ ) 2>&1`, + (f) => `( ${build(f)} ; echo __EXIT=$?__ ) 2>&1`, { timeout: timeoutMs, requireRunning: false }, ); const match = raw.match(/__EXIT=(\d+)__\s*$/); @@ -81,19 +102,30 @@ async function execWithExitMarker( // No marker means the wrapping shell never reached the echo - either // killed mid-flight or output was truncated. Treat as failure with the // raw output as the error body. - return { output: raw.trim(), code: -1 }; + return { flavor, output: raw.trim(), code: -1 }; } const code = Number(match[1]); const output = raw.replace(/__EXIT=\d+__\s*$/, "").trim(); - return { output, code }; + return { flavor, output, code }; } export interface ComponentActionResult { key: string; unit: string; action: ComponentAction; - /** Trimmed combined stdout+stderr from systemctl. Empty on a clean run. */ + /** Trimmed combined stdout+stderr from the supervisor. Empty on a clean run. */ output: string; + /** + * What the daemon was doing a moment after the supervisor accepted the job. + * + * "Accepted" is not "running": `supervisorctl restart` returns as soon as the + * program survives `startsecs`, so a ClamAV that FATALs on its next breath used to + * come back as an unqualified success and the UI toasted "ClamAV restarted". + * Absent when the state was still TRANSITIONAL or the probe itself failed — the + * caller then keeps its optimistic wording rather than crying wolf over a daemon + * that is simply still starting. + */ + settled?: MailUnitState; } export async function runComponentAction( @@ -110,24 +142,92 @@ export async function runComponentAction( (flavor) => mailUnitActionCommand(flavor, key, unit, action), 20_000, ); - if (code !== 0) { - throw new Error(output || `${action} ${unit} failed (exit ${code})`); + if (!NOT_INSTALLED.test(output) && (code !== 0 || FATAL_REFUSAL.test(output))) { + throw new MailComponentActionError(output || `${action} ${unit} failed (exit ${code})`); + } + return { key, unit, action, output, settled: await settledState(serverId, key, unit) }; +} + +/** + * "This box doesn't ship that daemon." supervisord says `ERROR (no such process)`, + * systemd says `not-found` / `not loaded`. Tested BEFORE the refusal grading in both + * the single and the bulk path, so the two can never grade identical output + * differently. + */ +const NOT_INSTALLED = /not[-\s]?found|not loaded|no such process/i; + +/** + * Refusals supervisorctl prints on stdout while still exiting 0 — it propagates an + * exit code for its OWN failures, not the program's, so `code !== 0` alone reported a + * restart that never happened as a success. + * + * `already started` / `not running` are deliberately absent: they mean the box is + * already in the state we asked for, so they ride back in `output` for the panel to + * show instead of failing the call. + */ +const FATAL_REFUSAL = /abnormal termination|spawn error|unknown error|not authorized/i; + +/** Long enough for a program that dies inside supervisord's `startsecs` to reach BACKOFF. */ +const SETTLE_MS = 1_500; + +/** + * Re-read the daemon's state after the supervisor accepted the job. + * + * Returns `undefined` — "accepted, not confirmed" — for a probe we could not take AND + * for a state that is still in motion. `systemctl --no-block restart dovecot` returns + * instantly and the unit sits in `activating` for 30-90 s, which is the normal case + * that forced `--no-block` in the first place; reporting that as a disagreement would + * cry wolf on every healthy slow restart. + * + * It also cannot catch a daemon that dies LATER — clamd loading a large signature set + * can survive `startsecs` and be OOM-killed at t+10 s — which is why the caller + * downgrades its toast rather than declaring success, and the Health tab's 10 s poll + * delivers the verdict. + */ +async function settledState( + serverId: string, + key: string, + unit: string, +): Promise { + await new Promise((resolve) => setTimeout(resolve, SETTLE_MS)); + try { + const { flavor, output } = await runMailCommand( + serverId, + (f) => mailUnitProbeCommand(f, key, unit), + { timeout: 15_000, requireRunning: false }, + ); + const state = parseMailUnitProbe(flavor, key, unit, output); + if (state.status === "activating" || state.status === "deactivating") return undefined; + if (state.status === "unknown") return undefined; + return state; + } catch { + return undefined; } - return { key, unit, action, output }; } export interface ComponentLogs { key: string; unit: string; - /** Newest-last journal lines. */ + /** Newest-last log lines. */ lines: string[]; + /** + * The read we performed, in display form: `docker exec openship-mail tail -n 300 + * /var/log/supervisor/clamav-daemon.log` on the container engine, `journalctl -u …` + * only on a legacy host. + * + * Carried with the payload on purpose. The drawer used to print a hardcoded + * journalctl header, naming a log the container engine has not got — and it must + * not be re-derived client-side either, which would be a second, unverified guess + * at this box's topology. + */ + source: string; } /** - * Tail recent log lines for a component. We cap the request size - * server-side so a misbehaving client can't ask for a million lines and - * tie up the SSH session, and use a remote-side `timeout` so a hung - * journal can't sit on the SSH channel either. + * Tail recent log lines for a component, and report which log they came from. We cap + * the request size server-side so a misbehaving client can't ask for a million lines + * and tie up the SSH session, and use a remote-side `timeout` so a hung read can't + * sit on the SSH channel either. */ export async function getComponentLogs( serverId: string, @@ -136,16 +236,13 @@ export async function getComponentLogs( ): Promise { const unit = resolveUnit(key); const n = clampLines(requested); - const { output } = await execWithExitMarker( - serverId, - (flavor) => mailUnitLogsCommand(flavor, key, unit, n), - 15_000, - ); + const read = (flavor: MailEngineFlavor) => mailUnitLogsRead(flavor, key, unit, n); + const { flavor, output } = await execWithExitMarker(serverId, (f) => read(f).command, 15_000); const lines = output .split("\n") .map((l) => l.replace(/\r$/, "")) .filter((l) => l.length > 0); - return { key, unit, lines }; + return { key, unit, lines, source: read(flavor).source }; } function clampLines(requested: number | undefined): number { @@ -176,6 +273,10 @@ export interface BulkRestartResult { * orchestrates actual timing; "restart all" just kicks each unit and trusts it. * iRedMail's stack tolerates concurrent restarts well in practice — postgres + * postfix + dovecot all settle within seconds. + * + * No per-unit re-probe here, unlike the single-component path: nine units would put a + * 13-second floor on one click. This path stays accept-only and the Health tab's poll + * is the confirmation; only its OUTPUT grading is shared with the single path. */ export async function restartAllComponents( serverId: string, @@ -188,10 +289,11 @@ export async function restartAllComponents( (flavor) => mailUnitActionCommand(flavor, comp.key, comp.unit, "restart"), 20_000, ); - if (code === 0) { + // Not-installed is graded FIRST: supervisord's `ERROR (no such process)` is + // this flavor's "this box doesn't ship that daemon", which is a no-op. + if (NOT_INSTALLED.test(output)) { results.push({ key: comp.key, unit: comp.unit, ok: true }); - } else if (/not[-\s]?found|not loaded/i.test(output)) { - // Unit isn't installed on this host - treat as a no-op. + } else if (code === 0 && !FATAL_REFUSAL.test(output)) { results.push({ key: comp.key, unit: comp.unit, ok: true }); } else { results.push({ diff --git a/apps/api/src/modules/mail/admin/dns-scan.service.ts b/apps/api/src/modules/mail/admin/dns-scan.service.ts index 9989f1bfa..20d89d22a 100644 --- a/apps/api/src/modules/mail/admin/dns-scan.service.ts +++ b/apps/api/src/modules/mail/admin/dns-scan.service.ts @@ -44,7 +44,7 @@ const resolveTxt = publicResolver.resolveTxt.bind(publicResolver); const reverse = publicResolver.reverse.bind(publicResolver); import { sshManager } from "../../../lib/ssh-manager"; import { readState } from "../mail-state"; -import { relayedDomainsFor, safeErrorMessage } from "@repo/core"; +import { relayedDomainsFor, safeErrorMessage, mailHostname } from "@repo/core"; export type DnsCheckStatus = "pass" | "warn" | "fail" | "unknown"; @@ -175,14 +175,63 @@ export async function scanDns(serverId: string, domain?: string): Promise= 0xfc00 && head <= 0xfdff; + } + const [a = 0, b = 0] = ip.split(".").map(Number); + if (a === 198 && (b === 18 || b === 19)) return true; + if (a >= 240) return true; + return false; +} + // ─── Per-record checks ─────────────────────────────────────────────────────── async function checkA(domain: string, exp?: ExpectedRecord): Promise { if (!exp?.value) return null; - const name = exp.name || `mail.${domain}`; + const name = exp.name || mailHostname(domain); try { const ips = await resolve4(name); const match = ips.includes(exp.value); + // A synthetic answer says nothing about the published zone, so report "we could not + // look" rather than "your record is wrong". + if (!match && ips.length > 0 && ips.every(looksSyntheticAddress)) { + return { + key: "a", + label: "A record", + description: `Points the mail server hostname (${name}) at the VPS public IP.`, + queriedName: name, + recordType: "A", + status: "unknown", + expected: exp.value, + actual: ips.join(", "), + message: + `DNS could not be verified from here: ${name} resolved to ${ips.join(", ")}, ` + + `which is a synthetic address from a local DNS interceptor (a fake-IP VPN or ` + + `proxy such as Clash or sing-box), not a published record. Re-run the scan with ` + + `that proxy off, or check the record from another network.`, + }; + } return { key: "a", label: "A record", @@ -205,7 +254,7 @@ async function checkA(domain: string, exp?: ExpectedRecord): Promise { if (!exp?.value) return null; - const name = exp.name || `mail.${domain}`; + const name = exp.name || mailHostname(domain); try { const ips = await resolve6(name); const match = ips.some((ip) => normaliseIpv6(ip) === normaliseIpv6(exp.value!)); @@ -512,7 +561,7 @@ async function checkPtr( relayedAll = false, ): Promise { if (!aRecord?.value) return null; - const expectedHost = trimDot(`mail.${domain}`); + const expectedHost = trimDot(mailHostname(domain)); const base = { key: "ptr", label: "PTR (reverse DNS)", diff --git a/apps/api/src/modules/mail/admin/domain-dns.service.ts b/apps/api/src/modules/mail/admin/domain-dns.service.ts index dada88ac1..3285b938c 100644 --- a/apps/api/src/modules/mail/admin/domain-dns.service.ts +++ b/apps/api/src/modules/mail/admin/domain-dns.service.ts @@ -25,6 +25,7 @@ import { readState, mutateState } from "../mail-state"; import type { DnsRecordSet, AdditionalDomainDns } from "../mail-state"; import { buildSpfValue } from "../mail.service"; import { withSpfInclude } from "./outbound-relay.service"; +import { mailHostname } from "@repo/core"; // ─── Record set construction ───────────────────────────────────────────────── @@ -53,7 +54,7 @@ export function buildDomainDnsRecords( ipv6?: string | null, opts?: { spfInclude?: string }, ): DnsRecordSet { - const mailHost = `mail.${installDomain}`; + const mailHost = mailHostname(installDomain); // `spfInclude` is the outbound relay's provider token when one is active for // this domain — a no-op when absent, so direct-send domains are unaffected. const spfValue = withSpfInclude(buildSpfValue(ipv4, ipv6), opts?.spfInclude); diff --git a/apps/api/src/modules/mail/admin/maildir.ts b/apps/api/src/modules/mail/admin/maildir.ts index ffbde2750..c805d42c3 100644 --- a/apps/api/src/modules/mail/admin/maildir.ts +++ b/apps/api/src/modules/mail/admin/maildir.ts @@ -19,8 +19,7 @@ * `userdb` query returns them and the LDA places mail in the right path. */ -import type { CommandExecutor } from "@repo/adapters"; -import { sshManager } from "../../../lib/ssh-manager"; +import { mailEngineCommand, runMailCommand, type MailTarget } from "../mail-engine"; export const STORAGE_BASE = "/var/vmail"; export const STORAGE_NODE = "vmail1"; @@ -76,35 +75,54 @@ export function generateMaildir( } /** - * Create the Maildir directory tree on the target VPS: + * The maildir root INSIDE the mailbox home, i.e. what Dovecot actually opens. * - * /var/vmail/vmail1//{cur,new,tmp} + * `mail_location = maildir:%Lh/Maildir/:INDEX=%Lh/Maildir/` (engine/samples/dovecot/ + * dovecot.conf:64) and the userdb query sets `home` to + * `//` (dovecot-sql.conf:19) — so the + * tree lives one level BELOW the home, not at it. This used to create + * `/{cur,new,tmp}`, a tree Dovecot never reads: harmless only because the LDA + * autocreates the real one on first delivery, which is also why nothing caught it. + */ +const MAILDIR_SUBDIR = "Maildir"; + +/** + * Create the Maildir directory tree where the mail engine lives: + * + * /var/vmail/vmail1//Maildir/{cur,new,tmp} * * Owned by `vmail:vmail` (the system user iRedMail's installer creates) so * Postfix/Dovecot can write into it. Mode `0700` per Dovecot's expectation. * + * Runs through `runMailCommand`, so it lands wherever `/var/vmail` and the `vmail` + * user actually are. Running it bare on the host executor is half of #562: a + * container-flavor host has no `vmail` user, so `chown` failed, the `&&` chain + * failed, and mailbox creation 500'd after already inserting the DB rows. + * + * The whole chain is handed to ONE `sh -c` rather than joined with `&&` at the top + * level, because `mailEngineCommand` prefixes `docker exec ` — an + * unwrapped `a && b` would run `a` in the engine and `b` on the HOST. Same wrapping + * precedent as mail.service.ts:91 and :863. + * * Idempotent: `mkdir -p` is fine if the path already exists, and `chown` * over an existing tree is harmless. */ export async function createMaildirOnDisk( - serverIdOrExec: string | CommandExecutor, + serverIdOrExec: MailTarget, layout: MaildirLayout, ): Promise { - const fullPath = `${layout.storagebasedirectory}/${layout.storagenode}/${layout.maildir}`; // Trailing slash already in maildir field; we don't need to add it again. - const cmd = [ - `mkdir -p ${shellQuote(fullPath + "cur")}`, - `mkdir -p ${shellQuote(fullPath + "new")}`, - `mkdir -p ${shellQuote(fullPath + "tmp")}`, - `chown -R vmail:vmail ${shellQuote(fullPath)}`, - `chmod -R 0700 ${shellQuote(fullPath)}`, + const home = maildirHome(layout); + const root = `${home}${MAILDIR_SUBDIR}`; + const script = [ + `mkdir -p ${shellQuote(`${root}/cur`)} ${shellQuote(`${root}/new`)} ${shellQuote(`${root}/tmp`)}`, + `chown -R vmail:vmail ${shellQuote(home)}`, + `chmod -R 0700 ${shellQuote(home)}`, ].join(" && "); - if (typeof serverIdOrExec === "string") { - await sshManager.withExecutor(serverIdOrExec, (exec) => exec.exec(cmd)); - } else { - await serverIdOrExec.exec(cmd); - } + await runMailCommand(serverIdOrExec, (flavor) => + mailEngineCommand(flavor, `sh -c ${shellQuote(script)}`), + ); } /** @@ -116,22 +134,28 @@ export async function createMaildirOnDisk( * has already validated the mailbox exists. */ export async function removeMaildirOnDisk( - serverIdOrExec: string | CommandExecutor, + serverIdOrExec: MailTarget, layout: MaildirLayout, ): Promise { - const fullPath = `${layout.storagebasedirectory}/${layout.storagenode}/${layout.maildir}`; + const fullPath = maildirHome(layout); // Guard: refuse to rm -rf anything that's not under /var/vmail/ if (!fullPath.startsWith(`${STORAGE_BASE}/`)) { throw new Error( `Refusing to remove maildir outside ${STORAGE_BASE}/: ${fullPath}`, ); } - const cmd = `rm -rf ${shellQuote(fullPath)}`; - if (typeof serverIdOrExec === "string") { - await sshManager.withExecutor(serverIdOrExec, (exec) => exec.exec(cmd)); - } else { - await serverIdOrExec.exec(cmd); - } + // Removes the home, so the `Maildir/` subtree inside it goes with it — no need to + // know the layout below. Flavor-routed for the same reason as the create path: + // `/var/vmail` is a volume in the engine, not a host directory. + await runMailCommand(serverIdOrExec, (flavor) => + mailEngineCommand(flavor, `rm -rf ${shellQuote(fullPath)}`), + ); +} + +/** The mailbox home — what the userdb query returns as `home`. Keeps the one path + * concatenation in a single place so create and remove cannot disagree. */ +function maildirHome(layout: MaildirLayout): string { + return `${layout.storagebasedirectory}/${layout.storagenode}/${layout.maildir}`; } function shellQuote(s: string): string { diff --git a/apps/api/src/modules/mail/admin/outbound-relay.service.test.ts b/apps/api/src/modules/mail/admin/outbound-relay.service.test.ts index 4269669d8..054baf88c 100644 --- a/apps/api/src/modules/mail/admin/outbound-relay.service.test.ts +++ b/apps/api/src/modules/mail/admin/outbound-relay.service.test.ts @@ -1,4 +1,19 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Repo root, found by marker so moving this file cannot silently no-op a check. */ +function repoRoot(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 12; i++) { + if (existsSync(join(dir, "apps", "email", "engine"))) return dir; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error("repo root not found - fix the marker walk in this test"); +} // Stub the state I/O (readState/mutateState) so the test isolates the service's // command construction + DNS patching from the on-VPS JSON plumbing. The @@ -477,6 +492,47 @@ describe("relay TLS is scoped to the hop, not the server", () => { }); } + /** + * GH-392: a global `smtp_tls_security_level=encrypt` also governs the + * Postfix→Amavis content-filter hop, because `smtp-amavis` is an smtp CLIENT + * service. Amavisd's :10024 offers no STARTTLS, so saving a relay with the + * DEFAULT scope took INBOUND mail down entirely — every message deferring with + * "TLS is required, but was not offered". The exemption has to be applied in + * both scopes: "selected" leaves the global at `may` today, but an operator may + * have hardened it by hand, and a box built before the master.cf fix carries + * its own copy on the /etc/postfix bind mount. + */ + for (const scope of ["all", "selected"] as const) { + test(`[${scope}] exempts the amavis content filter from the global TLS level`, async () => { + const { exec, execCalls } = makeExec(); + await configureOutboundRelay(exec, { + ...base, + scope, + ...(scope === "selected" ? { domains: ["example.com"] } : {}), + }); + expect(execCalls.join("\n")).toContain( + "postconf -P 'smtp-amavis/unix/smtp_tls_security_level=none'", + ); + }); + } + + test("the amavis exemption is pinned at the transport for new installs", async () => { + // The runtime repair above only reaches boxes where a relay is saved. New + // installs must be correct from the first boot, so the override lives in the + // transport definition too - and this asserts it sits inside the + // `smtp-amavis` entry, not merely somewhere in the file (the two pre-existing + // `=none` lines belong to the reinject smtpd services). + const masterCf = readFileSync( + join(repoRoot(), "apps/email/engine/samples/postfix/master.cf"), + "utf-8", + ); + const entry = masterCf.slice( + masterCf.indexOf("smtp-amavis unix"), + masterCf.indexOf("# smtp port used by Amavisd"), + ); + expect(entry).toContain("-o smtp_tls_security_level=none"); + }); + test("merges into an operator's existing policy map instead of clobbering it", async () => { const { exec, execCalls } = makeExec(); // Pretend the operator already pins a destination of their own. diff --git a/apps/api/src/modules/mail/admin/outbound-relay.service.ts b/apps/api/src/modules/mail/admin/outbound-relay.service.ts index 263a23218..d517cabb0 100644 --- a/apps/api/src/modules/mail/admin/outbound-relay.service.ts +++ b/apps/api/src/modules/mail/admin/outbound-relay.service.ts @@ -38,6 +38,15 @@ * smtp_sasl_password_maps=hash:/etc/postfix/sasl_passwd * smtp_sasl_security_options=noanonymous * smtp_tls_security_level=encrypt|may + * postconf -P smtp-amavis/unix/smtp_tls_security_level=none + * + * That last line is the third way a global TLS level goes wrong, and the one + * that bites hardest: `smtp-amavis` is an smtp CLIENT service inheriting + * smtp_tls_security_level, and amavisd's :10024 offers no STARTTLS, so a global + * `encrypt` stops INBOUND mail entirely (GH-392) — not just outbound. The + * exemption is pinned at the transport for new installs (see + * apps/email/engine/samples/postfix/master.cf) and re-applied here so boxes + * built before that fix are repaired on the next save. * smtp_tls_policy_maps=hash:… (selected only, merged) * postmap + postfix reload * @@ -515,8 +524,26 @@ export async function configureOutboundRelay( "smtp_sasl_security_options=noanonymous", ]; + // 3a) Exempt the content filter from whatever global TLS level we are about to + // set. `smtp-amavis` is an smtp CLIENT service, so it inherits + // smtp_tls_security_level, and amavisd's :10024 offers no STARTTLS: a + // global `encrypt` defers EVERY INBOUND message with "TLS is required, but + // was not offered" (GH-392). New installs get this from + // apps/email/engine/samples/postfix/master.cf, but a box deployed before + // that fix has its own master.cf on the /etc/postfix bind mount and would + // never pick it up — so repair it here, at the moment the global is + // written. `postconf -P` edits the master.cf override in place and is + // idempotent, so re-saving a relay is a no-op. Unconditional rather than + // inside the "all" branch: "selected" leaves the global at `may` today, + // but an operator may have hardened it by hand. + await exec + .exec(engine(`postconf -P ${sq("smtp-amavis/unix/smtp_tls_security_level=none")}`)) + .catch(() => {}); + if (scope === "all") { - // Everything leaves via the relay, so global relay-grade TLS is safe. + // Everything leaves via the relay, so global relay-grade TLS is safe for + // OUTBOUND. The Postfix→Amavis hop is exempted above; it is an inbound path + // that happens to use the same smtp client. sasl.push("smtp_tls_security_level=encrypt"); if (input.port === IMPLICIT_TLS_PORT) sasl.push("smtp_tls_wrappermode=yes"); await exec.exec(engine(`postconf -e ${[`relayhost=${nexthop}`, ...sasl].map(sq).join(" ")}`)); diff --git a/apps/api/src/modules/mail/admin/password.ts b/apps/api/src/modules/mail/admin/password.ts index 02dece091..5117b67d5 100644 --- a/apps/api/src/modules/mail/admin/password.ts +++ b/apps/api/src/modules/mail/admin/password.ts @@ -5,14 +5,22 @@ * `password` column. Hashing happens ON the target VPS via `doveadm pw` * so neither cleartext nor hash transits any intermediate process. * + * "On the target VPS" is not the same as "on the target HOST": `doveadm` lives + * wherever Dovecot does, which on a container-flavor box is inside the engine and + * NOT on the host. Running it bare against the host executor is what made every + * mailbox create return a 500 on a containerized install (#562) — the host has no + * `doveadm`, so the exec produced nothing, the hash regex rejected the empty + * string, and the thrown message never reached the operator. Every invocation now + * goes through `runMailCommand`, which resolves the box's flavor and renders the + * right prefix; see `../mail-engine.ts` for the topology matrix. + * * This is the same scheme used by `mail-credentials.service.ts` for the * postmaster password rotation - kept as a separate small module so the * admin services can call it without depending on the broader credentials * surface. */ -import type { CommandExecutor } from "@repo/adapters"; -import { sshManager } from "../../../lib/ssh-manager"; +import { mailEngineCommand, runMailCommand, type MailTarget } from "../mail-engine"; const SSHA512_HASH_RE = /^\{SSHA512\}[A-Za-z0-9+/=]+$/; @@ -25,22 +33,27 @@ function shellQuote(s: string): string { * `{SSHA512}...` string ready to drop into the `password` column. * * Throws if doveadm returns anything that doesn't match the expected hash - * format - easier to fail at hash time than to debug a broken auth row. + * format - easier to fail at hash time than to debug a broken auth row. The + * plaintext is never echoed in that error, only the (non-)hash we got back. */ export async function hashPassword( - serverIdOrExec: string | CommandExecutor, + serverIdOrExec: MailTarget, plaintext: string, ): Promise { - const cmd = `doveadm pw -s SSHA512 -p ${shellQuote(plaintext)}`; - const out = - typeof serverIdOrExec === "string" - ? await sshManager.withExecutor(serverIdOrExec, (exec) => exec.exec(cmd)) - : await serverIdOrExec.exec(cmd); + const { output, flavor } = await runMailCommand(serverIdOrExec, (f) => + mailEngineCommand(f, `doveadm pw -s SSHA512 -p ${shellQuote(plaintext)}`), + ); - const hash = out.trim(); + const hash = output.trim(); if (!SSHA512_HASH_RE.test(hash)) { + // Name the transport in the message: on a container box the overwhelmingly + // likely cause is that `doveadm` is missing from the engine image, and an + // error that only says "unexpected output" sends the reader hunting the + // password instead. throw new Error( - `doveadm pw returned unexpected output: ${hash.slice(0, 60)}…`, + `doveadm pw (${flavor} engine) returned no usable SSHA512 hash — got ${ + hash ? `"${hash.slice(0, 60)}…"` : "empty output" + }. Check that doveadm is present and Dovecot's config is readable.`, ); } return hash; diff --git a/apps/api/src/modules/mail/admin/platform-mailbox.service.ts b/apps/api/src/modules/mail/admin/platform-mailbox.service.ts index 67ba1e8fd..c1330cde8 100644 --- a/apps/api/src/modules/mail/admin/platform-mailbox.service.ts +++ b/apps/api/src/modules/mail/admin/platform-mailbox.service.ts @@ -36,7 +36,7 @@ import { randomBytes } from "node:crypto"; import type { CommandExecutor } from "@repo/adapters"; -import { safeErrorMessage } from "@repo/core"; +import { safeErrorMessage, mailHostname } from "@repo/core"; import { decrypt, encrypt } from "../../../lib/encryption"; import { sshManager } from "../../../lib/ssh-manager"; import { @@ -120,7 +120,7 @@ export async function ensureOpenshipPlatformMailbox( } const domain = rawDomain; const email = `${PLATFORM_LOCAL_PART}@${domain}`; - const smtpHost = `mail.${state.domain}`; + const smtpHost = mailHostname(state.domain); const rotate = opts?.rotate === true; // Fast path: cached creds match target identity and no rotation requested. diff --git a/apps/api/src/modules/mail/admin/psql-runner.ts b/apps/api/src/modules/mail/admin/psql-runner.ts index ca81b6d05..b07915e7e 100644 --- a/apps/api/src/modules/mail/admin/psql-runner.ts +++ b/apps/api/src/modules/mail/admin/psql-runner.ts @@ -24,8 +24,8 @@ * coded in the service files. * * Transport: - * HOW psql is reached is not decided here. `runMailCommand` resolves the box's - * mail topology and `mailPsqlCommand` renders the right invocation (the engine's + * HOW psql is reached is not decided here. `runMailSql` resolves the box's mail + * topology, renders the right invocation and types its failures (the engine's * pg sidecar, or `sudo -u postgres` on a legacy pre-container install) — see * `../mail-engine.ts`. This file was hardcoded to the sidecar, which is why * every admin read on a legacy box 500'd with "No such container". Same SQL @@ -34,7 +34,7 @@ import type { CommandExecutor } from "@repo/adapters"; import { safeErrorMessage } from "@repo/core"; -import { mailPsqlCommand, runMailCommand, type MailTarget } from "../mail-engine"; +import { runMailSql, type MailTarget } from "../mail-engine"; /** * Quote a value as a PostgreSQL string literal. Escapes embedded single @@ -166,12 +166,10 @@ export async function transaction( * (when the caller reuses one across several calls). * * A stopped engine / gone container surfaces as a typed - * `MailEngineUnavailableError` (→ 409 + a code the dashboard branches on), not as - * a raw shell error the panel prints as "API 500". + * `MailEngineUnavailableError`, and a `vmail` that was never seeded as + * `MailDbNotInitializedError` carrying the exact bootstrap command (→ 409 + a code the + * dashboard branches on), not as a raw shell error the panel prints as "API 500". */ async function runSql(serverIdOrExec: MailTarget, sql: string): Promise { - const { output } = await runMailCommand(serverIdOrExec, (flavor) => - mailPsqlCommand(flavor, sql), - ); - return output; + return runMailSql(serverIdOrExec, sql); } diff --git a/apps/api/src/modules/mail/admin/test-mailbox.service.ts b/apps/api/src/modules/mail/admin/test-mailbox.service.ts index 85365f60a..61cc38470 100644 --- a/apps/api/src/modules/mail/admin/test-mailbox.service.ts +++ b/apps/api/src/modules/mail/admin/test-mailbox.service.ts @@ -35,7 +35,7 @@ */ import type { CommandExecutor } from "@repo/adapters"; -import { safeErrorMessage } from "@repo/core"; +import { safeErrorMessage, mailHostname } from "@repo/core"; import { decrypt, encrypt } from "../../../lib/encryption"; import { sshManager } from "../../../lib/ssh-manager"; import { @@ -101,7 +101,7 @@ export async function ensureOpenshipTestMailbox( const email = `${PLATFORM_LOCAL_PART}@${targetDomain}`; // Submission host is always the primary install — every domain shares // the same `mail.` MX / submission endpoint. - const smtpHost = `mail.${state.domain}`; + const smtpHost = mailHostname(state.domain); const rotate = opts?.rotate === true; // Fast path: cached creds match target identity and no rotation diff --git a/apps/api/src/modules/mail/inbound/capture.ts b/apps/api/src/modules/mail/inbound/capture.ts new file mode 100644 index 000000000..1491fce72 --- /dev/null +++ b/apps/api/src/modules/mail/inbound/capture.ts @@ -0,0 +1,353 @@ +/** + * Arming and disarming inbound capture on the mail engine. + * + * The whole mechanism is one row in `vmail.recipient_bcc_domain`, a table the shipped + * `main.cf` already consults on every message via `proxy:pgsql`. So arming needs no + * postmap, no reload, no container recreate, and behaves identically on the container and + * legacy host flavors — which is what lets this ship to mail boxes that already exist. + * + * DOMAIN-KEYED, ALWAYS. `recipient_bcc_user` is PRIMARY KEY (username) and + * `recipient_bcc_domain` is PRIMARY KEY (domain) — one slot each — and Postfix consults + * the USER table first, so a mailbox-scope row would MASK the domain-scope row for that + * mailbox and make a domain rule silently skip it. Everything is armed at the domain + * level; mailbox/sender/subject scoping happens in the control plane (see filter.ts). + * + * NO MIRROR TABLE. What is armed is read back from vmail rather than cached: the token + * lives inside the `bcc_address` itself, and the collector's maildir lives in + * `vmail.mailbox`. A cache of the engine's own state is a thing that goes stale. + * + * NOT ATOMIC, and it cannot be: provisioning spans the engine's `vmail` DB and its disk, + * over SSH, with no transaction available across them. `mailboxes.service.ts` hits the + * same wall and compensates the same way — a transaction for the two SQL rows, then the + * disk step, then a rollback of the rows if the disk step fails. The orphan that matters + * is the reverse (a BCC row armed with no reachable collector, quietly copying a domain's + * mail into a folder nobody reads), which is why `reconcile` exists below. + */ + +// `shellQuote` from core rather than a local copy: the mail module already carries five +// byte-identical private ones, and core's docstring records that nine existed before it +// was consolidated. Every value interpolated into a command on this path reaches root on a +// host-networked, NET_ADMIN container, so it should be the audited one. +import { safeErrorMessage } from "@repo/core"; +import { execute, q, queryOne, transaction } from "../admin/psql-runner"; +import { hashPassword } from "../admin/password"; +import { createMaildirOnDisk, generateMaildir, STORAGE_BASE, STORAGE_NODE } from "../admin/maildir"; +import { + buildUpsertMailboxSql, + buildUpsertSelfForwardingSql, + randomPassword, +} from "../admin/platform-mailbox.service"; +import { mailEngineCommand, runMailCommand, type MailTarget } from "../mail-engine"; + +/** The local part every collector mailbox uses. One per domain. */ +export const COLLECTOR_LOCAL_PART = "openship-hook"; + +/** + * How a token is recognised as OURS in the single BCC slot. + * + * Anything else in that slot belongs to the operator (archiving, compliance BCC, + * iRedAdmin-Pro) and must never be overwritten — see {@link armDomain}. + */ +const COLLECTOR_PREFIX = `${COLLECTOR_LOCAL_PART}+`; + +/** + * Tokens are `[a-z0-9]` only, and that is a correctness requirement rather than style: + * the Dovecot LDA pipe runs with `flags=DRh`, whose `h` folds the DOMAIN to lowercase but + * NOT the extension, while the userdb query LOWER()s the whole home path. A mixed-case + * token would name a folder that the path we compute cannot find. + * + * It is also a bearer secret. `openship-hook+@` is a real, externally + * addressable mailbox on the public MX and nothing in the shipped config restricts who + * may send to it, so anyone who learns a token can post into the collector and fire + * notifications. 24 chars of base32-ish alphabet is ~120 bits. + */ +export function generateToken(): string { + return randomPassword(32).toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 24); +} + +export function collectorAddress(domain: string, token: string): string { + return `${COLLECTOR_PREFIX}${token}@${domain.toLowerCase()}`; +} + +export function collectorMailbox(domain: string): string { + return `${COLLECTOR_LOCAL_PART}@${domain.toLowerCase()}`; +} + +/** What the engine currently says about one domain's capture. */ +export interface ArmedState { + domain: string; + /** The raw value in the BCC slot, or null when the slot is empty. */ + bccAddress: string | null; + /** Our token, when the slot holds one of ours. */ + token: string | null; + /** True when the slot holds a value that is NOT ours — we must not touch it. */ + foreign: boolean; + /** Absolute maildir of the collector mailbox, when it exists. */ + maildirPath: string | null; +} + +/** Parse our token out of a bcc_address, or null when it is not ours. */ +export function tokenFromBcc(bccAddress: string | null | undefined): string | null { + if (!bccAddress) return null; + const local = bccAddress.split("@")[0] ?? ""; + if (!local.startsWith(COLLECTOR_PREFIX)) return null; + const token = local.slice(COLLECTOR_PREFIX.length); + return /^[a-z0-9]+$/.test(token) ? token : null; +} + +/** + * Read what is armed for a domain, straight from the engine. This is the source of truth + * — there is no local mirror to consult. + * + * The maildir is SELECTed, never recomputed: `generateMaildir` embeds a UTC creation + * timestamp, so a rebuilt path points at a directory that does not exist. + */ +export async function readArmedState(target: MailTarget, domain: string): Promise { + const d = domain.toLowerCase(); + const bcc = await queryOne<{ bcc_address: string }>( + target, + `SELECT bcc_address FROM recipient_bcc_domain WHERE domain = ${q(d)} AND active = 1`, + ); + const box = await queryOne<{ base: string; node: string; maildir: string }>( + target, + `SELECT storagebasedirectory AS base, storagenode AS node, maildir + FROM mailbox WHERE username = ${q(collectorMailbox(d))}`, + ); + const token = tokenFromBcc(bcc?.bcc_address); + return { + domain: d, + bccAddress: bcc?.bcc_address ?? null, + token, + foreign: Boolean(bcc?.bcc_address) && token === null, + maildirPath: box ? `${box.base}/${box.node}/${box.maildir}` : null, + }; +} + +/** Raised when the operator's own BCC occupies the slot. Never clobbered. */ +export class ForeignBccError extends Error { + constructor( + readonly domain: string, + readonly bccAddress: string, + ) { + super( + `${domain} already has a recipient BCC set to ${bccAddress}. Postfix allows only ` + + `one per domain, so arming inbound capture would silently disable it. Remove or ` + + `move that BCC first.`, + ); + this.name = "ForeignBccError"; + } +} + +/** + * Make sure the collector mailbox for a domain exists. + * + * Three artifacts have to line up, exactly as `mailboxes.service.ts` documents: the + * `vmail.mailbox` auth row, a SELF-FORWARDING row in `vmail.forwardings` (without which + * Postfix REJECTS the address even though the mailbox exists — and a domain catch-all + * cannot rescue it, because `catchall_maps.cf` explicitly excludes keys containing `+`), + * and the on-disk maildir. + * + * QUOTA 0 (unlimited) is not laziness. Over-quota is `552 5.2.2` — a PERMANENT 5xx — and + * because the BCC recipient is added in `cleanup(8)` AFTER smtpd, Dovecot's quota-status + * policy cannot reject it at RCPT time. It becomes a delivery-time bounce whose envelope + * sender is the ORIGINAL third party, so a full collector would mail confusing bounces to + * strangers who wrote to your users. `quota_full_tempfail` is set nowhere in the tree, so + * there is no defer to fall back on. The read job deletes every message it dispatches, + * which is what keeps unlimited safe. + * + * Idempotent: upserts, so a re-arm converges instead of failing. + */ +export async function ensureCollectorMailbox( + target: MailTarget, + domain: string, +): Promise<{ username: string; maildirPath: string }> { + const d = domain.toLowerCase(); + const username = collectorMailbox(d); + + const existing = await queryOne<{ base: string; node: string; maildir: string }>( + target, + `SELECT storagebasedirectory AS base, storagenode AS node, maildir + FROM mailbox WHERE username = ${q(username)}`, + ); + if (existing) { + return { username, maildirPath: `${existing.base}/${existing.node}/${existing.maildir}` }; + } + + // Never returned to anyone. The collector is not an interactive account: nothing signs + // in as it, and the control plane reads its maildir over the exec channel instead. + const hash = await hashPassword(target, randomPassword(40)); + const layout = generateMaildir(d, COLLECTOR_LOCAL_PART); + + await transaction(target, [ + buildUpsertMailboxSql({ + username, + passwordHash: hash, + name: "Openship inbound capture", + domain: d, + storagebasedirectory: layout.storagebasedirectory, + storagenode: layout.storagenode, + maildir: layout.maildir, + quotaMB: 0, + }), + buildUpsertSelfForwardingSql(username, d), + ]); + + try { + await createMaildirOnDisk(target, layout); + } catch (err) { + // Same compensating rollback as createMailbox: leaving the rows behind would make + // Postfix accept mail for an address whose storage does not exist, which bounces. + await execute(target, `DELETE FROM forwardings WHERE address = ${q(username)}`).catch(() => {}); + await execute(target, `DELETE FROM mailbox WHERE username = ${q(username)}`).catch(() => {}); + throw new Error( + `Could not create the collector maildir for ${d}: ${safeErrorMessage(err)}`, + ); + } + + return { + username, + maildirPath: `${STORAGE_BASE}/${STORAGE_NODE}/${layout.maildir}`, + }; +} + +/** + * Arm capture for one domain, returning the token in the slot. + * + * Refuses rather than overwrites when the slot holds somebody else's BCC: that single row + * is shared with operator archiving and compliance copies, and taking it would silently + * turn those off. + * + * Re-arming an already-armed domain KEEPS the existing token. Rotating it would orphan + * whatever is already sitting in the folder that token names. + */ +export async function armDomain(target: MailTarget, domain: string): Promise { + const d = domain.toLowerCase(); + const state = await readArmedState(target, d); + if (state.foreign) throw new ForeignBccError(d, state.bccAddress!); + + await ensureCollectorMailbox(target, d); + + if (state.token) return state.token; + + const token = generateToken(); + await execute( + target, + `INSERT INTO recipient_bcc_domain (domain, bcc_address, active) + VALUES (${q(d)}, ${q(collectorAddress(d, token))}, 1) + ON CONFLICT (domain) DO UPDATE SET + bcc_address = EXCLUDED.bcc_address, active = 1, modified = NOW()`, + ); + return token; +} + +/** + * Disarm a domain: drop the BCC row so Postfix stops copying immediately. + * + * The collector mailbox and anything still in it are LEFT ALONE. Deleting a mailbox is + * destructive and this is called from a rule delete, which the operator may well be + * undoing a moment later. Only our own row is removed — a foreign BCC is left untouched. + */ +export async function disarmDomain(target: MailTarget, domain: string): Promise { + const d = domain.toLowerCase(); + const state = await readArmedState(target, d); + if (!state.token) return; + await execute( + target, + `DELETE FROM recipient_bcc_domain + WHERE domain = ${q(d)} AND bcc_address LIKE ${q(`${COLLECTOR_PREFIX}%`)}`, + ); +} + +/** + * The domain a rule's target names — the one place that parses `scope` + `target`. + * + * Null for `scope: "all"`, which names no single domain and is resolved differently by + * each caller (the write path asks the engine for every domain; the read path asks which + * domains are actually armed). Everything else about the derivation is identical, and it + * lived twice — once in the controller and once in the read job — which is exactly how the + * two drift on a mailbox address that contains an unexpected character. + */ +export function ruleDomain(rule: { + scope: string; + target: string | null; +}): string | null { + const t = rule.target?.trim().toLowerCase(); + if (!t) return null; + if (rule.scope === "domain") return t; + if (rule.scope === "mailbox") { + const at = t.indexOf("@"); + return at > 0 ? t.slice(at + 1) : null; + } + return null; +} + +/** Every domain on the engine — what `scope: "all"` fans out over. */ +export async function listEngineDomains(target: MailTarget): Promise { + const rows = await queryOne<{ domains: string | null }>( + target, + `SELECT string_agg(domain, ',' ORDER BY domain) AS domains FROM domain WHERE active = 1`, + ); + return (rows?.domains ?? "").split(",").filter(Boolean); +} + +/** + * Bring the engine's armed rows in line with the rules that exist. + * + * This is the answer to the atomicity gap in this file's header, and it runs on a + * schedule rather than only on write. Two drifts it repairs: + * + * - a domain that SHOULD be armed but is not (a failed arm, or a new domain that a + * `scope: "all"` rule now covers — there is no global BCC in the shipped config, so + * `all` is a genuine per-domain fan-out and a domain added later is invisible + * until something re-arms it); + * - a domain armed with OUR token that no enabled rule wants any more, which would + * otherwise keep copying mail into a folder nobody reads, forever, on a bind mount + * with no quota of its own. + * + * A foreign BCC is reported, never touched. + */ +export async function reconcileDomains( + target: MailTarget, + wanted: { domains: Set; all: boolean }, +): Promise<{ armed: string[]; disarmed: string[]; refused: string[] }> { + const engineDomains = await listEngineDomains(target); + const shouldArm = wanted.all ? new Set(engineDomains) : wanted.domains; + + const armed: string[] = []; + const disarmed: string[] = []; + const refused: string[] = []; + + for (const d of engineDomains) { + const state = await readArmedState(target, d).catch(() => null); + if (!state) continue; + + if (shouldArm.has(d)) { + if (state.foreign) { + refused.push(d); + continue; + } + if (!state.token) { + await armDomain(target, d).then(() => armed.push(d)).catch(() => refused.push(d)); + } + continue; + } + if (state.token) { + await disarmDomain(target, d).then(() => disarmed.push(d)).catch(() => undefined); + } + } + return { armed, disarmed, refused }; +} + +/** + * The collector folder for a token, as an absolute path inside the engine. + * + * Maildir++ layout: the LDA's `-m ` creates a folder named for the token, and + * Maildir++ stores a top-level folder as a DOT-PREFIXED directory beside `cur/new/tmp`. + * `mail_location = maildir:%Lh/Maildir/` puts the whole tree one level under the home. + */ +export function collectorFolderPath(maildirPath: string, token: string): string { + const home = maildirPath.replace(/\/+$/, ""); + return `${home}/Maildir/.${token}`; +} + +export { mailEngineCommand, runMailCommand }; diff --git a/apps/api/src/modules/mail/inbound/filter.ts b/apps/api/src/modules/mail/inbound/filter.ts new file mode 100644 index 000000000..61001d9e7 --- /dev/null +++ b/apps/api/src/modules/mail/inbound/filter.ts @@ -0,0 +1,259 @@ +/** + * Deciding whether a captured message should fire a notification — pure, no I/O. + * + * Everything here runs on the HEADER BLOCK of a BCC copy sitting in the collector + * folder. Keeping it pure is deliberate: this is where the feature is dangerous (a loop + * that mails itself, a rule that leaks every message on the server, an alert per spam), + * and none of those failure modes should need a mail server to test. + * + * WHAT WE CAN AND CANNOT KNOW. `enable_original_recipient = no` in the shipped main.cf + * ("Avoid duplicate recipient messages"), so the BCC copy carries NO `X-Original-To` and + * its envelope recipient is the collector, not the mailbox the mail was for. The only + * evidence of the original recipient is the `To`/`Cc` header. That is authoritative for + * ordinary mail and WRONG for anything Bcc'd or alias-expanded — so a `mailbox`-scope + * rule can miss a message that genuinely arrived at its target. A `domain`-scope rule has + * no such gap, because the capture itself is domain-keyed. This is a property of Postfix's + * config, not something the control plane can fix; it is called out in the UI copy. + */ + +import type { MailInboundRule } from "@repo/db"; + +/** The subset of headers any decision here is allowed to depend on. */ +export interface ParsedHeaders { + /** `From:` — display form, e.g. `Alice `. */ + from?: string; + /** Lowercased bare address out of `From:`, e.g. `alice@example.com`. */ + fromAddress?: string; + /** `To:` and `Cc:` joined — the only evidence of the original recipient. */ + recipients: string[]; + subject?: string; + /** `Return-Path:` — the ENVELOPE sender. `<>` means this is a bounce. */ + returnPath?: string; + messageId?: string; + /** amavis writes these when SpamAssassin crosses the tag level. */ + spamFlagYes: boolean; + spamScore?: number; + autoSubmitted?: string; + precedence?: string; + listId?: string; +} + +/** Why a message was dropped before any rule was consulted. */ +export type DropReason = + | "bounce" + | "auto-submitted" + | "bulk-precedence" + | "mailing-list" + | "openship-sender" + | "spam-flagged"; + +export interface FilterDecision { + drop: boolean; + reason?: DropReason; +} + +/** + * Unfold and split an RFC 5322 header block. + * + * Continuation lines begin with space or tab and belong to the previous field, which + * matters immediately: long `Subject:` and `To:` values are folded in practice, and a + * naive line split would truncate a subject mid-word and lose half a recipient list. + * Field names are case-insensitive; the FIRST occurrence wins, because a second + * `From:` is either malformed or an attempt to confuse a reader downstream. + */ +export function parseHeaderBlock(raw: string): ParsedHeaders { + const fields = new Map(); + let currentName: string | null = null; + let currentValue = ""; + + const flush = () => { + if (currentName && !fields.has(currentName)) { + fields.set(currentName, currentValue.trim()); + } + currentName = null; + currentValue = ""; + }; + + // Normalize CRLF first so a Windows-authored fixture and a real message agree. + for (const line of raw.replace(/\r\n/g, "\n").split("\n")) { + // A blank line ends the header block; a body must never reach a rule decision. + if (line === "") break; + if (/^[ \t]/.test(line)) { + if (currentName) currentValue += " " + line.trim(); + continue; + } + const sep = line.indexOf(":"); + if (sep <= 0) continue; + flush(); + currentName = line.slice(0, sep).trim().toLowerCase(); + currentValue = line.slice(sep + 1); + } + flush(); + + const to = fields.get("to"); + const cc = fields.get("cc"); + const recipients = [to, cc] + .filter((v): v is string => Boolean(v)) + .flatMap((v) => v.split(",")) + .map((v) => bareAddress(v)) + .filter((v): v is string => Boolean(v)); + + const scoreRaw = fields.get("x-spam-score"); + const score = scoreRaw === undefined ? undefined : Number.parseFloat(scoreRaw); + + return { + from: fields.get("from"), + fromAddress: bareAddress(fields.get("from")), + recipients, + subject: fields.get("subject"), + returnPath: fields.get("return-path"), + messageId: fields.get("message-id"), + // amavis writes `X-Spam-Flag: YES`; anything else (absent, NO) is not a positive. + spamFlagYes: (fields.get("x-spam-flag") ?? "").trim().toUpperCase() === "YES", + spamScore: score !== undefined && Number.isFinite(score) ? score : undefined, + autoSubmitted: fields.get("auto-submitted"), + precedence: fields.get("precedence"), + listId: fields.get("list-id"), + }; +} + +/** `Alice ` / `a@b.com` / `` → `a@b.com`, lowercased. */ +export function bareAddress(value: string | undefined): string | undefined { + if (!value) return undefined; + const angled = value.match(/<([^>]*)>/); + const candidate = (angled ? angled[1] : value).trim().toLowerCase(); + // An empty `<>` is a real and meaningful value (a bounce), so it is preserved rather + // than being normalized away to undefined. + if (candidate === "") return ""; + return candidate.includes("@") ? candidate : undefined; +} + +/** + * The guards that run BEFORE any rule, in the order a mail incident actually happens. + * + * A notification about mail is itself mail. If any of these is missing, a single + * notification delivered to an address on the watched engine re-captures itself and the + * loop only stops when someone notices — so these are not tuning knobs. + * + * `openshipSenders` are the addresses this instance sends its own mail FROM (the platform + * mailbox, and whatever Settings→Email is configured with). They are passed in rather + * than derived so this stays pure. + */ +export function loopGuard( + h: ParsedHeaders, + opts: { openshipSenders?: readonly string[] } = {}, +): FilterDecision { + // A null envelope sender is a BOUNCE. Notifying on bounces is how a bounce storm + // becomes an alert storm, and the reply-to-a-bounce case is a classic mail loop. + if (h.returnPath !== undefined && bareAddress(h.returnPath) === "") { + return { drop: true, reason: "bounce" }; + } + // RFC 3834: anything not `no` is machine-generated, which includes our own alerts if a + // channel ever mails one back. + const auto = (h.autoSubmitted ?? "").trim().toLowerCase(); + if (auto && auto !== "no") return { drop: true, reason: "auto-submitted" }; + + const precedence = (h.precedence ?? "").trim().toLowerCase(); + if (precedence === "bulk" || precedence === "junk") { + return { drop: true, reason: "bulk-precedence" }; + } + // `List-Id` marks list traffic, which is the highest-volume way to flood a channel. + if (h.listId) return { drop: true, reason: "mailing-list" }; + + const senders = opts.openshipSenders ?? []; + if (h.fromAddress && senders.some((s) => s.toLowerCase() === h.fromAddress)) { + return { drop: true, reason: "openship-sender" }; + } + return { drop: false }; +} + +/** + * The spam gate, which exists because nothing upstream provides one. + * + * The shipped amavis policy sets `spam_lover='Y'` AND `bad_header_lover='Y'` on the + * catch-all `@.` policy with empty quarantine targets, so the global + * `$final_spam_destiny = D_DISCARD` never applies to any recipient: spam IS delivered, + * and therefore IS captured. Bad-header mail is delivered too and carries no + * `X-Spam-Flag` at all, which is why the score is checked independently of the flag. + * + * With no `maxSpamScore` set on the rule, a positive `X-Spam-Flag` alone is enough to + * drop — the conservative default, since the alternative is paging on spam. + */ +export function spamGate(h: ParsedHeaders, maxSpamScore: number | null): FilterDecision { + if (maxSpamScore === null || maxSpamScore === undefined) { + return h.spamFlagYes ? { drop: true, reason: "spam-flagged" } : { drop: false }; + } + if (h.spamScore !== undefined && h.spamScore > maxSpamScore) { + return { drop: true, reason: "spam-flagged" }; + } + return { drop: false }; +} + +/** + * Does this rule want this message? + * + * `capturedDomain` is the domain whose BCC row produced the copy — structural and + * trustworthy, unlike the recipient headers. + * + * FAIL CLOSED is the whole point of the target checks. There is no CHECK constraint + * tying `scope` to `target` (this schema has none anywhere), so a `mailbox` or `domain` + * rule with a null target is representable — and if it were treated as "no constraint" + * it would silently become "every message on the server", i.e. an operator who mistyped + * a rule quietly forwards a whole domain's mail metadata to Slack. It matches nothing. + */ +export function matchesRule( + rule: Pick< + MailInboundRule, + "scope" | "target" | "fromPattern" | "subjectPattern" | "enabled" | "pausedReason" + >, + h: ParsedHeaders, + capturedDomain: string, +): boolean { + if (!rule.enabled || rule.pausedReason) return false; + + const target = rule.target?.trim().toLowerCase() ?? ""; + + switch (rule.scope) { + case "all": + break; + case "domain": + if (!target) return false; + if (target !== capturedDomain.toLowerCase()) return false; + break; + case "mailbox": { + if (!target) return false; + // The To/Cc caveat in this file's header applies here: a Bcc'd or alias-expanded + // message cannot be attributed to a mailbox from headers alone. + if (!h.recipients.some((r) => r === target)) return false; + break; + } + default: + // An unknown scope is a rule this build does not understand. Matching nothing is + // the only safe reading — the alternative is matching everything. + return false; + } + + if (rule.fromPattern && !matchesPattern(rule.fromPattern, h.from ?? h.fromAddress)) { + return false; + } + if (rule.subjectPattern && !matchesPattern(rule.subjectPattern, h.subject)) { + return false; + } + return true; +} + +/** + * Operator-facing matching: case-insensitive substring, with `*` as a wildcard. + * + * Deliberately NOT a regular expression. These patterns come from a text box, they run + * once per delivered message, and an operator pasting `.*(a+)+$` would hand us a + * catastrophic-backtracking stall on the mail path. Every metacharacter is escaped and + * only `*` is given meaning. + */ +export function matchesPattern(pattern: string, value: string | undefined): boolean { + if (!value) return false; + const trimmed = pattern.trim(); + if (!trimmed) return true; + const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*"); + return new RegExp(escaped, "i").test(value); +} diff --git a/apps/api/src/modules/mail/inbound/inbound.controller.ts b/apps/api/src/modules/mail/inbound/inbound.controller.ts new file mode 100644 index 000000000..6a6c01bff --- /dev/null +++ b/apps/api/src/modules/mail/inbound/inbound.controller.ts @@ -0,0 +1,318 @@ +/** + * Inbound-rule CRUD for the mail admin panel. + * + * Every handler repeats the same three-part guard the ~30 existing mail handlers use, and + * that repetition is load-bearing rather than copy-paste: + * + * assertNotCloud — the mail engine is self-hosted only. + * permission.assert — with the CONCRETE serverId, never the wildcard. `mail_server` + * is a CONDITIONAL_SINGLETON: on a route with no `:serverId` + * PATH param the tag degrades to resourceId "*", which for + * owner/admin/member is a pure role×type decision naming no + * server — i.e. no tenant boundary at all. That is why serverId + * is in the path here and not in the body. + * isServerInOrg — ties the request to THIS org's server. + * + * Arming happens on write, and the engine is the source of truth for what is armed; there + * is no mirror table. A failed arm leaves the rule row disabled with the reason attached + * rather than a rule that looks live and captures nothing. + */ + +import type { Context } from "hono"; +import { safeErrorMessage } from "@repo/core"; +import { repos, type MailInboundScope } from "@repo/db"; +import { getRequestContext } from "../../../lib/request-context"; +import { permission } from "../../../lib/permission"; +import { param, isServerInOrg, assertNotCloud } from "../../../lib/controller-helpers"; +import { + armDomain, + disarmDomain, + ForeignBccError, + listEngineDomains, + ruleDomain, +} from "./capture"; +import { runInboundForServer } from "./read"; + +const SCOPES: readonly MailInboundScope[] = ["mailbox", "domain", "all"]; +const EMAIL_RE = /^[a-z0-9._+-]+@[a-z0-9.-]+\.[a-z]{2,}$/; +const DOMAIN_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/; + +function errorJson(c: Context, err: unknown) { + if (err instanceof ForeignBccError) { + return c.json({ error: err.message, code: "MAIL_FOREIGN_BCC" }, 409); + } + return c.json({ error: safeErrorMessage(err) }, 500); +} + +/** + * Guard shared by every handler below. Returns a Response to short-circuit, or the + * resolved context to continue with. + */ +async function guardServer(c: Context, action: "read" | "write") { + const cloud = assertNotCloud(c); + if (cloud) return { response: cloud } as const; + const serverId = param(c, "serverId"); + await permission.assert(getRequestContext(c), { + resourceType: "mail_server", + resourceId: serverId, + action, + }); + const ctx = getRequestContext(c); + if (!(await isServerInOrg(ctx, serverId))) { + return { response: c.json({ error: "Server not found" }, 404) } as const; + } + return { serverId, organizationId: ctx.organizationId! } as const; +} + +/** + * Validate the scope/target pair at the WRITE boundary. + * + * This is the enforcement the schema deliberately does not carry as a CHECK constraint: + * a mailbox or domain rule with no target would be representable, and if the filter ever + * read a missing target as "no constraint" it would silently widen to every message on the + * server. The filter already fails closed; this refuses to store the row in the first + * place, so the operator finds out at save time instead of never. + */ +function validateScope(scope: string, target: unknown): { scope: MailInboundScope; target: string | null } { + if (!SCOPES.includes(scope as MailInboundScope)) { + throw new Error(`Unknown scope "${scope}". Expected one of: ${SCOPES.join(", ")}.`); + } + const s = scope as MailInboundScope; + if (s === "all") return { scope: s, target: null }; + + const t = typeof target === "string" ? target.trim().toLowerCase() : ""; + if (!t) throw new Error(`A ${s} rule needs a target.`); + if (s === "mailbox" && !EMAIL_RE.test(t)) { + throw new Error(`"${t}" is not a valid email address.`); + } + if (s === "domain" && !DOMAIN_RE.test(t)) { + throw new Error(`"${t}" is not a valid domain.`); + } + return { scope: s, target: t }; +} + +/** + * The domains a rule needs armed on the engine. + * + * The scope→domain derivation itself lives in `ruleDomain` (capture.ts) so the write path + * and the read job cannot disagree about which domain a target names. Only the `all` + * fan-out differs here: the write path has to arm every domain the engine currently has, + * whereas the read job walks whatever is actually armed. + */ +async function domainsFor( + serverId: string, + scope: MailInboundScope, + target: string | null, +): Promise { + if (scope === "all") return listEngineDomains(serverId); + const domain = ruleDomain({ scope, target }); + return domain ? [domain] : []; +} + +/** + * Release the BCC on any candidate domain no enabled rule still wants. + * + * This has to run on EVERY mutation that can narrow what is watched, not just delete + * (GH-559). An armed domain keeps Postfix BCC'ing a full second copy of every message + * into a collector mailbox provisioned at QUOTA 0, and the only thing that prunes those + * copies is the read job visiting that domain. Disable a rule — or move its scope — and + * the copies keep arriving while nothing deletes them, on /var/vmail, a bind mount with + * no quota of its own. That fills the disk and takes the mail server down. + * + * Capture is domain-keyed and SHARED, so a domain a second rule still covers must stay + * armed; `candidates` is therefore only what the mutation could have orphaned, never the + * whole engine. Failures are swallowed because `reconcileDomains` repairs the residue on + * a schedule — a disarm that loses a race must not fail the operator's save. + */ +async function releaseOrphanedDomains(serverId: string, candidates: string[]): Promise { + if (candidates.length === 0) return; + const remaining = await repos.mailInbound.listEnabledByServer(serverId); + const stillWanted = new Set(); + for (const r of remaining) { + for (const d of await domainsFor(serverId, r.scope as MailInboundScope, r.target)) { + stillWanted.add(d); + } + } + for (const d of candidates) { + if (!stillWanted.has(d)) await disarmDomain(serverId, d).catch(() => undefined); + } +} + +export async function listRulesHandler(c: Context) { + const g = await guardServer(c, "read"); + if ("response" in g) return g.response; + try { + const rules = await repos.mailInbound.listByServer(g.serverId); + return c.json({ rules }); + } catch (err) { + return errorJson(c, err); + } +} + +export async function createRuleHandler(c: Context) { + const g = await guardServer(c, "write"); + if ("response" in g) return g.response; + + let body: Record; + try { + body = (await c.req.json()) as Record; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + let scope: MailInboundScope; + let target: string | null; + try { + ({ scope, target } = validateScope(String(body.scope ?? ""), body.target)); + } catch (err) { + return c.json({ error: safeErrorMessage(err) }, 400); + } + + const name = String(body.name ?? "").trim(); + if (!name) return c.json({ error: "A rule needs a name." }, 400); + + const channelIds = Array.isArray(body.channelIds) ? body.channelIds.map(String) : []; + if (channelIds.length === 0) { + return c.json({ error: "Pick at least one notification channel." }, 400); + } + + try { + // Arm FIRST. A rule row that exists while capture is off is a rule that looks live and + // silently sees nothing — the worse of the two half-states, and the one an operator + // cannot diagnose from the UI. + const domains = await domainsFor(g.serverId, scope, target); + for (const d of domains) await armDomain(g.serverId, d); + + const rule = await repos.mailInbound.create({ + serverId: g.serverId, + organizationId: g.organizationId, + name, + scope, + target, + fromPattern: typeof body.fromPattern === "string" ? body.fromPattern.trim() || null : null, + subjectPattern: + typeof body.subjectPattern === "string" ? body.subjectPattern.trim() || null : null, + maxSpamScore: typeof body.maxSpamScore === "number" ? body.maxSpamScore : null, + channelIds, + enabled: body.enabled === false ? false : true, + }); + return c.json({ rule }, 201); + } catch (err) { + return errorJson(c, err); + } +} + +export async function updateRuleHandler(c: Context) { + const g = await guardServer(c, "write"); + if ("response" in g) return g.response; + const ruleId = param(c, "ruleId"); + + let body: Record; + try { + body = (await c.req.json()) as Record; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + try { + const existing = await repos.mailInbound.findById(g.organizationId, ruleId); + if (!existing) return c.json({ error: "Rule not found" }, 404); + + const patch: Record = {}; + if (typeof body.name === "string" && body.name.trim()) patch.name = body.name.trim(); + if (Array.isArray(body.channelIds)) patch.channelIds = body.channelIds.map(String); + if (typeof body.enabled === "boolean") patch.enabled = body.enabled; + if ("fromPattern" in body) { + patch.fromPattern = typeof body.fromPattern === "string" ? body.fromPattern.trim() || null : null; + } + if ("subjectPattern" in body) { + patch.subjectPattern = + typeof body.subjectPattern === "string" ? body.subjectPattern.trim() || null : null; + } + if ("maxSpamScore" in body) { + patch.maxSpamScore = typeof body.maxSpamScore === "number" ? body.maxSpamScore : null; + } + // Clearing the pause is an explicit act, so resuming re-arms below. + if (body.pausedReason === null) patch.pausedReason = null; + + if (typeof body.scope === "string") { + const v = validateScope(body.scope, body.target ?? existing.target); + patch.scope = v.scope; + patch.target = v.target; + } + + // What this rule covered BEFORE the write - the set that may now be orphaned. Read + // first, because after the update the old scope is unrecoverable. + const previouslyCovered = await domainsFor( + g.serverId, + existing.scope as MailInboundScope, + existing.target, + ); + + const rule = await repos.mailInbound.update(g.organizationId, ruleId, patch as never); + // The row can vanish between the findById above and here (a concurrent delete). That + // delete already released its own domains, so there is nothing to arm and nothing to + // orphan - just report it honestly rather than arming for a rule that no longer exists. + if (!rule) return c.json({ error: "Rule not found" }, 404); + + // Arm from the PERSISTED row, so re-enabling a rule arms it again — the old code only + // armed inside the scope branch, so flipping `enabled` back to true left a live rule + // watching nothing. Unguarded on purpose: a failed arm is the operator's problem to + // see now, and the file header's atomicity gap is what reconcileDomains repairs. + if (rule.enabled) { + for (const d of await domainsFor(g.serverId, rule.scope as MailInboundScope, rule.target)) { + await armDomain(g.serverId, d); + } + } + // Then release whatever this rule no longer wants: `enabled: false`, a narrowed scope, + // or a retargeted domain (GH-559). + await releaseOrphanedDomains(g.serverId, previouslyCovered); + + return c.json({ rule }); + } catch (err) { + return errorJson(c, err); + } +} + +export async function deleteRuleHandler(c: Context) { + const g = await guardServer(c, "write"); + if ("response" in g) return g.response; + const ruleId = param(c, "ruleId"); + + try { + const existing = await repos.mailInbound.findById(g.organizationId, ruleId); + if (!existing) return c.json({ error: "Rule not found" }, 404); + + // Read what it covered before the row is gone. + const covered = await domainsFor( + g.serverId, + existing.scope as MailInboundScope, + existing.target, + ); + await repos.mailInbound.remove(g.organizationId, ruleId); + await releaseOrphanedDomains(g.serverId, covered); + return c.json({ ok: true }); + } catch (err) { + return errorJson(c, err); + } +} + +/** + * Run the read once, without dispatching or deleting — the "why did my rule not fire?" + * button. `mail_server:write` rather than `:read` because the boot scanner rejects a POST + * on a read-tagged route as a CRITICAL error and exits the process. + */ +export async function testRulesHandler(c: Context) { + const g = await guardServer(c, "write"); + if ("response" in g) return g.response; + try { + const result = await runInboundForServer({ + serverId: g.serverId, + organizationId: g.organizationId, + dryRun: true, + }); + return c.json(result); + } catch (err) { + return errorJson(c, err); + } +} diff --git a/apps/api/src/modules/mail/inbound/read.ts b/apps/api/src/modules/mail/inbound/read.ts new file mode 100644 index 000000000..a2eab64dc --- /dev/null +++ b/apps/api/src/modules/mail/inbound/read.ts @@ -0,0 +1,331 @@ +/** + * Reading captured mail out of the collector folder and turning it into notifications. + * + * DISPATCH THEN DELETE, and the deletion is the point. There is no cursor table and no + * UID bookkeeping: the BCC copy is a whole message sitting on disk, so removing it after + * dispatch is both "we have handled this" and the prune that stops a second full copy of + * every watched message accumulating forever under `/var/vmail` — a host bind mount with + * no quota of its own. The collector runs at quota 0 precisely because this runs. + * + * Dispatch means ENQUEUED: a `notification_delivery` row is written and the notification + * worker owns retries. So deleting the message immediately afterwards is correct — the + * durable retry lives in that table, not in the maildir. + * + * HEADERS ONLY cross the wire. Bodies and attachments are never read, which keeps the + * payload small, keeps message content out of the control plane's logs and out of the + * notification, and makes the read cost independent of a 20 MB attachment. + * + * Everything runs through `runMailCommand`/`mailEngineCommand`, so `/var/vmail` resolves + * inside the engine on a container box and on the host on a legacy one. Reading these + * paths with the raw host executor is the GH-562 bug class. + */ + +import { safeErrorMessage, shellQuote } from "@repo/core"; +import { repos, type MailInboundRule } from "@repo/db"; +import { mailEngineCommand, runMailCommand } from "../mail-engine"; +import { collectorFolderPath, readArmedState, ruleDomain, tokenFromBcc } from "./capture"; +import { loopGuard, matchesRule, parseHeaderBlock, spamGate, type ParsedHeaders } from "./filter"; + +/** Bytes of each message we read. Enough for a full header block, never a body. */ +const HEADER_BYTES = 8192; + +/** + * Messages handled per domain per tick. + * + * A COUNT, never a byte cap: slicing a batch by bytes can cut a record mid-field and + * corrupt the parse of an otherwise healthy message. A mailing-list burst that exceeds + * this is drained over subsequent ticks rather than dropped, and the digest below is what + * keeps it from becoming one alert per message. + */ +const MAX_PER_TICK = 40; + +/** + * Above this many messages in one tick for one rule, send ONE digest instead of N alerts. + * + * The notification subsystem has no rate limiting or windowed dedup of its own — burst + * control is explicitly the producer's job — so without this a list posting to a watched + * address is a channel flood, and a backlog after an outage is worse. + */ +const DIGEST_THRESHOLD = 5; + +export interface InboundMessage { + /** Maildir filename — unique, and what we delete by. */ + file: string; + headers: ParsedHeaders; +} + +/** + * List then read the new messages in a collector folder, in ONE round trip. + * + * `head -c` per file rather than `cat`: a body must not cross the wire, and a message with + * a 20 MB attachment must not make this read expensive. Each blob is fenced by a marker + * line carrying the filename, so one exec yields both identities and content — a + * per-message exec would be a round trip per message. + */ +export async function readCollectorFolder( + serverId: string, + folder: string, + limit = MAX_PER_TICK, +): Promise { + // `|| true` so an empty or absent folder is an empty read, not a thrown command: a + // domain armed a moment ago has no `new/` until the first delivery creates it. + const script = + `set -e; d=${shellQuote(`${folder}/new`)}; [ -d "$d" ] || exit 0; ` + + `for f in $(ls -1 "$d" 2>/dev/null | head -n ${limit}); do ` + + `echo "__OPENSHIP_MSG__ $f"; head -c ${HEADER_BYTES} "$d/$f" 2>/dev/null || true; ` + + `echo; done`; + + const { output } = await runMailCommand( + serverId, + (flavor) => mailEngineCommand(flavor, `sh -c ${shellQuote(script)}`), + { timeout: 30_000 }, + ); + + const out: InboundMessage[] = []; + for (const chunk of output.split("__OPENSHIP_MSG__ ").slice(1)) { + const nl = chunk.indexOf("\n"); + if (nl < 0) continue; + const file = chunk.slice(0, nl).trim(); + if (!file) continue; + out.push({ file, headers: parseHeaderBlock(chunk.slice(nl + 1)) }); + } + return out; +} + +/** + * Delete handled messages. Batched into one exec, and never a wildcard. + * + * Filenames come from `ls` of a directory we own, but they are still interpolated into a + * shell command on a root, host-networked container — so every one is individually + * shell-quoted, and anything that does not look like a maildir filename is skipped rather + * than quoted-and-hoped. + */ +export async function deleteMessages( + serverId: string, + folder: string, + files: readonly string[], +): Promise { + const safe = files.filter((f) => /^[A-Za-z0-9._,:=-]+$/.test(f)); + if (safe.length === 0) return; + const args = safe.map((f) => shellQuote(`${folder}/new/${f}`)).join(" "); + await runMailCommand(serverId, (flavor) => mailEngineCommand(flavor, `rm -f ${args}`), { + timeout: 30_000, + }); +} + +interface Matched { + rule: MailInboundRule; + message: InboundMessage; +} + +/** + * Run one server's collectors: read, filter, dispatch, delete. + * + * Returns counts rather than throwing on a per-domain failure — one unreachable domain + * must not stop the others, and the job wrapper reports the totals. + */ +export async function runInboundForServer(opts: { + serverId: string; + organizationId: string; + openshipSenders?: readonly string[]; + /** Skip dispatch and deletion — powers the UI's "test this rule" button. */ + dryRun?: boolean; +}): Promise<{ read: number; matched: number; emitted: number; dropped: number; errors: string[] }> { + const { serverId, organizationId, dryRun } = opts; + const errors: string[] = []; + let read = 0; + let matched = 0; + let emitted = 0; + let dropped = 0; + + const rules = await repos.mailInbound.listEnabledByServer(serverId); + if (rules.length === 0) return { read, matched, emitted, dropped, errors }; + + // Which domains to visit. `all` means every armed domain, so it is resolved from what + // the ENGINE says is armed rather than from the rule rows. + const wantsAll = rules.some((r) => r.scope === "all"); + const explicit = new Set( + rules + .filter((r) => r.scope === "domain" || r.scope === "mailbox") + .map((r) => ruleDomain(r)) + .filter((d): d is string => Boolean(d)), + ); + + const domains = wantsAll ? await armedDomains(serverId) : [...explicit]; + + for (const domain of domains) { + try { + const state = await readArmedState(serverId, domain); + if (!state.token || !state.maildirPath) continue; + + const folder = collectorFolderPath(state.maildirPath, state.token); + const messages = await readCollectorFolder(serverId, folder); + read += messages.length; + if (messages.length === 0) continue; + + const handled: string[] = []; + const perRule = new Map(); + + for (const message of messages) { + handled.push(message.file); + + const guard = loopGuard(message.headers, { openshipSenders: opts.openshipSenders }); + if (guard.drop) { + dropped++; + continue; + } + let any = false; + for (const rule of rules) { + if (!matchesRule(rule, message.headers, domain)) continue; + if (spamGate(message.headers, rule.maxSpamScore).drop) continue; + any = true; + const list = perRule.get(rule.id) ?? []; + list.push({ rule, message }); + perRule.set(rule.id, list); + } + if (any) matched++; + else dropped++; + } + + if (!dryRun) { + for (const [, group] of perRule) { + emitted += await emitForRule(organizationId, serverId, domain, group); + } + // Delete AFTER dispatch. A message that produced no match is deleted too: it was + // examined and rejected, and leaving it would re-examine it forever. + await deleteMessages(serverId, folder, handled); + for (const ruleId of perRule.keys()) { + await repos.mailInbound.markMatched(ruleId).catch(() => undefined); + } + } + } catch (err) { + errors.push(`${domain}: ${safeErrorMessage(err)}`); + } + } + + return { read, matched, emitted, dropped, errors }; +} + +/** + * Emit for one rule's matches — individually, or one digest above the threshold. + * + * The digest is the correct shape after a backlog: an operator returning from an outage + * wants "31 messages arrived", not thirty-one notifications spread across ten minutes. + * + * DELIVERY GOES TO THE RULE'S OWN CHANNELS, not to the org's category subscriptions. The + * operator picked channels per rule ("support@ → #support"), so routing through the + * dispatcher would ignore that choice and fan every rule to whoever happens to be + * subscribed. This is the same per-target enqueue `job-command.ts` uses for a job's + * `notifyConfig`, for the same reason. + * + * With no channels the rule delivers NOTHING and says so. Quietly falling back to org-wide + * subscriptions would send a rule's mail somewhere the operator never chose. + */ +async function emitForRule( + organizationId: string, + serverId: string, + domain: string, + group: readonly Matched[], +): Promise { + const rule = group[0]!.rule; + + const payloads = + group.length > DIGEST_THRESHOLD + ? [ + { + title: `${group.length} messages matched “${rule.name}”`, + message: + `${group.length} messages arrived for ${domain} matching “${rule.name}”. ` + + `Collapsed into one alert to avoid flooding this channel.`, + ruleName: rule.name, + domain, + count: group.length, + }, + ] + : group.map(({ message }) => { + const h = message.headers; + return { + title: h.subject ? `New mail: ${h.subject}` : "New mail received", + // Envelope-level facts only. No body, no snippet — the operator asked to be + // told mail ARRIVED, and a channel is not a mail client. It also keeps message + // content out of the control plane's delivery rows and logs. + message: + `From: ${h.from ?? h.fromAddress ?? "unknown"}\n` + + `To: ${h.recipients.join(", ") || domain}\n` + + `Rule: ${rule.name}`, + ruleName: rule.name, + domain, + from: h.fromAddress, + subject: h.subject, + }; + }); + + const channelIds = rule.channelIds ?? []; + if (channelIds.length === 0) { + console.warn( + `[mail:inbound] rule ${rule.id} (“${rule.name}”) matched ${group.length} message(s) ` + + `but has no notification channels — nothing was sent.`, + ); + return 0; + } + + let sent = 0; + for (const channelId of channelIds) { + const channel = await repos.notificationChannel.findById(channelId).catch(() => null); + // `verified` is the ONLY gate the dispatcher applies before shipping org payloads to + // an outbound URL, so a direct enqueue has to apply it too — otherwise this path + // becomes the way to post to an unverified endpoint. + if (!channel || !channel.enabled || !channel.verified) continue; + + // A rule must not be able to name another tenant's channel. The dispatcher gets this + // for free by looking subscriptions up per org; a direct enqueue has to check. + const channelOrgId = await resolveChannelOrg(channel.userId); + if (!channelOrgId || channelOrgId !== organizationId) continue; + + for (const payload of payloads) { + await repos.notificationDelivery + .create({ + userId: channel.userId, + organizationId, + auditEventId: null, + category: MAIL_INBOUND_EVENT, + channelId: channel.id, + channelKind: channel.kind, + status: "queued", + attempts: 0, + payload: { ...payload, resourceType: "mail_server", resourceId: serverId }, + }) + .then(() => { + sent++; + }) + .catch((err) => { + console.warn( + `[mail:inbound] could not queue delivery for rule ${rule.id}: ${safeErrorMessage(err)}`, + ); + }); + } + } + return sent; +} + +/** A channel belongs to a user; the org is that user's membership. */ +async function resolveChannelOrg(userId: string): Promise { + const members = await repos.member.listByUser(userId).catch(() => []); + return members[0]?.organizationId ?? null; +} + +/** + * Hoisted so the audit-taxonomy scan sees exactly one literal, and so a typo cannot make + * the producer and the registry disagree — an unmapped eventType is dropped silently. + */ +export const MAIL_INBOUND_EVENT = "mail.inbound_received"; + +/** Domains the ENGINE currently has one of our BCC rows on. */ +async function armedDomains(serverId: string): Promise { + const { queryRows, q } = await import("../admin/psql-runner"); + const rows = await queryRows<{ domain: string; bcc_address: string }>( + serverId, + `SELECT domain, bcc_address FROM recipient_bcc_domain WHERE active = 1 AND bcc_address LIKE ${q("openship-hook+%")}`, + ); + return rows.filter((r) => tokenFromBcc(r.bcc_address)).map((r) => r.domain); +} diff --git a/apps/api/src/modules/mail/inbound/senders.ts b/apps/api/src/modules/mail/inbound/senders.ts new file mode 100644 index 000000000..70249eaf9 --- /dev/null +++ b/apps/api/src/modules/mail/inbound/senders.ts @@ -0,0 +1,50 @@ +/** + * The addresses THIS instance sends its own mail from. + * + * This exists for exactly one reason: a notification about mail is itself mail. If an + * inbound rule watches a domain that also receives Openship's own alerts — a support + * address on the same engine, a member whose mailbox lives there — then the alert is + * captured, matched, and emitted again. The loop only stops when a human notices. + * + * `loopGuard` takes these as data so it can stay pure. Two sources, because the instance + * can send as either: + * - `instance_settings.smtpFrom`, i.e. whatever Settings→Email is configured with (the + * one sender for ALL system mail); + * - `openship@` for each provisioned mail server, the platform mailbox the mail + * module creates for itself. + * + * Best-effort and never fatal: a failure here must not stop the sweep, but it DOES widen + * the loop window, so it is logged rather than swallowed silently. + */ + +import { safeErrorMessage } from "@repo/core"; +import { repos } from "@repo/db"; +import { PLATFORM_LOCAL_PART } from "../admin/platform-mailbox.service"; + +export async function getInstanceSmtpSenders(): Promise { + const out = new Set(); + + try { + const settings = await repos.instanceSettings.get(); + const from = settings?.smtpFrom?.trim().toLowerCase(); + // smtpFrom may be a display form (`Openship `), so take the bare address. + if (from) { + const angled = from.match(/<([^>]+)>/); + const address = (angled ? angled[1] : from).trim(); + if (address.includes("@")) out.add(address); + } + } catch (err) { + console.warn(`[mail:inbound] could not read instance SMTP sender: ${safeErrorMessage(err)}`); + } + + try { + for (const server of await repos.mailServer.list()) { + const domain = server.domain?.trim().toLowerCase(); + if (domain) out.add(`${PLATFORM_LOCAL_PART}@${domain}`); + } + } catch (err) { + console.warn(`[mail:inbound] could not read mail server domains: ${safeErrorMessage(err)}`); + } + + return [...out]; +} diff --git a/apps/api/src/modules/mail/inbound/watch.ts b/apps/api/src/modules/mail/inbound/watch.ts new file mode 100644 index 000000000..63f7085d0 --- /dev/null +++ b/apps/api/src/modules/mail/inbound/watch.ts @@ -0,0 +1,141 @@ +/** + * The `mail:inbound-watch` job body — one sweep across every mail server with rules. + * + * Kept out of `job.registry.ts` and behind a dynamic import for the same reason the whole + * mail module is: it must not load into the cloud runtime, where the engine does not exist. + * + * Cheap when idle by design. A server with no enabled rules costs one indexed query and + * ZERO SSH, which matters at a one-minute cadence on an instance where most boxes are not + * mail servers. + */ + +import { safeErrorMessage } from "@repo/core"; +import { repos, type MailInboundScope } from "@repo/db"; +import { getInstanceSmtpSenders } from "./senders"; +import { runInboundForServer } from "./read"; +import { reconcileDomains, ruleDomain } from "./capture"; + +/** Index signature so this satisfies the job runner's `JobSummary` shape directly. */ +export interface InboundWatchResult { + [key: string]: number; + servers: number; + read: number; + emitted: number; + dropped: number; + errors: number; +} + +export async function runInboundWatch(): Promise { + const result: InboundWatchResult = { servers: 0, read: 0, emitted: 0, dropped: 0, errors: 0 }; + + // Only servers that actually have rules. `listEnabled` is one indexed read across the + // instance, versus a per-server probe that would cost SSH on every box every minute. + const rules = await repos.mailInbound.listEnabled().catch(() => []); + if (rules.length === 0) return result; + + // The addresses this instance sends its OWN mail from. Passed into the loop guard so a + // notification email that lands back inside a watched domain is recognised as ours and + // cannot feed itself. + const openshipSenders = await getInstanceSmtpSenders().catch(() => []); + + const byServer = new Map(); + for (const rule of rules) byServer.set(rule.serverId, rule.organizationId); + + for (const [serverId, organizationId] of byServer) { + result.servers++; + try { + const r = await runInboundForServer({ serverId, organizationId, openshipSenders }); + result.read += r.read; + result.emitted += r.emitted; + result.dropped += r.dropped; + if (r.errors.length > 0) { + result.errors += r.errors.length; + // Per-domain failures are already summarized by runInboundForServer. Logged rather + // than thrown: one unreachable mail box must not stop the rest of the sweep. + console.warn(`[mail:inbound-watch] ${serverId}: ${r.errors.join("; ")}`); + } + } catch (err) { + result.errors++; + console.warn(`[mail:inbound-watch] ${serverId} failed: ${safeErrorMessage(err)}`); + } + } + + return result; +} + +export interface InboundReconcileResult { + [key: string]: number; + servers: number; + armed: number; + disarmed: number; + refused: number; + errors: number; +} + +/** + * The `mail:inbound-reconcile` job body — drift repair for what is armed on each engine. + * + * `reconcileDomains` existed with no caller at all (GH-559), which left three drifts + * permanent. The write path now disarms what it orphans, so this is the backstop rather + * than the only defence — but it is the only thing that fixes: + * + * - a domain a `scope: "all"` rule should cover that was ADDED AFTER the rule. There is + * no global BCC in the shipped config, so "all" is a real per-domain fan-out and a new + * domain is invisible to it until something re-arms; + * - an arm or disarm that failed mid-write (the atomicity gap capture.ts documents); + * - a collector an operator deleted by hand from the Mailboxes tab. + * + * Slow cadence on purpose: every pass costs SSH per mail server, and none of the drifts + * above are latency-sensitive — the expensive one (an unpruned collector filling + * /var/vmail) is now closed on the write path, so this only has to catch the residue. + */ +export async function runInboundReconcile(): Promise { + const result: InboundReconcileResult = { + servers: 0, + armed: 0, + disarmed: 0, + refused: 0, + errors: 0, + }; + + const rules = await repos.mailInbound.listEnabled().catch(() => []); + if (rules.length === 0) return result; + + // Group the WANTED set per server. `all` short-circuits the domain list: reconcile + // resolves it against the engine's real domains, which is the whole point. + const wantedByServer = new Map; all: boolean }>(); + for (const rule of rules) { + let w = wantedByServer.get(rule.serverId); + if (!w) { + w = { domains: new Set(), all: false }; + wantedByServer.set(rule.serverId, w); + } + if ((rule.scope as MailInboundScope) === "all") { + w.all = true; + continue; + } + const domain = ruleDomain({ scope: rule.scope as MailInboundScope, target: rule.target }); + if (domain) w.domains.add(domain); + } + + for (const [serverId, wanted] of wantedByServer) { + result.servers++; + try { + const r = await reconcileDomains(serverId, wanted); + result.armed += r.armed.length; + result.disarmed += r.disarmed.length; + result.refused += r.refused.length; + if (r.refused.length > 0) { + // A foreign BCC is reported, never touched — the operator has to resolve it. + console.warn( + `[mail:inbound-reconcile] ${serverId}: left alone (foreign BCC or failed arm): ${r.refused.join(", ")}`, + ); + } + } catch (err) { + result.errors++; + console.warn(`[mail:inbound-reconcile] ${serverId} failed: ${safeErrorMessage(err)}`); + } + } + + return result; +} diff --git a/apps/api/src/modules/mail/mail-credentials.service.ts b/apps/api/src/modules/mail/mail-credentials.service.ts index e9a302524..ab3fb4248 100644 --- a/apps/api/src/modules/mail/mail-credentials.service.ts +++ b/apps/api/src/modules/mail/mail-credentials.service.ts @@ -4,10 +4,10 @@ * Flow: * 1. Hash the new password with `doveadm pw -s SSHA512` (the scheme * iRedMail's default `dovecot-sql.conf` uses for the `password` - * column). Hashing on the target server avoids sending the - * cleartext or the hash through any intermediate process. - * 2. UPDATE vmail.mailbox SET password = '' WHERE username = … - * via `sudo -u postgres psql`. + * column), via the shared `admin/password.ts` helper. Hashing on the + * target server avoids sending the cleartext or the hash through any + * intermediate process. + * 2. UPDATE vmail.mailbox SET password = … through `admin/psql-runner`. * 3. Scrub any leftover plaintext from the state file. We used to mirror * it back for the credentials card to display; that was a needless * attack surface and is gone - the only way to "know" the password @@ -15,37 +15,10 @@ */ import type { CommandExecutor } from "@repo/adapters"; +import { hashPassword } from "./admin/password"; +import { execute, q } from "./admin/psql-runner"; import { readState, mutateState } from "./mail-state"; -/** - * Shell-quote an arbitrary string so it survives as a single argv element - * inside a `bash -c …` command. Wraps in single quotes and escapes any - * embedded single quotes via the standard `'\''` trick. - */ -function shellQuote(s: string): string { - return `'${s.replace(/'/g, "'\\''")}'`; -} - -/** - * Hash a plaintext password via doveadm. Returns the `{SSHA512}...` string - * ready to drop into the `password` column. - */ -async function hashWithDovecot( - exec: CommandExecutor, - plaintext: string, -): Promise { - const out = await exec.exec( - `doveadm pw -s SSHA512 -p ${shellQuote(plaintext)}`, - ); - const hash = out.trim(); - if (!hash.startsWith("{SSHA512}")) { - throw new Error( - `doveadm pw returned unexpected output: ${hash.slice(0, 60)}…`, - ); - } - return hash; -} - /** * Update the postmaster password for ``. Caller is responsible * for validation (length, etc.) - this function trusts the input. @@ -59,23 +32,24 @@ export async function updatePostmasterPassword( newPassword: string, ): Promise { const username = `postmaster@${domain}`; - const hash = await hashWithDovecot(exec, newPassword); + // Shared with the admin panel's mailbox create/update, so the hash scheme and the + // engine-vs-host transport are decided in exactly one place. This used to be a + // private copy that ran `doveadm` bare on the host executor, which is dead on a + // container-flavor box (#562) — the same defect the mailbox-create path had. + const hash = await hashPassword(exec, newPassword); - // Sanity-check the values we're about to embed. Both come from controlled - // sources (doveadm output + `postmaster@`), so this is - // belt-and-suspenders against an upstream surprise. - if (!/^\{SSHA512\}[A-Za-z0-9+/=]+$/.test(hash)) { - throw new Error("doveadm pw returned a hash with unexpected characters"); - } + // Belt-and-suspenders against an upstream surprise: the username is derived from an + // already-validated domain, and `hashPassword` has its own format gate. if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(username)) { throw new Error(`Refusing to update for suspicious username: ${username}`); } - // iRedMail's pg_hba.conf grants the local `postgres` Unix user passwordless - // access. Single-quote-wrap the SQL string literals - hash chars are - // [A-Za-z0-9+/={}], username is similarly tame, so no escape gymnastics. - const psqlCmd = `sudo -u postgres psql -d vmail -v ON_ERROR_STOP=1 -c "UPDATE mailbox SET password='${hash}' WHERE username='${username}';"`; - await exec.exec(psqlCmd); + // Through psql-runner so the invocation matches the box's topology (the engine's pg + // sidecar, or `sudo -u postgres` on a legacy install) rather than assuming the latter. + await execute( + exec, + `UPDATE mailbox SET password = ${q(hash)} WHERE username = ${q(username)};`, + ); // Persist the new plaintext into state.secrets so the test-email flow // (and any future SMTP-from-orchestrator use) can authenticate over diff --git a/apps/api/src/modules/mail/mail-engine.ts b/apps/api/src/modules/mail/mail-engine.ts index f0fe56e5c..056b442c3 100644 --- a/apps/api/src/modules/mail/mail-engine.ts +++ b/apps/api/src/modules/mail/mail-engine.ts @@ -90,6 +90,60 @@ export class MailEngineUnavailableError extends AppError { } } +/** + * The engine image's idempotent schema bootstrap, at the path the Dockerfile bakes it + * to, plus the restart it needs to be useful: supervisord's default `startretries=3` + * has already put Postfix/Dovecot/Amavis in FATAL after their first failed starts + * against the empty database, and FATAL is terminal. + * + * DISPLAY-ONLY — this string is never handed to an executor, which is why the + * `${MAIL_CONTAINER}` interpolation here is not shell-quoted like every real docker + * string in this file. It mirrors the second half of what entrypoint.sh prints when the + * bootstrap fails, so the panel and the container log name the same fix. + */ +const MAIL_DB_BOOTSTRAP_COMMAND = + `docker exec ${MAIL_CONTAINER} bash /opt/openship-mail/db-bootstrap.sh` + + ` && docker restart ${MAIL_CONTAINER}`; + +/** + * The engine is up and psql answered — with "there is no schema here". + * + * `db-bootstrap.sh` is what seeds `vmail`, and it used to be able to leave without + * having done so. A box in that state serves SSH, runs the container, and answers every + * admin read with `relation "domain" does not exist` — which reached the panel as a bare + * 500 (GH-562). 409 for the same reason as the engine gate above: the request is valid + * and the box is reachable, the operation just cannot run until one documented command + * has been. The remediation is IN the message because that is the only place the + * operator looks. + * + * The copy hedges on purpose. `running` comes from `docker inspect .State.Running`, + * which is true from the instant the container starts — including the whole time the + * entrypoint is inside db-bootstrap.sh waiting up to 180s for the sidecar. A dashboard + * poll that lands in that window must not be told confidently to run a repair. + * + * A separate class from {@link MailEngineUnavailableError} on purpose: the engine IS + * installed and running here, so the dashboard's `isMailEngineUnavailable` path — which + * hides the message in favour of a banner that will not render — must not claim it. + * Since the entrypoint now exits non-zero when the bootstrap fails, a current-image box + * lands on `not_running` instead; this is the safety net for an older image, or a + * `vmail` wiped under a live engine. + */ +export class MailDbNotInitializedError extends AppError { + constructor( + readonly flavor: MailEngineFlavor, + readonly detail: string, + ) { + super( + flavor === "container" + ? `The mail database on this server has no schema yet (${detail}). If mail setup is still running, wait for it to finish; otherwise the engine's bootstrap did not complete — run: ${MAIL_DB_BOOTSTRAP_COMMAND}` + : `The mail database on this server has no schema yet (${detail}). Re-run mail setup on this server.`, + 409, + "MAIL_DB_NOT_INITIALIZED", + ); + this.name = "MailDbNotInitializedError"; + } +} + // ─── Resolution ────────────────────────────────────────────────────────────── const probes = new WeakMap>(); @@ -203,6 +257,50 @@ export async function runMailCommand( }); } +/** + * Run SQL against `vmail` on whichever engine this box has — the one funnel every + * mail-admin read and write goes through (see `admin/psql-runner`). + * + * It exists to give a psql failure a TYPE. The flavor is captured as the command is + * built, so the "no schema" answer can carry the flavor-correct remediation without a + * second topology probe and without psql-runner learning what a container is. `flavor` + * stays "none" only when the gate in `runMailCommand` refused before building — the one + * case where no SQL ran, and one the classifier never sees because that gate throws a + * typed error. + */ +export async function runMailSql(target: MailTarget, sql: string): Promise { + let flavor: MailEngineFlavor = "none"; + try { + const { output } = await runMailCommand(target, (resolved) => { + flavor = resolved; + return mailPsqlCommand(resolved, sql); + }); + return output; + } catch (err) { + if (err instanceof AppError) throw err; + const message = err instanceof Error ? err.message : String(err); + if (looksLikeMissingSchema(message)) { + // Logged here because the throw below is a 409, and the error handler only logs + // 5xx — without this line the condition being fixed leaves no trace at all, + // which is the complaint that opened the issue. + console.warn(`[mail] vmail schema missing on ${flavor} engine: ${firstOutputLine(message)}`); + throw new MailDbNotInitializedError(flavor, firstOutputLine(message)); + } + throw err; + } +} + +/** + * A psql answer that means the schema was never seeded — a missing `vmail` database + * (the sidecar initialized, the bootstrap never ran) or a missing table in it (it ran + * partially). Deliberately NOT `column … does not exist`: that means OUR SQL disagrees + * with a schema that IS there, which is our bug to read verbatim, not an operator's to + * bootstrap away. + */ +function looksLikeMissingSchema(text: string): boolean { + return /database "[^"]+" does not exist|relation "[^"]+" does not exist/i.test(text); +} + /** * Does this output mean "you talked to the wrong topology / the engine is gone"? * @@ -295,6 +393,65 @@ export function mailEngineCommand(flavor: MailEngineFlavor, cmd: string): string return flavor === "container" ? `docker exec ${MAIL_CONTAINER} ${cmd}` : cmd; } +/** + * The three renderers the BACKUP shell needs (GH-563). + * + * `mailPsqlCommand` above cannot serve them: it bakes in `-c `, while a backup + * streams a dump to stdout and replays a FILE. And a backup script is generated once and + * executed later by the generic custom_command producer over a bare SSH executor, so it + * cannot call back into this module — the topology has to be baked into the string. + * + * The container case is not simply "prefix with docker exec". Two things differ: + * + * - `-i`. The dump file lives in the producer's `$tmp` ON THE HOST, which the sidecar + * cannot see, so the replay has to arrive over stdin (`-f -`) and `docker exec` + * needs `-i` to forward it. A `-f "$tmp/…"` inside the container would just be a + * missing path. + * - WHICH container. Postgres is the sidecar; vmail ownership is the engine. Getting + * that pair backwards is how `chown -R vmail:vmail` ran somewhere with no vmail user. + */ +/** + * Just the topology, for callers that GENERATE a command instead of running one — the + * backup plan is built now and executed later, elsewhere, by the generic producer. + * `requireRunning` is deliberately not implied: a backup policy can legitimately be + * saved while the engine is stopped. + */ +export async function resolveMailFlavor(target: MailTarget): Promise { + return withMailEngine(target, async (probe) => { + if (probe.flavor === "none") { + throw new MailEngineUnavailableError("not_installed", probe.flavor); + } + return probe.flavor; + }); +} + +export function mailPgDumpToStdout(flavor: MailEngineFlavor, args: string): string { + return flavor === "container" + ? `docker exec ${MAIL_DB_CONTAINER} pg_dump -U postgres -d ${MAIL_DB_NAME} ${args}` + : `sudo -u postgres pg_dump -d ${MAIL_DB_NAME} ${args}`; +} + +/** psql reading SQL from STDIN, so the caller redirects a host-side file into it. */ +export function mailPsqlFromStdin(flavor: MailEngineFlavor): string { + const flags = `-d ${MAIL_DB_NAME} -v ON_ERROR_STOP=1 -f -`; + return flavor === "container" + ? `docker exec -i ${MAIL_DB_CONTAINER} psql -U postgres ${flags}` + : `sudo -u postgres psql ${flags}`; +} + +/** + * Pick the restored mail data up. The engine reads its accounts from Postgres and its + * config from the bind-mounted /etc paths, so a restore is inert until the daemons + * re-read both — `supervisorctl` in the container, `systemctl` on a legacy host. The + * supervisord program names are deliberately the same strings as the health probe's + * units (see mail-health.service.ts), so there is one vocabulary, not two. + */ +export function mailDaemonReloadCommand(flavor: MailEngineFlavor): string { + return flavor === "container" + ? `docker exec ${MAIL_CONTAINER} supervisorctl restart postfix dovecot amavis` + : "systemctl reload postfix dovecot 2>/dev/null; systemctl restart amavis 2>/dev/null"; +} + /** * The path each editable config file has INSIDE the engine. * @@ -390,7 +547,12 @@ export type MailUnitStatus = export interface MailUnitState { status: MailUnitStatus; - /** Free-form sub-state when running (systemd's, or supervisord's state word). */ + /** + * The supervisor's own state word — systemd's SubState, or supervisord's, + * lower-cased. Load-bearing beyond display: `status: "failed"` covers both + * supervisord FATAL (it has given up) and BACKOFF (it is still retrying), and + * this is the only thing that tells them apart. + */ subState?: string; /** ISO timestamp the unit entered its current state — systemd only. */ activeSince?: string; @@ -485,10 +647,10 @@ export function mailUnitActionCommand( ): string { if (flavor === "container") { return key === "postgresql" - ? `docker ${action} ${MAIL_DB_CONTAINER}` - : `docker exec ${MAIL_CONTAINER} supervisorctl ${action} ${unit}`; + ? `docker ${action} ${sq(MAIL_DB_CONTAINER)}` + : `docker exec ${sq(MAIL_CONTAINER)} supervisorctl ${action} ${sq(unit)}`; } - return `systemctl --no-block ${action} ${unit}`; + return `systemctl --no-block ${action} ${sq(unit)}`; } /** @@ -508,23 +670,39 @@ export function mailQueueProbeCommand(flavor: MailEngineFlavor, lines = 400): st } /** - * Tail one daemon's logs. supervisord writes each program to - * `/var/log/supervisor/.log` (see apps/email's supervisord.conf); the - * sidecar logs to its container; a legacy box has journald. `timeout 10` caps the - * exec so a hung log can't sit on the SSH channel. + * Tail one daemon's logs — the command to run, and the same read in the form we + * show the operator. + * + * supervisord writes each program to `/var/log/supervisor/.log` (see + * apps/email's supervisord.conf); the sidecar logs to its container; a legacy box + * has journald. `timeout 10` caps the exec so a hung log can't sit on the SSH + * channel. + * + * `source` exists because the drawer printed a hardcoded `journalctl -u …` header, + * naming a log the container engine does not have. Both halves come out of this one + * switch, so the header cannot drift from the read; `source` drops the `timeout`, + * the redirection and the shell quoting, and nothing else. The log path keeps + * `${unit}` unquoted deliberately — it is closed over by `MAIL_COMPONENTS`, not + * caller input. */ -export function mailUnitLogsCommand( +export function mailUnitLogsRead( flavor: MailEngineFlavor, key: string, unit: string, lines: number, -): string { +): { command: string; source: string } { if (flavor === "container") { - return key === "postgresql" - ? `timeout 10 docker logs --tail ${lines} ${MAIL_DB_CONTAINER} 2>&1 || true` - : `timeout 10 docker exec ${MAIL_CONTAINER} tail -n ${lines} /var/log/supervisor/${unit}.log 2>/dev/null || true`; + if (key === "postgresql") { + const source = `docker logs --tail ${lines} ${MAIL_DB_CONTAINER}`; + return { command: `timeout 10 ${source} 2>&1 || true`, source }; + } + const source = `docker exec ${MAIL_CONTAINER} tail -n ${lines} /var/log/supervisor/${unit}.log`; + return { command: `timeout 10 ${source} 2>/dev/null || true`, source }; } - return `timeout 10 journalctl -u ${sq(unit)} -n ${lines} --no-pager 2>&1 || true`; + return { + command: `timeout 10 journalctl -u ${sq(unit)} -n ${lines} --no-pager 2>&1 || true`, + source: `journalctl -u ${unit} -n ${lines}`, + }; } function firstOutputLine(text: string): string { @@ -543,6 +721,9 @@ function mapSupervisorState(s: string): MailUnitStatus { case "STOPPED": case "EXITED": return "inactive"; + // Both are `failed` for every consumer that grades this — the deploy gate, the + // serving check, the Health banner. What differs is whether anything is still + // trying, and that rides on `subState`: FATAL means supervisord has given up. case "FATAL": case "BACKOFF": return "failed"; diff --git a/apps/api/src/modules/mail/mail-health.service.ts b/apps/api/src/modules/mail/mail-health.service.ts index 1bb5ed6b8..ea64b883d 100644 --- a/apps/api/src/modules/mail/mail-health.service.ts +++ b/apps/api/src/modules/mail/mail-health.service.ts @@ -24,6 +24,28 @@ import { type MailUnitStatus, } from "./mail-engine"; +/** + * Does a mail server stop being a mail server without this daemon? + * + * - `required` — Postfix / Dovecot / PostgreSQL. Nothing is delivered without them. + * - `advisory` — the filtering and hardening daemons. Their absence degrades the + * box; it does not stop it being one. + * + * ONE definition, read by both the install gate (`stepDeployEngine`) and the Health + * banner, so the two cannot disagree about which daemon is load-bearing. + */ +/** + * How much a component's state says about whether this box is a working mail server. + * + * required — down means mail has stopped. Red. + * advisory — mail still flows, with reduced protection. Amber. + * informational — reported for completeness; NOTHING depends on it, so its state must + * never colour the banner (GH-240). Distinct from "advisory" on + * purpose: amber for a daemon no part of the stack consults teaches + * operators to ignore the banner, which is worse than not showing it. + */ +export type MailComponentSeverity = "required" | "advisory" | "informational"; + /** Components we check. `unit` is the supervisord program name in the engine image. */ export interface MailComponentDef { /** Stable id - used by the frontend as a React key + for icon lookup. */ @@ -32,6 +54,7 @@ export interface MailComponentDef { description: string; /** supervisord program name inside the engine (or the pg sidecar for postgresql). */ unit: string; + severity: MailComponentSeverity; } export const MAIL_COMPONENTS: MailComponentDef[] = [ @@ -40,30 +63,41 @@ export const MAIL_COMPONENTS: MailComponentDef[] = [ label: "Postfix", description: "SMTP server (receives + sends mail)", unit: "postfix", + severity: "required", }, { key: "dovecot", label: "Dovecot", description: "IMAP / POP3 / LMTP (inbox access + delivery)", unit: "dovecot", + severity: "required", }, { key: "amavis", label: "Amavis", description: "Filtering pipeline (spam + virus scan)", unit: "amavis", + severity: "advisory", }, { key: "clamav", label: "ClamAV", description: "Anti-virus engine", unit: "clamav-daemon", + // Advisory to the INSTALL gate (a large signature load can still be warming + // up), never "harmless": amavis has one scanner and no backup, so with clamd + // gone mail is DELIVERED UNSCANNED — it does not defer (GH-565; failing closed + // is an opt-in documented in the engine's amavisd.conf). The banner's copy + // says exactly that, because "advisory" here means "delivery still works", + // not "nothing is wrong". + severity: "advisory", }, { key: "freshclam", label: "ClamAV updates", description: "Auto-updates virus signatures", unit: "clamav-freshclam", + severity: "advisory", }, { key: "spamassassin", @@ -73,29 +107,38 @@ export const MAIL_COMPONENTS: MailComponentDef[] = [ // (>=4.0) ships its systemd unit as `spamd.service`, so checking // `spamassassin` always read LoadState=not-found ("Missing") on a perfectly // healthy legacy host — and the engine image's supervisord program is named - // to match. Note Amavis scores spam via its own in-process - // Mail::SpamAssassin integration regardless of this daemon's state; spamd is - // the standalone network-facing scorer other tools (spamc) talk to, and this - // check is about ITS state specifically. + // to match. unit: "spamd", + // INFORMATIONAL, not advisory (GH-240 FP1). Amavis scores spam through its own + // in-process Mail::SpamAssassin integration, which is what actually tags mail on + // this stack; `spamd` is the separate network-facing scorer that `spamc` and other + // external tools talk to, and NOTHING here speaks to it. So its state carries no + // information about whether spam filtering works, and reporting it as a degradation + // was a false positive on every host that simply does not run it — the issue's + // "SpamAssassin daemon reported not installed although SA scores in-process". + // Still listed, because an operator who deliberately runs spamd wants to see it. + severity: "informational", }, { key: "iredapd", label: "iRedAPD", description: "Policy daemon (greylisting, throttling)", unit: "iredapd", + severity: "advisory", }, { key: "fail2ban", label: "fail2ban", description: "Brute-force protection", unit: "fail2ban", + severity: "advisory", }, { key: "postgresql", label: "PostgreSQL", description: "Mail account + alias store", unit: "postgresql", + severity: "required", }, ]; @@ -107,8 +150,13 @@ export interface MailComponentHealth { label: string; description: string; unit: string; + severity: MailComponentSeverity; status: MailComponentStatus; - /** Free-form sub-state when running — systemd's, or supervisord's state word. */ + /** + * The supervisor's state word (systemd's SubState, or supervisord's, lower-cased). + * The only thing separating supervisord FATAL — given up — from BACKOFF, both of + * which arrive as `status: "failed"`. + */ subState?: string; /** ISO timestamp the unit entered its current state, if known (systemd only). */ activeSince?: string; @@ -179,9 +227,23 @@ function describe(comp: MailComponentDef) { label: comp.label, description: comp.description, unit: comp.unit, + severity: comp.severity, }; } +/** + * Does this component gate "is this box a mail server?" + * + * THE definition — `stepDeployEngine`'s install gate and the Health banner both read + * it, so a component can never be fatal to one and cosmetic to the other. + * Deliberately NOT merged with `SERVING_COMPONENTS`: that answers a different + * question ("is mail moving right now?", postfix + dovecot only) and PostgreSQL is + * required-but-not-serving. Two questions, two lists. + */ +export function requiresMailComponent(key: string): boolean { + return MAIL_COMPONENTS.some((c) => c.key === key && c.severity === "required"); +} + async function probeUnit( exec: CommandExecutor, flavor: MailEngineFlavor, diff --git a/apps/api/src/modules/mail/mail.controller.ts b/apps/api/src/modules/mail/mail.controller.ts index 6f00447d1..3e8c0180e 100644 --- a/apps/api/src/modules/mail/mail.controller.ts +++ b/apps/api/src/modules/mail/mail.controller.ts @@ -33,9 +33,10 @@ import { buildMailBackupPayload } from "./admin/backup-plan"; // goes through the same cron validation + schedule registration as a project's. import { syncPolicySchedule, validateCronExpression } from "../backups/triggers/cron"; import { streamSSE } from "../../lib/sse"; +import { requestTag } from "../../middleware/error-handler"; import { invalidatePlatformTransport } from "../../lib/mail"; import { env } from "../../config"; -import { safeErrorMessage, DEFAULT_RETAIN_COUNT } from "@repo/core"; +import { safeErrorMessage, DEFAULT_RETAIN_COUNT, mailHostname } from "@repo/core"; import { sshManager } from "../../lib/ssh-manager"; import { repos } from "@repo/db"; import { getRequestContext, type RequestContext } from "../../lib/request-context"; @@ -63,7 +64,7 @@ import { } from "./mail.service"; import { checkMailDelivery } from "./mail-delivery.service"; import { checkMailHealth, mailIsServing, MAIL_COMPONENTS } from "./mail-health.service"; -import { resolveMailEngine } from "./mail-engine"; +import { resolveMailEngine, resolveMailFlavor } from "./mail-engine"; import { updatePostmasterPassword } from "./mail-credentials.service"; import { reserveMailSetup } from "./mail-setup-lease"; import { preflightMailSetup } from "./mail-setup-preflight"; @@ -153,9 +154,9 @@ function statusFromState( const credentials = state.domain ? { username: `postmaster@${state.domain}`, - smtpHost: `mail.${state.domain}`, + smtpHost: mailHostname(state.domain), smtpPort: 587, - imapHost: `mail.${state.domain}`, + imapHost: mailHostname(state.domain), imapPort: 993, } : undefined; @@ -210,7 +211,7 @@ function buildPtrPayload( return { ipv4, ipv6, - target: `mail.${state.domain}`, + target: mailHostname(state.domain), resumeStep, }; } @@ -569,7 +570,7 @@ async function augmentStateWithHostRecords( const { ipv4, ipv6 } = await resolveHostIPs(server.sshHost); if (!ipv4) return state; - const mailDomain = `mail.${state.domain}`; + const mailDomain = mailHostname(state.domain); const augmented: Record = { a: { type: "A", name: mailDomain, value: ipv4, required: true }, ...(ipv6 && { @@ -1105,10 +1106,10 @@ export async function startSetup(c: Context) { data: JSON.stringify({ success: true, domain, - mailDomain: `mail.${domain}`, + mailDomain: mailHostname(domain), finishedAt: Date.parse(finishedAt), - webmailUrl: `https://mail.${domain}/mail`, - adminUrl: `https://mail.${domain}/iredadmin`, + webmailUrl: `https://${mailHostname(domain)}/mail`, + adminUrl: `https://${mailHostname(domain)}/iredadmin`, }), }); } catch (err) { @@ -1413,7 +1414,13 @@ export async function saveMailBackupPolicy(c: Context) { const messageData = body.messageData === true; const keys = body.keys !== false; // default: include keys/secrets - const payload = buildMailBackupPayload(mailRow.domain, { messageData, keys }); + // The produce/restore shell is baked HERE and executed later by the generic + // custom_command producer, which has no mail knowledge — so the topology has to be + // resolved at build time. A containerized engine keeps Postgres in a sidecar and the + // `vmail` user only inside the engine, and the old plan issued `sudo -u postgres` and + // `chown vmail` on the host, where neither exists (GH-563). + const flavor = await resolveMailFlavor(serverId); + const payload = buildMailBackupPayload(mailRow.domain, { messageData, keys }, flavor); const cronExpression = typeof body.cronExpression === "string" && body.cronExpression.trim() @@ -1616,6 +1623,9 @@ export async function getHealth(c: Context) { return c.json({ serverId, components, definitions: MAIL_COMPONENTS, delivery }); } catch (err) { const message = err instanceof Error ? err.message : "Health check failed"; + // Same reason as the mail-admin funnel: this 500 is answered here, so `app.onError` + // never logs it and every Health-tab failure was invisible in the API log. + console.error(`[MAIL HEALTH ERROR] ${requestTag(c)}`, err); return c.json({ error: message }, 500); } } diff --git a/apps/api/src/modules/mail/mail.routes.ts b/apps/api/src/modules/mail/mail.routes.ts index 72bf9e224..23aa71745 100644 --- a/apps/api/src/modules/mail/mail.routes.ts +++ b/apps/api/src/modules/mail/mail.routes.ts @@ -9,6 +9,7 @@ import { secureRouter } from "../../lib/secure-router"; import * as mail from "./mail.controller"; import * as admin from "./admin/admin.controller"; import * as webmail from "./webmail/webmail.controller"; +import * as inbound from "./inbound/inbound.controller"; const r = secureRouter(new Hono(), { module: "mail", @@ -243,5 +244,39 @@ r.post( webmail.startExternalDeployAsProjectHandler, ); +/* ── Inbound rules (mail arrives → notification channel) ──────────── */ +// `:serverId` is a PATH param on every one of these, and that is deliberate: +// `mail_server` is a CONDITIONAL_SINGLETON, so a route that took the id from the body +// would degrade to resourceId "*" — a pure role×type check that names no server and +// therefore establishes no tenant boundary. +r.get( + "/admin/:serverId/inbound-rules", + { tag: "mail_server:read" }, + inbound.listRulesHandler, +); +r.post( + "/admin/:serverId/inbound-rules", + { tag: "mail_server:write" }, + inbound.createRuleHandler, +); +r.patch( + "/admin/:serverId/inbound-rules/:ruleId", + { tag: "mail_server:write" }, + inbound.updateRuleHandler, +); +r.delete( + "/admin/:serverId/inbound-rules/:ruleId", + { tag: "mail_server:write" }, + inbound.deleteRuleHandler, +); +// Dry run — reads and reports what WOULD notify, dispatching and deleting nothing. +// Tagged `write` because the boot scanner treats a POST on a read-tagged route as a +// CRITICAL error and exits the process. +r.post( + "/admin/:serverId/inbound-rules/test", + { tag: "mail_server:write" }, + inbound.testRulesHandler, +); + export const mailRoutes = r.hono; diff --git a/apps/api/src/modules/mail/mail.service.ts b/apps/api/src/modules/mail/mail.service.ts index 64ab94225..0322dfb7c 100644 --- a/apps/api/src/modules/mail/mail.service.ts +++ b/apps/api/src/modules/mail/mail.service.ts @@ -14,8 +14,8 @@ import { randomBytes } from "node:crypto"; import type { CommandExecutor, SystemLogCallback, SystemLog } from "@repo/adapters"; -import { checkMailHealth } from "./mail-health.service"; -import { safeErrorMessage } from "@repo/core"; +import { checkMailHealth, requiresMailComponent } from "./mail-health.service"; +import { safeErrorMessage, mailHostname } from "@repo/core"; import { installDocker, installContainerEdge, @@ -28,6 +28,7 @@ import { MAIL_PORTS, opScript, resolveEnvironment, + rootOrDegrade, } from "@repo/adapters"; import { HOST_AMAVIS_CONF_CANDIDATES, @@ -495,10 +496,20 @@ export async function stepOpenMailFirewall( // collapsing the whole step. No check-then-insert guard any more: we only get here for // ufw and firewalld, both of which are idempotent about a rule they already hold — the // duplicate-stacking that guard existed for was a raw-iptables property. + // Elevated: `ufw`/`firewall-cmd` are root-only, and this ran on the raw executor — so on + // a box we log into as a non-root sudo user every rule failed and the step reported the + // ports as rejected by the firewall rather than as never attempted. The profile above + // only chooses the SYNTAX; it never decided who runs it. + const fw = await rootOrDegrade(exec, { + purpose: "Opening the inbound mail ports", + consequence: "The ports may stay closed, so inbound mail is rejected at the edge of the host.", + report: (message) => log(stepId, "warn", message), + }); + const failed: string[] = []; for (const { port, script } of rules) { try { - await exec.exec(script); + await fw.exec(script); } catch (err) { // A dropped connection is not a firewall verdict, and retrying it seven more times // just delays the real error. @@ -632,9 +643,11 @@ export async function stepDeployEngine( const health = await checkMailHealth(exec).catch(() => null); if (health) { // Only the mail-path daemons gate the deploy; ClamAV/freshclam can still be - // warming up (large signature load) without blocking a working mail server. - const CRITICAL = new Set(["postfix", "dovecot", "postgresql"]); - const down = health.filter((c) => CRITICAL.has(c.key) && c.status !== "active"); + // warming up (large signature load) without blocking a working mail server. The + // marker lives on the catalog (mail-health.service) so the Health tab grades the + // same daemons the same way — this was a private Set here, and the banner's own + // idea of "down" had drifted into painting an advisory daemon red. + const down = health.filter((c) => requiresMailComponent(c.key) && c.status !== "active"); if (down.length > 0) { return { stepId, @@ -690,7 +703,7 @@ export async function stepDkimKeys( domain: string, log: StepLogger, ): Promise { - const mailDomain = `mail.${domain}`; + const mailDomain = mailHostname(domain); log(6, "info", "Locating amavis binary..."); let amavis: Awaited>; @@ -993,7 +1006,7 @@ export async function stepRequestSSL( target: MailSslTarget, ): Promise { const stepId = 7; - const mailDomain = `mail.${domain}`; + const mailDomain = mailHostname(domain); if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(mailDomain)) { return { stepId, success: false, message: `Invalid mail domain: ${mailDomain}` }; } @@ -1060,7 +1073,7 @@ export async function stepConfigureSSL( log: StepLogger, ): Promise { const stepId = 8; - const mailDomain = `mail.${domain}`; + const mailDomain = mailHostname(domain); let flavor: MailEngineFlavor; try { diff --git a/apps/api/src/modules/mail/webmail/webmail-branding-head.test.ts b/apps/api/src/modules/mail/webmail/webmail-branding-head.test.ts new file mode 100644 index 000000000..6750ff53b --- /dev/null +++ b/apps/api/src/modules/mail/webmail/webmail-branding-head.test.ts @@ -0,0 +1,183 @@ +/** + * Branding must reach the webmail document . + * + * GH-568: `siteTitle` and `siteDescription` were accepted by + * `PATCH /admin/branding`, persisted, and echoed by `/branding.json` - yet the + * served page still said "OpenShip Mail", because the SPA is prebuilt and both + * values are frozen into `client/build/client/index.html`. The fix substitutes + * them server-side (apps/email/server/src/lib/index-html.ts) rather than in the + * browser, because the issue also asks for correct OpenGraph tags and + * link-preview crawlers do not execute JavaScript. + * + * The failure mode that matters is a SILENT no-op - a substitution that stops + * matching and reports success - so these tests pin the two things that make it + * silent: the tag patterns, and the `missing` report that surfaces a template + * whose changed shape. + * + * Why this test lives in apps/api rather than next to the code: the root + * package.json runs `turbo run test --filter=!@repo/email`, and apps/email + * declares no test script at all, so anything under apps/email/**\/test is + * never executed by CI. Its siblings webmail-catalog-contract.test.ts and + * webmail-client-origin.test.ts are here for the same reason. The import below + * reaches across app boundaries deliberately: `injectBranding` is pure - no + * env, no filesystem, no module state - specifically so it can be imported + * from a runner that CI actually runs. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { injectBranding, escapeHtml } from "../../../../../email/server/src/lib/index-html"; + +/** The emitted , trimmed to the tags under test. */ +const HEAD = [ + "", + '', + '', + "OpenShip Mail", + '', + '', + '', + '', + "", +].join(""); + +function clientFile(...parts: string[]): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 10; i++) { + const candidate = join(dir, "apps", "email", "client"); + try { + readFileSync(join(candidate, "react-router.config.ts")); + return readFileSync(join(candidate, ...parts), "utf-8"); + } catch { + /* keep walking */ + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error("apps/email/client not found - fix the marker walk in this test"); +} + +const BRANDED = { siteTitle: "Contribute Club eMail", siteDescription: "Mail Server for CC" }; + +describe("webmail branding head (GH-568)", () => { + it("substitutes every branded slot", () => { + const { html, missing } = injectBranding(HEAD, BRANDED); + + expect(missing).toEqual([]); + expect(html).toContain("Contribute Club eMail"); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + // The default must be gone, not merely accompanied. + expect(html).not.toContain("OpenShip Mail"); + expect(html).not.toContain("Your self-hosted mailbox."); + }); + + it("leaves tags it does not own alone", () => { + const { html } = injectBranding(HEAD, BRANDED); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it("escapes operator input instead of injecting markup", () => { + // The PATCH token bounds who can set this, not what it may do. A title is + // interpolated into both element text and an attribute value, so a `<` or a + // `"` must not be able to break out of either. + const { html } = injectBranding(HEAD, { + siteTitle: 'Acme "Mail" ', + siteDescription: "a & b", + }); + + expect(html).not.toContain("` in an operator string ends the block early. + const { html } = injectBranding(HEAD, { + siteTitle: "Acme ", + siteDescription: "d", + }); + expect(html).not.toContain("/g)).toHaveLength(1); + const json = html.match( + /"); + }); + + it("reports a template with no instead of dropping the payload", () => { + const { missing } = injectBranding("t", BRANDED); + expect(missing).toContain("embed"); + }); + + it("root.tsx builds its meta from the document, never from build-time constants", () => { + // The precise regression: `{ title: siteConfig.title }` in the meta export + // is what overwrote the server's value. og:image may stay - it is a + // relative path, not branding. + const root = clientFile("app", "root.tsx"); + const meta = root.slice(root.indexOf("export const meta"), root.indexOf("export function Layout")); + + expect(meta).toContain("runtimeBranding()"); + expect(meta).not.toContain("siteConfig.title"); + expect(meta).not.toContain("siteConfig.description"); + }); +}); diff --git a/apps/api/src/modules/mail/webmail/webmail-catalog-contract.test.ts b/apps/api/src/modules/mail/webmail/webmail-catalog-contract.test.ts index c65bdc84d..68c726285 100644 --- a/apps/api/src/modules/mail/webmail/webmail-catalog-contract.test.ts +++ b/apps/api/src/modules/mail/webmail/webmail-catalog-contract.test.ts @@ -64,6 +64,25 @@ describe("webmail catalog contract", () => { } }); + it("keeps both secrets un-inlined, so a missing one can be minted later", () => { + // `ensureGeneratedAppSecrets` refuses to mint a key the template substitutes into + // some other string, because those copies were written once and would contradict a + // new value. Inlining either of these would silently disable the backfill that + // stops webmail deploying without a SESSION_ENCRYPTION_KEY (#566) — and the image + // treats that as fatal. + const inlined = [ + ...(template?.services ?? []).flatMap((s) => Object.values(s.environment ?? {})), + ...(template?.files ?? []).map((f) => f.content), + ...(template?.services ?? []).map((s) => s.build?.dockerfile ?? ""), + ].join("\n"); + for (const key of ["SESSION_ENCRYPTION_KEY", "BRANDING_ADMIN_TOKEN"]) { + // Tolerant of inner whitespace, exactly like the substitution itself. + expect(inlined, `${key} must not be inlined`).not.toMatch( + new RegExp(`\\{\\{\\s*config:${key}\\s*\\}\\}`), + ); + } + }); + it("exposes one routable HTTP endpoint", () => { // `startWebmailDeploy` reads endpoint[0] to size its route instead of pinning a // port number — an app with no endpoint would deploy unreachable. diff --git a/apps/api/src/modules/mail/webmail/webmail-client-origin.test.ts b/apps/api/src/modules/mail/webmail/webmail-client-origin.test.ts new file mode 100644 index 000000000..06b262b34 --- /dev/null +++ b/apps/api/src/modules/mail/webmail/webmail-client-origin.test.ts @@ -0,0 +1,118 @@ +/** + * The webmail client must never learn its own origin at build time. + * + * GH-567: every route guard in the Zero client built its redirect by + * interpolating `import.meta.env.VITE_PUBLIC_APP_URL` - + * + * if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`); + * + * - so the published image froze whatever the BUILD host defined into the + * bundle. `ghcr.io/oblien/openship-webmail:latest` shipped + * `http://localhost:3000/login`; a build with the var unset ships the literal + * string `"undefined/login"`. On any real hostname a session expiry threw the + * user clean off their own domain, and no runtime env could correct it - the + * value was a constant in minified JavaScript. + * + * Two bans, both narrow and both load-bearing: + * + * VITE_PUBLIC_APP_URL - the app's own origin is not a build-time fact. It is + * `window.location.origin`, always: the Hono server serves the SPA and the + * API on one port (client/lib/backend-url.ts documents that invariant). + * + * Response.redirect - per the Fetch spec this REQUIRES an absolute URL, so + * reaching for it in a route loader is what forces an origin into the + * picture at all. `redirect()` from react-router takes a relative path and + * the router resolves it against the live origin, basename included. + * + * Why this test lives in apps/api rather than apps/email: `turbo run test` + * only runs packages that declare a test script, and apps/email declares none + * - so a test placed there would never run on a pull request. Its sibling + * webmail-catalog-contract.test.ts already asserts cross-boundary webmail + * facts from here for the same reason. The release build carries the matching + * OUTPUT check (apps/email/scripts/build-release.ts scans the built bundle for + * baked dev origins, so neither the image nor the tarball can publish one); + * this is the copy that fails on the PR that introduces it. + */ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync, existsSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Walk up from this file to the repo root - the directory that contains the + * webmail client. Resolved by marker rather than by a fixed `../../../..` + * count so moving this test doesn't silently turn it into a no-op. + */ +function findClientRoot(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 10; i++) { + const candidate = join(dir, "apps", "email", "client"); + if (existsSync(join(candidate, "react-router.config.ts"))) return candidate; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error("apps/email/client not found - fix the marker walk in this test"); +} + +const CLIENT = findClientRoot(); + +// The hand-written source. Everything else under client/ is generated or +// vendored: build/ (Vite output), paraglide/ (i18n codegen), .react-router/ +// (route typegen), node_modules/. +const SOURCE_DIRS = ["app", "components", "lib", "hooks", "providers", "store", "utils"]; +const SOURCE_EXTENSIONS = [".ts", ".tsx"]; + +function sourceFiles(): string[] { + const out: string[] = []; + const walk = (dir: string) => { + if (!existsSync(dir)) return; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules") continue; + walk(path); + } else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + out.push(path); + } + } + }; + for (const dir of SOURCE_DIRS) walk(join(CLIENT, dir)); + return out; +} + +const FILES = sourceFiles().map((path) => ({ + path: relative(CLIENT, path), + text: readFileSync(path, "utf-8"), +})); + +describe("webmail client origin (GH-567)", () => { + it("has source to check", () => { + // A broken walk would make every assertion below vacuously pass. + expect(FILES.length).toBeGreaterThan(100); + }); + + it("never reads its own origin from the build env", () => { + const offenders = FILES.filter((f) => f.text.includes("VITE_PUBLIC_APP_URL")).map( + (f) => f.path, + ); + expect( + offenders, + "The app's origin is window.location.origin at runtime, never a build-time env " + + "var (see client/lib/backend-url.ts). Use a relative redirect instead.", + ).toEqual([]); + }); + + it("never uses Response.redirect, which forces an absolute URL", () => { + const offenders = FILES.filter((f) => /\bResponse\.redirect\s*\(/.test(f.text)).map( + (f) => f.path, + ); + expect( + offenders, + "Response.redirect() requires an absolute URL. Use react-router's " + + "`replace('/path')` (auth guards and index bounces, so the bounced URL does " + + "not linger in history) or `redirect('/path')` - both resolve relative to the " + + "live origin and basename.", + ).toEqual([]); + }); +}); diff --git a/apps/api/src/modules/mail/webmail/webmail-install.service.ts b/apps/api/src/modules/mail/webmail/webmail-install.service.ts index 6ee685787..568a0da80 100644 --- a/apps/api/src/modules/mail/webmail/webmail-install.service.ts +++ b/apps/api/src/modules/mail/webmail/webmail-install.service.ts @@ -26,13 +26,20 @@ * so the operator cannot repoint it and we front it for them. */ -import { AppError, getAppEndpoints, safeErrorMessage, type AppTemplate } from "@repo/core"; +import { + AppError, + getAppEndpoints, + safeErrorMessage, + type AppTemplate, + type ComposeAdvanced, + type OpenshipReadiness, mailHostname } from "@repo/core"; import { repos, type Domain, type Project } from "@repo/db"; import { assertResourceInOrg } from "../../../lib/controller-helpers"; -import { pickCanonicalDomainRow } from "../../../lib/public-endpoints"; +import { pickCanonicalDomainRow, resolveServicePublicEndpoints } from "../../../lib/public-endpoints"; import type { RequestContext } from "../../../lib/request-context"; import { sshManager } from "../../../lib/ssh-manager"; import { + ensureGeneratedAppSecrets, installApp, planInstallRouting, serviceRoutingPatch, @@ -205,10 +212,25 @@ interface WebmailInstallPlan { settings: AppSettingChange[]; deployTarget: "server" | "cloud"; serverId?: string; - /** An already-deployed project to redeploy instead of installing fresh. */ + /** + * An already-LINKED project to redeploy instead of installing fresh. Not + * necessarily a deployed one: `beforeDeploy` stamps the mail server's FK before the + * build is queued, so a retry after a failed first deploy arrives here too — which + * is why the generated-secret backfill cannot live in the else branch. + */ reuse?: Project; /** Runs after the project exists and BEFORE the deploy is queued. */ beforeDeploy?: (projectId: string) => Promise; + /** + * Apply `routes` only AFTER `beforeDeploy` has run. + * + * For the mail server's own hostname the order is the authorization: the claim is + * allowed on the strength of `mail_servers.webmail_project_id` pointing at this + * project, and `beforeDeploy` is what stamps that link (#566). Routing first would be + * refused as a foreign hostname — the mail row belongs to the mail install's + * certificate renewal, not to any project. + */ + routeAfterLink?: boolean; } async function runWebmailInstall( @@ -220,16 +242,18 @@ async function runWebmailInstall( if (plan.reuse) { projectId = plan.reuse.id; - await reapplyRouting(ctx, template, plan.reuse, plan.routes); + if (!plan.routeAfterLink) await reapplyRouting(ctx, template, plan.reuse, plan.routes); } else { // The generic installer owns everything about the app itself: project row, - // service rows, declared volumes, and the generated SESSION_ENCRYPTION_KEY / - // BRANDING_ADMIN_TOKEN. A same-named draft from a failed attempt is adopted - // here rather than duplicated, which is why the name is derived, not typed. + // service rows, declared volumes, and the FIRST write of the generated + // SESSION_ENCRYPTION_KEY / BRANDING_ADMIN_TOKEN — first, not only: adoption skips + // that write for rows it did not create, so the guarantee comes from + // `ensureGeneratedAppSecrets` below. A same-named draft from a failed attempt is + // adopted here rather than duplicated, which is why the name is derived, not typed. const installed = await installApp(ctx, { templateId: WEBMAIL_TEMPLATE_ID, name: plan.name, - routes: plan.routes, + routes: plan.routeAfterLink ? [] : plan.routes, }); if (installed.kind !== "template") { throw new AppError("The webmail app isn't installable on this instance.", 409); @@ -244,10 +268,31 @@ async function runWebmailInstall( await updateAppProjectSettings(ctx, projectId, plan.settings); } + // Both branches, one call. The image treats SESSION_ENCRYPTION_KEY as fatal, so a + // project that reached the container without one crash-loops forever (issue #566) — + // and the reuse branch, which every retry takes, installs nothing. Idempotent: a + // stored key is reused, never rotated. Deliberately after the settings write, which + // is what proves this project really is the webmail app before we write its env. + await ensureGeneratedAppSecrets(projectId, template); + + // A crash loop must not report success. The restart-loop watch already exists + // (#335) but is OFF by default and webmail never asked for it, so a container that + // exited on a missing SESSION_ENCRYPTION_KEY and restarted ten times still finished + // the deploy as "deployed and running" (#566). + await enableRestartLoopWatch(ctx, projectId, template); + // Before the deploy, never after: the deploy's success hook resolves the mail // server FROM the project, and a build can finish before a later write lands. await plan.beforeDeploy?.(projectId); + // The mail host's route, now that the link authorizing it exists. Same plan builder + // and same per-service write as a first install — the only difference is that it could + // not have been accepted a few lines earlier. + if (plan.routeAfterLink) { + const project = await repos.project.findById(projectId); + if (project) await reapplyRouting(ctx, template, project, plan.routes); + } + const dep = await requestBuildAccess(ctx, { projectId, serviceDeploymentMode: "services", @@ -258,6 +303,46 @@ async function runWebmailInstall( return { projectId, deploymentId: dep.deployment_id }; } +/** + * Webmail's readiness gate: watch for a restart loop, and let it veto the deploy. + * + * No TCP probe. That asks a different question, and an enabled one adds up to 45s to + * the critical path; what distinguishes "it started" from "it kept starting" is the + * restart count. `onFailure: "fail"` because the deploy status vocabulary has no + * "started but unhealthy" — for a container that is bouncing, `failed` is the only + * honest verdict, and a warn would leave the deploy green. + */ +const WEBMAIL_READINESS: OpenshipReadiness = { stabilization: true, onFailure: "fail" }; + +/** + * Opt the webmail service into the restart-loop watch, once. + * + * Best-effort by design: a readiness row we could not write must not abort a deploy + * that would otherwise queue. Losing the watch costs honesty on a failure; throwing + * here costs the deploy. An `advanced.readiness` that already exists is left alone — + * that is an operator's explicit choice about their own project. + * + * Scoped to the endpoint service rather than every row: the gate is per-service, and a + * future multi-service webmail should not have a sidecar's restarts veto the deploy. + */ +async function enableRestartLoopWatch( + ctx: RequestContext, + projectId: string, + template: AppTemplate, +): Promise { + try { + const name = webmailEndpoint(template).service; + const row = (await repos.service.listByProject(projectId)).find((r) => r.name === name); + if (!row) return; + if ((row.advanced as ComposeAdvanced | null)?.readiness) return; + await updateService(ctx, projectId, row.id, { advanced: { readiness: WEBMAIL_READINESS } }); + } catch (err) { + console.warn( + `[webmail] could not enable the restart-loop watch on ${projectId}: ${safeErrorMessage(err)}`, + ); + } +} + /** * Re-apply the chosen routing to a project that already exists — the branch * `installApp` can't take, because its adoption only matches never-deployed @@ -321,7 +406,7 @@ export async function startWebmailDeploy( ); } - const mailHost = `mail.${installDomain}`; + const mailHost = mailHostname(installDomain); const isOwnMailSubdomain = input.hostname === mailHost; // `mail.`'s A record is pinned to the mail box — it carries IMAP, @@ -396,6 +481,9 @@ export async function startWebmailDeploy( serverId: input.target.kind === "self" ? input.target.serverId : undefined, // The legacy row is gone by now, so this is a fresh install, not a redeploy. reuse: legacy ? undefined : (linked ?? undefined), + // `mail.` is routable only by the mail server's LINKED webmail, and the + // link is stamped in `beforeDeploy` — so this one route has to wait for it (#566). + routeAfterLink: isOwnMailSubdomain && !useProxyVariant, beforeDeploy: (projectId) => repos.mailServer.setWebmailProject(input.mailServerId, projectId), }); @@ -530,7 +618,14 @@ export async function resolveWebmailSummary( // No route of its own + running on the cloud = the mail VPS fronts it (see // the proxy variant above). Derived, so nothing has to be stored. const proxied = rows !== null && !routed && !!project.cloudWorkspaceId; - const hostname = routed?.hostname ?? (proxied ? `mail.${mailServer.domain}` : ""); + // A webmail on the mail server's OWN hostname has no domain row by design — that row + // belongs to the mail install's certificate renewal and must stay project-less (#566, + // see lib/mail-host-claim). Its address therefore has to come from the SERVICE's routing + // instead, or a webmail that is deployed and serving reads as "not installed" and the + // card offers to deploy it again. + const hostname = + routed?.hostname ?? + (proxied ? mailHostname(mailServer.domain) : rows === null ? "" : await routedServiceHostname(project)); return { installed: await isLiveDeploymentReady(project.activeDeploymentId), @@ -546,6 +641,26 @@ export async function resolveWebmailSummary( }; } +/** + * The hostname this project's services actually route, read from the service rows. + * + * Only consulted when the project has no domain row at all. Restricted to `custom` + * endpoints: a free `*.opsh.io` route always has a row, so a rowless free endpoint would + * mean a routing state we could not have written, and guessing one is how a stale address + * ends up on the card. + */ +async function routedServiceHostname(project: Pick): Promise { + const rows = await repos.service.listByProject(project.id).catch(() => []); + for (const row of rows) { + const endpoints = resolveServicePublicEndpoints(row, { + projectSlug: project.slug ?? project.name, + }); + const custom = endpoints.find((e) => e.domainType === "custom" && e.customDomain); + if (custom?.customDomain) return custom.customDomain; + } + return ""; +} + async function isLiveDeploymentReady(deploymentId: string | null): Promise { if (!deploymentId) return false; const dep = await repos.deployment.findById(deploymentId).catch(() => null); @@ -582,7 +697,7 @@ export async function onWebmailDeployed( const platform = await resolveMailVpsPlatform(mailServer.serverId, project.organizationId); await platform.routing.registerRoute({ - domain: `mail.${mailServer.domain}`, + domain: mailHostname(mailServer.domain), tls: true, // We issue this host's cert right below, so the edge must keep a :443 // listener up meanwhile — a routed host with no TLS listener refuses @@ -592,7 +707,7 @@ export async function onWebmailDeployed( }); // The mail box already holds certs for IMAP/SMTP; this adds the HTTPS // one for the webmail UI, through the same Let's Encrypt feature. - await platform.ssl.provisionCert(`mail.${mailServer.domain}`); + await platform.ssl.provisionCert(mailHostname(mailServer.domain)); } catch (err) { console.warn( `[webmail] could not front mail. for project ${project.id}: ${safeErrorMessage(err)}`, @@ -629,7 +744,7 @@ export async function cleanupWebmailInstall(project: Project): Promise []); if (rows.length > 0 || !project.cloudWorkspaceId) return null; - const hostname = `mail.${mailServer.domain}`; + const hostname = mailHostname(mailServer.domain); const platform = await resolveMailVpsPlatform(mailServer.serverId, project.organizationId); await platform.routing.removeRoute(hostname); return `removed ${hostname} proxy`; diff --git a/apps/api/src/modules/mail/webmail/webmail-legacy-replace.test.ts b/apps/api/src/modules/mail/webmail/webmail-legacy-replace.test.ts index 26dfaa1bb..88fbe369b 100644 --- a/apps/api/src/modules/mail/webmail/webmail-legacy-replace.test.ts +++ b/apps/api/src/modules/mail/webmail/webmail-legacy-replace.test.ts @@ -61,6 +61,7 @@ const h = vi.hoisted(() => ({ requestBuildAccess: vi.fn(async () => ({ deployment_id: "dep-new" })), setWebmailProject: vi.fn(async () => {}), updateService: vi.fn(async () => {}), + ensureGeneratedAppSecrets: vi.fn(async () => [] as string[]), })); vi.mock("@repo/db", () => ({ @@ -88,6 +89,7 @@ vi.mock("../../apps/catalog-source", () => ({ vi.mock("../../apps/app-install.service", () => ({ installApp: h.installApp, planInstallRouting: vi.fn(() => new Map()), + ensureGeneratedAppSecrets: h.ensureGeneratedAppSecrets, })); vi.mock("../../apps/app-settings.service", () => ({ updateAppProjectSettings: h.updateAppProjectSettings, @@ -161,9 +163,13 @@ describe("legacy webmail replace", () => { force: true, wipeVolumes: true, }); - // Fresh install, NOT a redeploy of the row that just went. + // Fresh install, NOT a redeploy of the row that just went: `installApp` owns the + // routing, so no service row is patched with a routing plan. The one updateService + // call here is the restart-loop watch (#566), which every path arms. expect(h.installApp).toHaveBeenCalledOnce(); - expect(h.updateService).not.toHaveBeenCalled(); + for (const call of h.updateService.mock.calls as unknown as unknown[][]) { + expect(Object.keys(call[3] as object)).toEqual(["advanced"]); + } // Link stamped before the build is queued, so the deploy hook can resolve it. expect(h.setWebmailProject).toHaveBeenCalledWith("mail-1", WEBMAIL_PROJECT.id); expect(res).toEqual({ projectId: WEBMAIL_PROJECT.id, deploymentId: "dep-new" }); diff --git a/apps/api/src/modules/mail/webmail/webmail-summary-routing.test.ts b/apps/api/src/modules/mail/webmail/webmail-summary-routing.test.ts index d6f3d8129..6ba014f49 100644 --- a/apps/api/src/modules/mail/webmail/webmail-summary-routing.test.ts +++ b/apps/api/src/modules/mail/webmail/webmail-summary-routing.test.ts @@ -44,6 +44,8 @@ const h = vi.hoisted(() => ({ project: null as Record | null, /** null = the read REJECTED; an array = rows we actually have. */ rows: [] as Array> | null, + /** Service rows, consulted only when the project has no domain row at all. */ + services: [] as Array>, })); vi.mock("@repo/db", () => ({ @@ -53,6 +55,7 @@ vi.mock("@repo/db", () => ({ findFirstBySlug: vi.fn(async () => null), }, mailServer: { setWebmailProject: vi.fn(async () => {}) }, + service: { listByProject: vi.fn(async () => h.services) }, deployment: { findById: vi.fn(async () => ({ status: "ready" })) }, }, })); @@ -73,6 +76,7 @@ vi.mock("../../apps/catalog-source", () => ({ getTemplateForOrg: vi.fn(async () vi.mock("../../apps/app-install.service", () => ({ installApp: vi.fn(), planInstallRouting: vi.fn(() => new Map()), + ensureGeneratedAppSecrets: vi.fn(async () => []), })); vi.mock("../../apps/app-settings.service", () => ({ updateAppProjectSettings: vi.fn() })); vi.mock("../../deployments/build.service", () => ({ requestBuildAccess: vi.fn() })); @@ -135,6 +139,8 @@ describe("webmail summary routing", () => { h.project = SELF_WEBMAIL; const server = { ...MAIL_SERVER, webmailProjectId: SELF_WEBMAIL.id }; + // No domain row AND no routed service: genuinely addressless. + h.services = []; h.rows = []; const unrouted = await resolveWebmailSummary("org1", server); h.rows = null; @@ -149,6 +155,7 @@ describe("webmail summary routing", () => { // The proxy variant is cloud-only, so `mail.` must not appear here in // either direction — the failed-read path and the empty-list path agree. h.project = SELF_WEBMAIL; + h.services = []; const server = { ...MAIL_SERVER, webmailProjectId: SELF_WEBMAIL.id }; expect(await resolveWebmailSummary("org1", server)).toMatchObject({ hostname: "", url: "" }); @@ -156,4 +163,42 @@ describe("webmail summary routing", () => { h.rows = null; expect(await resolveWebmailSummary("org1", server)).toMatchObject({ hostname: "", url: "" }); }); + + /** + * A webmail on the mail server's OWN hostname owns no domain row on purpose — that row + * belongs to the mail install's certificate renewal (#566). Without this fallback the + * card reads a deployed, serving webmail as "not installed" and offers to deploy it + * again. + */ + it("reads the address off the service when the mail host owns no domain row", async () => { + h.project = SELF_WEBMAIL; + h.rows = []; + h.services = [ + { + name: "webmail", + exposed: true, + publicEndpoints: [{ port: 4080, domainType: "custom", customDomain: "mail.example.com" }], + }, + ]; + + expect(await resolveWebmailSummary("org1", { ...MAIL_SERVER, webmailProjectId: SELF_WEBMAIL.id })) + .toMatchObject({ hostname: "mail.example.com", url: "https://mail.example.com" }); + }); + + it("still refuses to guess when the route read failed", async () => { + // routingUnknown must win over the service fallback: an unreadable domain list is + // not evidence that the service's hostname is the live one. + h.project = SELF_WEBMAIL; + h.rows = null; + h.services = [ + { + name: "webmail", + exposed: true, + publicEndpoints: [{ port: 4080, domainType: "custom", customDomain: "mail.example.com" }], + }, + ]; + + expect(await resolveWebmailSummary("org1", { ...MAIL_SERVER, webmailProjectId: SELF_WEBMAIL.id })) + .toMatchObject({ hostname: "", routingUnknown: true }); + }); }); diff --git a/apps/api/src/modules/mail/webmail/webmail.controller.ts b/apps/api/src/modules/mail/webmail/webmail.controller.ts index e0f1fe1d2..8d0ccc51e 100644 --- a/apps/api/src/modules/mail/webmail/webmail.controller.ts +++ b/apps/api/src/modules/mail/webmail/webmail.controller.ts @@ -14,6 +14,7 @@ import type { Context } from "hono"; import { AppError, isRelayProviderId, RELAY_PROVIDER_IDS } from "@repo/core"; import { env } from "../../../config"; import { getRequestContext } from "../../../lib/request-context"; +import { requestTag } from "../../../middleware/error-handler"; import { listWebmailTargets } from "./webmail.service"; import { startWebmailDeploy, @@ -37,6 +38,9 @@ function deployError(c: Context, err: unknown) { return c.json({ error: err.message, code: err.code }, err.statusCode as 400); } const message = err instanceof Error ? err.message : "Failed to start deploy"; + // Answered here, so `app.onError` never logs it — same blind spot as the other two + // self-answered mail 500s. + console.error(`[WEBMAIL ERROR] ${requestTag(c)}`, err); return c.json({ error: message }, 500); } diff --git a/apps/api/src/modules/mcp/mcp-audit.ts b/apps/api/src/modules/mcp/mcp-audit.ts new file mode 100644 index 000000000..5dcf4f796 --- /dev/null +++ b/apps/api/src/modules/mcp/mcp-audit.ts @@ -0,0 +1,94 @@ +/** + * What an agent did, in the audit log. + * + * A dispatched tool call runs through the real HTTP stack, so a write/admin route + * already emits its own audit row on success — the secureRouter auto-emitter in + * route-permission.ts — now carrying `source: "mcp"` and the calling client. Two + * kinds of call that emitter never covers are exactly the two that were missing: + * + * - READS. A `read`/`list` route audits nothing, deliberately: for a human + * clicking around a dashboard, logging every page view is noise. For an + * autonomous agent it is the whole question — "what did it look at" had no + * answer at all. + * - FAILURES. The emitter fires only on 2xx/3xx, so a permission-denied write + * left no trace. An agent probing the edge of its scope was invisible + * precisely when someone would want to see it. + * + * Keeping the rule here, as one predicate, is what stops the two failure modes it + * sits between: record everything and every mutation is logged twice; record + * nothing extra and reads go dark again. + */ + +import { audit } from "../../lib/audit"; + +/** One executed tool call — the facts the audit decision is made from. */ +export interface ToolCallRecord { + tool: string; + method: string; + /** Route path with :params — never the filled-in one, which carries ids. */ + path: string; + /** The route's permission action ("read" | "list" | "write" | "admin" | …). */ + action: string; + status: number; + ok: boolean; +} + +/** Who made the call, resolved once per MCP request by the route. */ +export interface ToolCallActor { + /** Null → no org resolved; there is nothing to attribute a row to. */ + organizationId: string | null; + userId: string; + /** Canonical principal id — `oauth:` / `pat:`. */ + clientId: string; + /** The personal_access_token row behind this caller (the audit row's resource). */ + tokenId: string; + ipAddress: string | null; + userAgent: string | null; +} + +/** + * True when this call would otherwise leave no trace. A successful write already + * recorded itself as the change it made, which is a better row than a tool-call + * row: it names the resource and carries the diff. + */ +export function needsOwnAuditRow(record: Pick): boolean { + const selfAuditing = record.action === "write" || record.action === "admin"; + return !(record.ok && selfAuditing); +} + +/** + * Record a tool call, if it needs recording. + * + * The row carries the tool name, the route it hit and the status. NOT the + * arguments: they are unbounded and routinely hold env values and secrets, and + * the same reasoning already keeps grant tuples out of `mcp.scope_changed`. + */ +export function recordToolCall(actor: ToolCallActor, record: ToolCallRecord): void { + if (!actor.organizationId) return; + if (!needsOwnAuditRow(record)) return; + + audit.recordAsync( + { + organizationId: actor.organizationId, + actorUserId: actor.userId, + ipAddress: actor.ipAddress, + userAgent: actor.userAgent, + // Stated, not derived: `resolveCallSource` reads the credential, and a + // PAT-backed MCP client resolves to "api"/"cli" on the outer request. This + // request IS the MCP endpoint — there is nothing to infer. + source: "mcp", + sourceClientId: actor.clientId, + }, + { + eventType: "mcp.tool_called", + resourceType: "mcp_client", + resourceId: actor.tokenId, + after: { + tool: record.tool, + route: `${record.method} ${record.path}`, + status: record.status, + ok: record.ok, + }, + }, + ); +} diff --git a/apps/api/src/modules/mcp/mcp-dispatch.ts b/apps/api/src/modules/mcp/mcp-dispatch.ts index ec951436f..1e6c7852b 100644 --- a/apps/api/src/modules/mcp/mcp-dispatch.ts +++ b/apps/api/src/modules/mcp/mcp-dispatch.ts @@ -1,5 +1,5 @@ import { app } from "../../app"; -import { internalSourceHeader } from "../../lib/call-source"; +import { internalClientHeader, internalSourceHeader } from "../../lib/call-source"; import type { McpToolDef } from "./mcp-tools"; /** @@ -15,6 +15,24 @@ export interface DispatchResult { data: unknown; } +/** + * What the sub-request can't work out for itself. + * + * An in-process dispatch has no TCP peer and no browser to send headers, so + * without this the audit row for an MCP-driven write recorded the loopback + * address, no user agent, and no way to tell one connected assistant from + * another. All three are read off the OUTER request, which is a real HTTP request + * from the real client. + */ +export interface DispatchOrigin { + /** Canonical principal id — `oauth:` / `pat:`. */ + principalId: string; + /** The outer request's resolved client IP. */ + clientIp: string | null; + /** The outer request's user agent (the MCP client's, e.g. `claude-desktop/1.2`). */ + userAgent: string | null; +} + // Base host is irrelevant — Hono routes on the path. No Origin header is set, // so the PAT (a non-browser credential) is accepted by authMiddleware. const INTERNAL_BASE = "http://mcp.internal"; @@ -23,6 +41,7 @@ export async function dispatchTool( tool: McpToolDef, args: Record, bearerToken: string, + origin: DispatchOrigin, ): Promise { // Fill path params. let path = tool.path; @@ -48,11 +67,17 @@ export async function dispatchTool( // The nonce-signed source marker is the only thing that tells the audit log an // action came from an AI assistant rather than a script — everything else about - // this sub-request looks like an ordinary token call, by design. + // this sub-request looks like an ordinary token call, by design. The client + // marker is signed the same way; the IP and UA are plain, because for an + // in-process dispatch `x-real-ip` is already trusted (no TCP peer ⇒ it can only + // have come from us — see client-ip.ts) and the UA is not a gate anywhere. const headers: Record = { authorization: `Bearer ${bearerToken}`, ...internalSourceHeader("mcp"), + ...internalClientHeader(origin.principalId), }; + if (origin.clientIp) headers["x-real-ip"] = origin.clientIp; + if (origin.userAgent) headers["user-agent"] = origin.userAgent; const orgId = args.organizationId; if (typeof orgId === "string" && orgId) headers["x-organization-id"] = orgId; diff --git a/apps/api/src/modules/mcp/mcp-server.ts b/apps/api/src/modules/mcp/mcp-server.ts index 6fb9f66ea..8879dfaa8 100644 --- a/apps/api/src/modules/mcp/mcp-server.ts +++ b/apps/api/src/modules/mcp/mcp-server.ts @@ -1,5 +1,6 @@ import { getMcpTools, toClientTool, filterToolsForPrincipal, type McpPrincipal } from "./mcp-tools"; -import { dispatchTool } from "./mcp-dispatch"; +import { dispatchTool, type DispatchOrigin } from "./mcp-dispatch"; +import type { ToolCallRecord } from "./mcp-audit"; import { listPrompts, getPrompt } from "./mcp-prompts"; /** @@ -30,15 +31,24 @@ export function jsonRpcError(id: JsonRpcRequest["id"], code: number, message: st return { jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } }; } +export interface McpMessageContext { + /** The caller's credential, forwarded to dispatch so sub-requests re-auth. */ + bearerToken: string; + /** Effective capability, for `tools/list` filtering. */ + principal: McpPrincipal; + /** Facts only the outer HTTP request knows — see DispatchOrigin. */ + origin: DispatchOrigin; + /** Called once per executed tool call, after it returns. */ + onToolCall?: (record: ToolCallRecord) => void; +} + /** * Handle one JSON-RPC message. Returns the response object, or null for - * notifications (no `id` → no reply). `bearerToken` is the caller's PAT, - * forwarded to tool dispatch so sub-requests re-authenticate. + * notifications (no `id` → no reply). */ export async function handleMcpMessage( msg: JsonRpcRequest, - bearerToken: string, - principal: McpPrincipal, + { bearerToken, principal, origin, onToolCall }: McpMessageContext, ): Promise { const isNotification = msg.id === undefined || msg.id === null; @@ -83,7 +93,15 @@ export async function handleMcpMessage( const tool = getMcpTools().find((t) => t.name === name); if (!tool) return jsonRpcError(msg.id, -32602, `Unknown tool: ${name}`); - const dispatched = await dispatchTool(tool, args, bearerToken); + const dispatched = await dispatchTool(tool, args, bearerToken, origin); + onToolCall?.({ + tool: tool.name, + method: tool.method, + path: tool.path, + action: tool.perm.action, + status: dispatched.status, + ok: dispatched.ok, + }); return result(msg.id, { content: [{ type: "text", text: JSON.stringify(dispatched.data, null, 2) }], isError: !dispatched.ok, diff --git a/apps/api/src/modules/mcp/mcp.routes.ts b/apps/api/src/modules/mcp/mcp.routes.ts index 40a8c5245..da5650882 100644 --- a/apps/api/src/modules/mcp/mcp.routes.ts +++ b/apps/api/src/modules/mcp/mcp.routes.ts @@ -19,11 +19,28 @@ import { import { readTokenAudience } from "../../lib/mcp-token"; import { resolveActiveOrganizationId } from "../../middleware/active-organization"; import { resolveBearerIdentity } from "../../middleware/auth"; +import { recordToolCall } from "./mcp-audit"; import { handleMcpMessage, jsonRpcError } from "./mcp-server"; import type { McpPrincipal } from "./mcp-tools"; const r = secureRouter(new Hono(), { module: "mcp", basePath: "/api/mcp" }); +/** + * The caller, as this endpoint needs them: their effective capability plus the + * identity facts the audit trail is written from. + */ +interface McpCaller { + principal: McpPrincipal; + /** Canonical principal id — `oauth:` / `pat:`. */ + principalId: string; + userId: string; + /** Org the call acts in, default-resolved. Null → the user has none. */ + organizationId: string | null; + /** A real personal_access_token row backs this caller (usage is countable). */ + hasBinding: boolean; + tokenId: string; +} + /** * Resolve the caller's effective capability for `tools/list` filtering. NOT the * authorization gate — every `tools/call` re-auths through the real stack @@ -31,7 +48,7 @@ const r = secureRouter(new Hono(), { module: "mcp", basePath: "/api/mcp" }); * advertise tools the token can't use. Returns null on an invalid credential * (→ 401). Mirrors how authMiddleware resolves a bearer principal (same repos). */ -async function resolveMcpPrincipal(token: string, headers: Headers): Promise { +async function resolveMcpCaller(token: string, headers: Headers): Promise { // Same credential→identity lookup authMiddleware uses — one resolver, no fork. const id = await resolveBearerIdentity(token, headers); if (!id) return null; @@ -83,12 +100,19 @@ async function resolveMcpPrincipal(token: string, headers: Headers): Promise { if (!audienceAccepted(token, c.req.raw)) { return unauthorized(c, "Access token was issued for a different resource"); } - const principal = await resolveMcpPrincipal(token, c.req.raw.headers); - if (!principal) return unauthorized(c, "Missing or invalid access token"); + const caller = await resolveMcpCaller(token, c.req.raw.headers); + if (!caller) return unauthorized(c, "Missing or invalid access token"); return c.body(null, 405); }); @@ -162,8 +186,8 @@ r.public("post", "/", { reason: PUBLIC_REASON, rateLimit: "mcp" }, async (c) => // filtering AND gates the request (null → 401). It is NOT the per-tool // authorization — the real check runs on the dispatched sub-request through // authMiddleware (see tryPatAuth / tryOAuthMcpAuth); tools/call re-auths. - const principal = await resolveMcpPrincipal(token, c.req.raw.headers); - if (!principal) return unauthorized(c, "Missing or invalid access token"); + const caller = await resolveMcpCaller(token, c.req.raw.headers); + if (!caller) return unauthorized(c, "Missing or invalid access token"); let payload: unknown; try { @@ -177,7 +201,41 @@ r.public("post", "/", { reason: PUBLIC_REASON, rateLimit: "mcp" }, async (c) => return c.json(jsonRpcError(null, -32600, "Batch requests are not supported"), 400); } - const res = await handleMcpMessage(payload as Parameters[0], token, principal); + const message = payload as Parameters[0]; + + // Usage is stamped HERE for everything that does NOT dispatch — `initialize`, + // `tools/list`, `prompts/*`, `ping`. Those never reach authMiddleware (this route + // is public and resolves the credential itself, side-effect free), so a client + // that connected and read the catalog used its credential and still read as + // never-used in Settings. `tools/call` is excluded because its sub-request stamps + // it in authMiddleware — counting both would double every tool call. + if (caller.hasBinding && message?.method !== "tools/call") { + void repos.personalAccessToken.touchLastUsed(caller.tokenId).catch(() => {}); + } + + // Read off the OUTER request — a real HTTP request from the real client. The + // in-process sub-request has no peer and no browser, so this is the only place + // the assistant's own address and user agent exist. + const clientIp = c.var.clientIp ?? null; + const userAgent = c.req.header("user-agent") ?? null; + + const res = await handleMcpMessage(message, { + bearerToken: token, + principal: caller.principal, + origin: { principalId: caller.principalId, clientIp, userAgent }, + onToolCall: (record) => + recordToolCall( + { + organizationId: caller.organizationId, + userId: caller.userId, + clientId: caller.principalId, + tokenId: caller.tokenId, + ipAddress: clientIp, + userAgent, + }, + record, + ), + }); // Notification (no id) → 202 Accepted with no body (per JSON-RPC). if (!res) return c.body(null, 202); return c.json(res); diff --git a/apps/api/src/modules/migration/docker-inspect.service.ts b/apps/api/src/modules/migration/docker-inspect.service.ts index 3f8ca1cfb..d5c7402e8 100644 --- a/apps/api/src/modules/migration/docker-inspect.service.ts +++ b/apps/api/src/modules/migration/docker-inspect.service.ts @@ -341,6 +341,7 @@ export async function discoverServerStack( * declaration, then run the SAME `toDiscoveredService` merge — so the record is * byte-for-byte the keys the wizard shows masked (`maskDiscoveredStack`), only * with the real values. One round-trip, not a full re-scan. Read-only on the box. + * Returns the full map; the controller narrows it to the requested keys. * * Write-gated at the route (`server:write`): the masked scan is a `:read`, * revealing the real secret is a `:write`, the same split as the service-env diff --git a/apps/api/src/modules/migration/migration.controller.ts b/apps/api/src/modules/migration/migration.controller.ts index 1d38d1c3a..c08b6c28c 100644 --- a/apps/api/src/modules/migration/migration.controller.ts +++ b/apps/api/src/modules/migration/migration.controller.ts @@ -12,6 +12,7 @@ import { repos } from "@repo/db"; import { safeErrorMessage } from "@repo/core"; import { getRequestContext } from "../../lib/request-context"; import { permission } from "../../lib/permission"; +import { parseRevealKeys, pickRevealed } from "../../lib/env-reveal"; import { isServerInOrg, param } from "../../lib/controller-helpers"; import { streamRunSSE } from "../../lib/run-sse"; import { streamSSE } from "../../lib/sse"; @@ -181,18 +182,22 @@ export async function scanServerStream(c: Context) { } /** - * POST /migration/reveal-env { serverId, containerId } + * POST /migration/reveal-env { serverId, containerId, keys: string[] } * * #336: on-demand reveal of ONE discovered container's real env for the wizard's - * env viewer. The scan masks env (`maskDiscoveredStack`); this returns it UNMASKED - * for a single container the user chose to reveal. Write-gated (`server:write`, the - * route tag) — same read/write split as the service-env reveal — so the masked - * scan stays a `:read` and only a `:write` holder can pull the real secrets. + * env viewer. The scan masks env (`maskDiscoveredStack`); this returns UNMASKED + * plaintext for the keys the body names — one eye-press, one secret, not the + * container's whole env. Write-gated (`server:write`, the route tag) — same + * read/write split as the service-env reveal — so the masked scan stays a `:read` + * and only a `:write` holder can pull the real secrets. */ export async function revealServiceEnv(c: Context) { - const { serverId, containerId } = await c.req.json<{ serverId?: string; containerId?: string }>(); + type RevealBody = { serverId?: string; containerId?: string; keys?: unknown }; + const body = await c.req.json().catch(() => ({}) as RevealBody); + const { serverId, containerId } = body; if (!serverId) return c.json({ error: "serverId is required" }, 400); if (!containerId) return c.json({ error: "containerId is required" }, 400); + const keys = parseRevealKeys(body.keys); const ctx = getRequestContext(c); await permission.assert(ctx, { @@ -205,7 +210,9 @@ export async function revealServiceEnv(c: Context) { } try { - const environment = await revealContainerEnv(serverId, ctx.organizationId, containerId); + const full = await revealContainerEnv(serverId, ctx.organizationId, containerId); + const environment = pickRevealed(full, keys); + c.set("auditAfter", { containerId, revealedEnvKeys: Object.keys(environment) }); return c.json({ success: true, environment }); } catch (err) { return c.json({ error: `Reveal failed: ${safeErrorMessage(err)}` }, 502); diff --git a/apps/api/src/modules/notifications/notifications.controller.ts b/apps/api/src/modules/notifications/notifications.controller.ts index b6a24d093..fd3f1f4fc 100644 --- a/apps/api/src/modules/notifications/notifications.controller.ts +++ b/apps/api/src/modules/notifications/notifications.controller.ts @@ -42,21 +42,24 @@ const VALID_CHANNEL_KINDS = new Set([ /** * GET /categories — the static registry, plus the groups the Settings UI tabs by. * - * Billing is dropped outside CLOUD_MODE: those two categories are fed by - * Stripe/Oblien, so on a self-hosted box they are toggles that can never fire. - * The filter lives HERE and not in `CATEGORIES` on purpose — `findCategory` - * supplies the title and body of every delivered alert - * (notification-workers.ts) and the dispatcher's `defaultEnabled` fallback, so - * the registry has to stay complete or an org that already holds a billing row - * would start rendering the raw category id. + * Each group is dropped in the mode that can never produce it: `billing` is fed by + * Stripe/Oblien so it is cloud-only, and `mail` is fed by the self-hosted mail engine + * (the whole mail module is absent in cloud) so it is the mirror image. Either way a + * toggle that can never fire is worse than no toggle. + * + * The filter lives HERE and not in `CATEGORIES` on purpose — `findCategory` supplies the + * title and body of every delivered alert (notification-workers.ts) and the dispatcher's + * `defaultEnabled` fallback, so the registry has to stay complete or an org that already + * holds a row for a hidden category would start rendering the raw category id. + * + * Both lists are filtered symmetrically: a category whose group is gone would render + * under no tab at all. */ export async function listCategories(c: Context) { - if (env.CLOUD_MODE) { - return c.json({ categories: CATEGORIES, groups: CATEGORY_GROUPS }); - } + const hidden = new Set(env.CLOUD_MODE ? ["mail"] : ["billing"]); return c.json({ - categories: CATEGORIES.filter((cat) => cat.group !== "billing"), - groups: CATEGORY_GROUPS.filter((g) => g.id !== "billing"), + categories: CATEGORIES.filter((cat) => !hidden.has(cat.group)), + groups: CATEGORY_GROUPS.filter((g) => !hidden.has(g.id)), }); } diff --git a/apps/api/src/modules/projects/folder/folder.controller.ts b/apps/api/src/modules/projects/folder/folder.controller.ts index 6c0a2fa08..d8f33c38f 100644 --- a/apps/api/src/modules/projects/folder/folder.controller.ts +++ b/apps/api/src/modules/projects/folder/folder.controller.ts @@ -1,6 +1,7 @@ import type { Context } from "hono"; import { safeErrorMessage } from "@repo/core"; import { getRequestContext } from "../../../lib/request-context"; +import { parseRevealKeys, pickRevealed } from "../../../lib/env-reveal"; import { requestApiPublicUrl } from "../../../lib/public-url"; import { projectInfoToScanResponse } from "../../deployments/prepare.service"; import { createFolderSession, acceptRelayUpload, scanFolderSession } from "./folder.service"; @@ -87,22 +88,33 @@ export async function scanSession(c: Context) { } /** - * GET /projects/folder/scan/:sessionId/env-reveal + * POST /projects/folder/scan/:sessionId/env-reveal { service, keys: string[] } * #336: the scan response masks compose env, so the wizard's "show values" * toggle fetches the REAL values here. Backed by `session.services`, which the * scan captured PRE-mask. Write-gated at the route (project:write) so a - * read-only caller can't reveal. Returns real env keyed by service name. + * read-only caller can't reveal. + * + * Scoped to ONE service and the keys it names: this used to answer with every + * key of every service in the session, so revealing a single row of one service + * shipped the whole stack's secrets to the browser. */ export async function revealSessionEnv(c: Context) { const { organizationId } = getRequestContext(c); const sessionId = c.req.param("sessionId"); + type RevealBody = { service?: unknown; keys?: unknown }; + const body = await c.req.json().catch(() => ({}) as RevealBody); + const serviceName = typeof body.service === "string" ? body.service : ""; + if (!serviceName) return c.json({ error: "service is required" }, 400); + const keys = parseRevealKeys(body.keys); + const session = sessionId ? getFolderSession(sessionId) : undefined; if (!session || session.orgId !== organizationId) { return c.json({ error: "Upload session not found" }, 404); } - const environments: Record> = {}; - for (const s of session.services ?? []) { - if (s.name) environments[s.name] = s.environment ?? {}; - } - return c.json({ success: true, environments }); + const match = (session.services ?? []).find((s) => s.name === serviceName); + if (!match) return c.json({ error: "Service not found in this upload session" }, 404); + + const environment = pickRevealed(match.environment, keys); + c.set("auditAfter", { service: serviceName, revealedEnvKeys: Object.keys(environment) }); + return c.json({ success: true, environment }); } diff --git a/apps/api/src/modules/projects/project.routes.ts b/apps/api/src/modules/projects/project.routes.ts index 95f932916..04ea622a9 100644 --- a/apps/api/src/modules/projects/project.routes.ts +++ b/apps/api/src/modules/projects/project.routes.ts @@ -117,9 +117,10 @@ r.post( }, folder.scanSession, ); -r.get( +r.post( // #336: real (unmasked) compose env for the folder-scan wizard's reveal - // toggle. Write-gated (project:write); no mcp — reveal is a dashboard action. + // toggle — one service, only the keys the body names. Write-gated + // (project:write); no mcp — reveal is a dashboard action. "/folder/scan/:sessionId/env-reveal", { tag: "project:write", collection: true }, folder.revealSessionEnv, diff --git a/apps/api/src/modules/services/service.controller.ts b/apps/api/src/modules/services/service.controller.ts index 62e6acd03..c231688db 100644 --- a/apps/api/src/modules/services/service.controller.ts +++ b/apps/api/src/modules/services/service.controller.ts @@ -13,6 +13,7 @@ import { AppError } from "@repo/core"; import { streamSSE } from "../../lib/sse"; import { param } from "../../lib/controller-helpers"; import { getRequestContext } from "../../lib/request-context"; +import { parseRevealKeys, pickRevealed } from "../../lib/env-reveal"; import { audit, auditContextFrom } from "../../lib/audit"; import { sshManager } from "../../lib/ssh-manager"; import * as serviceService from "./service.service"; @@ -57,13 +58,27 @@ export async function getById(c: Context) { // ─── Reveal real env (#336) — write-gated, backs the "show values" toggle ───── +/** + * POST /projects/:id/services/:serviceId/env-reveal { keys: string[] } + * + * Per-key: returns plaintext for the named keys ONLY, so pressing one row's eye + * discloses one secret. `keys` is required (see parseRevealKeys) — no request can + * ask for the whole map. The auto-emitted `project:service:write` audit row + * carries the disclosed key names via `auditAfter`. + */ export async function revealEnv(c: Context) { const ctx = getRequestContext(c); const projectId = param(c, "id"); const serviceId = param(c, "serviceId"); + // Outside the try: a 400 from key validation must not be reported as a + // reveal failure. Body may be absent on a malformed client call. + const body = await c.req.json<{ keys?: unknown }>().catch(() => ({}) as { keys?: unknown }); + const keys = parseRevealKeys(body.keys); try { - const environment = await serviceService.revealServiceEnv(ctx, projectId, serviceId); + const stored = await serviceService.revealServiceEnv(ctx, projectId, serviceId); + const environment = pickRevealed(stored, keys); + c.set("auditAfter", { revealedEnvKeys: Object.keys(environment) }); return c.json({ success: true, environment }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to reveal service env"; @@ -231,6 +246,8 @@ export async function syncFromCompose(c: Context) { environment?: Record; volumes?: string[]; command?: string; + /** #332: exact argv — no `sh -c`. Wins over the lossy `command` string. */ + commandArgv?: string[]; restart?: string; exposed?: boolean; exposedPort?: string; diff --git a/apps/api/src/modules/services/service.routes.ts b/apps/api/src/modules/services/service.routes.ts index a2b82d865..0a3b28ba1 100644 --- a/apps/api/src/modules/services/service.routes.ts +++ b/apps/api/src/modules/services/service.routes.ts @@ -83,10 +83,13 @@ r.get( cloudProjectProxy, ctrl.getById, ); -r.get( - // #336: real (unmasked) compose env. Write-gated on purpose — read-only - // callers only ever see the masked map from GET /:serviceId. No mcp block: - // revealing secrets stays a dashboard action, off the automation surface. +r.post( + // #336: real (unmasked) compose env for the keys named in the body — never the + // whole map. Write-gated on purpose: read-only callers only ever see the masked + // map from GET /:serviceId. POST, not GET, because the requested key names are + // a body (out of proxy access logs and browser history) and are unbounded by + // URL length. No mcp block: revealing secrets stays a dashboard action, off the + // automation surface. "/:serviceId/env-reveal", { tag: "project:service:write" }, cloudProjectProxy, diff --git a/apps/api/src/modules/services/service.schema.ts b/apps/api/src/modules/services/service.schema.ts index 183455cfd..c097be658 100644 --- a/apps/api/src/modules/services/service.schema.ts +++ b/apps/api/src/modules/services/service.schema.ts @@ -264,7 +264,21 @@ export const SyncServicesBody = Type.Object({ dependsOn: Type.Optional(Type.Array(Type.String())), environment: Type.Optional(Type.Record(Type.String(), Type.String())), volumes: Type.Optional(Type.Array(Type.String())), - command: Type.Optional(Type.String()), + command: Type.Optional( + Type.String({ + description: + "Shell-string form. Stored for display and shell-word-split into argv; prefer `commandArgv` for a list command, whose string form here is a LOSSY join.", + }), + ), + // #332: without this the only way to express a command was the string above, + // so a list command round-tripped through this endpoint lost its quoting + // (`["sh","-c","a && b"]` → five words). The CLI has always sent it. + commandArgv: Type.Optional( + Type.Array(Type.String(), { + description: + "Exact container argv (docker-compose Cmd semantics: overrides the image CMD, keeps its ENTRYPOINT, no implicit `sh -c`). Wins over `command`. `[]` clears the image CMD.", + }), + ), restart: Type.Optional( Type.String({ description: 'Compose restart policy, e.g. "unless-stopped" or "on-failure:3".', diff --git a/apps/api/src/modules/services/service.service.ts b/apps/api/src/modules/services/service.service.ts index 03101c430..5b0196e72 100644 --- a/apps/api/src/modules/services/service.service.ts +++ b/apps/api/src/modules/services/service.service.ts @@ -3,7 +3,7 @@ */ import { normalizeRoutingFields, repos, composeSpecDiff, type Project, type Service, type ServicePublicEndpoint } from "@repo/db"; -import { aliasConflictsWithSiblings, getProjectType, mergeAdvanced, normalizeServiceLabel, normalizeAliasStrict, safeErrorMessage, withTimeout, type ComposeAdvanced, type ServiceContainerState, type StackId } from "@repo/core"; +import { aliasConflictsWithSiblings, getProjectType, mergeAdvanced, normalizeServiceLabel, normalizeAliasStrict, resolveCommandArgv, safeErrorMessage, withTimeout, type ComposeAdvanced, type ServiceContainerState, type StackId } from "@repo/core"; import { BuildLogger, DockerRuntime, @@ -231,6 +231,9 @@ export async function getService( * write-gated reveal that backs the "show values" toggle — the route tag is * `project:service:write`, so a read-only caller can never reach the plaintext * (the whole point: read = masked). `getService` above always masks. + * + * Returns the FULL stored map; the controller narrows it to the keys the request + * named (`pickRevealed`) so only those cross the wire. */ export async function revealServiceEnv( ctx: RequestContext, @@ -496,7 +499,13 @@ export async function createService( environment: data.environment ?? {}, volumes: data.volumes ?? [], command: trimOrNull(data.command), - commandArgv: data.commandArgv ?? null, // #332 + // #332: derive argv from the text command when the client didn't send one, or + // the row falls back to the `sh -c` wrap that breaks entrypoint+CMD images. + commandArgv: + resolveCommandArgv({ + incomingArgv: data.commandArgv, + incomingCommand: data.command, + }) ?? null, restart: data.restart ?? "unless-stopped", advanced, ...routing, @@ -590,6 +599,18 @@ export async function updateService( patch[key] = trimOrNull(patch[key]); } } + // #332: `commandArgv` wins over `command` at deploy time, so a command edit that + // leaves a stale argv behind silently keeps running the OLD command — the form's + // command field did nothing on any row imported from a compose file. Re-derive on + // a real edit; an echoed-back identical string keeps the stored argv (it may be a + // list command whose display join can't be re-split). + const nextCommandArgv = resolveCommandArgv({ + incomingArgv: patch.commandArgv, + incomingCommand: "command" in patch ? patch.command : undefined, + storedCommand: svc.command, + storedArgv: svc.commandArgv as string[] | null, + }); + if (nextCommandArgv !== undefined) patch.commandArgv = nextCommandArgv; // Monorepo sub-app build settings: same trim-or-null treatment so empty // strings become null in DB (matches the rest of the service columns). for (const key of [ @@ -1040,6 +1061,11 @@ export async function syncComposeServices( // holding a bad one (persisted before #342) must not be refused wholesale. assertValidCustomDomains(parsed, { known: customHostnamesOf(stored) }); + // #332: argv needs no restoring here. This endpoint accepts `command` as a string + // whose stored form is a lossy join, but `syncFromCompose` (composeWritePatch → + // resolveCommandArgv) is the ONE place that decides whether an unchanged string + // keeps the stored argv or a changed one re-derives — so every writer into that + // path, including the deploy request's own service list, gets the same rule. const reconciled = parsed.map((svc) => svc.environment ? { ...svc, environment: unmaskEnv(svc.environment, storedEnvByName.get(svc.name) ?? null) } diff --git a/apps/api/src/modules/system/server-check.controller.ts b/apps/api/src/modules/system/server-check.controller.ts index 339f85964..c81527ef5 100644 --- a/apps/api/src/modules/system/server-check.controller.ts +++ b/apps/api/src/modules/system/server-check.controller.ts @@ -22,6 +22,7 @@ import { COMPONENT_UNINSTALLERS, ensureEdge, getRemovalSupport, + invalidateHostChannelAuth, isHostChannelUnavailableError, isSshAuthError, recoverInterruptedTakeover, @@ -385,10 +386,6 @@ export async function checkServer(c: Context) { ) { return c.json({ error: "no_server", message }, 400); } - if (isSshAuthError(err)) { - return c.json({ error: "auth_failed", message }, 400); - } - // A connect failure on THIS box is almost never "the server is down" — it's the // container→host SSH channel, and the row's display sshHost (127.0.0.1) names // neither the right machine nor the right port (#490). Hand the UI the address @@ -397,7 +394,22 @@ export async function checkServer(c: Context) { // Only for failures that came from the transport: a component check that threw for // its own reasons is not evidence about the channel, and diagnosing it anyway costs // a TCP probe to tell the operator about a firewall that was never involved. - if (isHostChannelUnavailableError(err) || isTransportFailure(err)) { + // + // #527 added `isSshAuthError` here and moved the generic `auth_failed` answer BELOW + // this block. An auth rejection on the local row was being claimed by that branch + // before this one could run, and on that row the answer is always wrong: its stored + // credentials are display-only, nothing dials with them, so the operator got an + // edit-credentials form that could not change the outcome. Reordering is safe for + // remote servers because `host_channel_blocked` only ever comes back for a row that + // resolves to THIS box — a remote box with a genuinely rejected key still falls + // through to `auth_failed`. + if (isSshAuthError(err) || isHostChannelUnavailableError(err) || isTransportFailure(err)) { + // A rejection we are HOLDING beats a memoized "the key worked 20s ago". Without + // this, a health probe that cached success just before the key stopped working + // would answer `ok`, the diagnosis would not name the channel, and the failure + // would fall through to the generic credentials answer this branch exists to + // prevent — the #527 card, restored by a cache. + if (isSshAuthError(err)) invalidateHostChannelAuth(); const d = await sshManager.diagnoseReachability(serverId).catch(() => null); if (d?.code === "host_channel_blocked") { return c.json( @@ -417,6 +429,11 @@ export async function checkServer(c: Context) { ); } } + // Reached only when the diagnosis did NOT name the host channel — i.e. a real remote + // server whose key or password the far end refused, which is what this answer is for. + if (isSshAuthError(err)) { + return c.json({ error: "auth_failed", message }, 400); + } return c.json({ error: "connection_failed", message }, 502); } } diff --git a/apps/api/src/modules/system/server-containers.controller.ts b/apps/api/src/modules/system/server-containers.controller.ts index 8f575d40d..710ba7b0e 100644 --- a/apps/api/src/modules/system/server-containers.controller.ts +++ b/apps/api/src/modules/system/server-containers.controller.ts @@ -25,6 +25,7 @@ import { } from "./server-containers.service"; import { getActiveContainerApplySession, + listContainerApplySessions, subscribeContainerApplySession, } from "../../lib/server-container-session"; @@ -130,6 +131,89 @@ export async function containerIssues(c: Context) { return c.json(await loadOrgContainerIssues(ctx.organizationId)); } +/** How long a settled apply keeps reporting its outcome to the fleet view. */ +const SETTLED_WINDOW_MS = 90_000; + +/** + * GET /system/containers/applying — what the org has in flight right now, and what + * just settled. + * + * The progress read behind the fleet roll-up. Two sources, because neither alone is + * the whole truth: + * - the cached rows say which (server, component) pairs were ACCEPTED, including + * the ones a bulk run has queued but not started (they carry no session yet); + * - the in-memory sessions say how far the ones actually running have got, and are + * the only place an outcome exists — a drift row clears `behind` and its + * in-progress flag in the same write, so a reader watching rows alone sees work + * vanish and can never tell that it succeeded. + * + * `intent` comes from the row's own state (behind → update, otherwise a restart), + * which is what the operator pressed; sessions don't record it. + */ +export async function listApplyingContainers(c: Context) { + const cloudGuard = assertNotCloud(c); if (cloudGuard) return cloudGuard; + const ctx = getRequestContext(c); + const [servers, rows] = await Promise.all([ + repos.server.listByOrganization(ctx.organizationId), + repos.serverContainerStatus.listByOrg(ctx.organizationId), + ]); + const names = new Map(servers.map((s) => [s.id, s.name ?? s.sshHost])); + const sessions = listContainerApplySessions({ settledWithinMs: SETTLED_WINDOW_MS }).filter((s) => + names.has(s.serverId), + ); + const running = new Map( + sessions.filter((s) => s.status === "running").map((s) => [`${s.serverId}:${s.component}`, s]), + ); + const rowFor = new Map(rows.map((r) => [`${r.serverId}:${r.component}`, r])); + + // Flagged rows first (stable, and the only source that knows the intent), then any + // running session whose row went missing — a swap mid-flight is still in flight + // even if its cached row was dropped. + const active = [ + ...rows + .filter((r) => r.latestInProgress) + .map((r) => { + const key = `${r.serverId}:${r.component}`; + const session = running.get(key); + return { + serverId: r.serverId, + serverName: names.get(r.serverId) ?? r.serverId, + component: r.component, + state: session ? ("running" as const) : ("queued" as const), + intent: r.behind ? ("update" as const) : ("repair" as const), + ...(session + ? { sessionId: session.id, steps: session.steps, startedAt: new Date(session.startedAt).toISOString() } + : {}), + }; + }), + ...[...running.entries()] + .filter(([key]) => !rowFor.get(key)?.latestInProgress) + .map(([, session]) => ({ + serverId: session.serverId, + serverName: names.get(session.serverId) ?? session.serverId, + component: session.component, + state: "running" as const, + intent: null, + sessionId: session.id, + steps: session.steps, + startedAt: new Date(session.startedAt).toISOString(), + })), + ]; + + const recent = sessions + .filter((s) => s.status !== "running") + .map((s) => ({ + serverId: s.serverId, + serverName: names.get(s.serverId) ?? s.serverId, + component: s.component, + ok: s.status === "completed", + ...(s.error ? { error: s.error } : {}), + finishedAt: new Date(s.finishedAt ?? Date.now()).toISOString(), + })); + + return c.json({ active, recent }); +} + /** * POST /system/containers/apply-all — update every behind container and restart * every stopped one across the org, in one click. diff --git a/apps/api/src/modules/system/server-containers.service.ts b/apps/api/src/modules/system/server-containers.service.ts index 9370923af..0c80aca5a 100644 --- a/apps/api/src/modules/system/server-containers.service.ts +++ b/apps/api/src/modules/system/server-containers.service.ts @@ -259,7 +259,9 @@ function upsertView(server: Server, view: ServerContainerView, lastError?: strin runningVersion: view.runningVersion, pinnedVersion: view.pinnedVersion, behind: view.behind, - latestInProgress: false, + // Deliberately NOT written: a probe knows what the box runs, not whether an + // apply is mid-flight. `upsert` preserves the flag when it's omitted, so a scan + // landing during a swap can no longer erase the state every surface renders. detail, }); } @@ -663,7 +665,23 @@ export async function applyAllContainers( }); } - // Detached on purpose — the response is the classification, not the outcome. + // Flag EVERY accepted target — queued ones included — and await it before the + // response. `applyServerContainer` sets the same flag itself, but only when a + // worker slot picks the target up: with BULK_APPLY_CONCURRENCY at 3, targets 4..N + // were indistinguishable from "never started" for as long as the first swaps took, + // so a fleet view had nothing to render and a second click re-queued containers + // that were already accepted (the session dedup only catches a target a worker + // has actually reached). One flag per target, written up front, is the record that + // the work was taken on. + await Promise.all( + targets.map((t) => + repos.serverContainerStatus.setInProgress(t.server.id, t.component, true).catch(() => {}), + ), + ); + + // Detached on purpose — the response is the classification, not the outcome. Each + // apply clears its own flag in `applyServerContainer`'s finally; a target whose + // worker never runs (process death mid-run) is cleared at the next boot. void mapWithLimit(targets, BULK_APPLY_CONCURRENCY, async (t) => { await runContainerApply(t.server, t.component, t.intent).done.catch(() => {}); }).catch(() => {}); diff --git a/apps/api/src/modules/system/servers.controller.ts b/apps/api/src/modules/system/servers.controller.ts index e2ed5493f..be901d50d 100644 --- a/apps/api/src/modules/system/servers.controller.ts +++ b/apps/api/src/modules/system/servers.controller.ts @@ -242,6 +242,24 @@ export async function createServer(c: Context) { } /** PATCH /servers/:id - update a server */ +/** + * Everything on a server row that describes HOW to dial it — i.e. everything an isLocal + * row does not use. Listed once so a new credential field can't quietly become writable + * on the one row where writing it means nothing. + */ +const LOCAL_ROW_READONLY_FIELDS = [ + "sshHost", + "sshPort", + "sshUser", + "sshAuthMethod", + "sshPassword", + "sshKeyPath", + "sshPrivateKey", + "sshKeyPassphrase", + "sshJumpHost", + "sshArgs", +] as const; + export async function updateServer(c: Context) { const cloudGuard = assertNotCloud(c); if (cloudGuard) return cloudGuard; @@ -254,6 +272,33 @@ export async function updateServer(c: Context) { if (!existing) return c.json({ error: "Server not found" }, 404); const body = await c.req.json(); + + // #527: an isLocal row's ssh* fields are DISPLAY-ONLY. Every operation on this box goes + // through the container→host channel, whose credentials come from OPENSHIP_HOST_SSH_* + // and never from this row (see lib/startup/self-server.ts). Accepting them stored a + // credential nothing reads, displayed it back as though it were in use, and let + // `ensureLocalServer`'s reconcile silently revert ssh_user on the next `GET /servers`. + // + // That combination is most of what #527 cost its reporter: told their credentials were + // rejected, they came here, entered a username and key, watched it change nothing, and + // tried three more key paths. Refusing with the reason is the only answer that ends + // that loop. `name` stays editable — renaming this row is meaningful and harmless. + if (existing.isLocal) { + const attempted = LOCAL_ROW_READONLY_FIELDS.filter((f) => body[f] !== undefined); + if (attempted.length > 0) { + return c.json( + { + error: + "This row is the machine Openship runs on, so its SSH details are display-only " + + "— the connection to this host uses the channel key provisioned by " + + "`openship up`, not credentials stored here. Re-run `openship up` to change it.", + fields: attempted, + }, + 400, + ); + } + } + const patch: Record = {}; if (body.name !== undefined) patch.name = body.name?.trim() || null; diff --git a/apps/api/src/modules/system/system-health.controller.ts b/apps/api/src/modules/system/system-health.controller.ts index 9cae5979c..bb7c88d4d 100644 --- a/apps/api/src/modules/system/system-health.controller.ts +++ b/apps/api/src/modules/system/system-health.controller.ts @@ -19,7 +19,11 @@ import type { Context } from "hono"; import { db, getDriver, sql, count, eq, schema } from "@repo/db"; import { hostChannelHealth, type HostChannelHealth } from "@repo/adapters"; -import { HOST_CHANNEL_NOT_PROVISIONED, safeErrorMessage } from "@repo/core"; +import { + HOST_CHANNEL_AUTH_REJECTED, + HOST_CHANNEL_NOT_PROVISIONED, + safeErrorMessage, +} from "@repo/core"; import { env } from "../../config"; @@ -60,13 +64,17 @@ function reportHostChannel(h: HostChannelHealth): HostChannelReport { ok: h.ok, state: h.code, ...(h.cause ? { cause: h.cause } : {}), - // `key_unreadable`'s own hint names the key PATH, which never goes on the wire — - // and the remedy is the same one anyway: reprovision, then verify. + // `key_unreadable`'s own hint names the key PATH and `auth_rejected`'s names the + // channel TARGET; neither goes on the wire, for the reason the address and the + // firewall rule don't — this payload is for monitoring, not for an operator standing + // at a terminal. Both fall back to the shared prose, which is the same remedy anyway. ...(h.code === "key_unreadable" ? { remedy: HOST_CHANNEL_NOT_PROVISIONED } - : h.hint - ? { remedy: h.hint } - : {}), + : h.code === "auth_rejected" + ? { remedy: `${HOST_CHANNEL_AUTH_REJECTED} ${HOST_CHANNEL_NOT_PROVISIONED}` } + : h.hint + ? { remedy: h.hint } + : {}), }; } diff --git a/apps/api/src/modules/system/system.routes.ts b/apps/api/src/modules/system/system.routes.ts index 8326734c9..b8a6d38e8 100644 --- a/apps/api/src/modules/system/system.routes.ts +++ b/apps/api/src/modules/system/system.routes.ts @@ -183,6 +183,10 @@ r.get("/containers/issues", { tag: "server:read", collection: true }, serverCont // Global infra view — every server × component. No :id, so collection:true scopes // the check to the active org (same as /containers/behind). Scan is detect-only. r.get("/containers", { tag: "server:read", collection: true }, serverContainers.listAllContainers); +// Live progress for the fleet view: what's queued/running right now (cached rows × +// in-memory sessions) plus what just settled, which is the only place an outcome +// lives — a finished row clears its drift and its in-progress flag together. +r.get("/containers/applying", { tag: "server:read", collection: true }, serverContainers.listApplyingContainers); r.post("/containers/scan", { tag: "server:write", collection: true }, serverContainers.scanAllContainers); // Fleet bulk apply — targets are derived from the cache server-side, so the body // only carries which intents to run ("update" swaps, "repair" restarts). diff --git a/apps/api/src/modules/tokens/token.controller.ts b/apps/api/src/modules/tokens/token.controller.ts index f43b8f2c3..cde091652 100644 --- a/apps/api/src/modules/tokens/token.controller.ts +++ b/apps/api/src/modules/tokens/token.controller.ts @@ -32,6 +32,8 @@ function serialize(t: PublicPersonalAccessToken) { scoped: t.scoped, expiresAt: t.expiresAt, lastUsedAt: t.lastUsedAt, + /** Requests made with this token. Approximate — the write is fire-and-forget. */ + useCount: t.useCount, revokedAt: t.revokedAt, createdAt: t.createdAt, }; @@ -514,7 +516,7 @@ export async function authorizeMcpClient(c: Context) { * reproduced here. */ function serializeBinding( - b: { oauthClientId: string | null; name: string; organizationId: string | null; readOnly: boolean; scoped: boolean; createdAt: Date; lastUsedAt: Date | null }, + b: { id: string; oauthClientId: string | null; name: string; organizationId: string | null; readOnly: boolean; scoped: boolean; createdAt: Date; lastUsedAt: Date | null; useCount: number }, name: string, organizationName: string | null, grants: Array<{ resourceType: string; resourceId: string; permissions: Permission[]; scope?: SourceAccessScope }>, @@ -530,6 +532,10 @@ function serializeBinding( grantCount: grants.length, authorizedAt: b.createdAt, lastUsedAt: b.lastUsedAt, + /** Requests this client has made. Approximate — the write is fire-and-forget. */ + useCount: b.useCount, + /** Audit key for "everything this client did" (audit_event.source_client_id). */ + auditClientId: b.oauthClientId ? `oauth:${b.oauthClientId}` : `pat:${b.id}`, ...(opts.includeGrants ? { grants: grants.map((g) => ({ @@ -633,10 +639,38 @@ export async function disconnectMcpClient(c: Context) { const clientId = param(c, "clientId").trim(); if (!clientId) return c.json({ error: "clientId required", code: "CLIENT_ID_REQUIRED" }, 400); + // Read the binding BEFORE the teardown: it is the only place the scope the + // client held is still recorded, and an audit row that can only say "something + // named X was disconnected" answers none of the questions asked after the fact. + const binding = await repos.personalAccessToken.findOAuthBinding(ctx.userId, clientId); + const grantCount = binding ? (await repos.patGrant.listByToken(binding.id)).length : 0; + // Atomic: tokens + consent + binding + grants are torn down in one // transaction (see oauth repo). Self-scoped to ctx.userId, so a client // shared across users keeps working for everyone else. await repos.oauth.disconnectMcpClient(ctx.userId, clientId); + // Authorizing and re-scoping an agent were both recorded; REVOKING it was not, + // which left the one MCP lifecycle event with no trace — and the log reading as + // though a client that is long gone still holds its scope. + const organizationId = binding?.organizationId ?? ctx.organizationId; + if (organizationId) { + audit.recordAsync(auditContextFrom(c, organizationId, ctx.userId), { + eventType: "mcp.disconnected", + resourceType: "mcp_client", + resourceId: binding?.id ?? clientId, + before: binding + ? { + clientId, + scoped: binding.scoped, + readOnly: binding.readOnly, + grantCount, + useCount: binding.useCount, + } + : { clientId }, + after: null, + }); + } + return c.json({ data: { ok: true } }); } diff --git a/apps/api/test/e2e/mail-db-bootstrap.e2e.test.ts b/apps/api/test/e2e/mail-db-bootstrap.e2e.test.ts new file mode 100644 index 000000000..e2451750b --- /dev/null +++ b/apps/api/test/e2e/mail-db-bootstrap.e2e.test.ts @@ -0,0 +1,244 @@ +/** + * The mail engine's DB bootstrap, against a REAL PostgreSQL sidecar (GH-562). + * + * The bug this exists for could not be caught by any unit test, because the defect + * was the script's *failure semantics* rather than its SQL. `db-bootstrap.sh` ran + * under `set -uo pipefail` with no `-e`, so when it started before the sidecar was + * ready every psql failed in turn, the script reached its closing + * "── DB bootstrap complete ──" and exited 0. The caller's `|| log "ERROR"` could + * therefore never fire. Operators saw dovecot, iredapd and amavis crash-loop against + * an empty database with nothing anywhere explaining why, and the only recovery was + * to run the script by hand. + * + * A second, quieter variant: `doveadm pw` ran with `2>/dev/null` and its output + * unvalidated, so a missing `doveadm` seeded the postmaster with an EMPTY password + * and still reported success. + * + * What this asserts is therefore mostly about EXIT CODES and the absence of false + * success — the three cases below all passed (exit 0) before the fix. + * + * Why not boot `openship-mail` itself: that image is not published, and building it + * runs the full iRedMail installer — minutes to tens of minutes, which is not a test. + * The script's real collaborators are bash, psql, perl and the engine's SQL samples, + * so a Debian `postgres:16` runner with the repo's actual `engine/samples` copied in + * exercises the real script against a real database. `doveadm` is the one collaborator + * that must be stubbed, and stubbing it is what lets us drive its failure mode + * deliberately. The Dockerfile smoke gate covers doveadm's real presence. + * + * Files travel by `docker cp`, not a bind mount: a mount only works when the daemon + * can see the host path, which is false for a macOS temp dir under Colima and true in + * CI — i.e. the one setup where a gate must not evaporate. Same reasoning as + * edge-not-found-page.e2e.test.ts. + * + * Skips without a reachable daemon, FAILS under RUN_DOCKER_E2E=1 (what CI sets). + * See test/helpers/docker-e2e.ts. + */ + +import { it, expect, beforeAll, afterAll } from "vitest"; +import { execFile } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { describeDockerE2E, requireDocker } from "../helpers/docker-e2e"; + +const execFileAsync = promisify(execFile); +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../../../.."); +const EMAIL_DIR = join(REPO_ROOT, "apps/email"); + +/** Unique per run so a leftover container from a killed run can't collide. */ +const SUFFIX = process.pid.toString(36); +const NET = `openship-e2e-mailboot-${SUFFIX}`; +const DB = `openship-e2e-mailboot-db-${SUFFIX}`; +const RUNNER = `openship-e2e-mailboot-run-${SUFFIX}`; + +const DB_IMAGE = "postgres:16-alpine"; +/** Debian-based: the script is bash and uses perl for iRedMail's PH_ substitutions. */ +const RUNNER_IMAGE = "postgres:16"; + +const PG_PASSWORD = "e2e-root-pw"; +const BIND_PASSWORD = "e2e-bind-pw"; +const POSTMASTER_PLAIN = "e2e-postmaster-pw"; +const FIRST_DOMAIN = "e2e-mail.example"; +/** Any well-formed SSHA512 value; the script only validates the shape. */ +const STUB_HASH = "{SSHA512}ZTJlLXN0dWItaGFzaC12YWx1ZQ=="; + +const SCRIPT_IN_RUNNER = "/opt/openship-mail/db-bootstrap.sh"; + +async function docker(args: string[], opts: { allowFail?: boolean } = {}) { + try { + const { stdout, stderr } = await execFileAsync("docker", args, { + maxBuffer: 32 * 1024 * 1024, + }); + return { code: 0, stdout, stderr }; + } catch (err) { + const e = err as { code?: number; stdout?: string; stderr?: string; message?: string }; + if (!opts.allowFail) { + throw new Error( + `docker ${args.slice(0, 3).join(" ")} failed: ${e.stderr || e.message || "unknown"}`, + ); + } + return { code: typeof e.code === "number" ? e.code : 1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" }; + } +} + +/** Run db-bootstrap.sh in the runner. Never throws — the exit code IS the assertion. */ +async function runBootstrap(env: Record = {}) { + const envArgs = Object.entries({ + FIRST_DOMAIN, + VMAIL_DB_BIND_PASSWD: BIND_PASSWORD, + DOMAIN_ADMIN_PASSWD_PLAIN: POSTMASTER_PLAIN, + PGSQL_ROOT_PASSWD: PG_PASSWORD, + OPENSHIP_MAIL_DB_HOST: DB, + OPENSHIP_MAIL_DB_PORT: "5432", + ...env, + }).flatMap(([k, v]) => ["--env", `${k}=${v}`]); + + const r = await docker( + ["exec", ...envArgs, RUNNER, "bash", SCRIPT_IN_RUNNER], + { allowFail: true }, + ); + return { ...r, log: `${r.stdout}\n${r.stderr}` }; +} + +/** A one-shot query as the postgres superuser, from inside the runner. */ +async function query(sql: string, db = "vmail"): Promise { + const r = await docker([ + "exec", + "--env", `PGPASSWORD=${PG_PASSWORD}`, + RUNNER, + "psql", "-h", DB, "-U", "postgres", "-d", db, "-tAc", sql, + ]); + return r.stdout.trim(); +} + +/** + * Replace the stubbed `doveadm` so its failure mode can be driven deliberately. + * + * `%b`, not `%s`: printf only interprets `\n` in the FORMAT for %b, so `%s` wrote the + * two-character sequence and produced a one-line file that is not a runnable script. + * That silently turned every case into the "doveadm is broken" case. + */ +async function setDoveadmStub(body: string): Promise { + await docker([ + "exec", RUNNER, "bash", "-c", + `printf '%b\\n' ${JSON.stringify(body)} > /usr/bin/doveadm && chmod +x /usr/bin/doveadm`, + ]); + // Prove the stub is executable and behaves, so a broken stub can never masquerade + // as a finding about the script under test. + await docker(["exec", RUNNER, "/usr/bin/doveadm", "pw", "-s", "SSHA512", "-p", "x"], { + allowFail: true, + }); +} + +describeDockerE2E("mail engine DB bootstrap (real postgres)", () => { + beforeAll(async () => { + await requireDocker(); + + await docker(["network", "create", NET]); + + // vmail is PRE-CREATED here exactly as the real sidecar does it (POSTGRES_DB), + // because the script loads schema into it rather than creating it. + await docker([ + "run", "-d", "--name", DB, "--network", NET, + "--env", `POSTGRES_PASSWORD=${PG_PASSWORD}`, + "--env", "POSTGRES_DB=vmail", + DB_IMAGE, + ]); + + await docker([ + "run", "-d", "--name", RUNNER, "--network", NET, + "--entrypoint", "sleep", RUNNER_IMAGE, "3600", + ]); + + // The REAL script and the REAL iRedMail SQL samples. + await docker(["exec", RUNNER, "mkdir", "-p", "/opt/openship-mail", "/opt/iRedMail-engine"]); + await docker(["cp", join(EMAIL_DIR, "docker/db-bootstrap.sh"), `${RUNNER}:${SCRIPT_IN_RUNNER}`]); + await docker(["cp", join(EMAIL_DIR, "engine/samples"), `${RUNNER}:/opt/iRedMail-engine/samples`]); + + await setDoveadmStub(`#!/bin/sh\necho '${STUB_HASH}'`); + }, 300_000); + + afterAll(async () => { + await docker(["rm", "-f", RUNNER], { allowFail: true }); + await docker(["rm", "-f", DB], { allowFail: true }); + await docker(["network", "rm", NET], { allowFail: true }); + }, 120_000); + + // Ordered on purpose: each case leaves the database in the state the next expects, + // and the doveadm case must run while the schema is still absent. + it("waits for a database that is not ready yet instead of failing against it", async () => { + // The runner and the sidecar started together, so this first call races postgres's + // own initdb — which is the GH-562 race, reproduced rather than simulated. The old + // `nc -z` probe returned as soon as the port bound and the bootstrap proceeded. + // Nothing is asserted about timing; the point is that it SUCCEEDS below. + const r = await runBootstrap(); + expect(r.log).toMatch(/waiting for the mail database|mail database answered|bootstrapping/i); + expect(r.code).toBe(0); + }, 300_000); + + it("seeded the first domain and a postmaster with a NON-EMPTY password", async () => { + expect(await query("SELECT count(*) FROM domain")).toBe("1"); + expect(await query("SELECT domain FROM domain LIMIT 1")).toBe(FIRST_DOMAIN); + + // The whole point of the hash validation: this column must never be blank. + const blank = await query( + `SELECT count(*) FROM mailbox WHERE coalesce(password,'') = ''`, + ); + expect(blank).toBe("0"); + expect(await query(`SELECT password FROM mailbox WHERE username = 'postmaster@${FIRST_DOMAIN}'`)) + .toBe(STUB_HASH); + }, 60_000); + + it("is idempotent: a second run skips instead of re-seeding", async () => { + const r = await runBootstrap(); + expect(r.code).toBe(0); + expect(r.log).toMatch(/already present — skipping/i); + expect(await query("SELECT count(*) FROM domain")).toBe("1"); + }, 120_000); + + it("FAILS instead of reporting success when doveadm produces nothing", async () => { + // Reproduces a mail image without doveadm. Before the fix this seeded an empty + // password and exited 0; the operator learned about it when auth silently failed. + await setDoveadmStub("#!/bin/sh\nexit 127"); + try { + // Drop the gate so the run gets past the idempotency check to the hash step. + await docker(["exec", "--env", `PGPASSWORD=${PG_PASSWORD}`, RUNNER, + "psql", "-h", DB, "-U", "postgres", "-d", "vmail", "-c", "DROP TABLE mailbox CASCADE"]); + + const r = await runBootstrap(); + + expect(r.code).not.toBe(0); + expect(r.log).toMatch(/doveadm pw produced no output|is doveadm installed/i); + // And it must not have claimed success on the way out. + expect(r.log).not.toMatch(/DB bootstrap complete/); + } finally { + await setDoveadmStub(`#!/bin/sh\necho '${STUB_HASH}'`); + } + }, 180_000); + + it("FAILS with a diagnosable message when the database never answers", async () => { + const r = await runBootstrap({ + // TEST-NET-3 drops rather than refuses, which is the realistic bad case (a + // firewall, a wrong host). It is also the case that proves PGCONNECT_TIMEOUT is + // doing its job: without it libpq blocks for the OS default and the wait budget + // below never gets a second iteration. + OPENSHIP_MAIL_DB_HOST: "203.0.113.1", + OPENSHIP_MAIL_DB_WAIT_SECS: "4", + PGCONNECT_TIMEOUT: "2", + }); + + expect(r.code).not.toBe(0); + expect(r.log).toMatch(/did not accept queries within 4s/); + // The message has to point somewhere. A bare "failed" is what made this a + // multi-hour hunt in the field. + expect(r.log).toMatch(/docker logs openship-mail-db/); + expect(r.log).not.toMatch(/DB bootstrap complete/); + }, 180_000); + + it("negative control: the harness can still observe a failing script", async () => { + // A container test that quietly stopped running the script would look exactly + // like a passing one. Prove a non-zero exit is actually detected. + const r = await docker(["exec", RUNNER, "bash", "-c", "exit 3"], { allowFail: true }); + expect(r.code).toBe(3); + }, 60_000); +}); diff --git a/apps/api/test/e2e/update-from-previous-release.e2e.test.ts b/apps/api/test/e2e/update-from-previous-release.e2e.test.ts new file mode 100644 index 000000000..fa1865e61 --- /dev/null +++ b/apps/api/test/e2e/update-from-previous-release.e2e.test.ts @@ -0,0 +1,444 @@ +/** + * THE UPDATE ITSELF: last release's stack, real data in it, recreated onto this + * release's image. Real daemon, real Postgres, real containers. + * + * This is the case every other suite in the repo approximates and none of them runs. + * `migrate-chain` (packages/db) proves the migration chain applies to a populated + * database, but on PGlite — Postgres compiled to WASM, close but not the + * `postgres:16-alpine` an operator actually has. Nothing anywhere booted the PREVIOUS + * RELEASE and then updated it, which is the only sequence an existing user ever + * performs, and the one that produced "openship-api-1 enters a crash loop after + * `openship update`" from an operator whose 0.6.1 install was already unhealthy. + * + * What it does: + * 1. Brings up postgres + redis + the PREVIOUS release's api image, waits for + * /api/health, and confirms that api actually migrated the database. + * 2. Seeds rows into core tables, so what follows runs against a NON-EMPTY schema. + * 3. Repins the api image to THIS release's and runs the same command + * `openship update` runs — `up -d --force-recreate api` — against the same + * volume, same .env, same network. + * 4. Asserts the new api reaches /api/health, applied the remaining migrations, + * kept every seeded row, and did NOT bounce on the way (RestartCount 0 — a + * container that crash-looped its way to healthy is the failure being hunted). + * + * Deliberately narrower than `openship up`: postgres + redis + api, not edge/mail/ + * dashboard. The compose file below mirrors the shipped template for those three + * services (apps/cli/src/lib/compose.ts) — how the CLI GENERATES that file is covered + * by apps/cli/test/unit; what this owns is whether the update survives. + * + * Two inputs, because a checkout has neither side of an upgrade in it: + * OPENSHIP_E2E_NEW_API_IMAGE the api image for the NEW side. CI passes the digest + * `build-images` just pushed, so the bytes under test are + * the bytes about to be tagged. Unset → built from this + * checkout (slow, but makes the test runnable anywhere). + * OPENSHIP_E2E_OLD_VERSION the release to upgrade FROM. Unset → the newest + * `v*.*.*` tag that isn't the one at HEAD. + * + * Opt-in via `E2E_SCOPE=update` (see vitest.e2e.config.ts): it pulls a published + * release, which a plain local checkout has no reason to do on every run. + */ + +import { beforeAll, afterAll, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:net"; +import { describeDockerE2E, requireDocker } from "../helpers/docker-e2e"; + +const REGISTRY = process.env.OPENSHIP_E2E_IMAGE_REGISTRY ?? "ghcr.io/oblien"; +const PROJECT = `openship-e2e-update-${process.pid}`; +const SEED_ID = "e2e-upgrade-seed"; +const SEEDED_TABLES = ["organization", "project", "deployment", "servers"]; + +/** Long enough for a cold pull + initdb + the whole migration chain on a CI runner. */ +const BOOT_TIMEOUT_MS = 240_000; + +/** + * Budgets, as a hierarchy rather than three independent numbers. + * + * The first version of this file had them uncoordinated, and the arithmetic bit + * immediately: 3 pull attempts × 10min == the 30min `beforeAll` ceiling exactly, so on a + * slow link the setup could burn its ENTIRE budget fetching and leave nothing for the + * upgrade it exists to run — which is precisely what happened (three attempts killed + * mid-download, zero seconds spent testing). Fetching must be a fraction of setup, and + * visibly so. + */ +const PULL_TIMEOUT_MS = 600_000; +const PULL_ATTEMPTS = 2; +const BUILD_TIMEOUT_MS = 1_800_000; +/** Fetching (pull + a possible local build) plus boot, with room left over. */ +const SETUP_TIMEOUT_MS = PULL_TIMEOUT_MS * PULL_ATTEMPTS + BUILD_TIMEOUT_MS + BOOT_TIMEOUT_MS; + +let workdir = ""; +let apiPort = 0; +let oldVersion = ""; +let newApiImage = ""; +let migrationsAfterOld = 0; +let oldApiImageId = ""; + +function sh( + cmd: string, + args: string[], + opts: { cwd?: string; timeoutMs?: number } = {}, +): { code: number; stdout: string; stderr: string } { + const res = spawnSync(cmd, args, { + cwd: opts.cwd, + encoding: "utf8", + timeout: opts.timeoutMs ?? 300_000, + maxBuffer: 32 * 1024 * 1024, + }); + return { code: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" }; +} + +function compose(args: string[]): { code: number; stdout: string; stderr: string } { + return sh("docker", ["compose", "-p", PROJECT, ...args], { cwd: workdir }); +} + +/** Fail with the command's own output — a red release gate must say why on line one. */ +function composeOrThrow(args: string[], what: string): string { + const r = compose(args); + if (r.code !== 0) { + throw new Error(`${what} failed (exit ${r.code})\n--- stdout ---\n${r.stdout}\n--- stderr ---\n${r.stderr}`); + } + return r.stdout; +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.once("error", reject); + srv.listen(0, "127.0.0.1", () => { + const port = (srv.address() as { port: number }).port; + srv.close(() => resolve(port)); + }); + }); +} + +/** + * The release to upgrade from: newest `v*.*.*` tag that isn't the one being built. + * Prereleases are skipped — an rc is not what an operator is upgrading from. + */ +function resolvePreviousVersion(): string { + const override = process.env.OPENSHIP_E2E_OLD_VERSION?.trim(); + if (override) return override.replace(/^v/, ""); + + const atHead = new Set( + sh("git", ["tag", "--points-at", "HEAD"]).stdout.split("\n").map((t) => t.trim()).filter(Boolean), + ); + const tags = sh("git", ["tag", "--list", "v*.*.*", "--sort=-v:refname"]).stdout + .split("\n") + .map((t) => t.trim()) + .filter((t) => t && !t.includes("-") && !atHead.has(t)); + + if (!tags.length) { + throw new Error( + "No previous v*.*.* tag to upgrade from. CI must check out with fetch-depth: 0, " + + "or set OPENSHIP_E2E_OLD_VERSION.", + ); + } + return tags[0]!.replace(/^v/, ""); +} + +/** + * Pull the old side up front, with retries. + * + * Registry reads flake — a `manifest inspect` against a tag that demonstrably exists + * returned "not found" once while this was being written. A gate that blocks releases + * must not turn that into a red release, but it must not paper over a genuinely missing + * image either: retry, then fail naming the ref and the override. Deliberately NOT + * "walk back to an older release that does resolve" — that would quietly test an + * upgrade nobody asked about. + */ +function pullOrThrow(ref: string, attempts = PULL_ATTEMPTS): void { + let last = ""; + for (let i = 1; i <= attempts; i += 1) { + const r = sh("docker", ["pull", ref], { timeoutMs: PULL_TIMEOUT_MS }); + if (r.code === 0) return; + last = `${r.stdout}\n${r.stderr}`.trim(); + } + throw new Error( + `Could not pull ${ref} after ${attempts} attempts — the previous release's image has to ` + + `exist for there to be an upgrade to test. Override with OPENSHIP_E2E_OLD_VERSION ` + + `if this release genuinely has no published predecessor.\n${last}`, + ); +} + +/** + * The api image for the new side. CI hands us the digest `build-images` pushed; without + * one we build this checkout, so the test is runnable on a laptop. + */ +function resolveNewApiImage(): string { + const override = process.env.OPENSHIP_E2E_NEW_API_IMAGE?.trim(); + if (override) return override; + + const tag = `openship/e2e-update-api:${process.pid}`; + const repoRoot = join(import.meta.dirname, "..", "..", "..", ".."); + const built = sh( + "docker", + ["build", "-f", join("apps", "api", "Dockerfile"), "-t", tag, "."], + { cwd: repoRoot, timeoutMs: BUILD_TIMEOUT_MS }, + ); + if (built.code !== 0) { + throw new Error( + `Could not build the api image for the new side.\n${built.stdout}\n${built.stderr}`, + ); + } + return tag; +} + +/** + * postgres + redis + api, mirroring the shipped template: PGDATA in a SUBDIRECTORY of + * the volume (#350), a pg_isready healthcheck, and — the part this test exists to + * exercise — `depends_on: {condition: service_healthy}` on the api. + * + * The api image is a variable so phase 2 can repin it the way `openship update` repins + * OPENSHIP_VERSION, and recreate against the same volume. + */ +const COMPOSE_YAML = `services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + PGDATA: /var/lib/postgresql/data/pgdata + POSTGRES_USER: \${POSTGRES_USER} + POSTGRES_PASSWORD: \${POSTGRES_PASSWORD} + POSTGRES_DB: \${POSTGRES_DB} + expose: ["5432"] + volumes: [postgres_data:/var/lib/postgresql/data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"] + interval: 5s + timeout: 3s + retries: 12 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + expose: ["6379"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 12 + + api: + image: \${OPENSHIP_API_IMAGE} + restart: unless-stopped + ports: ["127.0.0.1:\${API_PORT}:\${API_PORT}"] + volumes: + - /var/run/docker.sock:/var/run/docker.sock + env_file: [.env] + environment: + NODE_ENV: production + PORT: "\${API_PORT}" + DATABASE_URL: postgresql://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@postgres:5432/\${POSTGRES_DB} + REDIS_URL: redis://redis:6379 + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_healthy } + +volumes: + postgres_data: +`; + +function writeEnv(apiImage: string): void { + writeFileSync( + join(workdir, ".env"), + [ + `OPENSHIP_API_IMAGE=${apiImage}`, + `API_PORT=${apiPort}`, + "POSTGRES_USER=openship", + "POSTGRES_PASSWORD=e2e-upgrade-password", + "POSTGRES_DB=openship", + // Stable across both phases, exactly as `openship update` carries them forward: + // a new BETTER_AUTH_SECRET would make every stored env var undecryptable (#488). + "BETTER_AUTH_SECRET=e2e0000000000000000000000000000000000000000000000000000000000000f", + "INTERNAL_TOKEN=e2e-internal-token", + "", + ].join("\n"), + ); +} + +/** One psql statement batch as the cluster superuser. */ +function psql(sql: string): string { + const r = compose([ + "exec", + "-T", + "postgres", + "psql", + "-v", + "ON_ERROR_STOP=1", + "-U", + "openship", + "-d", + "openship", + "-tAc", + sql, + ]); + if (r.code !== 0) { + throw new Error(`psql failed (exit ${r.code})\nSQL: ${sql}\n${r.stdout}\n${r.stderr}`); + } + return r.stdout.trim(); +} + +function appliedMigrations(): number { + return Number(psql(`select count(*) from drizzle."__drizzle_migrations"`)); +} + +/** + * Insert one row into `table`, with the INSERT built from the schema AS THE OLD RELEASE + * LEFT IT. Introspection rather than a column list, for the same reason as the + * migrate-chain suite: the old side moves every release, and a hardcoded list would + * break on the first migration that touched any of these tables. + */ +function seedRow(table: string): void { + psql(` + SET session_replication_role = replica; + DO $seed$ + DECLARE cols text; vals text; + BEGIN + SELECT string_agg(quote_ident(column_name), ', ' ORDER BY ordinal_position), + string_agg( + CASE + WHEN column_name = 'id' THEN quote_literal('${SEED_ID}') + WHEN data_type IN ('text','character varying','character') THEN quote_literal('seed') + WHEN data_type = 'uuid' THEN quote_literal('00000000-0000-0000-0000-000000000001') + WHEN data_type LIKE 'timestamp%' OR data_type = 'date' THEN 'now()' + WHEN data_type = 'boolean' THEN 'false' + WHEN data_type IN ('integer','bigint','smallint','numeric','real','double precision') THEN '0' + WHEN data_type IN ('json','jsonb') THEN quote_literal('{}') + WHEN data_type = 'ARRAY' THEN quote_literal('{}') + ELSE quote_literal('seed') + END, ', ' ORDER BY ordinal_position) + INTO cols, vals + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = '${table}' + AND is_generated = 'NEVER' + AND identity_generation IS NULL + -- id is forced in even when it has a default: the assertions find the row by it. + AND (column_name = 'id' OR (is_nullable = 'NO' AND column_default IS NULL)); + EXECUTE format('INSERT INTO %I (%s) VALUES (%s)', '${table}', cols, vals); + END + $seed$; + `); +} + +function rowCount(table: string): number { + return Number(psql(`select count(*) from "${table}" where id = '${SEED_ID}'`)); +} + +async function waitForHealth(label: string): Promise { + const deadline = Date.now() + BOOT_TIMEOUT_MS; + let lastErr = ""; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://127.0.0.1:${apiPort}/api/health`); + if (res.ok) return; + lastErr = `HTTP ${res.status}`; + } catch (err) { + lastErr = err instanceof Error ? err.message : String(err); + } + await new Promise((r) => setTimeout(r, 1_000)); + } + // The api's own logs, or the failure names nothing actionable. + const logs = compose(["logs", "--tail", "60", "api"]).stdout; + throw new Error( + `${label}: /api/health never became ready within ${BOOT_TIMEOUT_MS / 1000}s ` + + `(last: ${lastErr})\n--- api logs ---\n${logs}`, + ); +} + +function apiContainerId(): string { + const id = compose(["ps", "-q", "api"]).stdout.trim().split("\n")[0] ?? ""; + expect(id, "api container should exist").not.toBe(""); + return id; +} + +function apiRestartCount(): number { + return Number( + sh("docker", ["inspect", "--format", "{{.RestartCount}}", apiContainerId()]).stdout.trim(), + ); +} + +/** + * The image the api container is actually RUNNING, as a content ID. + * + * Compared before/after rather than the image reference: both sides are the same repo, + * so a name check ("does it contain openship-api") passes identically for the old + * release and proves nothing about which one is up. + */ +function apiImageId(): string { + return sh("docker", ["inspect", "--format", "{{.Image}}", apiContainerId()]).stdout.trim(); +} + +describeDockerE2E("update from the previous release", () => { + beforeAll(async () => { + await requireDocker(); + + oldVersion = resolvePreviousVersion(); + apiPort = await freePort(); + workdir = mkdtempSync(join(tmpdir(), "openship-e2e-update-")); + writeFileSync(join(workdir, "docker-compose.yml"), COMPOSE_YAML); + + // ── Phase 1: the previous release, exactly as an operator is running it now. + pullOrThrow(`${REGISTRY}/openship-api:${oldVersion}`); + writeEnv(`${REGISTRY}/openship-api:${oldVersion}`); + composeOrThrow(["up", "-d"], `bringing up the ${oldVersion} stack`); + await waitForHealth(`previous release (${oldVersion})`); + + migrationsAfterOld = appliedMigrations(); + oldApiImageId = apiImageId(); + + // ── Phase 2's precondition: rows, so the migrations below run against data. + for (const table of SEEDED_TABLES) seedRow(table); + + newApiImage = resolveNewApiImage(); + }, SETUP_TIMEOUT_MS); + + afterAll(() => { + if (workdir) { + compose(["down", "-v", "--remove-orphans"]); + rmSync(workdir, { recursive: true, force: true }); + } + }, 300_000); + + it("the previous release migrated the database and holds the seeded rows", () => { + // Anti-vacuity: if the old api never migrated, or the seeding quietly did nothing, + // then "the upgrade worked" below would be a statement about an empty database. + expect(migrationsAfterOld).toBeGreaterThan(10); + for (const table of SEEDED_TABLES) { + expect(rowCount(table), `${table} was not seeded on ${oldVersion}`).toBe(1); + } + }); + + it("recreates onto this release's image and comes up healthy, with the data intact", async () => { + // The update: repin the image and force-recreate ONLY the api, which is what + // `openship update` does (composeUpdate → composeUp → up -d --force-recreate). + writeEnv(newApiImage); + composeOrThrow(["up", "-d", "--force-recreate", "api"], "recreating the api onto the new image"); + + await waitForHealth("new release"); + + // Migrations moved forward (or were already complete) and nothing was lost. + const after = appliedMigrations(); + expect(after).toBeGreaterThanOrEqual(migrationsAfterOld); + for (const table of SEEDED_TABLES) { + expect(rowCount(table), `${table} lost its row across the update`).toBe(1); + } + + // Healthy is not enough: `restart: unless-stopped` will bounce a container until + // something works, so a crash loop that eventually succeeded still looks healthy + // here. It must have come up on the FIRST attempt. + expect(apiRestartCount(), "the api restarted on the way up — that is the crash loop").toBe(0); + + // And the running image actually CHANGED, or everything above is a statement about + // the old release still being up. + expect(oldApiImageId, "phase 1 image id should have been captured").not.toBe(""); + expect(apiImageId(), "the api is still running the previous release's image").not.toBe( + oldApiImageId, + ); + }, 600_000); +}); diff --git a/apps/api/test/lib/call-source.test.ts b/apps/api/test/lib/call-source.test.ts index b03b840be..1123f64ef 100644 --- a/apps/api/test/lib/call-source.test.ts +++ b/apps/api/test/lib/call-source.test.ts @@ -3,8 +3,11 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { ambientCallSource, + internalClientHeader, internalSourceHeader, + isAuditClientId, isAuditSource, + resolveCallClientId, resolveCallSource, runWithCallSource, } from "../../src/lib/call-source"; @@ -38,6 +41,20 @@ async function sourceFor(opts: { return ((await res.json()) as { source: string }).source; } +/** Resolve the source CLIENT for one synthetic request. */ +async function clientFor(opts: { + headers?: Record; + ctx?: Record; +}): Promise { + const app = new Hono(); + app.get("/probe", (c: Context) => { + if (opts.ctx) c.set("ctx" as never, opts.ctx as never); + return c.json({ client: resolveCallClientId(c) }); + }); + const res = await app.request("/probe", { headers: opts.headers }); + return ((await res.json()) as { client: string | null }).client; +} + const CLI_UA = "openship-cli/0.5.0"; const BROWSER_UA = "Mozilla/5.0 (Macintosh)"; @@ -163,6 +180,55 @@ describe("ambient source for emitters outside the handler chain", () => { }); }); +describe("which client of the surface", () => { + it("trusts the client id the MCP dispatcher signs", async () => { + expect(await clientFor({ headers: internalClientHeader("oauth:cli_abc") })).toBe("oauth:cli_abc"); + expect(await clientFor({ headers: internalClientHeader("pat:pat_123") })).toBe("pat:pat_123"); + }); + + it("is null when nobody claimed one — never guessed from the credential", async () => { + // A browser and the CLI have one client per request; a value here would be + // invented rather than reported. + expect(await clientFor({ ctx: cookieCtx })).toBeNull(); + expect(await clientFor({ ctx: patCtx, headers: { "user-agent": CLI_UA } })).toBeNull(); + }); + + it("ignores a forged claim — attribution a caller can write is worse than none", async () => { + const forged = { "x-openship-call-client": "oauth:someone-elses-agent.deadbeefdeadbeef" }; + expect(await clientFor({ headers: forged })).toBeNull(); + expect(await clientFor({ headers: { "x-openship-call-client": "oauth:x" } })).toBeNull(); + }); + + it("rejects a correctly-signed id that is not a principal id", async () => { + // The nonce proves we sent it; it does not prove we assembled it from + // something the column should hold, and the value reaches the audit UI. + const nonce = internalSourceHeader("mcp")["x-openship-call-source"]!.split(".").pop()!; + for (const bad of ["cli_abc", "oauth:", "session:abc", `oauth:${"x".repeat(129)}`, "oauth:a b"]) { + expect(await clientFor({ headers: { "x-openship-call-client": `${bad}.${nonce}` } }), bad).toBeNull(); + } + }); + + it("travels independently of the source claim", async () => { + // The dispatcher sends both; a request carrying only one is still coherent. + expect( + await sourceFor({ headers: internalClientHeader("oauth:cli_abc"), ctx: patCtx }), + ).toBe("api"); + expect(await clientFor({ headers: internalSourceHeader("mcp") })).toBeNull(); + }); +}); + +describe("isAuditClientId", () => { + it("accepts the two principal-id shapes and nothing else", () => { + expect(isAuditClientId("oauth:cli_abc")).toBe(true); + expect(isAuditClientId("pat:pat_9f2.a-b")).toBe(true); + expect(isAuditClientId("oauth:")).toBe(false); + expect(isAuditClientId("pat")).toBe(false); + expect(isAuditClientId("user:u1")).toBe(false); + expect(isAuditClientId("")).toBe(false); + expect(isAuditClientId(null)).toBe(false); + }); +}); + describe("isAuditSource", () => { it("accepts the stored values and nothing else", () => { for (const s of ["dashboard", "mcp", "cli", "api", "webhook", "system"]) { diff --git a/apps/api/test/lib/domain-claims.test.ts b/apps/api/test/lib/domain-claims.test.ts new file mode 100644 index 000000000..54960191d --- /dev/null +++ b/apps/api/test/lib/domain-claims.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The GENERAL question the general routing paths ask. + * + * Both of them — the deploy path (`lib/routing-domains`) and the service-domain edit path + * (`modules/domains/domain.service`) — used to call the MAIL predicate directly, each + * carrying its own copy of the mail-specific reasoning inside logic that is otherwise + * subsystem-blind. `domain.owner_type` was already general (`project`, `webhook`, `mail`); + * the code just had no matching question. + * + * What is pinned here is the DISPATCH, not mail's rules — those stay in + * mail-host-claim.test.ts. Namely: a hostname no subsystem speaks for is refused without + * touching the database, a claim's answer is passed through in both directions, and one + * claim throwing cannot turn the caller's conflict error into a 500. + */ + +const { findByDomain, getServer, findProject } = vi.hoisted(() => ({ + findByDomain: vi.fn(), + getServer: vi.fn(), + findProject: vi.fn(), +})); + +vi.mock("@repo/db", () => ({ + repos: { + mailServer: { findByDomain }, + server: { get: getServer }, + project: { findById: findProject }, + }, +})); + +import { routableWithoutOwnership } from "../../src/lib/domain-claims"; + +const MAIL_ROW = { + hostname: "mail.example.com", + ownerType: "mail", + projectId: null, +} as never; + +beforeEach(() => { + vi.clearAllMocks(); + findByDomain.mockResolvedValue({ + serverId: "srv_mail", + domain: "example.com", + webmailProjectId: "prj_webmail", + }); + getServer.mockResolvedValue({ id: "srv_mail", organizationId: "org_1" }); + findProject.mockResolvedValue({ id: "prj_webmail", organizationId: "org_1" }); +}); + +describe("routableWithoutOwnership", () => { + it("passes a registered claim's yes through", async () => { + expect(await routableWithoutOwnership("mail.example.com", "prj_webmail", MAIL_ROW)).toBe(true); + }); + + it("passes a registered claim's no through", async () => { + // Same hostname, a project the mail server does not link to. The dispatcher must not + // soften a claim's refusal — the caller's hijack guard depends on getting `false`. + expect(await routableWithoutOwnership("mail.example.com", "prj_other", MAIL_ROW)).toBe(false); + }); + + it("refuses a hostname no subsystem speaks for, without a query", async () => { + // The general paths call this for EVERY hostname on every deploy and domain edit, so + // "no claim applies" has to be the cheap answer. If a claim ever starts hitting the + // database before ruling itself out, this catches it as a cost regression. + expect(await routableWithoutOwnership("app.example.com", "prj_webmail", null)).toBe(false); + expect(findByDomain).not.toHaveBeenCalled(); + expect(getServer).not.toHaveBeenCalled(); + }); + + it("still answers for a hostname with no row at all", async () => { + // A claim can legitimately apply where nothing was ever recorded (mail's cert row is + // written best-effort). If the dispatcher required a row, the caller would fall + // through and MINT a project-owned row for the very host the claim protects. + expect(await routableWithoutOwnership("mail.example.com", "prj_webmail", null)).toBe(true); + }); + + it("does not let a claim's failure become the caller's error", async () => { + // Consulted on the way to a ConflictError. A thrown lookup must leave that refusal + // standing, not replace a clear 409 with a 500 on an unrelated deploy. + findByDomain.mockRejectedValue(new Error("db down")); + await expect( + routableWithoutOwnership("mail.example.com", "prj_webmail", MAIL_ROW), + ).resolves.toBe(false); + }); +}); diff --git a/apps/api/test/lib/env-reveal-gating.test.ts b/apps/api/test/lib/env-reveal-gating.test.ts index 379b1f1d8..600e1cf18 100644 --- a/apps/api/test/lib/env-reveal-gating.test.ts +++ b/apps/api/test/lib/env-reveal-gating.test.ts @@ -25,17 +25,20 @@ describe("#336 env reveal is write-gated; masked reads need only read", () => { return isPublicSpec(spec) ? "PUBLIC" : spec.tag; }; - // Reveal endpoints — write-gated. + // Reveal endpoints — write-gated. All POST: the requested key names travel in + // the body (not a URL that lands in proxy logs and browser history). expect( tagOf( - (r) => r.method === "GET" && r.path.endsWith("/env-reveal") && r.path.includes("/services/"), + (r) => + r.method === "POST" && r.path.endsWith("/env-reveal") && r.path.includes("/services/"), "service env-reveal", ), ).toBe("project:service:write"); expect( tagOf( - (r) => r.method === "GET" && r.path.endsWith("/env-reveal") && r.path.includes("/folder/scan/"), + (r) => + r.method === "POST" && r.path.endsWith("/env-reveal") && r.path.includes("/folder/scan/"), "folder-scan env-reveal", ), ).toBe("project:write"); diff --git a/apps/api/test/lib/env-reveal-keys.test.ts b/apps/api/test/lib/env-reveal-keys.test.ts new file mode 100644 index 000000000..f298cc575 --- /dev/null +++ b/apps/api/test/lib/env-reveal-keys.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { AppError } from "@repo/core"; +import { MAX_REVEAL_KEYS, parseRevealKeys, pickRevealed } from "../../src/lib/env-reveal"; + +/** + * Per-key reveal (#336 follow-up): opening ONE row's eye must disclose ONE secret. + * Before this, every reveal endpoint answered with the source's whole env map, so + * seeing `SMTP_HOST` also handed the browser `SMTP_PASS` and 30 others. + * + * Two properties are locked in here: a reveal request must NAME what it wants + * (there is no "give me everything" shape), and the response carries nothing but + * those names. + */ +describe("env reveal keys", () => { + const env = { NODE_ENV: "production", SMTP_PASS: "s3cret", EMPTY: "" }; + + it("returns only the requested keys", () => { + expect(pickRevealed(env, ["SMTP_PASS"])).toEqual({ SMTP_PASS: "s3cret" }); + expect(pickRevealed(env, ["NODE_ENV", "EMPTY"])).toEqual({ NODE_ENV: "production", EMPTY: "" }); + }); + + it("omits keys the source doesn't have, rather than erroring", () => { + expect(pickRevealed(env, ["NOPE"])).toEqual({}); + expect(pickRevealed(null, ["NODE_ENV"])).toEqual({}); + }); + + it("never resolves a key off the prototype chain", () => { + // `key in env` would answer with Object.prototype.constructor here. + expect(pickRevealed(env, ["constructor", "toString", "__proto__"])).toEqual({}); + }); + + it("rejects a request that names nothing", () => { + for (const bad of [undefined, null, [], {}, "SMTP_PASS"]) { + expect(() => parseRevealKeys(bad)).toThrow(AppError); + } + }); + + it("rejects non-string, empty and oversized key names", () => { + expect(() => parseRevealKeys(["OK", 1])).toThrow(AppError); + expect(() => parseRevealKeys([""])).toThrow(AppError); + expect(() => parseRevealKeys(["x".repeat(513)])).toThrow(AppError); + }); + + it("bounds one request and dedupes", () => { + expect(parseRevealKeys(["A", "A", "B"])).toEqual(["A", "B"]); + expect(() => parseRevealKeys(Array.from({ length: MAX_REVEAL_KEYS + 1 }, (_, i) => `K${i}`))).toThrow( + AppError, + ); + }); + + it("answers 400, not 500 — a bad keys list is a client error", () => { + try { + parseRevealKeys([]); + expect.unreachable(); + } catch (err) { + expect((err as AppError).statusCode).toBe(400); + } + }); +}); diff --git a/apps/api/test/lib/loopback-publish.test.ts b/apps/api/test/lib/loopback-publish.test.ts index 060455907..ca36fb547 100644 --- a/apps/api/test/lib/loopback-publish.test.ts +++ b/apps/api/test/lib/loopback-publish.test.ts @@ -1,5 +1,82 @@ import { describe, it, expect } from "vitest"; -import { specContainerPort, withLoopbackPublish } from "../../src/lib/loopback-publish"; +import { + specContainerPort, + withLoopbackPublish, + withLoopbackPublishAll, + upstreamHostPortFor, +} from "../../src/lib/loopback-publish"; + +describe("a service with SEVERAL routes gets one host port per routed port", () => { + // Regression: minio routes 9001 (console) and 9000 (s3 API). Pinning only the + // first route's port and then reusing that single host port for every route made + // the `s3` subdomain serve the console. + const pinned = new Map([ + [9001, 20500], + [9000, 20501], + ]); + + it("publishes a DISTINCT loopback binding per routed container port", () => { + expect(withLoopbackPublishAll([], pinned)).toEqual([ + "127.0.0.1:20500:9001", + "127.0.0.1:20501:9000", + ]); + }); + + it("replaces a template's own binding for each routed port and keeps the rest", () => { + const out = withLoopbackPublishAll(["9000:9000", "9001:9001", "1234:1234"], pinned); + expect(out).toContain("127.0.0.1:20500:9001"); + expect(out).toContain("127.0.0.1:20501:9000"); + expect(out).toContain("1234:1234"); + expect(out.filter((s) => s.endsWith(":9000"))).toHaveLength(1); + expect(out.filter((s) => s.endsWith(":9001"))).toHaveLength(1); + }); + + it("resolves each route to ITS OWN host port, never the primary's", () => { + expect(upstreamHostPortFor({ port: 9001, pinned, primaryPort: 9001, sameService: true })).toBe( + 20500, + ); + expect(upstreamHostPortFor({ port: 9000, pinned, primaryPort: 9001, sameService: true })).toBe( + 20501, + ); + }); + + it("never lends the daemon's single reported port to a SECONDARY route", () => { + const only = new Map(); + // The primary may fall back to what the deploy reported... + expect( + upstreamHostPortFor({ + port: 9001, + pinned: only, + primaryPort: 9001, + resultHostPort: 33333, + sameService: true, + }), + ).toBe(33333); + // ...but a secondary port must resolve to nothing, so the caller uses + // container-IP addressing rather than proxying to the wrong service. + expect( + upstreamHostPortFor({ + port: 9000, + pinned: only, + primaryPort: 9001, + resultHostPort: 33333, + sameService: true, + }), + ).toBeUndefined(); + }); + + it("ignores a reported port from a DIFFERENT container", () => { + expect( + upstreamHostPortFor({ + port: 9001, + pinned: new Map(), + primaryPort: 9001, + resultHostPort: 33333, + sameService: false, + }), + ).toBeUndefined(); + }); +}); describe("specContainerPort", () => { it("parses every docker port-spec form", () => { diff --git a/apps/api/test/lib/mail-host-claim.test.ts b/apps/api/test/lib/mail-host-claim.test.ts new file mode 100644 index 000000000..818ef572f --- /dev/null +++ b/apps/api/test/lib/mail-host-claim.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The mail server's own hostname is the one host a project may route without owning a + * row — and it must be exactly one project, exactly one host. + * + * `mail.` is recorded with `ownerType='mail', project_id=NULL` so the renewal sweep + * can find its certificate. Both hijack guards compare `owner.projectId !== projectId`, + * which NULL never satisfies, so the webmail the mail module itself deploys onto that host + * was refused at install and silently unrouted at deploy (#566). + * + * What is pinned here is the TIGHTNESS. The link — `mail_servers.webmail_project_id` — is + * the authorization, not "a project that looks like a webmail", because the loose version + * would let an ordinary service edit take over the mail hostname. + */ + +const { findByDomain, getServer, findProject } = vi.hoisted(() => ({ + findByDomain: vi.fn(), + getServer: vi.fn(), + findProject: vi.fn(), +})); + +vi.mock("@repo/db", () => ({ + repos: { + mailServer: { findByDomain }, + server: { get: getServer }, + project: { findById: findProject }, + }, +})); + +import { mailHostBaseDomain } from "@repo/core"; +import { mailHostRoutableByProject } from "../../src/lib/mail-host-claim"; + +const MAIL_ROW = { + hostname: "mail.example.com", + ownerType: "mail", + projectId: null, +} as never; + +beforeEach(() => { + vi.clearAllMocks(); + findByDomain.mockResolvedValue({ + serverId: "srv_mail", + domain: "example.com", + webmailProjectId: "prj_webmail", + }); + getServer.mockResolvedValue({ id: "srv_mail", organizationId: "org_1" }); + findProject.mockResolvedValue({ id: "prj_webmail", organizationId: "org_1" }); +}); + +// The local `mailBaseDomain` this used to cover is gone: it carried its own +// `/^mail\./` regex while ~40 other sites hand-wrote the forward template, so the +// build and the parse were two independent facts. Both now derive from +// MAIL_HOST_LABEL in @repo/core — same behaviour, one definition. +describe("mailHostBaseDomain", () => { + it("strips only a leading mail label", () => { + expect(mailHostBaseDomain("mail.example.com")).toBe("example.com"); + expect(mailHostBaseDomain("MAIL.Example.COM")).toBe("example.com"); + expect(mailHostBaseDomain("example.com")).toBeNull(); + expect(mailHostBaseDomain("webmail.example.com")).toBeNull(); + }); + + it("rejects a host whose base is not a domain", () => { + // `mail.com` used to strip to the bare TLD `com` and then cost a pointless + // mail-server lookup. No install's user domain is a single label. + expect(mailHostBaseDomain("mail.com")).toBeNull(); + }); +}); + +describe("mailHostRoutableByProject", () => { + it("lets the LINKED webmail route its mail server's host", async () => { + expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", MAIL_ROW)).toBe(true); + }); + + it("answers for a mail host with no row at all", async () => { + // recordMailCertDomain is best-effort, so the row can be missing. Without this the + // caller mints a PROJECT-owned row for the mail host and the next mail install + // refuses to record its certificate against it. + expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", null)).toBe(true); + }); + + it("refuses a project that is not the linked webmail", async () => { + expect(await mailHostRoutableByProject("mail.example.com", "prj_other", MAIL_ROW)).toBe(false); + }); + + it("refuses when no webmail is linked yet", async () => { + findByDomain.mockResolvedValue({ + serverId: "srv_mail", + domain: "example.com", + webmailProjectId: null, + }); + + expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", MAIL_ROW)).toBe(false); + }); + + it("refuses across organizations even when the link points here", async () => { + // The link is a pointer the mail module writes; org authority comes from the server. + findProject.mockResolvedValue({ id: "prj_webmail", organizationId: "org_2" }); + + expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", MAIL_ROW)).toBe(false); + }); + + it("refuses a row another project already owns", async () => { + const owned = { hostname: "mail.example.com", ownerType: null, projectId: "prj_other" } as never; + + expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", owned)).toBe(false); + }); + + it("refuses a row this project already owns — that row should not exist", async () => { + const mine = { hostname: "mail.example.com", ownerType: "mail", projectId: "prj_webmail" } as never; + + expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", mine)).toBe(false); + }); + + it("has nothing to say about a hostname that is not a mail host", async () => { + expect(await mailHostRoutableByProject("webmail.example.com", "prj_webmail", null)).toBe(false); + expect(findByDomain).not.toHaveBeenCalled(); + }); + + it("refuses when no mail server owns that base domain", async () => { + findByDomain.mockResolvedValue(undefined); + + expect(await mailHostRoutableByProject("mail.nothere.com", "prj_webmail", null)).toBe(false); + }); + + // It is consulted on the way to an error, so a failed lookup must leave the caller's + // refusal in place rather than replace it with a 500. + it("refuses rather than throwing when a lookup fails", async () => { + findByDomain.mockRejectedValue(new Error("db down")); + + expect(await mailHostRoutableByProject("mail.example.com", "prj_webmail", MAIL_ROW)).toBe(false); + }); +}); diff --git a/apps/api/test/lib/mail-image-seed-mounts.test.ts b/apps/api/test/lib/mail-image-seed-mounts.test.ts new file mode 100644 index 000000000..b0a74dac8 --- /dev/null +++ b/apps/api/test/lib/mail-image-seed-mounts.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { MAIL_CONTAINER_MOUNTS } from "@repo/adapters"; + +/** + * Three shipped artifacts have to agree about the mail engine's seeded mounts, and no + * compiler checks any of it: the mount table (TypeScript), the entrypoint that copies + * baked defaults onto those mounts, and the Dockerfile that bakes what it copies FROM. + * + * Issue #565 was one broken leg of exactly that. `/var/lib/clamav` was bind-mounted + * without `seed: true`, the entrypoint never seeded it, and the Dockerfile baked nothing + * to seed it from — so the signature database the installer wrote sat in the image layer + * where the mount hid it, clamd exited 1, and supervisord's default three retries were + * spent in ~6s and marked it FATAL for the life of the container. Amavis has no backup + * scanner, so that defers all inbound mail. + * + * Building the image to catch this is not an option here: the iRedMail installer plus a + * signature fetch is minutes to tens of minutes (see test/e2e/mail-db-bootstrap.e2e.test.ts). + */ + +const EMAIL_DIR = join(import.meta.dirname, "../../../../apps/email"); +const read = (p: string) => readFileSync(join(EMAIL_DIR, p), "utf8"); + +const ENTRYPOINT = read("docker/entrypoint.sh"); +const SUPERVISORD = read("docker/supervisord.conf"); +const DOCKERFILE = read("Dockerfile"); +const BUILD_CONFIG = read("docker/build-config"); + +/** + * The `seed ` calls the entrypoint actually makes. The + * optional `|| …` tail matters: the ClamAV seed is deliberately non-fatal, and a regex + * that stopped at end-of-line would silently stop seeing it. + */ +const seedCalls = [ + ...ENTRYPOINT.matchAll(/^seed[ \t]+(\S+)[ \t]+(\S+)[ \t]*(?:\\|\|\|.*)?$/gm), +].map(([, dir, path]) => ({ dir, path })); + +/** One `[program:x]` block, up to the next section. */ +function programBlock(name: string): string { + const block = SUPERVISORD.split(/^\[/m).find((b) => b.startsWith(`program:${name}]`)); + if (!block) throw new Error(`no [program:${name}] in supervisord.conf`); + return block; +} + +function numericSetting(program: string, key: string): number { + const found = new RegExp(`^${key}=(\\d+)$`, "m").exec(programBlock(program)); + if (!found) throw new Error(`[program:${program}] has no ${key}`); + return Number(found[1]); +} + +describe("openship-mail seed wiring", () => { + it("seeds every mount marked seed:true, and marks every mount it seeds", () => { + const declared = MAIL_CONTAINER_MOUNTS.filter((m) => m.seed) + .map((m) => m.container) + .sort(); + expect(seedCalls.map((c) => c.path).sort()).toEqual(declared); + }); + + it("bakes a seed dir in the image for every seed call", () => { + // A seed call reading from a directory no build step creates is a silent no-op: + // seed() skips when the source is absent. + for (const { dir } of seedCalls) { + expect(DOCKERFILE).toContain(`/opt/openship-mail/seed/${dir}`); + } + }); + + it("seeds the ClamAV signature database and gates the build on it being there", () => { + expect(seedCalls.some((c) => c.path === "/var/lib/clamav")).toBe(true); + expect(DOCKERFILE).toContain("freshclam --datadir=/opt/openship-mail/seed/clamav"); + // The gate is what turns "shipped with no loadable database" into a build failure. + expect(DOCKERFILE).toMatch(/FATAL: no ClamAV signature database/); + // And the installer must not fetch a second copy into the mounted path. + expect(BUILD_CONFIG).toMatch(/^export FRESHCLAM_UPDATE_IMMEDIATELY='NO'$/m); + }); + + it("hands the signature mount and clamd's socket dir to the clamav user", () => { + // The mount arrives root-owned from ensure-container-mail and both daemons drop + // privileges; /var/run/clamav holds clamd's socket and freshclam's pid file, and + // nothing in a container creates it. + expect(ENTRYPOINT).toContain("chown -R clamav:clamav /var/lib/clamav"); + expect(ENTRYPOINT).toContain("/var/run/clamav"); + }); + + it("keeps the ClamAV seed non-fatal", () => { + // Copying hundreds of MB onto a bind mount can fail on a full or read-only disk. + // The entrypoint runs under `set -e`, so an unguarded seed would take Postfix and + // Dovecot down over a virus scanner. + const clamavSeed = /^seed clamav \/var\/lib\/clamav.*$/m.exec(ENTRYPOINT)?.[0] ?? ""; + expect(clamavSeed).toMatch(/\\$|\|\|/); + }); +}); + +describe("clamd's start is not terminal", () => { + it("gives clamd and freshclam far more than supervisord's default 3 retries", () => { + // 3 retries is ~6 seconds, which is how a clamd that started before its signatures + // ended up FATAL — a state supervisord never retries. + expect(numericSetting("clamav-daemon", "startretries")).toBeGreaterThan(3); + // A freshclam that FATALs on a boot with no network yet never fetches the + // signatures clamd is waiting for. + expect(numericSetting("clamav-freshclam", "startretries")).toBeGreaterThan(3); + }); + + it("starts freshclam before clamd", () => { + expect(numericSetting("clamav-freshclam", "priority")).toBeLessThan( + numericSetting("clamav-daemon", "priority"), + ); + }); + + it("still launches clamd under the name the API probes", () => { + expect(programBlock("clamav-daemon")).toContain("command=/usr/sbin/clamd"); + }); +}); diff --git a/apps/api/test/lib/notification-categories.test.ts b/apps/api/test/lib/notification-categories.test.ts index e1b51cf8c..4823ab54f 100644 --- a/apps/api/test/lib/notification-categories.test.ts +++ b/apps/api/test/lib/notification-categories.test.ts @@ -72,6 +72,7 @@ describe("notification category registry", () => { "member.added", "member.removed", "invitation.sent", + "mail.inbound_received", "billing.alert", "quota.warning", ]); diff --git a/apps/api/test/lib/server-container-session.test.ts b/apps/api/test/lib/server-container-session.test.ts new file mode 100644 index 000000000..7c12dbb81 --- /dev/null +++ b/apps/api/test/lib/server-container-session.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +/** + * The container-apply session store — the in-memory transport behind an edge/mail + * image swap, and now also the source a FLEET view reads progress and outcomes from. + * + * These pin the enumerator: what it exposes (steps and outcome, never the subscriber + * set or the log ring), and the settled window that exists because a finished apply + * is the only place a "done" beat can come from — the drift row clears its update and + * its in-progress mark in one write, so a reader watching rows only sees work vanish. + */ + +import { + advanceStep, + createContainerApplySession, + finishContainerApplySession, + getActiveContainerApplySession, + listContainerApplySessions, +} from "../../src/lib/server-container-session"; + +/** Unique per case: the store is module-global and shared across this file. */ +let next = 0; +const serverId = () => `srv_${++next}`; + +describe("listContainerApplySessions", () => { + it("reports a running apply with its component and steps", () => { + const id = serverId(); + const session = createContainerApplySession(id, "edge"); + + const mine = listContainerApplySessions().filter((s) => s.serverId === id); + expect(mine).toHaveLength(1); + expect(mine[0]).toMatchObject({ id: session.id, component: "edge", status: "running" }); + expect(mine[0]!.steps.map((s) => s.id)).toEqual(["pull", "recreate", "verify"]); + }); + + it("carries the live step model, so a reader can render progress", () => { + const id = serverId(); + const session = createContainerApplySession(id, "edge"); + advanceStep(session.id, "Updating the edge to ghcr.io/oblien/openship-edge:0.5.0"); + + const mine = listContainerApplySessions().find((s) => s.serverId === id)!; + expect(mine.steps.find((s) => s.id === "pull")!.status).toBe("running"); + }); + + it("hands out a copy — a reader can never mutate the live session's steps", () => { + const id = serverId(); + const session = createContainerApplySession(id, "edge"); + + const snap = listContainerApplySessions().find((s) => s.serverId === id)!; + snap.steps[0]!.status = "error"; + + expect(session.steps[0]!.status).toBe("pending"); + // And nothing about the transport leaks into the read shape. + expect(snap).not.toHaveProperty("subscribers"); + expect(snap).not.toHaveProperty("logs"); + expect(snap).not.toHaveProperty("donePromise"); + }); + + it("drops a finished apply unless a settled window asks for it", () => { + const id = serverId(); + const session = createContainerApplySession(id, "mail"); + finishContainerApplySession(session.id, "completed", { updated: true, down: false }); + + expect(listContainerApplySessions().find((s) => s.serverId === id)).toBeUndefined(); + + const settled = listContainerApplySessions({ settledWithinMs: 60_000 }).find( + (s) => s.serverId === id, + ); + expect(settled).toMatchObject({ status: "completed", result: { updated: true, down: false } }); + // A completed run has no step left short of done — the beat is unambiguous. + expect(settled!.steps.every((s) => s.status === "done")).toBe(true); + }); + + it("keeps the failure reason on a settled apply", () => { + const id = serverId(); + const session = createContainerApplySession(id, "edge"); + finishContainerApplySession(session.id, "failed", undefined, "could not pull the image"); + + const settled = listContainerApplySessions({ settledWithinMs: 60_000 }).find( + (s) => s.serverId === id, + ); + expect(settled).toMatchObject({ status: "failed", error: "could not pull the image" }); + }); + + it("excludes a finish that is older than the window", () => { + const id = serverId(); + const session = createContainerApplySession(id, "edge"); + finishContainerApplySession(session.id, "completed", { updated: true, down: false }); + // Backdate the finish rather than waiting for the window to lapse. + session.finishedAt = Date.now() - 120_000; + + expect( + listContainerApplySessions({ settledWithinMs: 60_000 }).find((s) => s.serverId === id), + ).toBeUndefined(); + }); + + it("agrees with the per-(server, component) lookup the stream uses", () => { + const id = serverId(); + const session = createContainerApplySession(id, "edge"); + + expect(getActiveContainerApplySession(id, "edge")?.id).toBe(session.id); + expect(getActiveContainerApplySession(id, "mail")).toBeNull(); + + finishContainerApplySession(session.id, "completed", { updated: true, down: false }); + expect(getActiveContainerApplySession(id, "edge")).toBeNull(); + }); + + it("lists a fleet's runs oldest-first, so a queue reads in the order it was taken", () => { + const a = serverId(); + const b = serverId(); + const first = createContainerApplySession(a, "edge"); + const second = createContainerApplySession(b, "edge"); + + const ids = listContainerApplySessions() + .filter((s) => s.serverId === a || s.serverId === b) + .map((s) => s.id); + expect(ids).toEqual([first.id, second.id]); + }); +}); diff --git a/apps/api/test/modules/apps/app-install-routing.test.ts b/apps/api/test/modules/apps/app-install-routing.test.ts index 66d31cb8f..b7267c33f 100644 --- a/apps/api/test/modules/apps/app-install-routing.test.ts +++ b/apps/api/test/modules/apps/app-install-routing.test.ts @@ -20,7 +20,12 @@ const { createProjectMock, createServiceMock, setEnvMock, requireCloudMock, draf vi.mock("@repo/db", () => ({ repos: { - project: { findDraftByAppTemplate: draftMock }, + project: { + findDraftByAppTemplate: draftMock, + // installApp now ends in `ensureGeneratedAppSecrets`; nothing here asserts on it. + getEnvMap: async () => ({}), + mergeEnvVars: async () => {}, + }, service: { listByProject: async () => [ { id: "svc-backend", name: "backend" }, diff --git a/apps/api/test/modules/apps/app-secret-backfill.test.ts b/apps/api/test/modules/apps/app-secret-backfill.test.ts new file mode 100644 index 000000000..bd37e754d --- /dev/null +++ b/apps/api/test/modules/apps/app-secret-backfill.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * A generated config value must EXIST on every path that reaches a container. + * + * `installApp` writes them only for the services one call creates, so an adopted draft + * — every retry after a failed first attempt — kept whatever the first pass had managed + * to write. Webmail then deployed a container with no `SESSION_ENCRYPTION_KEY`, which + * its image treats as fatal: a crash loop the deploy reported as success (issue #566). + * + * The two properties that make the repair safe are as important as the repair: + * - presence is decided from the RAW stored keys, so an undecryptable row (after a + * BETTER_AUTH_SECRET rotation) is never overwritten — `mergeEnvVars` deletes before + * it inserts, and the value it would destroy can be a database password; + * - a key the template inlines into some OTHER string is never minted, because those + * copies were written once and would keep contradicting the new value. + */ + +const { getEnvMap, mergeEnvVars, listByProject } = vi.hoisted(() => ({ + getEnvMap: vi.fn(async () => ({}) as Record), + mergeEnvVars: vi.fn(async () => {}), + listByProject: vi.fn(async () => [{ id: "svc-webmail", name: "webmail" }]), +})); + +vi.mock("@repo/db", () => ({ + repos: { + project: { getEnvMap, mergeEnvVars, findDraftByAppTemplate: vi.fn() }, + service: { listByProject }, + customAppTemplate: { findByAppId: async () => undefined, listByOrg: async () => [] }, + }, +})); + +// Stubbed for the same reason the sibling install test stubs them: importing the real +// service layer drags in lib/auth, which needs a full `schema` export off @repo/db. +vi.mock("../../../src/modules/projects/project-crud.service", () => ({ + createProject: vi.fn(), +})); +vi.mock("../../../src/modules/services/service.service", () => ({ + createService: vi.fn(), + updateService: vi.fn(), + setServiceEnvVars: vi.fn(), +})); +vi.mock("../../../src/lib/cloud/require-cloud", () => ({ requireCloud: vi.fn() })); + +import { getAppTemplate } from "@repo/core"; +import { ensureGeneratedAppSecrets } from "../../../src/modules/apps/app-install.service"; +import { decrypt, encrypt } from "../../../src/lib/encryption"; + +const WEBMAIL = getAppTemplate("webmail")!; + +/** The upsert list from the single mergeEnvVars call, keyed by env var name. */ +function upserted() { + const [, , upserts] = mergeEnvVars.mock.calls[0] as unknown as [ + string, + string, + { key: string; value: string; isSecret: boolean }[], + ]; + return new Map(upserts.map((u) => [u.key, u])); +} + +beforeEach(() => { + vi.clearAllMocks(); + getEnvMap.mockResolvedValue({}); + listByProject.mockResolvedValue([{ id: "svc-webmail", name: "webmail" }]); +}); + +describe("ensureGeneratedAppSecrets", () => { + it("mints the webmail secrets the image refuses to boot without", async () => { + const written = await ensureGeneratedAppSecrets("prj_1", WEBMAIL); + + expect(written).toContain("SESSION_ENCRYPTION_KEY"); + expect(written).toContain("BRANDING_ADMIN_TOKEN"); + const vars = upserted(); + // Stored encrypted, and a real value — a blank row boots no better than none. + expect(decrypt(vars.get("SESSION_ENCRYPTION_KEY")!.value).length).toBeGreaterThan(16); + expect(vars.get("SESSION_ENCRYPTION_KEY")!.isSecret).toBe(true); + expect(mergeEnvVars).toHaveBeenCalledTimes(1); + // Merge, never replace: the settings the app already has must survive. + expect(mergeEnvVars.mock.calls[0][3]).toEqual([]); + expect(mergeEnvVars.mock.calls[0][4]).toBe("svc-webmail"); + }); + + it("reuses a stored key instead of rotating it", async () => { + getEnvMap.mockResolvedValue({ SESSION_ENCRYPTION_KEY: "ciphertext-we-cannot-read" }); + + const written = await ensureGeneratedAppSecrets("prj_1", WEBMAIL); + + expect(written).not.toContain("SESSION_ENCRYPTION_KEY"); + expect(written).toContain("BRANDING_ADMIN_TOKEN"); + expect(upserted().has("SESSION_ENCRYPTION_KEY")).toBe(false); + }); + + /** + * The destructive case. `decryptEnvMap` DROPS keys it cannot decrypt, so deciding + * presence from decrypted values would report every secret as missing after a + * BETTER_AUTH_SECRET rotation and overwrite the only copy. + */ + it("leaves an undecryptable row alone rather than overwriting it", async () => { + getEnvMap.mockResolvedValue({ + SESSION_ENCRYPTION_KEY: "not-valid-ciphertext", + BRANDING_ADMIN_TOKEN: "also-not-valid", + }); + + expect(await ensureGeneratedAppSecrets("prj_1", WEBMAIL)).toEqual([]); + expect(mergeEnvVars).not.toHaveBeenCalled(); + }); + + it("does nothing for a service row that does not exist yet", async () => { + listByProject.mockResolvedValue([]); + + expect(await ensureGeneratedAppSecrets("prj_1", WEBMAIL)).toEqual([]); + expect(mergeEnvVars).not.toHaveBeenCalled(); + }); + + it("is a no-op for a template with no generated fields", async () => { + const plain = { ...WEBMAIL, configFields: [] }; + + expect(await ensureGeneratedAppSecrets("prj_1", plain)).toEqual([]); + expect(getEnvMap).not.toHaveBeenCalled(); + }); + + /** + * Redis inlines `VALKEY_PASSWORD` into a mounted `redis.conf`. Minting a new one here + * would write password A into the env row while the file still says B — a server + * nobody can authenticate against, with nothing logged. + */ + it("refuses to mint a key the template inlines elsewhere", async () => { + const redis = getAppTemplate("redis")!; + const inlinedKeys = (redis.configFields ?? []) + .filter((f) => f.generate) + .map((f) => f.key); + listByProject.mockResolvedValue( + (redis.services ?? []).map((s, i) => ({ id: `svc-${i}`, name: s.name })), + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const written = await ensureGeneratedAppSecrets("prj_1", redis); + + for (const key of inlinedKeys) expect(written).not.toContain(key); + expect(mergeEnvVars).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + /** + * Ghost's `ghostdb` group spans TWO services: MYSQL_ROOT_PASSWORD on ghost-db and + * database__connection__password on ghost. They are the same password, so a + * per-service view of the group repairs one half with a value the other half does not + * know — an app that cannot authenticate to its own MySQL, silently. + */ + it("gives one value to a generate group that spans services", async () => { + const ghost = getAppTemplate("ghost")!; + listByProject.mockResolvedValue( + (ghost.services ?? []).map((s) => ({ id: `svc-${s.name}`, name: s.name })), + ); + + await ensureGeneratedAppSecrets("prj_1", ghost); + + const all = new Map( + (mergeEnvVars.mock.calls as unknown as [string, string, { key: string; value: string }[]][]) + .flatMap(([, , ups]) => ups.map((u) => [u.key, decrypt(u.value)] as const)), + ); + expect(all.get("MYSQL_ROOT_PASSWORD")).toBeDefined(); + expect(all.get("MYSQL_ROOT_PASSWORD")).toBe(all.get("database__connection__password")); + }); + + /** + * Half the pair already stored: the missing half must be REPAIRED to the stored + * value, never rotated to a fresh one — rotating would break the half that works. + */ + it("repairs the missing half of a group from the stored half", async () => { + const ghost = getAppTemplate("ghost")!; + listByProject.mockResolvedValue( + (ghost.services ?? []).map((s) => ({ id: `svc-${s.name}`, name: s.name })), + ); + getEnvMap.mockImplementation(async (_p: string, _e: string, serviceId: string) => + serviceId === "svc-ghost-db" ? { MYSQL_ROOT_PASSWORD: encrypt("the-live-password") } : {}, + ); + + const written = await ensureGeneratedAppSecrets("prj_1", ghost); + + expect(written).toEqual(["database__connection__password"]); + const [, , upserts] = mergeEnvVars.mock.calls[0] as unknown as [ + string, + string, + { key: string; value: string }[], + ]; + expect(decrypt(upserts[0].value)).toBe("the-live-password"); + }); + + it("leaves a group alone when the stored half cannot be read", async () => { + const ghost = getAppTemplate("ghost")!; + listByProject.mockResolvedValue( + (ghost.services ?? []).map((s) => ({ id: `svc-${s.name}`, name: s.name })), + ); + getEnvMap.mockImplementation(async (_p: string, _e: string, serviceId: string) => + serviceId === "svc-ghost-db" ? { MYSQL_ROOT_PASSWORD: "unreadable-ciphertext" } : {}, + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Anything minted here would disagree with the copy MySQL is already using. + expect(await ensureGeneratedAppSecrets("prj_1", ghost)).toEqual([]); + expect(mergeEnvVars).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/apps/api/test/modules/audit/_harness.ts b/apps/api/test/modules/audit/_harness.ts index 621ad3391..07eff7230 100644 --- a/apps/api/test/modules/audit/_harness.ts +++ b/apps/api/test/modules/audit/_harness.ts @@ -183,6 +183,8 @@ export interface SeedEventInput { resourceType?: string | null; resourceId?: string | null; source?: string | null; + /** `oauth:` / `pat:` — which agent, for MCP rows. */ + sourceClientId?: string | null; createdAt?: Date; } @@ -203,6 +205,7 @@ export async function seedEvent(input: SeedEventInput): Promise { resourceType: input.resourceType ?? null, resourceId: input.resourceId ?? null, source: input.source ?? null, + sourceClientId: input.sourceClientId ?? null, createdAt: input.createdAt ?? new Date(), }); return id; diff --git a/apps/api/test/modules/audit/audit-agent-attribution.test.ts b/apps/api/test/modules/audit/audit-agent-attribution.test.ts new file mode 100644 index 000000000..ad56e22e9 --- /dev/null +++ b/apps/api/test/modules/audit/audit-agent-attribution.test.ts @@ -0,0 +1,289 @@ +/** + * "What did THIS agent do." + * + * `source` could already answer "what did an AI assistant do", which was enough + * while one connection per user was the norm. With two — Claude Desktop and Cursor, + * or a personal client beside a CI one — every row still read identically, so the + * question a reader actually has when something unexpected appears ("which of these + * do I revoke?") had no answer, and the log could not distinguish an agent that + * deployed on request from one looping unattended. + * + * `source_client_id` holds the canonical principal id the auth layer already mints + * (`oauth:` / `pat:`), so a name comes from a table that already + * exists. These run against the real router and a real (in-memory) Postgres because + * the parts most likely to break are the ones a mock hides: a partial index, a + * facet counted without its own filter, and two name lookups across two tables. + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { db, schema } from "@repo/db"; +import { repos } from "@repo/db"; +import { mintPatToken } from "../../../src/lib/pat"; +import { makeApp, req, seedEvent, seedOwner } from "./_harness"; +import type { SeededOwner } from "./_harness"; + +const app = makeApp(); + +let owner: SeededOwner; +let other: SeededOwner; +/** `pat:` for a static-token MCP connection — no OAuth app to name it. */ +let patClient: string; + +// The stored column holds the PREFIXED principal id; the oauth_application row is +// keyed by the bare client_id. Keeping both spellings explicit is deliberate — +// conflating them is exactly how a name lookup silently resolves to nothing. +const DESKTOP_CLIENT = "cli_desktop"; +const CURSOR_CLIENT = "cli_cursor"; +const DESKTOP = `oauth:${DESKTOP_CLIENT}`; +const CURSOR = `oauth:${CURSOR_CLIENT}`; +/** A client id with nothing behind it — a disconnected, still-audited agent. */ +const GHOST = "oauth:cli_gone"; + +/** Register an OAuth application so its clientId resolves to a display name. */ +async function seedOauthApp(clientId: string, name: string, userId: string) { + await db.insert(schema.oauthApplication).values({ + id: `app_${clientId}`, + name, + clientId, + redirectUrls: "http://localhost/callback", + type: "public", + userId, + }); +} + +beforeAll(async () => { + owner = await seedOwner("Ada Owner"); + other = await seedOwner("Other Org Owner"); + + await seedOauthApp(DESKTOP_CLIENT, "Claude Desktop", owner.userId); + await seedOauthApp(CURSOR_CLIENT, "Cursor", owner.userId); + + const pat = mintPatToken(); + const row = await repos.personalAccessToken.create({ + userId: owner.userId, + organizationId: owner.orgId, + name: "CI agent token", + tokenPrefix: pat.tokenPrefix, + tokenHash: pat.tokenHash, + readOnly: false, + scoped: false, + expiresAt: null, + }); + patClient = `pat:${row.id}`; + + // Desktop: three calls, one of them a refused write. Cursor: one read. + await seedEvent({ + organizationId: owner.orgId, + eventType: "mcp.tool_called", + actorUserId: owner.userId, + resourceType: "mcp_client", + source: "mcp", + sourceClientId: DESKTOP, + }); + await seedEvent({ + organizationId: owner.orgId, + eventType: "mcp.tool_called", + actorUserId: owner.userId, + source: "mcp", + sourceClientId: DESKTOP, + }); + await seedEvent({ + organizationId: owner.orgId, + eventType: "project:write", + actorUserId: owner.userId, + resourceType: "project", + source: "mcp", + sourceClientId: DESKTOP, + }); + await seedEvent({ + organizationId: owner.orgId, + eventType: "mcp.tool_called", + actorUserId: owner.userId, + source: "mcp", + sourceClientId: CURSOR, + }); + await seedEvent({ + organizationId: owner.orgId, + eventType: "mcp.tool_called", + actorUserId: owner.userId, + source: "mcp", + sourceClientId: patClient, + }); + await seedEvent({ + organizationId: owner.orgId, + eventType: "mcp.tool_called", + actorUserId: owner.userId, + source: "mcp", + sourceClientId: GHOST, + }); + // An MCP row from before attribution existed, plus an ordinary dashboard row. + await seedEvent({ + organizationId: owner.orgId, + eventType: "mcp.authorized", + actorUserId: owner.userId, + source: "mcp", + }); + await seedEvent({ + organizationId: owner.orgId, + eventType: "deployment.succeeded", + actorUserId: owner.userId, + source: "dashboard", + }); + // Another org's agent — must never appear, however the filter is spelled. + await seedEvent({ + organizationId: other.orgId, + eventType: "mcp.tool_called", + actorUserId: other.userId, + source: "mcp", + sourceClientId: DESKTOP, + }); +}); + +describe("filtering to one agent", () => { + it("returns only that client's rows", async () => { + const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(DESKTOP)}`, { + auth: owner.auth, + }); + expect(res.status).toBe(200); + expect(res.body.total).toBe(3); + for (const row of res.body.data) expect(row.sourceClientId).toBe(DESKTOP); + }); + + it("excludes the other agent, and MCP rows with no attribution at all", async () => { + const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(CURSOR)}`, { + auth: owner.auth, + }); + expect(res.body.total).toBe(1); + expect(res.body.data[0].eventType).toBe("mcp.tool_called"); + }); + + it("never crosses the org boundary", async () => { + // The same clientId is connected in both orgs — the only thing keeping them + // apart is the org predicate, so this is the case worth stating. + const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(DESKTOP)}`, { + auth: other.auth, + }); + expect(res.body.total).toBe(1); + expect(res.body.data[0].actorUserId).toBe(other.userId); + }); + + it("composes with the other filters", async () => { + const res = await req( + app, + "GET", + `/?sourceClientId=${encodeURIComponent(DESKTOP)}&category=agent`, + { auth: owner.auth }, + ); + // The refused-write row (project:write) is Desktop's but not in the agent + // category, so the two filters intersect rather than either winning. + expect(res.body.total).toBe(2); + }); + + it("degrades to unfiltered on a malformed id rather than 400-ing", async () => { + // Same contract as an unknown category: a stale bookmark still renders a feed. + const res = await req(app, "GET", "/?sourceClientId=not-a-principal-id", { auth: owner.auth }); + expect(res.status).toBe(200); + expect(res.body.total).toBe(8); + }); +}); + +describe("naming the agent", () => { + it("resolves an OAuth client id to the registered application name", async () => { + const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(DESKTOP)}`, { + auth: owner.auth, + }); + for (const row of res.body.data) expect(row.sourceClientName).toBe("Claude Desktop"); + }); + + it("resolves a static-token connection to the token's name", async () => { + // A PAT-backed client has no OAuth application, so the token name is the only + // label there is — and without it these rows would show a raw pat_ id. + const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(patClient)}`, { + auth: owner.auth, + }); + expect(res.body.data[0].sourceClientName).toBe("CI agent token"); + }); + + it("keeps a row whose client no longer exists, with a null name", async () => { + const res = await req(app, "GET", `/?sourceClientId=${encodeURIComponent(GHOST)}`, { + auth: owner.auth, + }); + expect(res.body.total).toBe(1); + expect(res.body.data[0].sourceClientId).toBe(GHOST); + expect(res.body.data[0].sourceClientName).toBeNull(); + }); + + it("leaves unattributed rows null instead of inventing an agent", async () => { + const res = await req(app, "GET", "/?eventType=deployment.succeeded", { auth: owner.auth }); + expect(res.body.data[0].sourceClientId).toBeNull(); + expect(res.body.data[0].sourceClientName).toBeNull(); + }); + + it("resolves both id kinds in ONE page without an N+1", async () => { + const res = await req(app, "GET", "/?source=mcp", { auth: owner.auth }); + const byClient = new Map( + res.body.data.map((r: { sourceClientId: string | null; sourceClientName: string | null }) => [ + r.sourceClientId ?? "none", + r.sourceClientName, + ]), + ); + expect(byClient.get(DESKTOP)).toBe("Claude Desktop"); + expect(byClient.get(CURSOR)).toBe("Cursor"); + expect(byClient.get(patClient)).toBe("CI agent token"); + expect(byClient.get("none")).toBeNull(); + }); +}); + +describe("the agent facet", () => { + it("lists the agents that appear, busiest first, with counts and names", async () => { + const res = await req(app, "GET", "/facets", { auth: owner.auth }); + const clients = res.body.clients as { id: string; name: string | null; count: number }[]; + expect(clients[0]).toMatchObject({ id: DESKTOP, name: "Claude Desktop", count: 3 }); + expect(clients.map((c) => c.id).sort()).toEqual([CURSOR, DESKTOP, GHOST, patClient].sort()); + expect(clients.find((c) => c.id === GHOST)).toMatchObject({ name: null, count: 1 }); + }); + + it("never lists a null client — an unattributed row is not an agent", async () => { + const res = await req(app, "GET", "/facets", { auth: owner.auth }); + const clients = res.body.clients as { id: string | null }[]; + expect(clients.every((c) => !!c.id)).toBe(true); + }); + + it("counts each agent WITHOUT its own filter, so the choice is reversible", async () => { + // The same rule the source facet follows: picking Desktop must leave Cursor + // clickable, or the filter is a one-way door. + const res = await req(app, "GET", `/facets?sourceClientId=${encodeURIComponent(DESKTOP)}`, { + auth: owner.auth, + }); + const clients = res.body.clients as { id: string; count: number }[]; + expect(clients.find((c) => c.id === CURSOR)?.count).toBe(1); + // ...while the category counts DO respect it. + const counts = Object.fromEntries( + res.body.categories.map((c: { id: string; count: number }) => [c.id, c.count]), + ); + expect(counts.agent).toBe(2); + expect(counts.deployments).toBe(0); + }); + + it("respects the other filters", async () => { + const res = await req(app, "GET", "/facets?category=deployments", { auth: owner.auth }); + expect(res.body.clients).toEqual([]); + }); + + it("is empty for an org with no agents", async () => { + const res = await req(app, "GET", "/facets", { auth: other.auth }); + expect((res.body.clients as unknown[]).length).toBe(1); // other org has its own one row + }); +}); + +describe("the agent category", () => { + it("collects the whole MCP lifecycle, tool calls included", async () => { + const res = await req(app, "GET", "/?category=agent", { auth: owner.auth }); + const types = new Set(res.body.data.map((r: { eventType: string }) => r.eventType)); + expect(types).toContain("mcp.tool_called"); + expect(types).toContain("mcp.authorized"); + // A write an agent made is filed under what it changed, not under the agent — + // that row already says "project:write" and names the project. + expect(types).not.toContain("project:write"); + }); +}); diff --git a/apps/api/test/modules/mail/dns-scan.service.test.ts b/apps/api/test/modules/mail/dns-scan.service.test.ts index d8fda6fb3..c4cc595d6 100644 --- a/apps/api/test/modules/mail/dns-scan.service.test.ts +++ b/apps/api/test/modules/mail/dns-scan.service.test.ts @@ -81,7 +81,9 @@ vi.mock("../../../src/modules/mail/mail-state", () => ({ readState: async () => state, })); -import { scanDns } from "../../../src/modules/mail/admin/dns-scan.service"; +import { scanDns, + looksSyntheticAddress, +} from "../../../src/modules/mail/admin/dns-scan.service"; beforeEach(() => { state = BASE_STATE; @@ -592,3 +594,33 @@ describe("resolver selection", () => { expect(dns.setServers).toHaveBeenCalledWith(expect.arrayContaining(["1.1.1.1"])); }); }); + +/** + * GH-240 FP2: pinning the resolver to 1.1.1.1/8.8.8.8 does not escape a fake-IP TUN. + * Clash/sing-box capture UDP:53 to ANY destination and synthesise a per-hostname address, + * so the query never leaves the machine. Comparing that handle to the real public IP told + * operators their DNS was wrong when only the scanning host could not see it. + */ +describe("synthetic address detection (GH-240)", () => { + test("recognises the fake-IP ranges proxies actually use", () => { + // Clash / sing-box default: 198.18.0.0/15 (RFC 2544). + expect(looksSyntheticAddress("198.18.0.7")).toBe(true); + expect(looksSyntheticAddress("198.19.255.254")).toBe(true); + // RFC 1112 class-E reserved. + expect(looksSyntheticAddress("240.0.0.1")).toBe(true); + // IPv6 ULA, covering sing-box's fd00::/18. + expect(looksSyntheticAddress("fd00::1")).toBe(true); + expect(looksSyntheticAddress("fc00::1")).toBe(true); + }); + + test("does not mistake a real public address for a synthetic one", () => { + // The neighbours of 198.18/15 must not be swept in. + expect(looksSyntheticAddress("198.17.0.1")).toBe(false); + expect(looksSyntheticAddress("198.20.0.1")).toBe(false); + // Ordinary public IPs, RFC1918, loopback and a real v6 address are all "not synthetic": + // 127.0.1.1 is a DIFFERENT false positive, already handled by pinning the resolver. + for (const ip of ["1.2.3.4", "203.0.113.10", "192.168.1.1", "10.0.0.1", "127.0.1.1", "2606:4700::1111"]) { + expect(looksSyntheticAddress(ip), ip).toBe(false); + } + }); +}); diff --git a/apps/api/test/modules/mail/inbound-capture.test.ts b/apps/api/test/modules/mail/inbound-capture.test.ts new file mode 100644 index 000000000..65eb3c82e --- /dev/null +++ b/apps/api/test/modules/mail/inbound-capture.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + collectorAddress, + collectorFolderPath, + collectorMailbox, + generateToken, + tokenFromBcc, +} from "../../../src/modules/mail/inbound/capture"; + +/** + * The pure half of arming. Two of these are load-bearing beyond tidiness: + * + * - token CASE. The Dovecot LDA pipe runs `flags=DRh`, whose `h` folds the DOMAIN to + * lowercase but NOT the `-m ${extension}`, while the userdb query LOWER()s the whole + * home path. A mixed-case token names a folder the computed path cannot find. + * - FOREIGN BCC detection. `recipient_bcc_domain` has one slot per domain, shared with + * operator archiving and compliance copies. Misreading somebody else's value as ours + * means overwriting it and silently switching their archiving off. + */ + +describe("token round trip", () => { + it("builds the collector address and reads the token back out", () => { + const addr = collectorAddress("Acme.COM", "abc123"); + expect(addr).toBe("openship-hook+abc123@acme.com"); + expect(tokenFromBcc(addr)).toBe("abc123"); + }); + + it("names one collector mailbox per domain, lowercased", () => { + expect(collectorMailbox("Acme.COM")).toBe("openship-hook@acme.com"); + }); + + it("generates lowercase [a-z0-9] tokens with real entropy", () => { + for (let i = 0; i < 40; i++) { + const t = generateToken(); + // Anything outside this class would name a folder we cannot find again. + expect(t).toMatch(/^[a-z0-9]+$/); + expect(t.length).toBeGreaterThanOrEqual(16); + } + // Not a constant. + expect(new Set([generateToken(), generateToken(), generateToken()]).size).toBe(3); + }); +}); + +describe("tokenFromBcc — foreign values are never ours", () => { + it("rejects an operator's own BCC", () => { + // The case that must never be overwritten: archiving, compliance, iRedAdmin-Pro. + expect(tokenFromBcc("archive@acme.com")).toBeNull(); + expect(tokenFromBcc("compliance+legal@acme.com")).toBeNull(); + expect(tokenFromBcc("openship@acme.com")).toBeNull(); + }); + + it("rejects a near-miss that only looks like ours", () => { + // A prefix match without the delimiter is a DIFFERENT mailbox. + expect(tokenFromBcc("openship-hooks+abc@acme.com")).toBeNull(); + expect(tokenFromBcc("openship-hook@acme.com")).toBeNull(); + // Empty extension carries no folder, so it is not a usable token. + expect(tokenFromBcc("openship-hook+@acme.com")).toBeNull(); + }); + + it("rejects a token with characters the LDA would not fold", () => { + expect(tokenFromBcc("openship-hook+ABC123@acme.com")).toBeNull(); + expect(tokenFromBcc("openship-hook+abc.123@acme.com")).toBeNull(); + expect(tokenFromBcc("openship-hook+abc-123@acme.com")).toBeNull(); + }); + + it("handles absent values", () => { + expect(tokenFromBcc(null)).toBeNull(); + expect(tokenFromBcc(undefined)).toBeNull(); + expect(tokenFromBcc("")).toBeNull(); + }); +}); + +describe("collectorFolderPath — Maildir++ layout", () => { + it("puts the token folder dot-prefixed under Maildir/", () => { + // mail_location = maildir:%Lh/Maildir/, home from the userdb query, and Maildir++ + // stores a top-level folder as a dot-prefixed directory beside cur/new/tmp. + expect(collectorFolderPath("/var/vmail/vmail1/acme.com/o/op/openship-hook-20260101000000/", "tok")).toBe( + "/var/vmail/vmail1/acme.com/o/op/openship-hook-20260101000000/Maildir/.tok", + ); + }); + + it("tolerates a home with or without its trailing slash", () => { + const withSlash = collectorFolderPath("/var/vmail/vmail1/a/", "t"); + const without = collectorFolderPath("/var/vmail/vmail1/a", "t"); + expect(withSlash).toBe(without); + expect(without).toBe("/var/vmail/vmail1/a/Maildir/.t"); + }); +}); diff --git a/apps/api/test/modules/mail/inbound-disarm.test.ts b/apps/api/test/modules/mail/inbound-disarm.test.ts new file mode 100644 index 000000000..e1a91b3db --- /dev/null +++ b/apps/api/test/modules/mail/inbound-disarm.test.ts @@ -0,0 +1,195 @@ +/** + * Every mutation that narrows what is watched must RELEASE the BCC it orphans. + * + * GH-559: an armed domain makes Postfix BCC a full second copy of every message into a + * collector mailbox provisioned at QUOTA 0, and the only thing that prunes those copies is + * the read job visiting that domain. Disabling a rule (or moving its scope) stopped the + * pruning while the copies kept arriving, on /var/vmail — a bind mount with no quota of its + * own. The disk fills and the mail server goes down. + * + * Only `deleteRuleHandler` released anything; `updateRuleHandler` armed on a scope change + * and disarmed nothing, and `enabled: false` disarmed nothing at all. These tests pin the + * release on each path, and pin the thing that makes it SAFE: a domain a second enabled + * rule still covers must stay armed, because capture is domain-keyed and shared. + */ +import { describe, expect, it, beforeEach, vi } from "vitest"; + +const engine = vi.hoisted(() => ({ + armed: [] as string[], + disarmed: [] as string[], + domains: ["acme.com", "beta.com"], +})); + +vi.mock("../../../src/modules/mail/inbound/capture", () => ({ + armDomain: vi.fn(async (_s: string, d: string) => { + engine.armed.push(d); + return "tok"; + }), + disarmDomain: vi.fn(async (_s: string, d: string) => { + engine.disarmed.push(d); + }), + listEngineDomains: vi.fn(async () => engine.domains), + ruleDomain: ({ scope, target }: { scope: string; target: string | null }) => + scope === "domain" ? target : scope === "mailbox" ? (target?.split("@")[1] ?? null) : null, + ForeignBccError: class extends Error {}, +})); + +vi.mock("../../../src/modules/mail/inbound/read", () => ({ + runInboundForServer: vi.fn(async () => ({ read: 0, emitted: 0, dropped: 0, errors: [] })), +})); + +/** Rule rows, keyed by id. `enabled` drives the "still wanted" computation. */ +type Row = { + id: string; + serverId: string; + organizationId: string; + scope: string; + target: string | null; + enabled: boolean; +}; +const db = vi.hoisted(() => ({ rows: new Map() })); + +vi.mock("@repo/db", () => ({ + repos: { + mailInbound: { + findById: vi.fn(async (_org: string, id: string) => db.rows.get(id)), + update: vi.fn(async (_org: string, id: string, patch: Record) => { + const cur = db.rows.get(id) as Row | undefined; + if (!cur) return undefined; + const next = { ...cur, ...patch }; + db.rows.set(id, next); + return next; + }), + remove: vi.fn(async (_org: string, id: string) => { + db.rows.delete(id); + }), + listEnabledByServer: vi.fn(async (serverId: string) => + [...db.rows.values()].filter((r) => (r as Row).serverId === serverId && (r as Row).enabled), + ), + }, + }, +})); + +vi.mock("../../../src/lib/request-context", () => ({ + getRequestContext: () => ({ organizationId: "org1" }), +})); +vi.mock("../../../src/lib/permission", () => ({ + permission: { assert: vi.fn(async () => undefined) }, +})); +vi.mock("../../../src/lib/controller-helpers", () => ({ + param: (_c: unknown, name: string) => (name === "serverId" ? "srv1" : "r1"), + isServerInOrg: vi.fn(async () => true), + assertNotCloud: () => null, +})); + +import { + updateRuleHandler, + deleteRuleHandler, +} from "../../../src/modules/mail/inbound/inbound.controller"; + +/** Minimal hono Context: a JSON body in, a captured JSON response out. */ +function ctx(body: unknown) { + const sent: { body?: unknown; status?: number } = {}; + return { + c: { + req: { json: async () => body }, + json: (b: unknown, status?: number) => { + sent.body = b; + sent.status = status ?? 200; + return sent; + }, + } as never, + sent, + }; +} + +function seed(rows: Row[]) { + db.rows.clear(); + for (const r of rows) db.rows.set(r.id, r); +} + +const RULE: Row = { + id: "r1", + serverId: "srv1", + organizationId: "org1", + scope: "domain", + target: "acme.com", + enabled: true, +}; + +beforeEach(() => { + engine.armed = []; + engine.disarmed = []; + engine.domains = ["acme.com", "beta.com"]; +}); + +describe("inbound rule mutations release the BCC they orphan (GH-559)", () => { + it("disabling a rule disarms its domain", async () => { + seed([{ ...RULE }]); + const { c } = ctx({ enabled: false }); + await updateRuleHandler(c); + + expect(engine.disarmed).toEqual(["acme.com"]); + // A disabled rule must not be left armed either. + expect(engine.armed).toEqual([]); + }); + + it("re-enabling a rule arms it again", async () => { + // The old code only armed inside the scope branch, so a resumed rule watched nothing. + seed([{ ...RULE, enabled: false }]); + const { c } = ctx({ enabled: true }); + await updateRuleHandler(c); + + expect(engine.armed).toEqual(["acme.com"]); + expect(engine.disarmed).toEqual([]); + }); + + it("retargeting a domain rule releases the domain it left", async () => { + seed([{ ...RULE }]); + const { c } = ctx({ scope: "domain", target: "beta.com" }); + await updateRuleHandler(c); + + expect(engine.armed).toEqual(["beta.com"]); + expect(engine.disarmed).toEqual(["acme.com"]); + }); + + it("keeps a domain armed when a second enabled rule still covers it", async () => { + // The invariant that makes the release safe: capture is domain-keyed and SHARED. + seed([ + { ...RULE }, + { id: "r2", serverId: "srv1", organizationId: "org1", scope: "domain", target: "acme.com", enabled: true }, + ]); + const { c } = ctx({ enabled: false }); + await updateRuleHandler(c); + + expect(engine.disarmed).toEqual([]); + }); + + it("does not release a domain another rule covers via scope:all", async () => { + seed([ + { ...RULE }, + { id: "r2", serverId: "srv1", organizationId: "org1", scope: "all", target: null, enabled: true }, + ]); + const { c } = ctx({ enabled: false }); + await updateRuleHandler(c); + + // scope:all resolves to every engine domain, acme.com included. + expect(engine.disarmed).toEqual([]); + }); + + it("deleting a rule still releases its domain", async () => { + seed([{ ...RULE }]); + const { c } = ctx({}); + await deleteRuleHandler(c); + + expect(engine.disarmed).toEqual(["acme.com"]); + }); + + it("a mailbox-scoped rule releases the domain behind the address", async () => { + seed([{ ...RULE, scope: "mailbox", target: "support@acme.com" }]); + const { c } = ctx({ enabled: false }); + await updateRuleHandler(c); + + expect(engine.disarmed).toEqual(["acme.com"]); + }); +}); diff --git a/apps/api/test/modules/mail/inbound-filter.test.ts b/apps/api/test/modules/mail/inbound-filter.test.ts new file mode 100644 index 000000000..1666de3d9 --- /dev/null +++ b/apps/api/test/modules/mail/inbound-filter.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; +import { + bareAddress, + loopGuard, + matchesPattern, + matchesRule, + parseHeaderBlock, + spamGate, +} from "../../../src/modules/mail/inbound/filter"; +import type { MailInboundRule } from "@repo/db"; + +/** + * The decisions that make inbound-mail notifications safe rather than an incident. + * + * Three failure modes are covered deliberately, because each is silent in production: + * - a loop (a notification about mail is itself mail), + * - a rule that quietly widens to every message on the server, + * - an alert per spam, because nothing upstream filters spam for us. + */ + +const HEADERS = [ + "Return-Path: ", + "From: Alice Example ", + "To: support@acme.com, ops@acme.com", + "Subject: Invoice 42 is overdue", + "Message-Id: ", + "", + "body must never be parsed", +].join("\n"); + +function rule(over: Partial = {}): MailInboundRule { + return { + scope: "domain", + target: "acme.com", + fromPattern: null, + subjectPattern: null, + enabled: true, + pausedReason: null, + ...over, + } as MailInboundRule; +} + +describe("parseHeaderBlock", () => { + it("parses the fields a decision is allowed to use, and stops at the body", () => { + const h = parseHeaderBlock(HEADERS); + expect(h.fromAddress).toBe("alice@example.com"); + expect(h.recipients).toEqual(["support@acme.com", "ops@acme.com"]); + expect(h.subject).toBe("Invoice 42 is overdue"); + expect(h.messageId).toBe(""); + // A blank line ends the block; body content must not become a "header". + expect(JSON.stringify(h)).not.toContain("body must never"); + }); + + it("unfolds continuation lines instead of truncating them", () => { + // Long To/Subject values ARE folded in real mail. A naive line split loses half the + // recipient list, which would make a mailbox-scope rule miss its own target. + const h = parseHeaderBlock( + ["Subject: a very long subject", "\tthat continues here", "To: one@acme.com,", " two@acme.com", ""].join("\n"), + ); + expect(h.subject).toBe("a very long subject that continues here"); + expect(h.recipients).toEqual(["one@acme.com", "two@acme.com"]); + }); + + it("is case-insensitive on names and keeps the FIRST occurrence", () => { + const h = parseHeaderBlock(["FROM: first@a.com", "From: second@b.com", ""].join("\n")); + expect(h.fromAddress).toBe("first@a.com"); + }); + + it("handles CRLF, so a real message and a fixture agree", () => { + const h = parseHeaderBlock("Subject: hi\r\nTo: a@b.com\r\n\r\nbody"); + expect(h.subject).toBe("hi"); + expect(h.recipients).toEqual(["a@b.com"]); + }); + + it("reads X-Spam-Flag and a float score, ignoring a malformed score", () => { + expect(parseHeaderBlock("X-Spam-Flag: YES\n").spamFlagYes).toBe(true); + expect(parseHeaderBlock("X-Spam-Flag: no\n").spamFlagYes).toBe(false); + expect(parseHeaderBlock("X-Spam-Score: 7.4\n").spamScore).toBe(7.4); + expect(parseHeaderBlock("X-Spam-Score: not-a-number\n").spamScore).toBeUndefined(); + }); +}); + +describe("bareAddress", () => { + it("preserves the empty angle pair, because <> IS the bounce signal", () => { + expect(bareAddress("<>")).toBe(""); + expect(bareAddress("Alice ")).toBe("a@b.com"); + expect(bareAddress("A@B.COM")).toBe("a@b.com"); + expect(bareAddress("not-an-address")).toBeUndefined(); + expect(bareAddress(undefined)).toBeUndefined(); + }); +}); + +describe("loopGuard — the four guards", () => { + it("drops a bounce (null envelope sender)", () => { + const h = parseHeaderBlock("Return-Path: <>\nFrom: mailer@x.com\n"); + expect(loopGuard(h)).toEqual({ drop: true, reason: "bounce" }); + }); + + it("drops anything Auto-Submitted other than 'no'", () => { + expect(loopGuard(parseHeaderBlock("Auto-Submitted: auto-generated\n")).drop).toBe(true); + expect(loopGuard(parseHeaderBlock("Auto-Submitted: auto-replied\n")).drop).toBe(true); + expect(loopGuard(parseHeaderBlock("Auto-Submitted: no\n")).drop).toBe(false); + }); + + it("drops bulk/junk precedence and list traffic", () => { + expect(loopGuard(parseHeaderBlock("Precedence: bulk\n")).reason).toBe("bulk-precedence"); + expect(loopGuard(parseHeaderBlock("Precedence: junk\n")).reason).toBe("bulk-precedence"); + expect(loopGuard(parseHeaderBlock("List-Id: \n")).reason).toBe("mailing-list"); + }); + + it("drops our OWN outbound sender — the direct self-feeding loop", () => { + // A notification delivered to an address inside a watched domain would otherwise be + // captured and emit again, forever. + const h = parseHeaderBlock("From: Openship \n"); + expect(loopGuard(h, { openshipSenders: ["NoReply@acme.com"] })).toEqual({ + drop: true, + reason: "openship-sender", + }); + }); + + it("lets ordinary human mail through", () => { + expect(loopGuard(parseHeaderBlock(HEADERS))).toEqual({ drop: false }); + }); +}); + +describe("spamGate", () => { + it("drops flagged spam when no threshold is set (the conservative default)", () => { + expect(spamGate(parseHeaderBlock("X-Spam-Flag: YES\n"), null).reason).toBe("spam-flagged"); + expect(spamGate(parseHeaderBlock("Subject: hi\n"), null).drop).toBe(false); + }); + + it("honours an explicit threshold on the score", () => { + const spammy = parseHeaderBlock("X-Spam-Flag: YES\nX-Spam-Score: 9.1\n"); + expect(spamGate(spammy, 10).drop).toBe(false); // operator asked for a loose gate + expect(spamGate(spammy, 5).drop).toBe(true); + }); + + it("catches bad-header mail, which is delivered with NO X-Spam-Flag at all", () => { + // bad_header_lover='Y' in the shipped policy, and bad-header mail is not flagged — + // so the score has to be checked independently of the flag. + const badHeader = parseHeaderBlock("X-Spam-Score: 8.0\n"); + expect(badHeader.spamFlagYes).toBe(false); + expect(spamGate(badHeader, 6.9).drop).toBe(true); + }); +}); + +describe("matchesRule — fail closed", () => { + const h = parseHeaderBlock(HEADERS); + + it("matches a domain rule against the CAPTURED domain, not a header", () => { + expect(matchesRule(rule({ scope: "domain", target: "acme.com" }), h, "acme.com")).toBe(true); + expect(matchesRule(rule({ scope: "domain", target: "acme.com" }), h, "other.com")).toBe(false); + }); + + it("matches a mailbox rule on To/Cc", () => { + expect(matchesRule(rule({ scope: "mailbox", target: "support@acme.com" }), h, "acme.com")).toBe(true); + expect(matchesRule(rule({ scope: "mailbox", target: "nobody@acme.com" }), h, "acme.com")).toBe(false); + }); + + it("matches EVERYTHING captured for scope=all", () => { + expect(matchesRule(rule({ scope: "all", target: null }), h, "anything.com")).toBe(true); + }); + + // The reason there is no CHECK constraint and this logic exists instead. + it("matches NOTHING when a mailbox/domain rule has no target", () => { + expect(matchesRule(rule({ scope: "mailbox", target: null }), h, "acme.com")).toBe(false); + expect(matchesRule(rule({ scope: "domain", target: null }), h, "acme.com")).toBe(false); + expect(matchesRule(rule({ scope: "domain", target: " " }), h, "acme.com")).toBe(false); + }); + + it("matches nothing for a scope this build does not understand", () => { + expect(matchesRule(rule({ scope: "everything", target: null }), h, "acme.com")).toBe(false); + }); + + it("respects enabled and pausedReason", () => { + expect(matchesRule(rule({ enabled: false }), h, "acme.com")).toBe(false); + expect(matchesRule(rule({ pausedReason: "rate limit" }), h, "acme.com")).toBe(false); + }); + + it("applies from and subject filters on top of scope", () => { + expect(matchesRule(rule({ fromPattern: "alice@" }), h, "acme.com")).toBe(true); + expect(matchesRule(rule({ fromPattern: "bob@" }), h, "acme.com")).toBe(false); + expect(matchesRule(rule({ subjectPattern: "overdue" }), h, "acme.com")).toBe(true); + expect(matchesRule(rule({ subjectPattern: "refund" }), h, "acme.com")).toBe(false); + }); +}); + +describe("matchesPattern", () => { + it("is case-insensitive substring with * as the only wildcard", () => { + expect(matchesPattern("INVOICE", "Invoice 42")).toBe(true); + expect(matchesPattern("inv*42", "Invoice 42")).toBe(true); + expect(matchesPattern("*@acme.com", "bob@acme.com")).toBe(true); + }); + + it("treats regex metacharacters literally, so an operator cannot inject one", () => { + // A pasted `.*` must not match everything, and a pasted `(a+)+$` must not stall the + // mail path with catastrophic backtracking. + expect(matchesPattern(".*", "anything")).toBe(false); + expect(matchesPattern("a.c", "abc")).toBe(false); + expect(matchesPattern("a.c", "a.c")).toBe(true); + expect(matchesPattern("price?", "price?")).toBe(true); + expect(() => matchesPattern("(a+)+$", "aaaaaaaaaaaaaaaaaaaaaaaaaaa!")).not.toThrow(); + }); + + it("an empty pattern is 'no filter', and a missing value never matches", () => { + expect(matchesPattern(" ", "anything")).toBe(true); + expect(matchesPattern("x", undefined)).toBe(false); + }); +}); diff --git a/apps/api/test/modules/mail/mail-backup-flavor.test.ts b/apps/api/test/modules/mail/mail-backup-flavor.test.ts new file mode 100644 index 000000000..8b4cb12e7 --- /dev/null +++ b/apps/api/test/modules/mail/mail-backup-flavor.test.ts @@ -0,0 +1,78 @@ +/** + * The backup shell has to be written for the topology it will run on (GH-563). + * + * The produce/restore commands are BAKED here and executed later by the generic + * custom_command producer over a bare SSH executor, which knows nothing about mail. So a + * containerized engine cannot be discovered at run time - the plan has to already say + * `docker exec`. It did not: it issued `sudo -u postgres pg_dump` and + * `chown -R vmail:vmail` on the host, where on a container box there is no `postgres` + * user, no `pg_dump`, and no `vmail`. The dump aborted the whole backup under `set -e`, + * and on restore the chown was masked by `|| true`, leaving maildirs root-owned and + * unreadable by Dovecot while the run reported success. + */ +import { describe, expect, it } from "vitest"; +import { buildMailBackupPayload } from "../../../src/modules/mail/admin/backup-plan"; + +const FLAGS = { messageData: true, keys: true }; + +describe("mail backup plan targets the right topology", () => { + it("[container] runs Postgres in the sidecar and chown in the engine", () => { + const { payloadConfig } = buildMailBackupPayload("example.com", FLAGS, "container"); + const { produceCommand: produce, restoreCommand: restore } = payloadConfig; + + // Nothing may assume a host-side postgres or vmail user. + expect(produce).not.toContain("sudo -u postgres"); + expect(restore).not.toContain("sudo -u postgres"); + expect(produce).toContain("docker exec openship-mail-db pg_dump -U postgres"); + + // The dump file is staged in the producer's $tmp ON THE HOST, which the sidecar + // cannot see - so the replay must arrive over stdin, and docker exec needs -i. + expect(restore).toContain("docker exec -i openship-mail-db psql -U postgres"); + expect(restore).not.toMatch(/psql[^\n]*-f "\$tmp/); + + // Ownership must be applied where the vmail user exists: the ENGINE, not the sidecar, + // and not the host. + expect(restore).toContain("docker exec openship-mail chown -R vmail:vmail /var/vmail/vmail1"); + // ...and a failure there must not be swallowed - root-owned maildirs are unreadable. + expect(restore).not.toMatch(/chown -R vmail:vmail [^\n]*\|\| true/); + + // Daemons re-read via supervisord in the container; systemctl does not exist there. + expect(restore).toContain("supervisorctl restart postfix dovecot amavis"); + expect(restore).not.toContain("systemctl reload"); + }); + + it("[host] keeps the legacy sudo/systemctl form", () => { + const { payloadConfig } = buildMailBackupPayload("example.com", FLAGS, "host"); + const { produceCommand: produce, restoreCommand: restore } = payloadConfig; + + expect(produce).toContain("sudo -u postgres pg_dump -d vmail"); + expect(restore).toContain("sudo -u postgres psql"); + expect(restore).toContain("chown -R vmail:vmail /var/vmail/vmail1"); + expect(restore).toContain("systemctl reload postfix dovecot"); + expect(produce).not.toContain("docker exec"); + expect(restore).not.toContain("docker exec"); + }); + + it("proves sudo works before relying on it to tell absent from unreadable", () => { + // The `sudo -n test`/`sudo -n cp` reads below only distinguish those two cases if sudo + // is known to work. That used to be guaranteed by `sudo -u postgres pg_dump` running + // first under `set -e`; routing the dump through the sidecar removed the guarantee on + // container boxes, so the probe has to be explicit - otherwise a failing `test` reads + // as absence and the archive is stamped `keys: true` with no keys in it. + for (const flavor of ["container", "host"] as const) { + const { payloadConfig } = buildMailBackupPayload("example.com", FLAGS, flavor); + const produce = payloadConfig.produceCommand; + expect(produce).toContain("sudo -n true"); + expect(produce.indexOf("sudo -n true")).toBeLessThan(produce.indexOf("sudo -n test -d /var/lib/dkim")); + } + }); + + it("keeps the maildir tar on the host, where the bind mount is", () => { + // /var/vmail is a bind mount, so tar reads it in place on either topology - moving it + // into the container would need the archive to come back out again. + for (const flavor of ["container", "host"] as const) { + const { payloadConfig } = buildMailBackupPayload("example.com", FLAGS, flavor); + expect(payloadConfig.produceCommand).toContain('sudo -n tar -c -C "$tmp" . -C /var/vmail vmail1'); + } + }); +}); diff --git a/apps/api/test/modules/mail/mail-component-actions.test.ts b/apps/api/test/modules/mail/mail-component-actions.test.ts new file mode 100644 index 000000000..4575f2b9a --- /dev/null +++ b/apps/api/test/modules/mail/mail-component-actions.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Two lies this surface used to tell (issue #565): + * + * 1. The logs drawer's header. The container engine's read is supervisord's + * per-program log, but the payload carried no record of that, so the drawer + * printed a `journalctl -u …` string naming a log the engine has not got. + * 2. "ClamAV restarted". supervisorctl prints `ERROR (abnormal termination)` and + * still exits 0 — it propagates an exit code for its OWN failures, not the + * program's — so grading on the exit code alone reported a daemon that never came + * up as a success. + * + * `components.service.ts` reaches the box only through `runMailCommand`, so the real + * builders and parsers stay live and only that one function is stubbed. + */ + +const runMailCommand = vi.fn(); +vi.mock("../../../src/modules/mail/mail-engine", async (importOriginal) => ({ + ...(await importOriginal()), + runMailCommand: (...args: unknown[]) => runMailCommand(...args), +})); + +import { + getComponentLogs, + runComponentAction, + MailComponentActionError, +} from "../../../src/modules/mail/admin/components.service"; + +/** Answer each build() with a canned output, marker appended as the real shell would. */ +function replies(...outputs: string[]) { + let i = 0; + runMailCommand.mockImplementation( + async (_target: unknown, build: (f: string) => string) => { + const command = build("container"); + const output = outputs[i++] ?? ""; + return { + flavor: "container", + output: command.includes("__EXIT=") ? `${output}\n__EXIT=0__` : output, + command, + }; + }, + ); +} + +/** + * `runComponentAction` waits out a settle delay before re-probing. Driving that with + * fake timers keeps the suite fast, but the action has to be STARTED before the clock + * is advanced — hence the split await rather than a plain one. + */ +async function act(...args: Parameters) { + const pending = runComponentAction(...args); + // A refused action rejects BEFORE it ever reaches the settle timer, so the rejection + // would be unattached for the length of the advance below. Park it. + pending.catch(() => {}); + await vi.advanceTimersByTimeAsync(2_000); + return pending; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("getComponentLogs", () => { + it("reports the read it actually performed, not journalctl", async () => { + replies("line one\nline two"); + + const logs = await getComponentLogs("srv_1", "clamav", 300); + + expect(logs.source).toBe( + "docker exec openship-mail tail -n 300 /var/log/supervisor/clamav-daemon.log", + ); + expect(logs.source).not.toContain("journalctl"); + expect(logs.lines).toEqual(["line one", "line two"]); + }); + + it("names the sidecar's container for the DB row", async () => { + replies(""); + + expect((await getComponentLogs("srv_1", "postgresql")).source).toBe( + "docker logs --tail 200 openship-mail-db", + ); + }); +}); + +describe("runComponentAction", () => { + it("fails a restart supervisorctl refused while exiting 0", async () => { + replies("clamav-daemon: ERROR (abnormal termination)"); + + await expect(act("srv_1", "clamav", "restart")).rejects.toBeInstanceOf( + MailComponentActionError, + ); + }); + + it("reports the settled state rather than the acknowledgement", async () => { + replies( + "clamav-daemon: started", + "clamav-daemon FATAL Exited too quickly (process log may have details)", + ); + + const res = await act("srv_1", "clamav", "restart"); + + expect(res.settled?.status).toBe("failed"); + expect(res.settled?.subState).toBe("fatal"); + }); + + // `ERROR (not running)` after a stop means we are already where we asked to be, so + // it rides back in `output` for the panel to show instead of failing the call. + it("surfaces a benign refusal in the output instead of swallowing it", async () => { + replies("clamav-daemon: ERROR (not running)", "clamav-daemon STOPPED Not started"); + + const res = await act("srv_1", "clamav", "stop"); + + expect(res.output).toContain("ERROR (not running)"); + expect(res.settled?.status).toBe("inactive"); + }); + + // A transitional state is not a disagreement: `systemctl --no-block restart dovecot` + // returns instantly and the unit sits in `activating` for up to 90s. Reporting that + // as "has not come up" would cry wolf on every healthy slow restart, so the settle + // probe withholds a verdict instead. + it("withholds a verdict while the daemon is still starting", async () => { + replies("dovecot: started", "dovecot STARTING pid 41, uptime 0:00:01"); + + const res = await act("srv_1", "dovecot", "restart"); + + expect(res.settled).toBeUndefined(); + }); + + it("treats a daemon this box does not ship as a no-op, not a refusal", async () => { + replies("iredapd: ERROR (no such process)", "iredapd: ERROR (no such process)"); + + const res = await act("srv_1", "iredapd", "restart"); + + expect(res.settled?.status).toBe("missing"); + }); +}); diff --git a/apps/api/test/modules/mail/mail-db-not-initialized.test.ts b/apps/api/test/modules/mail/mail-db-not-initialized.test.ts new file mode 100644 index 000000000..879e8ba46 --- /dev/null +++ b/apps/api/test/modules/mail/mail-db-not-initialized.test.ts @@ -0,0 +1,123 @@ +import "./_setup-env"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * An unseeded `vmail` must fail TYPED, with the fix in the message. + * + * The other half of GH-562: `db-bootstrap.sh` could exit 0 without having loaded the + * schema, leaving a box that serves SSH, runs the engine container, and answers every + * admin read with `relation "domain" does not exist`. That reached the panel as a bare + * 500 — "API 500" with no cause and no next step — because `runMailCommand` only + * re-types wrong-topology signatures and a missing relation matches none of them. + * + * Asserting on the STATUS and the MESSAGE, not on rows: the remediation text is the + * deliverable, since the dashboard renders `body.error` verbatim. + */ + +vi.mock("@repo/adapters", () => ({ + HOST_STATE_DIR: "/root/.openship", + detectMailEngine: vi.fn(), + MAIL_CONTAINER: "openship-mail", + MAIL_DB_CONTAINER: "openship-mail-db", + MAIL_DB_NAME: "vmail", + MAIL_HOST_PATHS: { + saslPasswd: "/opt/openship/mail/postfix/sasl_passwd", + senderRelayhost: "/opt/openship/mail/postfix/sender_relayhost", + amavisUserConf: "/opt/openship/mail/amavis/50-user", + }, +})); + +import { detectMailEngine } from "@repo/adapters"; +import { + forgetMailEngine, + runMailSql, + MailDbNotInitializedError, +} from "../../../src/modules/mail/mail-engine"; + +const CONTAINER = { + flavor: "container" as const, + running: true, + exists: true, + image: "ghcr.io/oblien/openship-mail:0.6.5", +}; +const HOST = { flavor: "host" as const, running: true, exists: true, image: null }; + +/** An executor whose psql always rejects with `stderr`, as the SSH layer does. */ +function failing(stderr: string) { + const exec = { + exec: vi.fn(async () => { + throw new Error(stderr); + }), + } as never; + // The topology memo is keyed by executor; a fresh object per case still needs the + // probe seeded, since forgetMailEngine only drops a cached answer. + forgetMailEngine(exec); + return exec; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER); +}); + +describe("runMailSql — unseeded schema", () => { + it("types a missing table as 409 with the bootstrap command", async () => { + const exec = failing('ERROR: relation "domain" does not exist\nLINE 1: SELECT ...'); + + const err = await runMailSql(exec, "SELECT count(*) FROM domain").catch((e) => e); + + expect(err).toBeInstanceOf(MailDbNotInitializedError); + expect(err.statusCode).toBe(409); + expect(err.code).toBe("MAIL_DB_NOT_INITIALIZED"); + expect(err.message).toContain("db-bootstrap.sh"); + // The daemons are already FATAL against the empty database by this point, and + // supervisord never retries that — the schema alone does not revive them. + expect(err.message).toContain("docker restart openship-mail"); + // Must not read as a verdict while setup may still be mid-bootstrap. + expect(err.message).toContain("still running"); + }); + + it("types a missing database the same way", async () => { + const exec = failing('psql: FATAL: database "vmail" does not exist'); + + const err = await runMailSql(exec, "SELECT 1").catch((e) => e); + + expect(err).toBeInstanceOf(MailDbNotInitializedError); + expect(err.code).toBe("MAIL_DB_NOT_INITIALIZED"); + }); + + it("points a legacy host box at mail setup, not at a docker command", async () => { + vi.mocked(detectMailEngine).mockResolvedValue(HOST); + const exec = failing('ERROR: relation "mailbox" does not exist'); + + const err = await runMailSql(exec, "SELECT 1").catch((e) => e); + + expect(err).toBeInstanceOf(MailDbNotInitializedError); + expect(err.message).toContain("Re-run mail setup"); + expect(err.message).not.toContain("docker"); + }); + + /** + * `column … does not exist` means OUR SQL disagrees with a schema that IS there. + * That is our bug to read verbatim, not an operator's to bootstrap away — and + * dressing it up as "no schema" would send them to run a command that fixes nothing. + */ + it("leaves a schema mismatch as the raw error", async () => { + const exec = failing('ERROR: column "nonesuch" does not exist'); + + const err = await runMailSql(exec, "SELECT nonesuch FROM domain").catch((e) => e); + + expect(err).not.toBeInstanceOf(MailDbNotInitializedError); + expect(err.message).toContain('column "nonesuch" does not exist'); + }); + + it("leaves an unrelated psql failure alone", async () => { + const exec = failing("psql: error: connection to server failed: Connection refused"); + + const err = await runMailSql(exec, "SELECT 1").catch((e) => e); + + expect(err).not.toBeInstanceOf(MailDbNotInitializedError); + expect(err.message).toContain("Connection refused"); + }); +}); diff --git a/apps/api/test/modules/mail/mail-firewall-step.test.ts b/apps/api/test/modules/mail/mail-firewall-step.test.ts index dd8deaf31..7b46e7680 100644 --- a/apps/api/test/modules/mail/mail-firewall-step.test.ts +++ b/apps/api/test/modules/mail/mail-firewall-step.test.ts @@ -13,12 +13,30 @@ import type { EnvironmentProfile } from "@repo/adapters"; * Only `resolveEnvironment` is mocked: `envOps`, `opScript` and the firewall tables are * the real ones, so these assertions are on the command this host would really get. */ -const h = vi.hoisted(() => ({ host: {} as Partial })); +const h = vi.hoisted(() => ({ + host: {} as Partial, + /** + * The privilege gate, stubbed to the identity. + * + * `ufw`/`firewall-cmd` are root-only, so the step runs them through `rootOrDegrade`. + * That helper probes the host with its own `opsh_*` script, which would land in the + * asserted command list and make every expectation here about privilege detection + * instead of about firewall syntax — which is what this file exists to pin. The gate's + * own behaviour (elevate on sudo, report and degrade otherwise) is covered by the + * adapters' privilege tests; what THIS file asserts about it is only that the step goes + * through it at all, below. + */ + gate: vi.fn(async (executor: unknown) => executor), +})); vi.mock("@repo/adapters", async (importOriginal) => { const real = await importOriginal(); const fixtures = await import("../../../../../packages/adapters/src/system/environment.fixtures"); - return { ...real, resolveEnvironment: async () => fixtures.profileFixture(h.host) }; + return { + ...real, + resolveEnvironment: async () => fixtures.profileFixture(h.host), + rootOrDegrade: h.gate, + }; }); import { MAIL_PORTS } from "@repo/adapters"; @@ -44,6 +62,7 @@ function run(exec: unknown) { beforeEach(() => { h.host = {}; + h.gate.mockClear(); }); describe("stepOpenMailFirewall", () => { @@ -78,6 +97,32 @@ describe("stepOpenMailFirewall", () => { expect(all).not.toMatch(/\bufw\b/); }); + test("opens the ports through the privilege gate, not on the raw executor", async () => { + // `ufw`/`firewall-cmd` are root-only. This ran on the raw executor, so on a box we log + // into as a non-root sudo user every rule failed and the step reported the ports as + // REJECTED BY THE FIREWALL rather than as never attempted — a wrong diagnosis about + // the operator's host. The profile above only chooses the syntax; it never decided who + // runs it. + h.host = { firewall: "ufw" }; + const { executor } = fakeExecutor(); + + await run(executor); + + expect(h.gate).toHaveBeenCalledTimes(1); + expect(h.gate.mock.calls[0]?.[0]).toBe(executor); + }); + + test("does not reach for the gate when there is no firewall to open", async () => { + // No rules means no root-only command, and probing privileges to run nothing is a + // round-trip charged to every mail setup on a box with no firewall. + h.host = { firewall: "none" }; + const { executor } = fakeExecutor(); + + await run(executor); + + expect(h.gate).not.toHaveBeenCalled(); + }); + test("no active firewall → touches nothing and says so", async () => { h.host = { firewall: "none" }; const { executor, commands } = fakeExecutor(); diff --git a/apps/api/test/modules/mail/mail-health-probe.test.ts b/apps/api/test/modules/mail/mail-health-probe.test.ts index 0efb6b75b..fb0e11582 100644 --- a/apps/api/test/modules/mail/mail-health-probe.test.ts +++ b/apps/api/test/modules/mail/mail-health-probe.test.ts @@ -64,6 +64,20 @@ describe("parseMailUnitProbe — container flavor", () => { ); expect(state.status).toBe("failed"); + // FATAL and BACKOFF are both `failed`; `subState` is the ONLY thing that says + // whether supervisord is still trying, and the row's hint reads it. + expect(state.subState).toBe("fatal"); + }); + + it("keeps BACKOFF distinguishable from FATAL through the sub-state", () => { + const state = parse( + "clamav-daemon BACKOFF Exited too quickly", + "clamav", + "clamav-daemon", + ); + + expect(state.status).toBe("failed"); + expect(state.subState).toBe("backoff"); }); describe("postgresql sidecar", () => { diff --git a/apps/api/test/modules/mail/mail-installer-invocation.test.ts b/apps/api/test/modules/mail/mail-installer-invocation.test.ts index a1e6b6da1..fb7380321 100644 --- a/apps/api/test/modules/mail/mail-installer-invocation.test.ts +++ b/apps/api/test/modules/mail/mail-installer-invocation.test.ts @@ -24,10 +24,14 @@ vi.mock("@repo/adapters", async (importOriginal) => ({ // Health-gate reports everything up so the step reaches its success return. vi.mock("../../../src/modules/mail/mail-health.service", () => ({ checkMailHealth: vi.fn(async () => [ - { key: "postfix", label: "Postfix", description: "", unit: "postfix", status: "active" }, - { key: "dovecot", label: "Dovecot", description: "", unit: "dovecot", status: "active" }, - { key: "postgresql", label: "PostgreSQL", description: "", unit: "postgresql", status: "active" }, + { key: "postfix", label: "Postfix", description: "", unit: "postfix", severity: "required", status: "active" }, + { key: "dovecot", label: "Dovecot", description: "", unit: "dovecot", severity: "required", status: "active" }, + { key: "postgresql", label: "PostgreSQL", description: "", unit: "postgresql", severity: "required", status: "active" }, ]), + // The gate reads the CATALOG, not `c.severity` off the row, so this mock must export + // it — a missing export throws loudly instead of silently gating on nothing. + requiresMailComponent: (key: string) => + ["postfix", "dovecot", "postgresql"].includes(key), MAIL_COMPONENTS: [], })); diff --git a/apps/api/test/modules/mail/mailbox-create-flavor.test.ts b/apps/api/test/modules/mail/mailbox-create-flavor.test.ts new file mode 100644 index 000000000..0c7f452ae --- /dev/null +++ b/apps/api/test/modules/mail/mailbox-create-flavor.test.ts @@ -0,0 +1,195 @@ +import "./_setup-env"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The mailbox-creation path must reach the mail engine, not the host (GH-562). + * + * `doveadm` and the `vmail` user live wherever Dovecot does. On a container-flavor + * box that is INSIDE `openship-mail` and emphatically not on the host — yet + * `hashPassword` ran a bare `doveadm pw` and `createMaildirOnDisk` ran a bare + * `chown -R vmail:vmail`, both straight against the host executor. The result was a + * 500 on every mailbox create on a containerized install: no `doveadm` on the host + * meant empty output, and the SSHA512 gate rejected it. + * + * Every existing test in this directory hands these helpers a `vi.fn()` executor and + * asserts on the ROWS, so all of them stayed green while the command that produced + * those rows could not run anywhere. The assertions here are deliberately on the + * COMMAND STRING, because the prefix is the entire bug. + * + * These run on every PR — no daemon needed. The companion real-Docker case is + * test/e2e/mail-db-bootstrap.e2e.test.ts, which covers the other half of GH-562. + */ + +vi.mock("@repo/adapters", () => ({ + HOST_STATE_DIR: "/root/.openship", + detectMailEngine: vi.fn(), + MAIL_CONTAINER: "openship-mail", + MAIL_DB_CONTAINER: "openship-mail-db", + MAIL_DB_NAME: "vmail", + MAIL_HOST_PATHS: { + saslPasswd: "/opt/openship/mail/postfix/sasl_passwd", + senderRelayhost: "/opt/openship/mail/postfix/sender_relayhost", + amavisUserConf: "/opt/openship/mail/amavis/50-user", + }, +})); + +import { detectMailEngine } from "@repo/adapters"; +import { hashPassword } from "../../../src/modules/mail/admin/password"; +import { + createMaildirOnDisk, + generateMaildir, + removeMaildirOnDisk, +} from "../../../src/modules/mail/admin/maildir"; +import { forgetMailEngine } from "../../../src/modules/mail/mail-engine"; + +const HASH = "{SSHA512}c2FsdGVkaGFzaHZhbHVl"; + +const CONTAINER = { + flavor: "container" as const, + running: true, + exists: true, + image: "ghcr.io/oblien/openship-mail:0.6.5", +}; +const HOST = { flavor: "host" as const, running: true, exists: true, image: null }; + +/** An executor that records every command and answers with `reply`. */ +function recorder(reply: string) { + const calls: string[] = []; + const exec = { + exec: vi.fn(async (cmd: string) => { + calls.push(cmd); + return reply; + }), + }; + forgetMailEngine(exec as never); + return { exec, calls, last: () => calls[calls.length - 1] ?? "" }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("hashPassword transport", () => { + it("runs doveadm INSIDE the engine on a container-flavor box", async () => { + vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER); + const r = recorder(HASH); + + await expect(hashPassword(r.exec as never, "sekrit-pw")).resolves.toBe(HASH); + + // The prefix IS the fix. Without it the host answers, and the host has no doveadm. + expect(r.last()).toContain("docker exec openship-mail "); + expect(r.last()).toContain("doveadm pw -s SSHA512"); + }); + + it("runs doveadm bare on a legacy host-flavor box", async () => { + vi.mocked(detectMailEngine).mockResolvedValue(HOST); + const r = recorder(HASH); + + await expect(hashPassword(r.exec as never, "sekrit-pw")).resolves.toBe(HASH); + + expect(r.last()).not.toContain("docker exec"); + expect(r.last()).toContain("doveadm pw -s SSHA512"); + }); + + it("keeps the plaintext out of the thrown message when doveadm answers nothing", async () => { + vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER); + const r = recorder(""); + + // The empty-output case is exactly what a missing doveadm produced, and the + // operator saw only "500 Internal Server Error" for it. + await expect(hashPassword(r.exec as never, "sekrit-pw")).rejects.toThrow(/empty output/); + await expect(hashPassword(r.exec as never, "sekrit-pw")).rejects.not.toThrow(/sekrit-pw/); + }); + + it("names the engine flavor in the failure, so the reader knows which box answered", async () => { + vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER); + const r = recorder("doveadm: command not found"); + + await expect(hashPassword(r.exec as never, "pw")).rejects.toThrow(/container engine/); + }); +}); + +describe("createMaildirOnDisk transport + layout", () => { + it("creates the tree Dovecot actually opens: /Maildir/{cur,new,tmp}", async () => { + vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER); + const r = recorder(""); + const layout = generateMaildir("acme.com", "alice", new Date("2026-01-02T03:04:05Z")); + + await createMaildirOnDisk(r.exec as never, layout); + const cmd = r.last(); + + // mail_location = maildir:%Lh/Maildir/ (engine/samples/dovecot/dovecot.conf:64), + // with home = //. A tree at /cur is one level shallow + // and Dovecot never reads it. + const home = `/var/vmail/vmail1/${layout.maildir}`; + expect(cmd).toContain(`${home}Maildir/cur`); + expect(cmd).toContain(`${home}Maildir/new`); + expect(cmd).toContain(`${home}Maildir/tmp`); + expect(cmd).not.toContain(`'${home}cur'`); + }); + + it("wraps the compound command in one sh -c so && cannot leak to the host shell", async () => { + vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER); + const r = recorder(""); + + await createMaildirOnDisk(r.exec as never, generateMaildir("acme.com", "bob")); + const cmd = r.last(); + + // `docker exec a && b` would run `a` in the engine and `b` on the HOST — + // which is how a chown meant for the engine's vmail user hits a host without one. + expect(cmd).toMatch(/^docker exec openship-mail sh -c /); + const afterShC = cmd.slice(cmd.indexOf("sh -c ")); + expect(afterShC.startsWith("sh -c '")).toBe(true); + // Every && must sit INSIDE the quoted script, never between top-level words. + expect(cmd.replace(/'.*'/s, "''")).not.toContain("&&"); + }); + + it("chowns to vmail INSIDE the engine, where that user exists", async () => { + vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER); + const r = recorder(""); + + await createMaildirOnDisk(r.exec as never, generateMaildir("acme.com", "carol")); + + expect(r.last()).toContain("chown -R vmail:vmail"); + expect(r.last()).toMatch(/^docker exec openship-mail /); + }); + + it("stays bare on a legacy host-flavor box", async () => { + vi.mocked(detectMailEngine).mockResolvedValue(HOST); + const r = recorder(""); + + await createMaildirOnDisk(r.exec as never, generateMaildir("acme.com", "dave")); + + expect(r.last()).not.toContain("docker exec"); + expect(r.last()).toMatch(/^sh -c /); + }); +}); + +describe("removeMaildirOnDisk", () => { + it("removes the home through the engine, covering the Maildir subtree", async () => { + vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER); + const r = recorder(""); + const layout = generateMaildir("acme.com", "erin"); + + await removeMaildirOnDisk(r.exec as never, layout); + + expect(r.last()).toBe( + `docker exec openship-mail rm -rf '/var/vmail/vmail1/${layout.maildir}'`, + ); + }); + + it("still refuses a path outside /var/vmail before any command is built", async () => { + vi.mocked(detectMailEngine).mockResolvedValue(CONTAINER); + const r = recorder(""); + + await expect( + removeMaildirOnDisk(r.exec as never, { + storagebasedirectory: "/etc", + storagenode: "passwd", + maildir: "", + }), + ).rejects.toThrow(/Refusing to remove maildir outside/); + expect(r.calls).toHaveLength(0); + }); +}); diff --git a/apps/api/test/modules/mail/webmail-restart-loop-watch.test.ts b/apps/api/test/modules/mail/webmail-restart-loop-watch.test.ts new file mode 100644 index 000000000..d2059c80f --- /dev/null +++ b/apps/api/test/modules/mail/webmail-restart-loop-watch.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import type { OpenshipReadiness } from "@repo/core"; +import { resolveReadinessGate } from "../../../src/modules/deployments/readiness-gate"; + +/** + * The gate webmail arms, read through the resolver that decides what actually runs. + * + * Issue #566: a webmail container that exited on a missing `SESSION_ENCRYPTION_KEY` and + * restarted ten times finished its deploy as "the app is deployed and running". The + * restart-loop watch that would have caught it already existed (#335) — it is OFF by + * default, and webmail never asked for it. + * + * Pinned here rather than in the install test because the VALUE is the decision: it must + * enable stabilization (a restart-loop watch), must NOT enable the TCP probe (a different + * question, and up to 45s on the critical path), and must veto rather than warn — a warn + * leaves the deploy green, which is the bug. + */ + +// Kept in sync with WEBMAIL_READINESS in webmail-install.service.ts, which is private to +// that module; duplicating the literal is what makes a change to it fail here. +const WEBMAIL_READINESS: OpenshipReadiness = { stabilization: true, onFailure: "fail" }; + +describe("webmail's readiness gate", () => { + it("watches for a restart loop and vetoes the deploy", () => { + const gate = resolveReadinessGate(WEBMAIL_READINESS); + + expect(gate.stabilization.enabled).toBe(true); + expect(gate.stabilization.windowMs).toBeGreaterThan(0); + expect(gate.onFailure).toBe("fail"); + // `active` is what makes the pipeline run a gate at all rather than skip the step. + expect(gate.active).toBe(true); + }); + + it("does not turn on the TCP probe", () => { + expect(resolveReadinessGate(WEBMAIL_READINESS).probe.enabled).toBe(false); + }); + + it("is the difference from the default, which watches nothing", () => { + const off = resolveReadinessGate(undefined); + + expect(off.active).toBe(false); + expect(off.stabilization.enabled).toBe(false); + // The default failure policy is a warning; ours must be an explicit veto. + expect(off.onFailure).toBe("warn"); + }); +}); diff --git a/apps/api/test/modules/mcp/mcp-services-sync-body.test.ts b/apps/api/test/modules/mcp/mcp-services-sync-body.test.ts index 256df16cb..29e266e3b 100644 --- a/apps/api/test/modules/mcp/mcp-services-sync-body.test.ts +++ b/apps/api/test/modules/mcp/mcp-services-sync-body.test.ts @@ -25,6 +25,7 @@ const cliPayload = { environment: { NODE_ENV: "production", EMPTY: "" }, volumes: ["./static:/srv/static:ro"], command: "node server.js", + commandArgv: ["node", "server.js"], restart: "on-failure:3", }, { name: "db", image: "postgres:16", volumes: ["pgdata:/var/lib/postgresql/data"] }, @@ -75,6 +76,35 @@ describe("POST /projects/:id/services/sync body schema", () => { expect(Value.Check(SyncServicesBody, dashboardPayload)).toBe(true); }); + it("advertises `commandArgv`, the only faithful way to send a list command (#332)", () => { + // Without it the string was the sole option, and the string form of a list + // command is a lossy join: `["sh","-c","a && b"]` → "sh -c a && b" → 5 words. + // An MCP agent reads this schema to know what it may send. + const entry = ( + (SyncServicesBody as unknown as { + properties: { services: { items: { properties: Record } } }; + }).properties.services.items.properties + ); + expect(entry.commandArgv).toBeDefined(); + + expect( + Value.Check(SyncServicesBody, { + services: [{ name: "web", commandArgv: ["sh", "-c", "a && b"] }], + }), + ).toBe(true); + // `[]` is meaningful — it clears the image CMD. + expect(Value.Check(SyncServicesBody, { services: [{ name: "web", commandArgv: [] }] })).toBe( + true, + ); + // argv entries are strings, like every other compose scalar here. + expect(Value.Check(SyncServicesBody, { services: [{ name: "web", commandArgv: [8080] }] })).toBe( + false, + ); + expect( + Value.Check(SyncServicesBody, { services: [{ name: "web", commandArgv: "node server.js" }] }), + ).toBe(false); + }); + it("accepts compose restart policies outside the four-value enum", () => { expect( Value.Check(SyncServicesBody, { services: [{ name: "web", restart: "on-failure:3" }] }), diff --git a/apps/api/test/modules/mcp/mcp-tool-call-audit.test.ts b/apps/api/test/modules/mcp/mcp-tool-call-audit.test.ts new file mode 100644 index 000000000..d3c762780 --- /dev/null +++ b/apps/api/test/modules/mcp/mcp-tool-call-audit.test.ts @@ -0,0 +1,139 @@ +/** + * Which tool calls get their own audit row. + * + * `source: "mcp"` told the log that an assistant acted, but only for the calls + * something else already recorded — the secureRouter auto-emitter, which fires on + * write/admin routes that returned 2xx/3xx. That left two holes, and they were the + * ones an operator asks about after the fact: + * + * - every READ an agent made (no row at all — read routes don't auto-emit) + * - every call it was REFUSED (the emitter skips non-2xx, so a denied write was + * indistinguishable from a call never made) + * + * The fix is one row per otherwise-unrecorded call, which puts the rule between two + * failure modes worth pinning down: record everything and every mutation an agent + * makes is logged twice, record nothing extra and reads go dark again. + */ + +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const auditCreate = vi.hoisted(() => vi.fn(async () => ({}))); + +vi.mock("@repo/db", () => ({ repos: { auditEvent: { create: auditCreate } } })); + +import { needsOwnAuditRow, recordToolCall } from "../../../src/modules/mcp/mcp-audit"; +import type { ToolCallActor, ToolCallRecord } from "../../../src/modules/mcp/mcp-audit"; + +const ACTOR: ToolCallActor = { + organizationId: "org1", + userId: "u1", + clientId: "oauth:cli_abc", + tokenId: "pat_binding1", + ipAddress: "203.0.113.7", + userAgent: "claude-desktop/1.2", +}; + +function call(over: Partial = {}): ToolCallRecord { + return { + tool: "get_projects", + method: "GET", + path: "/api/projects", + action: "list", + status: 200, + ok: true, + ...over, + }; +} + +/** The row handed to the repo, after the fire-and-forget write settles. */ +async function rowFor(record: ToolCallRecord, actor: ToolCallActor = ACTOR) { + recordToolCall(actor, record); + await new Promise((resolve) => setTimeout(resolve, 0)); + return auditCreate.mock.calls.at(-1)?.[0] as unknown as Record | undefined; +} + +beforeEach(() => { + auditCreate.mockClear(); +}); + +describe("the rule: only calls nothing else records", () => { + it("records reads and lists — the blind spot this exists for", () => { + expect(needsOwnAuditRow({ ok: true, action: "read" })).toBe(true); + expect(needsOwnAuditRow({ ok: true, action: "list" })).toBe(true); + }); + + it("stays out of the way of a successful write, which records itself", () => { + // route-permission.ts's emitter already wrote a row naming the resource and + // carrying the diff — a strictly better row than a tool-call row. + expect(needsOwnAuditRow({ ok: true, action: "write" })).toBe(false); + expect(needsOwnAuditRow({ ok: true, action: "admin" })).toBe(false); + }); + + it("records a write that FAILED — the emitter fires only on 2xx/3xx", () => { + // An agent pushing at the edge of its scope, which is the case where the + // absence of a row was worst. + expect(needsOwnAuditRow({ ok: false, action: "write" })).toBe(true); + expect(needsOwnAuditRow({ ok: false, action: "admin" })).toBe(true); + expect(needsOwnAuditRow({ ok: false, action: "read" })).toBe(true); + }); + + it("records an unrecognized action rather than dropping it", () => { + // A new permission verb must not silently create a fresh blind spot. + expect(needsOwnAuditRow({ ok: true, action: "create" })).toBe(true); + expect(needsOwnAuditRow({ ok: true, action: "" })).toBe(true); + }); +}); + +describe("the row", () => { + it("is attributed to the agent, not just to 'an assistant'", async () => { + const row = await rowFor(call()); + expect(row).toMatchObject({ + organizationId: "org1", + actorUserId: "u1", + eventType: "mcp.tool_called", + resourceType: "mcp_client", + resourceId: "pat_binding1", + source: "mcp", + sourceClientId: "oauth:cli_abc", + }); + }); + + it("carries the real client IP and user agent, not the loopback dispatch", async () => { + // An in-process sub-request has no peer and no UA; both are read off the outer + // request, which is the assistant's own. + const row = await rowFor(call()); + expect(row).toMatchObject({ ipAddress: "203.0.113.7", userAgent: "claude-desktop/1.2" }); + }); + + it("names the tool and the route it hit, with the status", async () => { + const row = await rowFor(call({ tool: "delete_project", method: "DELETE", path: "/api/projects/:id", action: "admin", status: 403, ok: false })); + expect(row?.after).toEqual({ + tool: "delete_project", + route: "DELETE /api/projects/:id", + status: 403, + ok: false, + }); + }); + + it("stores the route TEMPLATE, so no resource id rides along in it", async () => { + const row = await rowFor(call({ path: "/api/projects/:id/services/:serviceId" })); + expect(String((row?.after as { route: string }).route)).toContain(":serviceId"); + }); + + it("never carries the arguments", async () => { + // Tool args routinely hold env values and secrets. The same reasoning keeps + // grant tuples out of mcp.scope_changed. + const row = await rowFor(call()); + expect(Object.keys(row?.after as object).sort()).toEqual(["ok", "route", "status", "tool"]); + }); + + it("is skipped when no org resolved — there is nothing to attribute it to", async () => { + await rowFor(call(), { ...ACTOR, organizationId: null }); + expect(auditCreate).not.toHaveBeenCalled(); + }); + + it("writes nothing for a successful write", async () => { + await rowFor(call({ method: "POST", action: "write", status: 201 })); + expect(auditCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/test/modules/notifications/categories-cloud-gate.test.ts b/apps/api/test/modules/notifications/categories-cloud-gate.test.ts index e477d7b2d..a968622f7 100644 --- a/apps/api/test/modules/notifications/categories-cloud-gate.test.ts +++ b/apps/api/test/modules/notifications/categories-cloud-gate.test.ts @@ -88,4 +88,37 @@ describe("GET /categories billing gate", () => { const { categories, groups } = await fetchCategories(true); expect(new Set(categories.map((c) => c.group))).toEqual(new Set(groups.map((g) => g.id))); }); + + // Mail is the mirror image of billing: the engine is self-hosted-only and the whole + // mail module is absent from the cloud runtime, so the gate has to run the other way. + // Nothing else pins the DIRECTION, and a later "simplification" that filtered + // CATEGORIES itself — or dropped one of the two branches — would ship green. + it("keeps the mail group on self-hosted", async () => { + const { categories, groups } = await fetchCategories(false); + expect(groups.map((g) => g.id)).toContain("mail"); + expect(categories.map((c) => c.id)).toContain("mail.inbound_received"); + }); + + it("drops the mail group from both arrays on cloud", async () => { + const { categories, groups } = await fetchCategories(true); + + expect(groups.map((g) => g.id)).not.toContain("mail"); + expect(categories.map((c) => c.id)).not.toContain("mail.inbound_received"); + const groupIds = new Set(groups.map((g) => g.id)); + for (const cat of categories) expect(groupIds).toContain(cat.group); + }); + + it("still renders an inbound-mail alert on a cloud box", async () => { + // Same registry-stays-complete guarantee as billing, in the other direction: a row + // stored before a migration to cloud must keep its label, not degrade to the raw id. + await fetchCategories(true); + expect(findCategory("mail.inbound_received")?.label).toBe("Inbound email received"); + }); + + // Per-message events must never default on: the dispatcher's fallback fans a + // default-enabled category to every member's verified email channel, and a + // notification mail landing back on the watched engine captures itself. + it("never defaults inbound mail to enabled", async () => { + expect(findCategory("mail.inbound_received")?.defaultEnabled).toBe(false); + }); }); diff --git a/apps/api/test/modules/services/service-command-argv.test.ts b/apps/api/test/modules/services/service-command-argv.test.ts new file mode 100644 index 000000000..e96086a31 --- /dev/null +++ b/apps/api/test/modules/services/service-command-argv.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const projectRepo = vi.hoisted(() => ({ findById: vi.fn() })); +const serviceRepo = vi.hoisted(() => ({ + findById: vi.fn(), + update: vi.fn(), + listByProject: vi.fn(), + syncFromCompose: vi.fn(), +})); + +vi.mock("@repo/db", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + repos: { ...actual.repos, project: projectRepo, service: serviceRepo }, + }; +}); + +import { syncComposeServices, updateService } from "../../../src/modules/services/service.service"; + +/** + * #332 left the EDITORS behind: the compose parser produced `commandArgv`, but + * every writer that takes a text `command` ignored it. Two live consequences, + * both covered here: + * • PATCH — argv wins at deploy time, so a stale argv kept running the OLD + * command. The service form's command field did nothing on any row imported + * from a compose file. + * • sync/deploy — those wire shapes took only the string, and the stored string + * is a lossy display join for a list command, so a read-then-post-back re-split + * `["sh","-c","a && b"]` into five words. That decision now lives one layer + * down, in composeWritePatch (see packages/db compose-spec-command.test.ts). + */ +const ctx = { organizationId: "org_1" } as never; +const project = { id: "proj_1", organizationId: "org_1", internalAlias: null }; + +const row = (over: Record = {}) => ({ + id: "svc_1", + projectId: project.id, + name: "web", + kind: "compose", + image: "ghcr.io/acme/app:1", + command: "server start", + commandArgv: ["server", "start"], + environment: {}, + ports: [], + restart: "unless-stopped", + enabled: true, + exposed: false, + ...over, +}); + +/** The patch handed to repos.service.update. */ +const written = () => serviceRepo.update.mock.calls.at(-1)?.[1] as Record; +/** The entries handed to repos.service.syncFromCompose. */ +const synced = () => + serviceRepo.syncFromCompose.mock.calls.at(-1)?.[1] as Array>; + +beforeEach(() => { + projectRepo.findById.mockReset().mockResolvedValue(project); + serviceRepo.findById.mockReset().mockResolvedValue(row()); + serviceRepo.update.mockReset().mockResolvedValue(undefined); + serviceRepo.listByProject.mockReset().mockResolvedValue([]); + serviceRepo.syncFromCompose.mockReset().mockResolvedValue([]); +}); + +describe("updateService — command edits keep argv in step", () => { + it("re-derives argv on a command edit, so the new command actually runs", async () => { + await updateService(ctx, project.id, "svc_1", { command: "server start --verbose" } as never); + + expect(written().command).toBe("server start --verbose"); + expect(written().commandArgv).toEqual(["server", "start", "--verbose"]); + }); + + it("gives a row with no argv real argv instead of resurrecting the `sh -c` wrap", async () => { + serviceRepo.findById.mockResolvedValue(row({ command: null, commandArgv: null })); + + await updateService(ctx, project.id, "svc_1", { command: "server start" } as never); + + expect(written().commandArgv).toEqual(["server", "start"]); + }); + + it("does not touch argv when the patch never mentions the command", async () => { + await updateService(ctx, project.id, "svc_1", { restart: "always" } as never); + + expect(written()).not.toHaveProperty("commandArgv"); + }); + + it("keeps a list command intact when the form echoes its lossy display join back", async () => { + // `command: ["sh","-c","a && b"]` is STORED as "sh -c a && b" for display. The + // service form posts every field it owns, so any unrelated save re-sends that + // string — re-splitting it would hand the container five arguments. + serviceRepo.findById.mockResolvedValue( + row({ command: "sh -c a && b", commandArgv: ["sh", "-c", "a && b"] }), + ); + + await updateService(ctx, project.id, "svc_1", { + command: "sh -c a && b", + restart: "always", + } as never); + + expect(written().commandArgv).toEqual(["sh", "-c", "a && b"]); + }); + + it("clears argv when the command field is cleared", async () => { + await updateService(ctx, project.id, "svc_1", { command: "" } as never); + + expect(written().command).toBeNull(); + expect(written().commandArgv).toBeNull(); + }); + + it("lets an explicit argv win over the string", async () => { + await updateService(ctx, project.id, "svc_1", { + command: "sh -c 'a && b'", + commandArgv: ["sh", "-c", "a && b"], + } as never); + + expect(written().commandArgv).toEqual(["sh", "-c", "a && b"]); + }); +}); + +describe("syncComposeServices — hands the command to the repo untouched", () => { + // The argv DECISION lives in composeWritePatch (packages/db), so that every + // writer into syncFromCompose gets it — including the deploy request's service + // list, which never passes through this function. What this layer owes is not + // mangling the command on the way there, while still restoring masked env. + it("forwards an explicit argv verbatim (what the CLI sends)", async () => { + serviceRepo.listByProject.mockResolvedValue([row()]); + + await syncComposeServices(ctx, project.id, [ + { + name: "web", + image: "ghcr.io/acme/app:1", + command: "sh -c a && b", + commandArgv: ["sh", "-c", "a && b"], + }, + ]); + + expect(synced()[0]?.commandArgv).toEqual(["sh", "-c", "a && b"]); + expect(synced()[0]?.command).toBe("sh -c a && b"); + }); + + it("restores masked env without inventing an argv the caller didn't send", async () => { + serviceRepo.listByProject.mockResolvedValue([row({ environment: { SECRET: "real-value" } })]); + + await syncComposeServices(ctx, project.id, [ + { + name: "web", + image: "ghcr.io/acme/app:1", + command: "server start --verbose", + environment: { SECRET: "••••••••" }, + }, + ]); + + expect(synced()[0]?.environment).toEqual({ SECRET: "real-value" }); + expect(synced()[0]).not.toHaveProperty("commandArgv"); + }); +}); diff --git a/apps/api/test/modules/system/containers-applying-endpoint.test.ts b/apps/api/test/modules/system/containers-applying-endpoint.test.ts new file mode 100644 index 000000000..e92bea22b --- /dev/null +++ b/apps/api/test/modules/system/containers-applying-endpoint.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * `GET /system/containers/applying` — the fleet's live apply progress. + * + * It answers from two stores because neither is the whole truth, and this pins the + * seam between them: + * - the cached rows know every component that was ACCEPTED, including the ones a + * bulk run has queued but not started (they have no session yet, and used to be + * byte-identical to "never asked for"); + * - the in-memory sessions know how far the running ones have got and how the + * finished ones ended — the only place an outcome exists, since a component that + * lands clears its drift and its in-progress mark in the same row write. + */ + +vi.mock("@repo/db", () => ({ + repos: { + server: { listByOrganization: vi.fn() }, + serverContainerStatus: { listByOrg: vi.fn() }, + }, +})); +vi.mock("../../../src/lib/controller-helpers", () => ({ + assertNotCloud: vi.fn(() => undefined), +})); +vi.mock("../../../src/lib/request-context", () => ({ + getRequestContext: () => ({ userId: "u1", organizationId: "org_1" }), +})); + +import { repos } from "@repo/db"; +import { listApplyingContainers } from "../../../src/modules/system/server-containers.controller"; +import { + createContainerApplySession, + finishContainerApplySession, +} from "../../../src/lib/server-container-session"; + +interface Progress { + active: { + serverId: string; + serverName: string; + component: string; + state: "queued" | "running"; + intent: "update" | "repair" | null; + sessionId?: string; + steps?: { id: string; status: string }[]; + }[]; + recent: { serverId: string; component: string; ok: boolean; error?: string }[]; +} + +const mocked = { + server: vi.mocked(repos.server), + status: vi.mocked(repos.serverContainerStatus), +}; + +const row = (over: Record = {}) => ({ + serverId: "srv_1", + component: "edge", + behind: true, + latestInProgress: true, + detail: null, + ...over, +}); + +async function call(): Promise { + return (await listApplyingContainers({ json: (body: unknown) => body } as never)) as never; +} + +/** Unique per case: the session store is module-global. */ +let n = 0; +const nextServer = () => `srv_live_${++n}`; + +beforeEach(() => { + vi.clearAllMocks(); + mocked.server.listByOrganization.mockResolvedValue([ + { id: "srv_1", name: "web-1", sshHost: "10.0.0.1" }, + ] as never); + mocked.status.listByOrg.mockResolvedValue([] as never); +}); + +describe("listApplyingContainers", () => { + it("is empty when nothing is in flight", async () => { + expect(await call()).toEqual({ active: [], recent: [] }); + }); + + it("reports an accepted-but-unstarted target as queued, with no session", async () => { + mocked.status.listByOrg.mockResolvedValue([row()] as never); + + const { active } = await call(); + expect(active).toMatchObject([ + { serverId: "srv_1", serverName: "web-1", component: "edge", state: "queued", intent: "update" }, + ]); + expect(active[0]!.sessionId).toBeUndefined(); + }); + + it("promotes a target to running once its session exists, and carries its steps", async () => { + mocked.status.listByOrg.mockResolvedValue([row()] as never); + const session = createContainerApplySession("srv_1", "edge"); + try { + const { active } = await call(); + expect(active).toMatchObject([{ state: "running", sessionId: session.id }]); + expect(active[0]!.steps?.map((s) => s.id)).toEqual(["pull", "recreate", "verify"]); + } finally { + finishContainerApplySession(session.id, "completed", { updated: true, down: false }); + } + }); + + it("names the intent from the row — a stopped component is being restarted", async () => { + mocked.status.listByOrg.mockResolvedValue([ + row({ behind: false, detail: { down: true } }), + ] as never); + + expect((await call()).active).toMatchObject([{ intent: "repair" }]); + }); + + it("still reports a running swap whose cached row went missing", async () => { + const id = nextServer(); + mocked.server.listByOrganization.mockResolvedValue([ + { id, name: null, sshHost: "10.0.0.9" }, + ] as never); + const session = createContainerApplySession(id, "mail"); + try { + const { active } = await call(); + expect(active).toMatchObject([ + // No row means no recorded intent — the caller falls back to update wording. + { serverId: id, serverName: "10.0.0.9", component: "mail", state: "running", intent: null }, + ]); + } finally { + finishContainerApplySession(session.id, "completed", { updated: true, down: false }); + } + }); + + it("counts a component once when both stores describe it", async () => { + mocked.status.listByOrg.mockResolvedValue([row()] as never); + const session = createContainerApplySession("srv_1", "edge"); + try { + expect((await call()).active).toHaveLength(1); + } finally { + finishContainerApplySession(session.id, "completed", { updated: true, down: false }); + } + }); + + it("reports how a just-finished apply ended, success and failure alike", async () => { + const ok = nextServer(); + const bad = nextServer(); + mocked.server.listByOrganization.mockResolvedValue([ + { id: ok, name: "ok-box", sshHost: "10.0.0.2" }, + { id: bad, name: "bad-box", sshHost: "10.0.0.3" }, + ] as never); + const good = createContainerApplySession(ok, "edge"); + const failed = createContainerApplySession(bad, "edge"); + finishContainerApplySession(good.id, "completed", { updated: true, down: false }); + finishContainerApplySession(failed.id, "failed", undefined, "could not pull the image"); + + const { active, recent } = await call(); + expect(active).toEqual([]); + expect(recent).toMatchObject([ + { serverId: ok, ok: true }, + { serverId: bad, ok: false, error: "could not pull the image" }, + ]); + }); + + it("never reports a server outside the caller's org", async () => { + const foreign = nextServer(); + // The org owns a different box entirely, so neither store may surface this run. + mocked.server.listByOrganization.mockResolvedValue([ + { id: nextServer(), name: "ours", sshHost: "10.0.0.4" }, + ] as never); + const session = createContainerApplySession(foreign, "edge"); + try { + expect(await call()).toEqual({ active: [], recent: [] }); + } finally { + finishContainerApplySession(session.id, "completed", { updated: true, down: false }); + } + }); +}); diff --git a/apps/api/test/modules/system/local-row-readonly-fields.test.ts b/apps/api/test/modules/system/local-row-readonly-fields.test.ts new file mode 100644 index 000000000..193054d00 --- /dev/null +++ b/apps/api/test/modules/system/local-row-readonly-fields.test.ts @@ -0,0 +1,58 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +/** + * #527: the isLocal row's connection fields must stay refused, INCLUDING the next one + * somebody adds. + * + * `updateServer` refuses a connection edit on the row that represents this box, because + * nothing dials with those fields — the container→host channel authenticates with + * OPENSHIP_HOST_SSH_*, and `ensureLocalServer` reverts `ssh_user` on the next read anyway. + * Storing them meant the UI displayed a credential that was neither used nor kept, which + * is where #527's reporter spent most of a dozen messages. + * + * The failure mode this guards is drift, not logic: the guard is a list, and a list goes + * stale the moment someone adds `sshFoo` to the patch builder and not to it — restoring + * exactly the silent-accept behaviour for one field, on the one row where it means + * nothing. Asserted over the SOURCE rather than by importing the controller: the check is + * about which lines exist, and importing would drag repos/permission/audit into a test + * that needs none of them. + */ +describe("updateServer — every ssh* field it accepts is refused on the local row", () => { + const SRC = readFileSync( + join(__dirname, "../../../src/modules/system/servers.controller.ts"), + "utf8", + ); + + /** The declared list. */ + const listed = (() => { + const block = SRC.match(/LOCAL_ROW_READONLY_FIELDS = \[([\s\S]*?)\] as const/); + expect(block, "LOCAL_ROW_READONLY_FIELDS was renamed or removed").toBeTruthy(); + return new Set([...block![1].matchAll(/"(\w+)"/g)].map((m) => m[1])); + })(); + + /** Every ssh* field the patch builder actually writes. */ + const handled = new Set( + [...SRC.matchAll(/if \(body\.(ssh\w+) !== undefined\)/g)].map((m) => m[1]), + ); + + it("finds the fields at all, so a rewrite can't make this vacuously pass", () => { + expect(handled.size).toBeGreaterThan(5); + expect(listed.size).toBeGreaterThan(5); + }); + + it("refuses every ssh* field the handler can write", () => { + expect([...handled].filter((f) => !listed.has(f))).toEqual([]); + }); + + it("lists nothing the handler doesn't write, so the refusal names real fields", () => { + expect([...listed].filter((f) => !handled.has(f))).toEqual([]); + }); + + it("leaves `name` writable — renaming this row is meaningful", () => { + expect(listed.has("name")).toBe(false); + expect(SRC).toContain("if (body.name !== undefined) patch.name"); + }); +}); diff --git a/apps/api/test/modules/system/server-containers.test.ts b/apps/api/test/modules/system/server-containers.test.ts index 039c1b173..3fc1961ad 100644 --- a/apps/api/test/modules/system/server-containers.test.ts +++ b/apps/api/test/modules/system/server-containers.test.ts @@ -39,6 +39,9 @@ vi.mock("@repo/db", () => ({ vi.mock("@repo/adapters", () => ({ dockerAvailable: vi.fn().mockResolvedValue(true), + // Only the bulk apply reaches this, and only to pre-check 80/443 before promising + // an edge REPAIR. Clean by default so classification isn't the thing under test. + probeEdge: vi.fn().mockResolvedValue({ canProceedClean: true }), detectEdgeContainer: vi .fn() .mockResolvedValue({ name: null, running: false, image: null, exists: false }), @@ -76,6 +79,7 @@ vi.mock("../../../src/lib/ssh-manager", () => ({ import { imageTag, + applyAllContainers, classifyContainerIssues, detectServerContainers, applyServerContainer, @@ -332,6 +336,75 @@ describe("detectServerContainers", () => { expect(mocked.status.upsert).not.toHaveBeenCalled(); expect(mocked.status.remove).not.toHaveBeenCalledWith("srv_1", "edge"); }); + + it("never writes the in-progress flag — a scan must not clear a running apply", async () => { + mocked.edge.mockResolvedValue(edgeContainer("ghcr.io/oblien/openship-edge:0.4.0") as never); + + await detectServerContainers(server); + + // The repo preserves the flag only when the payload omits it. A probe knows what + // the box runs, not whether a swap is mid-flight; writing its default is what let + // the 6-hourly scan (and the dashboard's own mount auto-scan) erase the state. + const payload = mocked.status.upsert.mock.calls + .map(([p]) => p as { component: string }) + .find((p) => p.component === "edge"); + expect(payload).toBeDefined(); + expect(Object.keys(payload!)).not.toContain("latestInProgress"); + }); +}); + +describe("applyAllContainers", () => { + const box = (id: string) => ({ id, name: id.toUpperCase(), sshHost: `10.0.0.${id.slice(-1)}` }); + const behindEdge = (serverId: string) => ({ + serverId, + component: "edge" as const, + behind: true, + latestInProgress: false, + detail: null, + }); + + it("flags every accepted target before returning — queued ones included", async () => { + // Five targets against a concurrency of 3: two of them cannot have been started + // by the time the response is built, and used to be indistinguishable from + // "never asked for" on every surface that reads the cache. + const servers = ["srv_1", "srv_2", "srv_3", "srv_4", "srv_5"]; + mocked.server.listByOrganization.mockResolvedValue(servers.map(box) as never); + mocked.status.listByOrg.mockResolvedValue(servers.map(behindEdge) as never); + + const result = await applyAllContainers("org_1", ["update"]); + + expect(result.started).toHaveLength(5); + expect(result.skipped).toEqual([]); + for (const id of servers) { + expect(mocked.status.setInProgress).toHaveBeenCalledWith(id, "edge", true); + } + }); + + it("refuses a target whose apply is already in flight", async () => { + mocked.server.listByOrganization.mockResolvedValue([box("srv_1")] as never); + mocked.status.listByOrg.mockResolvedValue([ + { ...behindEdge("srv_1"), latestInProgress: true }, + ] as never); + + const result = await applyAllContainers("org_1", ["update"]); + + expect(result.started).toEqual([]); + expect(result.skipped).toMatchObject([{ serverId: "srv_1", reason: "already_running" }]); + expect(mocked.reconcileEdge).not.toHaveBeenCalled(); + }); + + it("acts only on the intents it was given", async () => { + mocked.server.listByOrganization.mockResolvedValue([box("srv_1"), box("srv_2")] as never); + mocked.status.listByOrg.mockResolvedValue([ + behindEdge("srv_1"), + { serverId: "srv_2", component: "edge" as const, behind: false, latestInProgress: false, detail: { down: true } }, + ] as never); + + const result = await applyAllContainers("org_1", ["repair"]); + + expect(result.started).toMatchObject([{ serverId: "srv_2", intent: "repair" }]); + expect(mocked.status.setInProgress).not.toHaveBeenCalledWith("srv_1", "edge", true); + }); }); describe("applyServerContainer", () => { diff --git a/apps/api/test/modules/tokens/mcp-client-scope.test.ts b/apps/api/test/modules/tokens/mcp-client-scope.test.ts index a3f32cdf9..51bcbbe1d 100644 --- a/apps/api/test/modules/tokens/mcp-client-scope.test.ts +++ b/apps/api/test/modules/tokens/mcp-client-scope.test.ts @@ -45,6 +45,7 @@ const mocks = vi.hoisted(() => ({ scoped: boolean; createdAt: Date; lastUsedAt: Date | null; + useCount: number; }, /** Grants attached to that binding. */ grants: [] as Grant[], @@ -58,6 +59,7 @@ const mocks = vi.hoisted(() => ({ organizationId: args.organizationId, })), auditCreate: vi.fn(async () => {}), + disconnectMcpClient: vi.fn(async () => {}), })); vi.mock("@repo/db", () => ({ @@ -85,7 +87,10 @@ vi.mock("@repo/db", () => ({ upsertOAuthBindingWithGrants: mocks.upsertOAuthBindingWithGrants, }, organization: { findManyById: vi.fn(async () => [{ id: ORG, name: "Acme" }]) }, - oauth: { listApplicationsByClientIds: vi.fn(async () => [{ clientId: "cli1", name: "Claude Code" }]) }, + oauth: { + listApplicationsByClientIds: vi.fn(async () => [{ clientId: "cli1", name: "Claude Code" }]), + disconnectMcpClient: mocks.disconnectMcpClient, + }, auditEvent: { create: mocks.auditCreate }, project: { findById: vi.fn(async (id: string) => ({ id, organizationId: ORG })), findEnvVarById: vi.fn(async () => null) }, server: { get: vi.fn(async (id: string) => ({ id, organizationId: ORG })) }, @@ -100,7 +105,11 @@ vi.mock("@repo/db", () => ({ })); vi.mock("../../../src/config/env", () => ({ env: { CLOUD_MODE: false } })); -import { authorizeMcpClient, getMcpClient } from "../../../src/modules/tokens/token.controller"; +import { + authorizeMcpClient, + disconnectMcpClient, + getMcpClient, +} from "../../../src/modules/tokens/token.controller"; interface Reply { body: { data?: Record; error?: string; code?: string }; @@ -162,6 +171,12 @@ async function read(clientId: string): Promise { return reply(); } +async function disconnect(clientId: string): Promise { + const { c, reply } = request({}, { clientId }); + await disconnectMcpClient(c); + return reply(); +} + function connect(over: Partial> = {}) { mocks.state.binding = { id: "binding1", @@ -172,6 +187,7 @@ function connect(over: Partial> = {}) { scoped: true, createdAt: new Date("2026-01-01T00:00:00Z"), lastUsedAt: null, + useCount: 0, ...over, }; } @@ -186,6 +202,7 @@ beforeEach(() => { mocks.state.userGrants = []; mocks.upsertOAuthBindingWithGrants.mockClear(); mocks.auditCreate.mockClear(); + mocks.disconnectMcpClient.mockClear(); }); describe("GET one client", () => { @@ -394,3 +411,74 @@ describe("the audit row carries counts, never the grant tuples", () => { expect(call.before ?? null).toBeNull(); }); }); + +/** + * Authorizing and re-scoping an agent were recorded; REVOKING it was not. That + * left the one MCP lifecycle event with no trace, and a log that reads as though a + * client which is long gone still holds the scope it was last seen with. + */ +describe("disconnecting is recorded too", () => { + it("records mcp.disconnected with the scope the client HELD", async () => { + connect({ scoped: true, readOnly: true, useCount: 42 }); + mocks.state.grants = [PROJECT_GRANT, { ...PROJECT_GRANT, resourceId: "P2" }]; + + const r = await disconnect("cli1"); + expect(r.status).toBe(200); + expect(mocks.disconnectMcpClient).toHaveBeenCalledWith("u1", "cli1"); + + const call = mocks.auditCreate.mock.calls.at(-1)?.[0] as unknown as Record; + expect(call).toMatchObject({ + eventType: "mcp.disconnected", + resourceType: "mcp_client", + resourceId: "binding1", + organizationId: ORG, + }); + // Read BEFORE the teardown — afterwards the binding and its grants are gone, + // and the row could only have said "something named cli1 was disconnected". + expect(call.before).toMatchObject({ + clientId: "cli1", + scoped: true, + readOnly: true, + grantCount: 2, + useCount: 42, + }); + expect(call.after ?? null).toBeNull(); + }); + + it("keeps the grant tuples out of the row, like every other mcp.* event", async () => { + connect(); + mocks.state.grants = [PROJECT_GRANT]; + await disconnect("cli1"); + const call = mocks.auditCreate.mock.calls.at(-1)?.[0] as unknown as Record; + expect(JSON.stringify(call)).not.toContain("P1"); + }); + + it("still records when the binding is already gone", async () => { + // A double-click, or a client disconnected in another tab: the teardown is + // idempotent, and the attempt is still part of the history. + mocks.state.binding = null; + const r = await disconnect("cli1"); + expect(r.status).toBe(200); + const call = mocks.auditCreate.mock.calls.at(-1)?.[0] as unknown as Record; + expect(call).toMatchObject({ eventType: "mcp.disconnected", resourceId: "cli1" }); + expect(call.before).toEqual({ clientId: "cli1" }); + }); + + it("400s on a blank clientId, and tears down nothing", async () => { + // A present-but-empty param: an absent one throws in `param()` and is mapped + // by the error handler, so this guard is what catches "/mcp-clients/%20". + const r = await disconnect(" "); + expect(r.status).toBe(400); + expect(r.body.code).toBe("CLIENT_ID_REQUIRED"); + expect(mocks.disconnectMcpClient).not.toHaveBeenCalled(); + expect(mocks.auditCreate).not.toHaveBeenCalled(); + }); +}); + +describe("the client list carries its usage", () => { + it("reports the call count and the key for this client's audit rows", async () => { + connect({ useCount: 1204 }); + const r = await read("cli1"); + expect(r.body.data).toMatchObject({ useCount: 1204, auditClientId: "oauth:cli1" }); + }); +}); diff --git a/apps/api/vitest.e2e.config.ts b/apps/api/vitest.e2e.config.ts index f83651a3e..2b6eb5ee8 100644 --- a/apps/api/vitest.e2e.config.ts +++ b/apps/api/vitest.e2e.config.ts @@ -22,8 +22,17 @@ import { sharedTestOptions, testAlias } from "./vitest.config"; * cross-platform and there is exactly one entry point to run these locally. */ const HEAVY = "test/e2e/rollback-build-restore.e2e.test.ts"; +/** + * `update` is its own scope because it needs things a checkout does not have: the + * PREVIOUS release's published images, and an api image for the new side. CI runs it + * between `build-images` and `merge-images` in docker-images.yml, where the new image + * exists as a pushed digest — so it is excluded from every other scope, INCLUDING the + * local default, rather than silently pulling a release on someone's laptop. + */ +const UPDATE = "test/e2e/update-from-previous-release.e2e.test.ts"; const scope = process.env.E2E_SCOPE; -const include = scope === "heavy" ? [HEAVY] : ["test/e2e/**/*.e2e.test.ts"]; +const include = + scope === "heavy" ? [HEAVY] : scope === "update" ? [UPDATE] : ["test/e2e/**/*.e2e.test.ts"]; /** * The sandbox `backup-volume-roundtrip` puts its destination inside, read back @@ -54,7 +63,12 @@ export default defineConfig({ BACKUP_ALLOW_LOCAL_DESTINATION: "true", BACKUP_LOCAL_ROOT: BACKUP_ROOT, }, - exclude: [...configDefaults.exclude, ...(scope === "fast" ? [HEAVY] : [])], + exclude: [ + ...configDefaults.exclude, + ...(scope === "fast" ? [HEAVY] : []), + // Opt-in only: `E2E_SCOPE=update` is the sole way to run it (see UPDATE above). + ...(scope === "update" ? [] : [UPDATE]), + ], // Pulling and building images and streaming volumes all happen in // beforeAll. There is no sane default here, which is why every E2E hook // currently passes its own timeout inline. diff --git a/apps/cli/src/commands/wizard.ts b/apps/cli/src/commands/wizard.ts index 9497b0215..ff0110571 100644 --- a/apps/cli/src/commands/wizard.ts +++ b/apps/cli/src/commands/wizard.ts @@ -919,10 +919,18 @@ export async function runWizard(): Promise { migratedCertPems, migratedStaticRootOverrides, ); - if (!imported.ok) { + // A PARTIAL import is not a total failure: `importMigratedSites` returns + // ok:false when even one site missed, so keying the warning off `ok` and + // printing `migratedSites.length` claimed every site was dark one line after + // the import itself said "Migrated 3/4". Report only the real shortfall, and + // leave the retry advice to the import — it's the only layer that knows + // whether the cause was transient (edge still starting) or a config it will + // reject identically on every re-run. + const missed = migratedSites.length - imported.registered.length; + if (missed > 0) { log.warn( - `Your ${migratedSites.length} existing site${migratedSites.length === 1 ? "" : "s"} ` + - "aren't served yet — re-run `openship up` to retry the import.", + `${missed} of your ${migratedSites.length} existing site${migratedSites.length === 1 ? "" : "s"} ` + + `${missed === 1 ? "isn't" : "aren't"} served yet — see the import output above.`, ); } } diff --git a/apps/cli/src/lib/compose.ts b/apps/cli/src/lib/compose.ts index 1d4b3af7f..a9915e0af 100644 --- a/apps/cli/src/lib/compose.ts +++ b/apps/cli/src/lib/compose.ts @@ -39,7 +39,9 @@ import { sanitizeEdgeVhosts } from "@repo/adapters/proxy"; import { DEFAULT_IMAGE_REGISTRY, explainHostChannelCause, + hostChannelAccount, HOST_CHANNEL_BLOCKED, + HOST_CHANNEL_DEFAULT_ACCOUNT, HOST_CHANNEL_RECHECK, HOST_CHANNEL_UNAFFECTED, wrapText, @@ -793,10 +795,18 @@ export type HostChannelTarget = { user: string; keyPath: string; authKeysPath: string; - viaSudo: boolean; /** No way to reach root (non-root invoker, no passwordless sudo): the channel logs * in as the invoker and CANNOT do root host ops — the caller must warn. */ rootUnavailable: boolean; + /** + * How this channel reaches root once connected — what the api will do at run time, not + * what provisioning did. + * + * `login` is a root session; `sudo` is a non-root session that elevates per operation + * through `privilegedExecutor`. Surfaced so the dry-run preview can say which, instead + * of repeating a root warning that no longer applies to the sudo case. + */ + elevation: "login" | "sudo" | "none"; }; const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys"; @@ -805,15 +815,36 @@ const ROOT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys"; * PURE. Decide which account the container→host SSH channel logs in as, given the * invoking user and whether passwordless sudo is available. * - * The platform runs every host op AS ROOT — `/root/.openship` state, iRedMail install, - * the edge binding :80/:443 — and the auto-created server record defaults `ssh_user=root`. - * So the channel must be root, or the two disagree and host ops fail on root-owned paths - * (issue #489: `mkdir: cannot create directory '/root': Permission denied`). - * - * - invoked AS root → already root, write /root directly. - * - non-root + passwordless sudo → authorize root's authorized_keys via sudo, log in as root. - * - non-root, no sudo → fall back to the invoking user (a channel that can't do - * root host ops) and flag it so the caller warns loudly. + * Host operations need root. They do NOT need a root LOGIN — that is the distinction this + * function used to miss. `privilegedExecutor` already elevates a non-root sudo session per + * operation (packages/adapters/src/system/privilege.ts), and the deploy path, the + * installer and the on-box state store all reach root that way. So a sudo-capable operator + * gets a channel that logs in AS THEM and elevates, rather than one that mints a standing + * root SSH credential to buy access the platform already had. + * + * Minting it was the old middle arm, and it bought a materially bigger blast radius than + * the docker socket this channel is justified against — the argument this file already + * makes about the key's own scope. It also broke boxes it was supposed to help: a host with + * `PermitRootLogin no` (a normal hardening choice, and #527's likely shape) got a root + * channel sshd refuses, after the working alternative had been revoked. + * + * - invoked AS root → already root, write /root directly. Nothing to mint. + * - non-root + passwordless sudo → log in as the invoker and ELEVATE per operation. + * - non-root, no sudo → the invoker, with no route to root at all; flagged so + * the caller warns loudly. + * + * What still needs a root login, and is therefore degraded on the sudo arm: the host + * TERMINAL is a PTY channel that no elevation decorator wraps, so it opens an unprivileged + * shell (the operator types `sudo -i`). SFTP cannot be elevated either, which is why + * `scratchDir()` exists beside `stateDir()` in adapters. + * + * An operator who WANTS a root channel runs the installer as root — `sudo openship up` + * makes the invoker uid 0 and takes the first arm. That is deliberately the only way to + * end up with a standing root credential: this function will not mint one for an account + * it is not logging in as. It is also the escape hatch for a box provisioned as root by an + * older CLI, because a re-run as a non-root sudo user MIGRATES that install to an invoker + * channel (and revokes the old root line). Nothing has to move for that: the on-box state + * under /root/.openship is reached through `privilegedExecutor`, which elevates. * * Exported for tests: this decision is the whole fix, so it's verified directly. */ @@ -824,17 +855,23 @@ export function chooseHostChannelUser(input: { hasPasswordlessSudo: boolean; }): Omit { if (input.invokerUid === 0) { - return { user: "root", authKeysPath: ROOT_AUTHORIZED_KEYS, viaSudo: false, rootUnavailable: false }; - } - if (input.hasPasswordlessSudo) { - return { user: "root", authKeysPath: ROOT_AUTHORIZED_KEYS, viaSudo: true, rootUnavailable: false }; + return { + user: "root", + authKeysPath: ROOT_AUTHORIZED_KEYS, + rootUnavailable: false, + elevation: "login", + }; } - return { + const invoker = { user: input.invokerName, authKeysPath: join(input.invokerHome, ".ssh", "authorized_keys"), - viaSudo: false, - rootUnavailable: true, }; + if (input.hasPasswordlessSudo) { + // Reaches root through sudo at run time, so this is NOT `rootUnavailable` — the + // caller must not warn that mail and edge will fail, because they won't. + return { ...invoker, rootUnavailable: false, elevation: "sudo" }; + } + return { ...invoker, rootUnavailable: true, elevation: "none" }; } /** @@ -893,30 +930,12 @@ function authorizeKeyAt(authKeysPath: string, pub: string): void { } /** - * The same read-modify-write, but on /root/.ssh/authorized_keys through `sudo -n`, for a - * non-root invoker with passwordless sudo. Returns false if any step fails so the caller - * can fall back rather than leave a half-authorized root channel. + * Openship no longer AUTHORIZES a root key on a non-root box — `chooseHostChannelUser` + * logs in as the invoker and elevates instead, so there is nothing to mint. The revoke + * below is deliberately kept: an install provisioned by an older CLI has a root line we + * are no longer using, and leaving a standing root credential behind would be worse than + * having created it. */ -function authorizeRootKeyViaSudo(pub: string): boolean { - const mk = spawnSync("sudo", ["-n", "sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"], { - stdio: "ignore", - }); - if (mk.status !== 0) return false; - const read = spawnSync( - "sudo", - ["-n", "sh", "-c", `cat ${ROOT_AUTHORIZED_KEYS} 2>/dev/null || true`], - { encoding: "utf8" }, - ); - if (read.status !== 0) return false; - const next = rewriteHostAuthorizedKeys(read.stdout ?? "", pub); - // `tee` (not a shell `>`) so the redirect happens under root, not this shell. - const write = spawnSync("sudo", ["-n", "tee", ROOT_AUTHORIZED_KEYS], { - input: next, - stdio: ["pipe", "ignore", "ignore"], - }); - if (write.status !== 0) return false; - return spawnSync("sudo", ["-n", "chmod", "600", ROOT_AUTHORIZED_KEYS], { stdio: "ignore" }).status === 0; -} /** * Best-effort: strip our line from an `authorized_keys` this process can write. @@ -937,8 +956,9 @@ function revokeKeyAt(authKeysPath: string): boolean { } } -/** The same, on /root/.ssh/authorized_keys through `sudo -n` — the mirror of - * {@link authorizeRootKeyViaSudo}, for a non-root invoker with passwordless sudo. */ +/** The same, on /root/.ssh/authorized_keys through `sudo -n`. Sweeps a root line left by + * an older CLI (or by a box that used to be provisioned as root) once this install has + * moved to an invoker channel. */ function revokeRootKeyViaSudo(): boolean { const read = spawnSync( "sudo", @@ -980,7 +1000,7 @@ function retireHostSshChannel(prev: Record): void { // Which file holds the line is what the channel logged in AS — root unless it fell // back to the invoker (#489). A pre-#489 install wrote the invoker's file either way, // so that one is always swept too. - const asRoot = (prev.OPENSHIP_HOST_SSH_USER?.trim() || "root") === "root"; + const asRoot = hostChannelAccount(prev) === HOST_CHANNEL_DEFAULT_ACCOUNT; const weAreRoot = typeof process.getuid === "function" && process.getuid() === 0; const revoked = asRoot ? weAreRoot @@ -1025,12 +1045,26 @@ type HostChannelIssueCode = /** Reading or authorizing it threw: a `mkdir`/`authorized_keys` this user can't touch. */ | "error" /** Provisioned fine, but nothing on this host is listening for SSH (see probeHostSshd). */ - | "no-sshd"; + | "no-sshd" + /** + * sshd is listening and REFUSED the key we just authorized (see verifyChannelAuth). + * + * The state #527 had no name for. `authorized_keys` is a file we write, not a verdict + * sshd gives: it can be the wrong account's file, or the right one on a host whose + * sshd permits that account no login at all. Both passed every check this install ran, + * so the operator was told host control was on and found out weeks later, through an + * unrelated operation, in wording that blamed their own credentials. + */ + | "auth-refused"; export interface HostChannelIssue { code: HostChannelIssueCode; /** The errno/message/port behind it, verbatim, for the operator. */ detail?: string; + /** The host account the channel logs in as. Only `auth-refused` needs it, and needs it + * badly: "the key was refused" is unactionable until you know WHICH account to go and + * authorize it for (root via sudo, or the invoking user — see chooseHostChannelUser). */ + account?: string; } interface HostChannelProvision { @@ -1072,14 +1106,21 @@ function provisionHostSshChannel(cfg: { }): HostChannelProvision { const target = plannedHostChannel(cfg.hostControl); if (!target) return { channel: null }; - const { user, keyPath, authKeysPath, viaSudo, rootUnavailable } = target; + const { user, keyPath, authKeysPath, elevation, rootUnavailable } = target; const provisioned = (channel: { user: string; keyPath: string }): HostChannelProvision => { // The key is authorized and `.env` is about to point the api at the host, but a key // is not a server: `ssh-keygen` is the openssh CLIENT and `authorized_keys` is just a // file, so every step above succeeds on a box with no sshd at all. const port = toPort(cfg.hostSshPort) ?? HOST_CHANNEL_DEFAULT_PORT; - return probeHostSshd(port) === "absent" - ? { channel, issue: { code: "no-sshd", detail: `port ${port}` } } + if (probeHostSshd(port) === "absent") { + return { channel, issue: { code: "no-sshd", detail: `port ${port}` } }; + } + // And a LISTENER is not an accepted key. This is the check whose absence is #527: + // everything above can succeed while sshd still refuses the account, and the only + // thing that can settle it is asking sshd. + const auth = verifyChannelAuth(channel.user, channel.keyPath, port); + return auth.state === "refused" + ? { channel, issue: { code: "auth-refused", detail: auth.detail, account: channel.user } } : { channel }; }; try { @@ -1111,32 +1152,75 @@ function provisionHostSshChannel(cfg: { return provisioned({ user, keyPath }); } - if (viaSudo) { - if (!authorizeRootKeyViaSudo(pub)) { - // `sudo -n true` passed but a step still failed — don't leave a half-authorized root - // channel. Fall back to the invoker's own file and dial in as them, with a warning. - console.warn( - " ⚠ Could not authorize the host key for root via sudo — falling back to the\n" + - " current user. Root host operations (mail/edge) may fail.", - ); - const invoker = userInfo().username; - authorizeKeyAt(join(homedir(), ".ssh", "authorized_keys"), pub); - return provisioned({ user: invoker, keyPath }); - } - // The channel now logs in as root, so any line the old CLI left under the invoking - // user is dead — revoke it. - revokeKeyAt(join(homedir(), ".ssh", "authorized_keys")); - return provisioned({ user, keyPath }); - } - - // Root invoker writing /root directly (authKeysPath === /root/.ssh/authorized_keys). + // The account's OWN file, on every arm: a root invoker writes /root directly, and a + // non-root one writes its own home. Nothing is ever authorized for an account we are + // not logging in as — which is what collapsed the old write-goes-through-sudo + // distinction: no arm touches another account's authorized_keys any more. authorizeKeyAt(authKeysPath, pub); + // Switching AWAY from root must not leave the old root key live. Symmetric with the + // sweep the root-ward transition used to do alone, and cheap: `revokeRootKeyViaSudo` + // is a no-op when there is no line of ours to remove. + if (elevation === "sudo") revokeRootKeyViaSudo(); return provisioned({ user, keyPath }); } catch (err) { return { channel: null, issue: { code: "error", detail: (err as Error)?.message } }; } } +type ChannelAuthCheck = + | { state: "ok" } + | { state: "refused"; detail: string } + /** The question could not be asked — no `ssh` client, or it never ran. Never reported + * as a refusal: failing an otherwise-good install over a missing binary would be the + * opposite mistake. */ + | { state: "unknown" }; + +/** + * Does sshd actually ACCEPT this key for this account? + * + * Dialed at 127.0.0.1, not `host.docker.internal` — that name only resolves inside the + * container, and it is not the half in question. This asks the one thing only sshd can + * answer (is this key authorized for this user, and may this user log in at all); the + * bridge half is what `probeHostSshd` and the firewall probe already cover. + * + * `BatchMode=yes` is load-bearing: without it a host that falls back to password auth + * would sit at a prompt inside `openship up` forever. `UserKnownHostsFile=/dev/null` + * keeps a verification dial from writing an entry for a host the operator never asked to + * trust, and `PreferredAuthentications=publickey` stops a success arriving via some other + * method — which would report the channel working when its key is not. + */ +function verifyChannelAuth(user: string, keyPath: string, port: number): ChannelAuthCheck { + const r = spawnSync( + "ssh", + [ + "-i", keyPath, + "-p", String(port), + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + "-o", "PreferredAuthentications=publickey", + "-o", "IdentitiesOnly=yes", + `${user}@127.0.0.1`, + "true", + ], + { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"], timeout: 15_000 }, + ); + + if (r.status === 0) return { state: "ok" }; + if ((r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" || r.status === null) { + return { state: "unknown" }; + } + // `StrictHostKeyChecking=no` announces every first contact on stderr; that line is not + // a cause and would bury the one that is. + const detail = (r.stderr || "") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !/^Warning: Permanently added/.test(l)) + .join(" "); + return { state: "refused", detail: detail || `ssh exited ${r.status}` }; +} + /** Why `ssh-keygen` didn't produce a key — a missing binary is the common one, and it * reads as an exit code with no output unless it's named. */ function keygenWhy(r: { status: number | null; stderr?: string; error?: Error }): string { @@ -1214,7 +1298,7 @@ export function listensOnPort(listing: string, port: number): boolean { function carriedHostChannel(prev: Record): { user: string; keyPath: string } | null { const keyPath = prev.OPENSHIP_HOST_KEY_PATH?.trim(); if (!prev.OPENSHIP_HOST_SSH_HOST?.trim() || !keyPath || !existsSync(keyPath)) return null; - return { user: prev.OPENSHIP_HOST_SSH_USER?.trim() || "root", keyPath }; + return { user: hostChannelAccount(prev), keyPath }; } const HOST_ISSUE_INDENT = " "; @@ -1252,7 +1336,9 @@ export function renderHostChannelIssue( issue.code === "no-sshd" ? ` ⚠ Host control is provisioned, but nothing on this host is listening for SSH` + `${detail}.` - : ` ⚠ Host control could NOT be provisioned on this box.`; + : issue.code === "auth-refused" + ? ` ⚠ Host control is provisioned, but this host REFUSED the channel's key.` + : ` ⚠ Host control could NOT be provisioned on this box.`; let cause: string; switch (issue.code) { @@ -1278,13 +1364,25 @@ export function renderHostChannelIssue( sshdEnableHint: thisHost().sshdEnableHint(), }).body; break; + case "auth-refused": + // The one cause here an operator can act on immediately, so it names both the + // possibilities and the command that shows which — #527's reporter had neither, and + // spent a dozen messages moving key files that were never read. + cause = + `The key was authorized for \`${issue.account ?? "the channel account"}\` and sshd ` + + `is listening, but the dial was refused${detail}. Either that account's ` + + `\`authorized_keys\` is not the ` + + `file we wrote to, or sshd permits it no login — \`sshd -T | grep -i ` + + `permitrootlogin\` shows the second. Until it is fixed the api container will be ` + + `refused every time it dials ${ctx.target}.`; + break; case "error": cause = `Setting up the channel's key failed${detail}.`; break; } const continuity = - issue.code === "no-sshd" + issue.code === "no-sshd" || issue.code === "auth-refused" ? "" : ctx.kept ? hostIssueNote( @@ -2040,7 +2138,7 @@ export function composeHostChannel(): { host: string; port: number; user: string return { host, port: toPort(env.OPENSHIP_HOST_SSH_PORT) ?? 22, - user: env.OPENSHIP_HOST_SSH_USER?.trim() || "root", + user: hostChannelAccount(env), }; } diff --git a/apps/cli/src/lib/repair.ts b/apps/cli/src/lib/repair.ts index 29d883a6a..f3cad1434 100644 --- a/apps/cli/src/lib/repair.ts +++ b/apps/cli/src/lib/repair.ts @@ -26,6 +26,7 @@ import { import { startService, ensureInternalToken } from "../commands/up"; import { summarizeHostChannelCause, + HOST_CHANNEL_AUTH_REJECTED_SHORT, HOST_CHANNEL_PROVISION_COMMAND, type HostChannelCause, } from "@repo/core"; @@ -279,6 +280,11 @@ export function hostControlRow( case "key_unreadable": case "unreachable": return row("fail", `${api.state.replace(/_/g, " ")} — see the api container's boot log`); + // Its own case, NOT grouped above: this is the one fault here an operator can act + // on without the withheld address, and #527 is what happens when the only thing + // we tell them is to go read a log. + case "auth_rejected": + return row("fail", HOST_CHANNEL_AUTH_REJECTED_SHORT); // `disabled` (a hardening choice) and `not_applicable` (a bare install) are the // same non-event the local path returns null for. Whitelisted rather than derived // from `ok === false`, which `disabled` also is. diff --git a/apps/cli/src/lib/up-plan.ts b/apps/cli/src/lib/up-plan.ts index 44479465d..195ae014b 100644 --- a/apps/cli/src/lib/up-plan.ts +++ b/apps/cli/src/lib/up-plan.ts @@ -174,10 +174,19 @@ export async function planUp(opts: UpPlanOpts): Promise { ...(stack.hostChannel ? [ stack.hostChannel.keyPath, - `${stack.hostChannel.authKeysPath}${stack.hostChannel.viaSudo ? " (via sudo)" : ""} (authorizes that key for ${stack.hostChannel.user}'s host ops; --no-host-control skips it)`, + `${stack.hostChannel.authKeysPath} (authorizes that key for ${stack.hostChannel.user}'s host ops; --no-host-control skips it)`, + // Say which route to root this channel will take, rather than repeating a root + // warning at a box that reaches root perfectly well via sudo. `sudo` is a + // supported shape now, not a degradation, and the preview is where an operator + // decides whether that is what they wanted. + ...(stack.hostChannel.elevation === "sudo" + ? [ + `logs in as ${stack.hostChannel.user} and elevates with sudo for root host ops (no root login is created)`, + ] + : []), ...(stack.hostChannel.rootUnavailable ? [ - "⚠ host control needs root — this user isn't root and passwordless sudo isn't available, so mail/edge host ops will fail (re-run as root to fix)", + "⚠ host control needs root — this user isn't root and passwordless sudo isn't available, so mail/edge host ops will fail (re-run as root, or enable passwordless sudo, to fix)", ] : []), ] diff --git a/apps/cli/test/unit/compose-env-preserve.test.ts b/apps/cli/test/unit/compose-env-preserve.test.ts index f164a532c..8e204ba03 100644 --- a/apps/cli/test/unit/compose-env-preserve.test.ts +++ b/apps/cli/test/unit/compose-env-preserve.test.ts @@ -46,6 +46,16 @@ const h = vi.hoisted(() => ({ stdout: "LISTEN 0 128 0.0.0.0:22 0.0.0.0:*", stderr: "", }), + /** + * The verification dial (`ssh … true`) that proves sshd ACCEPTS the key we just + * authorized (#527). Default: it does — so the refusal warning only fires for the + * tests that ask for it, the same convention `listeners` follows. + * + * Declared explicitly rather than left to the mock's status-0 fallthrough: a check + * whose default answer is an accident is a check that stops being exercised the moment + * the fallthrough changes. + */ + channelAuth: () => ({ status: 0, stdout: "", stderr: "" }), })); vi.mock("node:child_process", () => ({ @@ -68,6 +78,8 @@ vi.mock("node:child_process", () => ({ // Host-channel provisioning: the keypair, then the sshd prerequisite (#509). if (cmd === "ssh-keygen") return h.keygen(); if (cmd === "ss" || cmd === "netstat") return h.listeners(); + // ...then the dial that asks sshd whether the key actually works (#527). + if (cmd === "ssh") return h.channelAuth(); // #486 preflight: registry manifest probe. Driven per-ref by h.manifestInspect. if (cmd === "docker" && args[0] === "manifest" && args[1] === "inspect") { return h.manifestInspect(String(args[2])); @@ -1536,6 +1548,52 @@ describe("composeUp — the host channel is provisioned out loud (#509)", () => expect(writtenEnv().OPENSHIP_HOST_SSH_HOST).toBe("host.docker.internal"); expect(warned.join("\n")).not.toContain("listening for SSH"); }); + + /** + * #527: a LISTENER is not an accepted key. + * + * The reporter's box passed every check above — key generated, `authorized_keys` + * written, sshd listening on 22 — and the install said host control was on. The channel + * was refused on every dial, which they discovered weeks later through an unrelated + * feature, worded as their own stored credentials being wrong. The only thing that can + * settle it is asking sshd, and nothing did. + */ + it("reports a key sshd refuses, and still writes the channel", async () => { + seedHostKey(); + h.channelAuth = () => ({ + status: 255, + stdout: "", + stderr: "root@127.0.0.1: Permission denied (publickey).", + }); + + const res = await composeUp({}); + + // Still not fatal, and the key stays authorized: it is right the moment the host + // permits that account, exactly as the no-sshd case stays right once sshd starts. + expect(res.ok).toBe(true); + expect(writtenEnv().OPENSHIP_HOST_SSH_HOST).toBe("host.docker.internal"); + const msg = warned.join("\n"); + expect(msg).toContain("REFUSED the channel's key"); + // The account, because "the key was refused" is unactionable without knowing which + // authorized_keys to go and look at. + expect(msg).toContain("root"); + // Both causes, and the command that distinguishes them — the half the reporter was + // never told, and could not have guessed from inside a container. + expect(msg).toContain("permitrootlogin"); + expect(msg).toContain("Ordinary deploys to this box still work"); + }); + + it("stays quiet when the verification dial can't run at all", async () => { + // No `ssh` client: the question was never asked, and answering "refused" would fail + // an install that is fine — the mirror of the ss-not-installed case above. + seedHostKey(); + h.channelAuth = () => ({ status: null, stdout: "", stderr: "", error: Object.assign(new Error("spawn ssh ENOENT"), { code: "ENOENT" }) }); + + await composeUp({}); + + expect(writtenEnv().OPENSHIP_HOST_SSH_HOST).toBe("host.docker.internal"); + expect(warned.join("\n")).not.toContain("REFUSED"); + }); }); describe("composePgDataRisk — the disk-facing gate entry (#487)", () => { diff --git a/apps/cli/test/unit/host-ssh-authorized-keys.test.ts b/apps/cli/test/unit/host-ssh-authorized-keys.test.ts index f490ad764..b929c9403 100644 --- a/apps/cli/test/unit/host-ssh-authorized-keys.test.ts +++ b/apps/cli/test/unit/host-ssh-authorized-keys.test.ts @@ -147,23 +147,26 @@ describe("chooseHostChannelUser", () => { expect(out).toEqual({ user: "root", authKeysPath: "/root/.ssh/authorized_keys", - viaSudo: false, rootUnavailable: false, + elevation: "login", }); }); - it("authorizes root via sudo when a non-root user has passwordless sudo", () => { + it("logs in as the invoker and elevates when a non-root user has passwordless sudo", () => { const out = chooseHostChannelUser({ invokerUid: 1000, invokerName: "ubuntu", invokerHome: "/home/ubuntu", hasPasswordlessSudo: true, }); + // The change #527 argued for: root is reached by ELEVATING this session, not by + // minting a standing root SSH credential. `rootUnavailable` stays false because root + // is still reachable — through sudo — so the caller must not warn that mail/edge fail. expect(out).toEqual({ - user: "root", - authKeysPath: "/root/.ssh/authorized_keys", - viaSudo: true, + user: "ubuntu", + authKeysPath: "/home/ubuntu/.ssh/authorized_keys", rootUnavailable: false, + elevation: "sudo", }); }); @@ -177,8 +180,8 @@ describe("chooseHostChannelUser", () => { expect(out).toEqual({ user: "deploy", authKeysPath: "/home/deploy/.ssh/authorized_keys", - viaSudo: false, rootUnavailable: true, + elevation: "none", }); }); }); diff --git a/apps/dashboard/scripts/check-i18n.mjs b/apps/dashboard/scripts/check-i18n.mjs index 343d5f634..8eadfab65 100644 --- a/apps/dashboard/scripts/check-i18n.mjs +++ b/apps/dashboard/scripts/check-i18n.mjs @@ -30,18 +30,45 @@ function leafKeys(obj, prefix = "") { return out; } +function leafEntries(obj, prefix = "", out = {}) { + for (const [k, v] of Object.entries(obj)) { + const kp = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object" && !Array.isArray(v)) leafEntries(v, kp, out); + else out[kp] = v; + } + return out; +} + function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } +/** + * Is this value worth flagging when a locale copies English verbatim? + * + * Plenty of strings are LEGITIMATELY identical across languages — "Email", "DNS", + * "GitHub", "OK", "URL", a `••••••••` placeholder, `you@example.com`. Flagging those + * would bury the real signal, so this only considers values that read like PROSE: + * multiple words, and long enough that a shared product noun is unlikely. + * + * That is a heuristic, deliberately. It exists to catch the case that key-parity + * cannot see at all — a key that is PRESENT in every locale while still holding the + * English sentence, so the UI silently renders English and every test stays green. + */ +function looksTranslatable(value) { + return typeof value === "string" && value.trim().length > 3 && /\s/.test(value.trim()); +} + /** * Compare every non-English locale to the English source across all namespaces. * @returns {{ * missing: {locale:string,namespace:string,key:string}[], * extra: {locale:string,namespace:string,key:string}[], + * untranslated: {locale:string,namespace:string,key:string}[], * byNamespaceMissing: Record, * byLocaleMissing: Record, - * totalMissing: number, totalExtra: number, + * byNamespaceUntranslated: Record, + * totalMissing: number, totalExtra: number, totalUntranslated: number, * }} */ export function checkI18nParity(localesDir = defaultLocalesDir()) { @@ -56,18 +83,29 @@ export function checkI18nParity(localesDir = defaultLocalesDir()) { const missing = []; const extra = []; + const untranslated = []; const byNamespaceMissing = {}; const byLocaleMissing = {}; + const byNamespaceUntranslated = {}; for (const ns of namespaces) { - const base = leafKeys(readJson(path.join(enDir, `${ns}.json`))); + const baseEntries = leafEntries(readJson(path.join(enDir, `${ns}.json`))); + const base = Object.keys(baseEntries); const baseSet = new Set(base); for (const locale of locales) { const file = path.join(localesDir, locale, `${ns}.json`); - let localeKeys = new Set(); - if (fs.existsSync(file)) localeKeys = new Set(leafKeys(readJson(file))); + let localeEntries = {}; + if (fs.existsSync(file)) localeEntries = leafEntries(readJson(file)); + const localeKeys = new Set(Object.keys(localeEntries)); for (const k of base) if (!localeKeys.has(k)) missing.push({ locale, namespace: ns, key: k }); for (const k of localeKeys) if (!baseSet.has(k)) extra.push({ locale, namespace: ns, key: k }); + // Present, but still the English sentence — invisible to the two checks above. + for (const k of localeKeys) { + if (!baseSet.has(k)) continue; + if (localeEntries[k] === baseEntries[k] && looksTranslatable(baseEntries[k])) { + untranslated.push({ locale, namespace: ns, key: k }); + } + } } } @@ -75,14 +113,20 @@ export function checkI18nParity(localesDir = defaultLocalesDir()) { byNamespaceMissing[m.namespace] = (byNamespaceMissing[m.namespace] ?? 0) + 1; byLocaleMissing[m.locale] = (byLocaleMissing[m.locale] ?? 0) + 1; } + for (const u of untranslated) { + byNamespaceUntranslated[u.namespace] = (byNamespaceUntranslated[u.namespace] ?? 0) + 1; + } return { missing, extra, + untranslated, byNamespaceMissing, byLocaleMissing, + byNamespaceUntranslated, totalMissing: missing.length, totalExtra: extra.length, + totalUntranslated: untranslated.length, }; } @@ -95,12 +139,15 @@ if (isMain()) { const full = process.argv.includes("--full"); const r = checkI18nParity(); - if (r.totalMissing === 0 && r.totalExtra === 0) { + if (r.totalMissing === 0 && r.totalExtra === 0 && r.totalUntranslated === 0) { console.log("✓ i18n parity: every locale matches the English source."); process.exit(0); } - console.log(`i18n drift vs "${SOURCE_LOCALE}" — missing: ${r.totalMissing}, extra: ${r.totalExtra}\n`); + console.log( + `i18n drift vs "${SOURCE_LOCALE}" — missing: ${r.totalMissing}, extra: ${r.totalExtra}, ` + + `untranslated: ${r.totalUntranslated}\n`, + ); const nsRows = Object.entries(r.byNamespaceMissing).sort((a, b) => b[1] - a[1]); if (nsRows.length) { @@ -114,6 +161,14 @@ if (isMain()) { for (const [l, n] of locRows) console.log(` ${l.padEnd(6)} ${n}`); console.log(""); } + // Present in every locale, still the English sentence — the case key parity cannot + // see, and the one that renders English in the UI while looking complete. + const untRows = Object.entries(r.byNamespaceUntranslated).sort((a, b) => b[1] - a[1]); + if (untRows.length) { + console.log("Untranslated VALUES by namespace (locale string == English):"); + for (const [ns, n] of untRows) console.log(` ${ns.padEnd(18)} ${n}`); + console.log(""); + } if (full) { const group = (items) => { @@ -125,6 +180,13 @@ if (isMain()) { } return [...m.entries()].sort((a, b) => a[0].localeCompare(b[0])); }; + if (r.untranslated.length) { + console.log("UNTRANSLATED (key → locales still showing English):"); + for (const [id, locs] of group(r.untranslated)) { + console.log(` ${id} [${[...locs].sort().join(",")}]`); + } + console.log(""); + } if (r.missing.length) { console.log("MISSING (key → locales that lack it):"); for (const [id, locs] of group(r.missing)) console.log(` ${id} [${[...locs].sort().join(",")}]`); diff --git a/apps/dashboard/src/app/(dashboard)/(deployment)/deploy/mail/page.tsx b/apps/dashboard/src/app/(dashboard)/(deployment)/deploy/mail/page.tsx index 4747f9471..5aa5eec75 100644 --- a/apps/dashboard/src/app/(dashboard)/(deployment)/deploy/mail/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/(deployment)/deploy/mail/page.tsx @@ -43,6 +43,7 @@ import { type WebmailTargetOption, } from "@/lib/api"; import { getApiErrorCode, getApiErrorMessage } from "@/lib/api/client"; +import { mailHostname } from "@repo/core"; /** The API's code for "this webmail predates the catalog app". */ const LEGACY_WEBMAIL_CODE = "LEGACY_WEBMAIL"; @@ -94,7 +95,7 @@ export default function DeployMailPage() { const existing = st.webmail?.hostname; if (existing) setDomain(existing); else if (st.webmail?.routingUnknown) setDomain(""); - else if (st.domain) setDomain(`mail.${st.domain}`); + else if (st.domain) setDomain(mailHostname(st.domain)); if (st.webmail?.legacy) setReplacing(true); } setTargets(tg.options); @@ -121,7 +122,7 @@ export default function DeployMailPage() { return true; }, [domain, selectedTarget]); - const mailHostnameFromStatus = status?.domain ? `mail.${status.domain}` : ""; + const mailHostnameFromStatus = status?.domain ? mailHostname(status.domain) : ""; // When cloud is chosen AND the chosen domain is the mail server's own // `mail.` subdomain, the deploy uses the proxy variant: the // workload runs on Opshcloud at *.opsh.io, the mail VPS proxies the @@ -131,6 +132,14 @@ export default function DeployMailPage() { selectedTarget?.kind === "opshcloud" && !!mailHostnameFromStatus && domain.toLowerCase() === mailHostnameFromStatus; + // The mail server's own hostname, deployed on the mail server itself: it already + // resolves here and already has a certificate, so there is nothing for the operator + // to set up. Worth saying, because this used to be refused (#566) and the hint that + // asks for DNS reads as work that isn't needed. + const isMailHostOnMailServer = + selectedTarget?.kind === "mail" && + !!mailHostnameFromStatus && + domain.toLowerCase() === mailHostnameFromStatus; const startDeploy = async () => { if (!canSubmit || !selectedTarget) return; @@ -190,7 +199,7 @@ export default function DeployMailPage() { // loads targets) - it drives both the proxy-variant detection and the // submit-button disabled state, so it lives there rather than here. const domainPlaceholder = status?.domain - ? `mail.${status.domain}` + ? mailHostname(status.domain) : "mail.example.com"; return ( @@ -270,9 +279,11 @@ export default function DeployMailPage() { hint={ isCloudProxyVariant ? tm.domainHintProxy - : selectedTarget?.kind === "opshcloud" - ? tm.domainHintCloud - : tm.domainHintDefault + : isMailHostOnMailServer + ? tm.domainHintMailHost + : selectedTarget?.kind === "opshcloud" + ? tm.domainHintCloud + : tm.domainHintDefault } > (resumeDeploymentId); const [projectId, setProjectId] = useState(adoptedProjectId); const [progress, setProgress] = useState(0); + // Epoch ms this install started, for the progress panel's elapsed clock. Set + // when we enter `installing`, and on a mid-install refresh from the build's own + // `buildStartedAt` so the resumed view doesn't restart the clock at zero. + const [startedAt, setStartedAt] = useState(null); const [phaseLabel, setPhaseLabel] = useState(""); const [liveUrl, setLiveUrl] = useState(null); const [logs, setLogs] = useState(""); @@ -670,6 +675,9 @@ export default function AppInstallPage() { const s = res?.data ?? res ?? {}; const status: string = s.deploymentStatus ?? s.status ?? ""; if (typeof s.progress === "number") setProgress(s.progress); + // Resuming: keep the clock on the real start when the build reports one. + const startedIso = Date.parse(String(s.buildStartedAt ?? "")); + setStartedAt((prev) => prev ?? (Number.isFinite(startedIso) ? startedIso : Date.now())); if ( ["ready", "failed", "cancelled", "partial_failure", "action_required", "rejected", "no_changes"].includes( status, @@ -908,6 +916,7 @@ export default function AppInstallPage() { deployTarget: "server", serverId: server.id, serverHost: server.sshHost, + serverName: server.name ?? undefined, }) } /> @@ -1004,6 +1013,7 @@ export default function AppInstallPage() { } } setPhaseLabel(w.phaseQueued); + setStartedAt(Date.now()); setPhase("installing"); } catch (err) { // Strip the server's "Pre-deploy checks failed:" prefix for a cleaner @@ -1078,6 +1088,7 @@ export default function AppInstallPage() { setErrorMsg(""); setDeploymentId(null); setCancelled(false); + setStartedAt(null); try { const url = new URL(window.location.href); url.searchParams.delete("deployment"); @@ -1087,6 +1098,96 @@ export default function AppInstallPage() { } setPhase("form"); }; + + // What this install was configured WITH — the aside's read-out, and the thing + // an operator can't get from the stepper or the logs. Every row is read from + // the pickers' own state, so it shows the configuration that was sent, never + // one re-derived from the template. A value this view can't know (the + // destination after a mid-install refresh — only the routing pickers + // rehydrate) is left out rather than guessed. + const summary: DeploySummaryRow[] = []; + const destinationValue = + destination?.deployTarget === "cloud" + ? t.deploy.targetStep.options.cloud + : (destination?.serverName || destination?.serverHost || ""); + if (destinationValue) { + summary.push({ + id: "destination", + label: w.summaryDestination, + value: destinationValue, + mono: destination?.deployTarget === "server" && !destination.serverName, + }); + } + for (const e of appEndpoints) { + const st = expo[endpointKey(e)]; + if (!st) continue; + const id = `ep-${endpointKey(e)}`; + const hostPort = hostPortForEndpoint(template.services, e); + if (st.kind === "http" && st.mode === "domain") { + // The hostname the install will actually write — same resolver the + // routing payload uses, seeded with the same default label. + const host = resolvePublicEndpointHostname( + { + domainType: st.ep.domainType, + domain: st.ep.domain.trim() ? normalizeServiceLabel(st.ep.domain) : defaultFreeLabel(e), + customDomain: normalizeCustomHostname(st.ep.customDomain), + }, + baseDomain, + ); + if (host) summary.push({ id, label: e.label, value: host, mono: true }); + } else if (st.kind === "tcp" && st.mode === "internal") { + summary.push({ id, label: e.label, value: w.tcpInternalLabel }); + } else { + // Port-only web, or a published database port: the reachable HOST port. + summary.push({ + id, + label: e.label, + value: destination?.serverHost ? `${destination.serverHost}:${hostPort}` : `:${hostPort}`, + mono: true, + }); + } + } + const serviceCount = template.services?.length ?? 0; + if (serviceCount > 0) { + summary.push({ id: "services", label: w.summaryServices, value: String(serviceCount) }); + } + for (const req of requires) { + const sourceName = candidates.find((p) => p.id === connChoices[req.id])?.name; + if (sourceName) { + summary.push({ + id: `req-${req.id}`, + label: resolveLocalized(req.label, locale), + value: sourceName, + }); + } + } + // The business fields the operator filled in. Secrets are never shown, and a + // boolean is skipped rather than rendered as a bare "true"; capped so a + // settings-heavy app doesn't push the actions off a short viewport. + const fieldValueOf = (service: string, key: string) => values[fk(service, key)]; + for (const f of installFields) { + if (summary.length >= 10) break; + if (f.secret || f.type === "boolean" || !isFieldVisible(f, fieldValueOf)) continue; + const raw = values[fk(f.service, f.key)]; + if (typeof raw !== "string" || raw.trim() === "") continue; + summary.push({ + id: `set-${f.service}-${f.key}`, + label: f.label, + value: f.options?.find((o) => o.value === raw)?.label ?? raw.trim(), + }); + } + if (declaresResources) { + const needs = [ + template.minResources?.memoryMb + ? interpolate(w.needsMemory, { value: formatMemoryMb(template.minResources.memoryMb) }) + : null, + template.minResources?.cpuCores ? formatCpuCores(template.minResources.cpuCores) : null, + ] + .filter(Boolean) + .join(" · "); + if (needs) summary.push({ id: "resources", label: w.needsTitle, value: needs }); + } + return ( mailServers.find((s) => s.completed && s.domain) ?? null, [mailServers], ); - const ourMailHost = ourMailServer?.domain ? `mail.${ourMailServer.domain}` : null; + const ourMailHost = ourMailServer?.domain ? mailHostname(ourMailServer.domain) : null; useEffect(() => { void (async () => { diff --git a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/_shared/logs-drawer.tsx b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/_shared/logs-drawer.tsx index 31e094031..28b895099 100644 --- a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/_shared/logs-drawer.tsx +++ b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/_shared/logs-drawer.tsx @@ -1,14 +1,14 @@ "use client"; /** - * Reusable journalctl tail drawer. Slides in from the right, locks body - * scroll, ESC + backdrop close. Shared by: + * Daemon log drawer for the Health tab. Slides in from the right, locks body + * scroll, ESC + backdrop close. * - * - Health tab - "Logs" link on a failed component. - * - Advanced tab - every row in the Components panel. - * - * Keeping a single implementation means both surfaces stay in sync when - * we add features (filter, follow, copy-all). + * The header prints the read the SERVER performed (`logs.source`) rather than a + * command this file composes. It used to hardcode `journalctl -u `, which on + * the container engine named a log that does not exist — the real read is + * `docker exec openship-mail tail -n N /var/log/supervisor/.log`. Re-deriving + * it here would only move the guess client-side; only the box knows its topology. */ import { useCallback, useEffect, useRef, useState } from "react"; @@ -19,7 +19,6 @@ import { useI18n, interpolate } from "@/components/i18n-provider"; interface LogsDrawerProps { serverId: string; componentKey: string; - unit: string; label: string; onClose: () => void; } @@ -27,7 +26,6 @@ interface LogsDrawerProps { export function LogsDrawer({ serverId, componentKey, - unit, label, onClose, }: LogsDrawerProps) { @@ -91,8 +89,8 @@ export function LogsDrawer({

{interpolate(t.emailsAdmin.shared.logsTitle, { label })}

-

- journalctl -u {unit} -n 300 +

+ {logs?.source}

+ + + + + {usable.length === 0 && !loading && ( +

{a.noChannels}

+ )} + {error &&

{error}

} + {notice &&

{notice}

} + + r.id} + loading={loading} + empty={{ icon: Inbox, title: a.emptyTitle, description: a.emptyBody }} + rowActions={(r) => ( + <> + openEditor(r)} /> + openDelete(r)} + /> + + )} + /> + + ); +} + +function RuleForm({ + serverId, + rule, + channels, + primaryDomain, + onCancel, + onSaved, +}: { + serverId: string; + rule?: InboundRule; + channels: NotificationChannel[]; + primaryDomain: string; + onCancel: () => void; + onSaved: () => void; +}) { + const { t } = useI18n(); + const a = t.emailsAdmin.inbound; + + const [name, setName] = useState(rule?.name ?? ""); + const [scope, setScope] = useState(rule?.scope ?? "mailbox"); + const [target, setTarget] = useState(rule?.target ?? ""); + const [fromPattern, setFromPattern] = useState(rule?.fromPattern ?? ""); + const [subjectPattern, setSubjectPattern] = useState(rule?.subjectPattern ?? ""); + const [selected, setSelected] = useState(rule?.channelIds ?? []); + const [enabled, setEnabled] = useState(rule?.enabled ?? true); + + const needsTarget = scope !== "all"; + const invalid = + name.trim().length === 0 || selected.length === 0 || (needsTarget && target.trim().length === 0); + + const scopeLabel = (s: InboundScope) => + s === "mailbox" ? a.scopeMailbox : s === "domain" ? a.scopeDomain : a.scopeAll; + + return ( + { + const payload = { + name: name.trim(), + scope, + target: needsTarget ? target.trim() : null, + fromPattern: fromPattern.trim() || null, + subjectPattern: subjectPattern.trim() || null, + channelIds: selected, + enabled, + }; + // FormModalContent surfaces a thrown error, and getApiErrorMessage is what + // unwraps the server's real message — an ApiError's own `.message` is the + // useless "API 409: Conflict", which is exactly the foreign-BCC refusal the + // operator has to read to know what to do. + try { + if (rule) await mailAdminApi.inbound.update(serverId, rule.id, payload); + else await mailAdminApi.inbound.create(serverId, payload); + } catch (err) { + throw new Error(getApiErrorMessage(err, a.saveFailed)); + } + onSaved(); + }} + > + + setName(e.target.value)} + placeholder={a.namePlaceholder} + /> + + + + + + + {needsTarget && ( + + setTarget(e.target.value)} + placeholder={scope === "mailbox" ? `support@${primaryDomain}` : primaryDomain} + /> + + )} + + + setFromPattern(e.target.value)} + placeholder={a.patternPlaceholder} + /> + + + + setSubjectPattern(e.target.value)} + placeholder={a.patternPlaceholder} + /> + + + +
+ {channels.map((c) => ( + + ))} +
+
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/overview-tab.tsx b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/overview-tab.tsx index 630b206e3..116241aaa 100644 --- a/apps/dashboard/src/app/(dashboard)/emails/_components/admin/overview-tab.tsx +++ b/apps/dashboard/src/app/(dashboard)/emails/_components/admin/overview-tab.tsx @@ -60,6 +60,7 @@ import { getMarketingOrigin } from "@/lib/api/urls"; import { webmailCta } from "../../_lib/webmail-cta"; import { Skeleton } from "./_shared/skeleton"; import { useI18n, interpolate } from "@/components/i18n-provider"; +import { mailHostname } from "@repo/core"; interface OverviewTabProps { status: MailSetupStatus; @@ -69,7 +70,7 @@ interface OverviewTabProps { export function OverviewTab({ status, serverId }: OverviewTabProps) { const domain = status.domain ?? ""; - const mailHost = domain ? `mail.${domain}` : ""; + const mailHost = domain ? mailHostname(domain) : ""; return (
diff --git a/apps/dashboard/src/app/(dashboard)/emails/_components/mail-console.tsx b/apps/dashboard/src/app/(dashboard)/emails/_components/mail-console.tsx index 0111d116a..47e22d96a 100644 --- a/apps/dashboard/src/app/(dashboard)/emails/_components/mail-console.tsx +++ b/apps/dashboard/src/app/(dashboard)/emails/_components/mail-console.tsx @@ -28,7 +28,7 @@ import { type MailSSEEvent, type PortConflict, } from "@/lib/api"; -import { relayProvider } from "@repo/core"; +import { relayProvider, mailHostname } from "@repo/core"; import { mailProvider } from "@/lib/mail-providers"; import type { ServerOption } from "@/components/shared/ServerSelector"; import { PageContainer } from "@/components/ui/PageContainer"; @@ -281,7 +281,7 @@ function MailConsoleInner() { setPtrPending({ ipv4, ipv6, - target: `mail.${s.domain}`, + target: mailHostname(s.domain), resumeStep: s.resumeStep ?? 12, }); } @@ -759,7 +759,7 @@ function MailConsoleInner() { setPtrPending({ ipv4, ipv6, - target: `mail.${domain}`, + target: mailHostname(domain), resumeStep: next, }); } else { diff --git a/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.test.ts b/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.test.ts index f0dbe6eda..d854077cf 100644 --- a/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.test.ts +++ b/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.test.ts @@ -28,6 +28,7 @@ function daemon(overrides: Partial = {}): MailComponentHeal label: "Postfix", description: "SMTP server", unit: "postfix", + severity: "required", status: "active", ...overrides, }; @@ -172,4 +173,136 @@ describe("summarizeHealth", () => { expect(s?.banner).toContain("danger"); expect(s?.sub).toBe(h.summary.partDelivery); }); + + /** + * The #565 defect: a dead ClamAV painted the same red "Issues need attention" + * banner as a dead Postfix, so the one signal that means "stop what you are doing" + * fired for a box that was still delivering mail. + */ + it("is amber, not red, when only an advisory daemon is down", () => { + const s = summarizeHealth( + [ + daemon({ key: "clamav", label: "ClamAV", severity: "advisory", status: "failed", subState: "fatal" }), + ...NINE_UP, + ], + [dns("pass")], + delivery(), + h, + ); + + expect(s?.banner).toContain("warning"); + expect(s?.label).toBe(h.summary.degradedLabel); + expect(s?.sub).toContain("ClamAV"); + /** + * GH-565: this used to assert the copy said mail would "queue", which was the + * opposite of what the engine does. `apps/email/engine/samples/amavisd/amavisd.conf` + * sets neither `$virus_scanners_failure_is_fatal` nor `$final_unchecked_destiny`, and + * `$undecipherable_subject_tag` is `undef` - so a message no scanner could examine is + * DELIVERED, unlabelled. The old assertion made a passing test guard a false promise, + * which is worse than no test: an operator reading "may queue" waits for mail to flow + * again instead of learning that unscanned mail is already reaching inboxes. + * + * If you want the queue behaviour, it is one line in amavisd.conf - see the opt-in + * documented there - and this assertion should flip back at the same time. + */ + expect(s?.sub).toMatch(/WITHOUT virus scanning/i); + expect(s?.sub).not.toContain("queue"); + }); + + it("says signatures, not scanning, when only freshclam is down", () => { + const s = summarizeHealth( + [ + daemon({ key: "freshclam", label: "ClamAV updates", severity: "advisory", status: "inactive" }), + ...NINE_UP, + ], + [dns("pass")], + delivery(), + h, + ); + + expect(s?.banner).toContain("warning"); + expect(s?.sub).toContain("signatures"); + expect(s?.sub).not.toContain("queue"); + }); + + it("still names an advisory daemon when something required is also down", () => { + const s = summarizeHealth( + [ + daemon({ key: "dovecot", label: "Dovecot", status: "failed" }), + daemon({ key: "clamav", label: "ClamAV", severity: "advisory", status: "failed" }), + ...NINE_UP, + ], + [dns("pass")], + delivery(), + h, + ); + + expect(s?.banner).toContain("danger"); + expect(s?.sub).toContain("Dovecot"); + expect(s?.sub).toContain("ClamAV"); + }); + + /** + * GH-240 FP1: `spamd` is reported for completeness, but amavis scores spam through its + * own in-process Mail::SpamAssassin integration and nothing on this stack speaks to the + * daemon - so its state says nothing about whether spam filtering works. It used to be + * graded `advisory`, which put a permanent amber banner on every host that simply does + * not run it. An amber that is always wrong teaches operators to ignore the banner. + */ + it("stays green when only an informational daemon is down", () => { + const s = summarizeHealth( + [ + daemon({ key: "spamassassin", label: "SpamAssassin", severity: "informational", status: "failed" }), + ...NINE_UP, + ], + [dns("pass")], + delivery(), + h, + ); + + expect(s?.label).toBe(h.summary.allGoodLabel); + expect(s?.sub ?? "").not.toContain("SpamAssassin"); + }); + + it("stays green when an informational daemon is missing entirely", () => { + // The common shape on a legacy host: the unit was never installed. + const s = summarizeHealth( + [ + daemon({ key: "spamassassin", label: "SpamAssassin", severity: "informational", status: "missing" }), + ...NINE_UP, + ], + [dns("pass")], + delivery(), + h, + ); + + expect(s?.label).toBe(h.summary.allGoodLabel); + }); + + it("is not green while an advisory daemon is down", () => { + const s = summarizeHealth( + [daemon({ key: "fail2ban", label: "fail2ban", severity: "advisory", status: "inactive" }), ...NINE_UP], + [dns("pass")], + delivery(), + h, + ); + + expect(s?.label).not.toBe(h.summary.allGoodLabel); + expect(s?.sub).toBe("fail2ban not running"); + }); + + /** + * A row an older API didn't stamp with `severity` must fail SAFE to required — + * landing in neither bucket would leave the amber branch with a heading and no + * sentence, which this file's whole priority contract forbids. + */ + it("treats a row with no severity marker as required", () => { + const unstamped = { ...daemon({ label: "Postfix", status: "failed" }) } as MailComponentHealth; + delete (unstamped as { severity?: unknown }).severity; + + const s = summarizeHealth([unstamped, ...NINE_UP], [dns("pass")], delivery(), h); + + expect(s?.banner).toContain("danger"); + expect(s?.sub).toContain("Postfix"); + }); }); diff --git a/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.ts b/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.ts index ff5542e79..60db2a621 100644 --- a/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.ts +++ b/apps/dashboard/src/app/(dashboard)/emails/_lib/health-summary.ts @@ -6,10 +6,11 @@ * single sentence an operator can act on. That means a priority order, and the * order is the whole point: * - * red = something is broken right now (a daemon down, DNS failing, or mail - * not leaving the box) - * amber = working, but with something worth looking at (records that only warn, - * a daemon this host doesn't ship, mail waiting in the queue) + * red = something is broken right now (a REQUIRED daemon down, DNS failing, or + * mail not leaving the box) + * amber = working, but with something worth looking at (an advisory daemon down, + * records that only warn, a daemon this host doesn't ship, mail waiting + * in the queue) * green = all three readings clean * * A reading we couldn't take never colours the banner. `unknown` means "we didn't @@ -44,16 +45,30 @@ export function summarizeHealth( ): BannerSummary | null { if (!components && !checks && !delivery) return null; - // Separate "missing" from "down" - a unit that isn't installed on this - // host is a different operator problem than one that exists and is - // failing. The banner names which is which so the user doesn't have - // to scan the whole list to figure out what's broken. - const downComponents = - components?.filter( - (c) => c.status === "failed" || c.status === "inactive", - ) ?? []; - const missingComponents = - components?.filter((c) => c.status === "missing") ?? []; + // Down splits by SEVERITY before anything else. A dead ClamAV and a dead Postfix + // are both `failed`, but only one of them means this box has stopped being a mail + // server — and one bucket painted the banner red for either, so an advisory daemon + // read as an outage. The marker is the server's (the same one the install gate + // uses); grading it off a key list here would be a second definition. + // + // `!== "advisory"` rather than `=== "required"`: severity is required in TypeScript + // but arrives as unvalidated JSON, and a row an older API didn't stamp must fail + // SAFE to required — landing in neither bucket would give the banner a heading with + // no sentence under it. + // + // "Missing" stays its own bucket: a unit this host never shipped is a different + // operator problem from one that exists and is failing. + // + // `informational` is dropped BEFORE that split, and it has to be: the fail-safe above + // would otherwise sort it into `requiredDown` and paint the banner RED for a daemon + // nothing consults (GH-240 — spamd, while amavis scores spam in-process). Explicitly + // opting a row out is a different act from an older API forgetting to stamp one, which + // is why this is a separate marker and not a third value of the same test. + const graded = components?.filter((c) => c.severity !== "informational") ?? []; + const down = graded.filter((c) => c.status === "failed" || c.status === "inactive"); + const requiredDown = down.filter((c) => c.severity !== "advisory"); + const advisoryDown = down.filter((c) => c.severity === "advisory"); + const missingComponents = graded.filter((c) => c.status === "missing"); const dnsFails = checks?.filter((c) => c.status === "fail").length ?? 0; const dnsWarns = checks?.filter((c) => c.status === "warn").length ?? 0; @@ -75,7 +90,7 @@ export function summarizeHealth( : null; const allClean = - downComponents.length === 0 && + down.length === 0 && missingComponents.length === 0 && dnsFails === 0 && dnsWarns === 0 && @@ -94,59 +109,60 @@ export function summarizeHealth( }; } - if ( - downComponents.length === 0 && - missingComponents.length === 0 && - dnsFails === 0 && - !deliveryFails - ) { + // Amber: nothing REQUIRED is down and nothing hard-fails. Advisory daemons that are + // down, daemons this host never shipped, warn-only DNS records and a queue with + // something in it all live here — one branch, so a box with two amber causes gets one + // sentence instead of whichever branch happened to come first. (That merge is a + // deliberate behaviour change for one combination: missing daemons alongside DNS + // warnings used to drop the DNS sentence.) + if (requiredDown.length === 0 && dnsFails === 0 && !deliveryFails) { const almost: string[] = []; + almost.push(...advisoryNotes(advisoryDown, h)); + // The heading names the most consequential amber cause: a daemon that is down and + // meant to be running outranks one this host never shipped. + const namesMissing = missingComponents.map((c) => c.label).join(", "); + const labelNamesMissing = advisoryDown.length === 0 && missingComponents.length > 0; + if (missingComponents.length > 0) { + // When the heading already names them, the sub explains; when it doesn't, the sub + // has to name them, or "Mail still works without it" refers to nothing. + almost.push( + labelNamesMissing + ? missingComponents.length === 1 + ? h.summary.notInstalledSubOne + : h.summary.notInstalledSubOther + : interpolate(h.summary.partNotInstalled, { names: namesMissing }), + ); + } if (dnsWarns > 0) { almost.push( interpolate(dnsWarns === 1 ? h.summary.almostSubOne : h.summary.almostSubOther, { count: String(dnsWarns) }), ); } if (queueNote) almost.push(queueNote); + const label = + advisoryDown.length > 0 + ? h.summary.degradedLabel + : labelNamesMissing + ? interpolate(h.summary.notInstalledLabel, { names: namesMissing }) + : h.summary.almostLabel; return { Icon: AlertTriangle, banner: "bg-warning-bg border-warning-border", iconBg: "bg-warning-bg", iconColor: "text-warning", textColor: "text-warning", - label: h.summary.almostLabel, + label, sub: almost.join(" · "), }; } - // If only "missing" daemons (nothing actually down, no DNS fails), it's - // a soft warning - the box doesn't ship that daemon. Don't paint the - // whole banner red for that. - if ( - downComponents.length === 0 && - missingComponents.length > 0 && - dnsFails === 0 && - !deliveryFails - ) { - const names = missingComponents.map((c) => c.label).join(", "); - const sub = - missingComponents.length === 1 - ? h.summary.notInstalledSubOne - : h.summary.notInstalledSubOther; - return { - Icon: AlertTriangle, - banner: "bg-warning-bg border-warning-border", - iconBg: "bg-warning-bg", - iconColor: "text-warning", - textColor: "text-warning", - label: interpolate(h.summary.notInstalledLabel, { names }), - sub: queueNote ? `${sub} · ${queueNote}` : sub, - }; - } - const parts: string[] = []; - if (downComponents.length > 0) { - parts.push(interpolate(h.summary.partDown, { names: downComponents.map((c) => c.label).join(", ") })); + if (requiredDown.length > 0) { + parts.push(interpolate(h.summary.partDown, { names: requiredDown.map((c) => c.label).join(", ") })); } + // An advisory daemon down beside a real outage still gets named — it just doesn't + // get to be the reason the banner is red. + parts.push(...advisoryNotes(advisoryDown, h)); if (missingComponents.length > 0) { parts.push( interpolate(h.summary.partNotInstalled, { names: missingComponents.map((c) => c.label).join(", ") }), @@ -169,3 +185,37 @@ export function summarizeHealth( sub: parts.join(" · "), }; } + +/** + * Advisory is not "harmless", and the kinds of advisory-down have different + * consequences — so the sentence is chosen by WHICH daemon it is. + * + * - amavis / clamav: the engine's runtime amavis config (apps/email/engine/samples/ + * amavisd/amavisd.conf, landed as /etc/amavis/conf.d/50-user) declares ONE scanner + * (clamav-socket) and `@av_scanners_backup = ()`, so with clamd gone there is nothing + * to scan with. The message is then DELIVERED UNSCANNED (GH-565): that config sets + * neither `$virus_scanners_failure_is_fatal` nor `$final_unchecked_destiny`, and + * `$undecipherable_subject_tag` is `undef`, so nothing defers it and nothing labels + * it. This copy used to promise the opposite ("may queue") — which left an operator + * waiting for mail to resume while unscanned mail was already landing in inboxes. + * Amavis still stamps `X-Virus-Scanned`; that header means amavis SAW the message, + * not that a scanner passed it. Failing closed is an opt-in documented in amavisd.conf. + * - freshclam: clamd keeps answering, so mail keeps flowing — against a signature set + * that has stopped moving forward. Scanned, but against ageing signatures. + * - spamd / iRedAPD / fail2ban: delivery is unaffected. + */ +const SCANNING_KEYS = new Set(["clamav", "amavis"]); +const SIGNATURE_KEYS = new Set(["freshclam"]); + +function advisoryNotes(rows: MailComponentHealth[], h: HealthDict): string[] { + if (rows.length === 0) return []; + const names = (subset: MailComponentHealth[]) => subset.map((c) => c.label).join(", "); + const scanning = rows.filter((c) => SCANNING_KEYS.has(c.key)); + const signatures = rows.filter((c) => SIGNATURE_KEYS.has(c.key)); + const other = rows.filter((c) => !SCANNING_KEYS.has(c.key) && !SIGNATURE_KEYS.has(c.key)); + const out: string[] = []; + if (scanning.length > 0) out.push(interpolate(h.summary.partScanning, { names: names(scanning) })); + if (signatures.length > 0) out.push(interpolate(h.summary.partSignatures, { names: names(signatures) })); + if (other.length > 0) out.push(interpolate(h.summary.partAdvisory, { names: names(other) })); + return out; +} diff --git a/apps/dashboard/src/app/(dashboard)/emails/_lib/mail-section.ts b/apps/dashboard/src/app/(dashboard)/emails/_lib/mail-section.ts index 5d4858c66..ab0ea3a9f 100644 --- a/apps/dashboard/src/app/(dashboard)/emails/_lib/mail-section.ts +++ b/apps/dashboard/src/app/(dashboard)/emails/_lib/mail-section.ts @@ -62,6 +62,8 @@ export function getMailSectionHeading(tab: string, t: Dictionary): MailSectionHe return { title: a.mailboxes.heading, description: a.mailboxes.description }; case "aliases": return { title: a.aliases.heading, description: a.aliases.description }; + case "inbound": + return { title: a.inbound.heading, description: a.inbound.description }; case "dns": return { title: a.dns.heading, description: a.dns.description }; case "health": diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/DraftProjectView.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/DraftProjectView.tsx index aff5b044e..2c7b573c1 100644 --- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/DraftProjectView.tsx +++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/DraftProjectView.tsx @@ -196,9 +196,8 @@ export function DraftProjectView({ onDeleteProject }: DraftProjectViewProps) {

{heading}

- {projectStatusLabel(status, t)}
diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/ProjectSidebar.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/ProjectSidebar.tsx index bb8107d38..55a16b18f 100644 --- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/ProjectSidebar.tsx +++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/ProjectSidebar.tsx @@ -168,9 +168,8 @@ export const ProjectSidebar = () => {
- {projectStatusLabel(status, t)} diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx index f42552b95..1dda3d171 100644 --- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx +++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/services/ServiceDetailPanel.tsx @@ -777,10 +777,11 @@ export function ServiceDetailPanel({ isEditingMode={true} setIsEditingMode={() => { /* always editing in the Env tab */ }} showSettingsActions={false} - // #336: env values arrive masked; reveal the real ones on demand - // (the endpoint is write-gated, so read-only members can't). - onRevealAll={async () => - (await servicesApi.revealEnv(projectId, service.id)).environment + // #336: env values arrive masked; reveal only the keys the operator + // actually opens (the endpoint is write-gated, so read-only members + // can't reveal at all). + onReveal={async (keys) => + (await servicesApi.revealEnv(projectId, service.id, keys)).environment } borderless /> diff --git a/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/connection-banner.tsx b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/connection-banner.tsx index 47da27a30..ef0ba6697 100644 --- a/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/connection-banner.tsx +++ b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/connection-banner.tsx @@ -126,6 +126,21 @@ export function ConnectionBanner(props: { const copy = (() => { switch (kind) { case "host_channel": + // Refused, not unanswered (#527). Checked before the address cases because the + // fault is not about the address at all: something DID answer there, so the + // generic "nothing answered" copy would be false, and the operator's next move + // is a key, not a firewall. Red rather than amber: unlike a channel that was + // never provisioned, this one was set up and has stopped working. + if (diagnosis?.channel === "auth_rejected") { + return { + title: t.servers.banner.hostChannelAuthTitle, + body: interpolate(t.servers.banner.hostChannelAuthBody, { + target: dialed ?? "the host", + }), + icon: KeyRound, + tone: "red", + }; + } // Nothing dialed → the channel was never provisioned, so no address can be // named as unresponsive and nothing here is down (#509). return dialed === null @@ -183,11 +198,20 @@ export function ConnectionBanner(props: { */ const fix = kind !== "host_channel" ? null - : dialed === null - ? { label: t.servers.banner.hostChannelProvisionFix, command: HOST_CHANNEL_PROVISION_COMMAND } - : diagnosis?.rule - ? { label: t.servers.banner.hostChannelFix, command: diagnosis.rule } - : null; + : diagnosis?.channel === "auth_rejected" + // Re-running the installer re-authorizes the key, so the remedy is the same command + // as an unprovisioned channel with a different reason for running it. A firewall + // rule is never offered here: a packet arrived and was answered, so no packet filter + // has been established — the distinction #490 was fixed to preserve. + ? { + label: t.servers.banner.hostChannelReauthorizeFix, + command: HOST_CHANNEL_PROVISION_COMMAND, + } + : dialed === null + ? { label: t.servers.banner.hostChannelProvisionFix, command: HOST_CHANNEL_PROVISION_COMMAND } + : diagnosis?.rule + ? { label: t.servers.banner.hostChannelFix, command: diagnosis.rule } + : null; const tone = copy.tone === "red" ? "bg-danger-bg border-danger-border text-danger" diff --git a/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/container-updates.tsx b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/container-updates.tsx index f05537e04..e4d8a727f 100644 --- a/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/container-updates.tsx +++ b/apps/dashboard/src/app/(dashboard)/servers/[serverId]/_components/container-updates.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Boxes, RefreshCw } from "lucide-react"; import { systemApi, type ServerContainerStatus } from "@/lib/api/system"; import { ContainerStatusRow, EdgeInstallRow } from "@/components/infra/ContainerStatusRow"; @@ -21,6 +21,10 @@ import { useReattachActiveFix } from "@/hooks/useReattachActiveFix"; * to the drift cache the operator can trigger by hand; the roll-up in Settings → * Infrastructure does the same across the whole fleet. */ +/** Settle cadence + a stall bound (~4 min) while a swap on this box is in flight. */ +const SETTLE_MS = 3000; +const SETTLE_BUDGET = 80; + export function ServerContainerUpdates({ serverId }: { serverId: string }) { const { t } = useI18n(); const [rows, setRows] = useState([]); @@ -42,6 +46,26 @@ export function ServerContainerUpdates({ serverId }: { serverId: string }) { void load(); }, [load]); + // Settle-watch: a row renders "Updating…" from the cached in-progress flag, and + // nothing else on this page re-reads it — so dismissing the streamed modal used to + // leave the row disabled at "Updating…" until a manual Scan, long after the swap + // landed. Chains off `rows` (every load sets them) and stops as soon as nothing is + // in flight, so there is no interval to clear. Bounded, because a flag can outlive + // its run if the API dies mid-swap; the next boot clears those. + const inFlight = rows.some((r) => r.latestInProgress); + const polls = useRef(0); + useEffect(() => { + if (!inFlight) polls.current = 0; + }, [inFlight]); + useEffect(() => { + if (!inFlight || polls.current >= SETTLE_BUDGET) return; + const timer = setTimeout(() => { + polls.current += 1; + void load(); + }, SETTLE_MS); + return () => clearTimeout(timer); + }, [inFlight, rows, load]); + // Refresh recovery for an in-flight image swap (edge/mail update or mail // repair). Container-apply ONLY — NOT install: the parent server page already // re-attaches the install/repair session into the setup wizard, so passing @@ -83,7 +107,9 @@ export function ServerContainerUpdates({ serverId }: { serverId: string }) { // never-scanned box with an edge already present (nothing else) stays hidden. if (loading || (rows.length === 0 && hasEdge)) return null; - const behindCount = rows.filter((r) => r.behind).length; + // Work still to do — a component mid-swap is reported by its own row, so counting + // it here too would keep announcing an update that is already being applied. + const behindCount = rows.filter((r) => r.behind && !r.latestInProgress).length; const c = t.servers.containers; return ( diff --git a/apps/dashboard/src/app/(dashboard)/servers/page.tsx b/apps/dashboard/src/app/(dashboard)/servers/page.tsx index d9f7595eb..989d9d9fb 100644 --- a/apps/dashboard/src/app/(dashboard)/servers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/servers/page.tsx @@ -24,7 +24,7 @@ import { HardDrive, } from "lucide-react"; import { systemApi } from "@/lib/api"; -import type { ContainerApplyIntent } from "@/lib/api/system"; +import type { ContainerApplyActive, ContainerApplyIntent } from "@/lib/api/system"; import { PageContainer } from "@/components/ui/PageContainer"; import DropdownMenu from "@/components/ui/DropdownMenu"; import { Tabs, type TabDef } from "@/components/ui/Tabs"; @@ -32,6 +32,7 @@ import { usePlatform } from "@/context/PlatformContext"; import { useI18n, interpolate } from "@/components/i18n-provider"; import { useToast } from "@/components/toast"; import { useInfraFleet, type InfraSegment } from "@/hooks/useInfraFleet"; +import { useContainerApplyModal } from "@/hooks/useSystemPrepareModal"; import { InfraFleetCard } from "@/components/infra/InfraFleetCard"; import { InfraFilters } from "@/components/infra/InfraFilters"; import { ComingSoonPanel } from "./_components/coming-soon-panel"; @@ -205,25 +206,57 @@ export default function ServersPage() { // ── Managed containers (edge / mail) across the fleet ────────────────────── const infra = useInfraFleet(infraEnabled); const ic = t.servers.list.infra; + const openContainerApply = useContainerApplyModal(); const [segment, setSegment] = useState("all"); const [search, setSearch] = useState(""); + /** + * Watch one in-flight component's log. GET re-attach by session id — never a POST, + * so opening the log can't start a second swap for a run already going. This is the + * only way into a bulk run's output from the page that launched it; the per-server + * page has the same modal on its own rows. + */ + const openApplyLog = useCallback( + (target: ContainerApplyActive) => { + if (!target.sessionId) return; + openContainerApply(target.serverId, target.component, { + label: + target.component === "mail" + ? t.servers.containers.componentMail + : t.servers.containers.componentEdge, + intent: target.intent ?? "update", + attachSessionId: target.sessionId, + onDone: () => void infra.reload(), + }); + }, + // `infra.reload` is stable; the whole `infra` object is not. + // eslint-disable-next-line react-hooks/exhaustive-deps + [openContainerApply, t.servers.containers, infra.reload], + ); + + /** + * Start a bulk apply. Deliberately quiet on success: the card now shows the run + * itself — what's queued, what's pulling, and how it ended — so a toast claiming + * "Updating N components" the instant the request returns added a second, greener, + * less accurate account of the same thing. A toast is left only for what the card + * cannot show: nothing to do, targets it must hand back, and a failed start. + */ const runBulk = useCallback( async (intent: ContainerApplyIntent) => { try { const res = await infra.applyAll(intent); if (!res) return; // infra disabled (cloud) — the buttons aren't rendered there - const n = res.started.length; - const skipped = res.skipped.length; - if (n === 0 && skipped === 0) { + if (res.started.length === 0 && res.skipped.length === 0) { toast("info", ic.nothingToDo); return; } - const head = interpolate(intent === "update" ? ic.started : ic.startedRestart, { - n: String(n), - }); - const tail = skipped > 0 ? interpolate(ic.skipped, { n: String(skipped) }) : ""; - toast(n > 0 ? "success" : "info", tail ? `${head} · ${tail}` : head); + const skipped = res.skipped.length; + if (skipped > 0) { + toast( + "info", + interpolate(skipped === 1 ? ic.skippedOne : ic.skippedMany, { n: String(skipped) }), + ); + } } catch { toast("error", ic.applyFailed); } @@ -232,17 +265,13 @@ export default function ServersPage() { ); /** - * Which bucket a server falls in — attention wins over updates. `null` until the - * fleet view loads: an unread server matches no segment rather than being called - * healthy, so the segment counts and the filtered list can never disagree. + * Which bucket a server falls in. Read straight off the summary — the rule lives + * in `useInfraFleet` so the roll-up counts, these segments and the row chip cannot + * drift apart. `null` until the fleet view loads: an unread server matches no + * segment rather than being called healthy. */ const bucketOf = useCallback( - (id: string): InfraBucket | null => { - const s = infra.summaries.get(id); - if (!s) return null; - if (s.down.length + s.missing.length > 0 || s.edgeAbsent) return "attention"; - return s.updates > 0 ? "updates" : "healthy"; - }, + (id: string): InfraBucket | null => infra.summaries.get(id)?.bucket ?? null, [infra.summaries], ); @@ -446,6 +475,13 @@ export default function ServersPage() { {downParts.join(" · ")} + ) : comp && comp.applying > 0 ? ( + // Mid-apply outranks the drift it is fixing: the row would + // otherwise keep offering "1 update" for a swap already running. + + + {ic.chipUpdating} + ) : comp && comp.updates > 0 ? ( {interpolate(comp.updates === 1 ? ic.chipUpdateOne : ic.chipUpdates, { @@ -549,8 +585,11 @@ export default function ServersPage() { counts={infra.counts} scanning={infra.scanning} applying={infra.applying} + active={infra.active} + outcome={infra.outcome} onScan={() => void infra.scan()} onApply={(intent) => void runBulk(intent)} + onViewLogs={openApplyLog} /> )} diff --git a/apps/dashboard/src/app/(dashboard)/settings/_components/AuditTab.tsx b/apps/dashboard/src/app/(dashboard)/settings/_components/AuditTab.tsx index 2d2188ced..321efba92 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/_components/AuditTab.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/_components/AuditTab.tsx @@ -77,6 +77,7 @@ const CATEGORY_ICONS: Record | null | undefined; if (payload && typeof payload === "object") { - for (const field of ["email", "hostname", "name", "slug"]) { + // `tool` is what a tool-call row is about — "ran the MCP tool get_projects" + // beats "ran the MCP tool a connected AI agent". + for (const field of ["email", "hostname", "name", "slug", "tool"]) { const value = payload[field]; if (typeof value === "string" && value.trim()) return value; } @@ -301,6 +304,15 @@ function AuditDetailsBody({ label={t.settings.audit.details.cameFrom} value={sourceLabel(event.source)} /> + {/* The agent, when there was one. Named separately from "came from" + because "an AI assistant" and "Cursor" answer different questions — + the second is the one that decides which connection to revoke. */} + {event.sourceClientId && ( + + )} {(event.resourceType || event.resourceId) && ( @@ -462,7 +474,17 @@ export function AuditTab() { return hit ? hit.id : "all"; }, [categoryParam]); - const [source, setSource] = useState("all"); + // Source and client live in the URL alongside category, not in component state: + // "what has this agent been doing" is a link other screens hand out (the MCP + // settings row does) and a question people paste to each other, neither of which + // works if the answer is only reachable by clicking two pills in order. + const sourceParam = searchParams.get("source"); + const source: SourceKey = useMemo( + () => (sourceParam && SOURCE_ICONS[sourceParam as AuditSource] ? (sourceParam as AuditSource) : "all"), + [sourceParam], + ); + const client = searchParams.get("client") ?? ""; + const [actorUserId, setActorUserId] = useState(""); const [period, setPeriod] = useState("all"); const [search, setSearch] = useState(""); @@ -486,11 +508,12 @@ export function AuditTab() { () => ({ category: category === "all" ? undefined : category, source: source === "all" ? undefined : source, + sourceClientId: client || undefined, actorUserId: actorUserId || undefined, from: periodStart(period), q: debouncedSearch || undefined, }), - [category, source, actorUserId, period, debouncedSearch], + [category, source, client, actorUserId, period, debouncedSearch], ); useEffect(() => { @@ -579,22 +602,50 @@ export function AuditTab() { [query, showToast, t], ); + /** + * The audit URL with some filters changed and the rest kept. Merging rather + * than rebuilding is what stops a category tab from silently dropping the + * source/client filter the reader already chose. + */ + const hrefWith = useCallback( + (patch: Record) => { + const next = new URLSearchParams(searchParams.toString()); + next.set("tab", "audit"); + for (const [key, value] of Object.entries(patch)) { + if (value) next.set(key, value); + else next.delete(key); + } + return `/settings?${next.toString()}`; + }, + [searchParams], + ); + // Every filter change invalidates the offset — page 3 of the old result set // has nothing to do with the new one, so each setter resets it rather than an // effect on `query` (which would fetch the stale page first, then page 1). const setCategory = useCallback( (next: CategoryKey) => { - const url = next === "all" ? "/settings?tab=audit" : `/settings?tab=audit&category=${next}`; - router.replace(url, { scroll: false }); + router.replace(hrefWith({ category: next === "all" ? undefined : next }), { scroll: false }); setPage(1); }, - [router], + [router, hrefWith], ); - const pickSource = useCallback((next: SourceKey) => { - setSource(next); - setPage(1); - }, []); + const pickSource = useCallback( + (next: SourceKey) => { + router.replace(hrefWith({ source: next === "all" ? undefined : next }), { scroll: false }); + setPage(1); + }, + [router, hrefWith], + ); + + const pickClient = useCallback( + (next: string) => { + router.replace(hrefWith({ client: next || undefined }), { scroll: false }); + setPage(1); + }, + [router, hrefWith], + ); const pickActor = useCallback((next: string) => { setActorUserId(next); @@ -618,17 +669,17 @@ export function AuditTab() { key: "all" as CategoryKey, label: t.settings.audit.tabs.all, count: facets?.total, - href: "/settings?tab=audit", + href: hrefWith({ category: undefined }), }, ...AUDIT_CATEGORIES.map((cat) => ({ key: cat.id as CategoryKey, label: cat.label, icon: CATEGORY_ICONS[cat.id], count: counts.get(cat.id), - href: `/settings?tab=audit&category=${cat.id}`, + href: hrefWith({ category: cat.id }), })), ]; - }, [facets, t]); + }, [facets, t, hrefWith]); const sourceOptions: PillOption[] = useMemo(() => { const seen = new Set( @@ -649,6 +700,27 @@ export function AuditTab() { ]; }, [facets, source, t]); + /** + * The connected agents that appear in the feed. A dropdown rather than pills: + * the list is unbounded (any registered client), unlike the six fixed sources. + * Only rendered when there is more than one agent to choose between. + */ + const clientOptions = useMemo(() => { + const rows = facets?.clients ?? []; + // Keep the current selection listed even at zero, same as the source pills — + // otherwise a link into a quiet window leaves the filter unclearable. + const listed = rows.some((row) => row.id === client); + return [ + { value: "", label: t.settings.audit.filters.anyAgent }, + ...rows.map((row) => ({ + value: row.id, + label: row.name || row.id, + description: interpolate(t.settings.audit.filters.agentCalls, { count: String(row.count) }), + })), + ...(client && !listed ? [{ value: client, label: client }] : []), + ]; + }, [facets, client, t]); + const actorOptions = useMemo( () => [ { value: "", label: t.settings.audit.filters.anyone }, @@ -727,15 +799,24 @@ export function AuditTab() { }, [events, t]); const filtersActive = - category !== "all" || source !== "all" || !!actorUserId || period !== "all" || !!debouncedSearch; + category !== "all" || + source !== "all" || + !!client || + !!actorUserId || + period !== "all" || + !!debouncedSearch; const clearFilters = useCallback(() => { - setSource("all"); setActorUserId(""); setPeriod("all"); setSearch(""); - setCategory("all"); - }, [setCategory]); + // One navigation for all three URL-held filters — clearing them separately + // would race, each push overwriting the previous one's params. + router.replace(hrefWith({ category: undefined, source: undefined, client: undefined }), { + scroll: false, + }); + setPage(1); + }, [router, hrefWith]); return (
@@ -789,6 +870,18 @@ export function AuditTab() { placeholder={t.settings.audit.filters.period} className="w-40" /> + {/* Hidden when there is nothing to choose between — but ALWAYS shown + while a client filter is active, or arriving from the MCP tab's + Activity link (one connected agent) would leave no way to clear it. */} + {(clientOptions.length > 2 || !!client) && ( + + )}
{sourceOptions.length > 1 && ( @@ -851,9 +944,15 @@ export function AuditTab() {

{clockTime(e.createdAt)} {e.source === "mcp" && ( - - - {t.settings.audit.sources.mcp} + // The client's own name when we have it: at a glance + // "Claude Desktop" tells the reader which of their + // connected agents this was, which the generic + // "AI assistant" chip never could. + + + + {e.sourceClientName || t.settings.audit.sources.mcp} + )} {e.source && e.source !== "mcp" && e.source !== "dashboard" && ( diff --git a/apps/dashboard/src/app/(dashboard)/settings/_components/McpConnection.tsx b/apps/dashboard/src/app/(dashboard)/settings/_components/McpConnection.tsx index ad79dfb4a..a4350a1a4 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/_components/McpConnection.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/_components/McpConnection.tsx @@ -8,7 +8,7 @@ import { useEffect, useState, type ComponentType } from "react"; import Link from "next/link"; -import { Boxes, Copy, Check, ShieldCheck, Unplug, Loader2, ChevronDown, ExternalLink, KeyRound, SlidersHorizontal } from "lucide-react"; +import { Boxes, Copy, Check, ScrollText, ShieldCheck, Unplug, Loader2, ChevronDown, ExternalLink, KeyRound, SlidersHorizontal } from "lucide-react"; import { SettingsSection } from "./SettingsSection"; import { McpAccessEditor } from "./McpAccessEditor"; import { getRestApiBaseUrl } from "@/lib/api/urls"; @@ -626,6 +626,15 @@ function ClientsList({ {c.organizationName ? interpolate(t.settings.mcp.orgPrefix, { org: c.organizationName }) : ""} {interpolate(t.settings.mcp.authorized, { date: formatDate(c.authorizedAt) })} {c.lastUsedAt ? interpolate(t.settings.mcp.lastUsedSuffix, { date: formatDate(c.lastUsedAt) }) : ""} + {/* A timestamp says the agent is alive; the count says how much + it has actually done, which is the difference between a client + someone tried once and one running unattended all week. */} + {c.useCount > 0 + ? interpolate( + c.useCount === 1 ? t.settings.mcp.callsOne : t.settings.mcp.callsMany, + { count: c.useCount.toLocaleString() }, + ) + : ""}

{confirming ? ( @@ -650,6 +659,16 @@ function ClientsList({ // Edit leads; Disconnect keeps its danger hover. Both are hidden while // the row is confirming a disconnect — that interaction owns the row.
+ {/* Straight to this agent's own history. The audit log could + already filter to "an AI assistant", but arriving from the + row that names one and having to re-pick it was the gap. */} + + + {t.settings.mcp.viewActivity} +
)} - {/* #336: reveal masked values. Only when a reveal source is wired - (onRevealAll) and there's something masked, or already revealed. */} - {onRevealAll && (hasMaskedRow || revealedValues) && ( + {/* #336: bulk reveal — the one action that asks for every masked key at + once. Only when a reveal source is wired and something is masked. */} + {onReveal && hasMaskedRow && ( )} {collapsible && ( @@ -695,6 +755,18 @@ const EnvironmentVariables: React.FC = ({ {currentEnvVars.map((env, index) => { const resolution = getEnvResolutionState(envMeta?.[env.key], env.value, t); const inputStateClass = resolution?.inputClass ?? ""; + // #336: a masked row holds only the sentinel — the real value arrives in + // the overlay when THAT key is revealed, and its visibility lives in + // `shownKeys`. A plaintext row (new / typed) shows its own value and keeps + // its own `visible` flag. Masked with no reveal source wired: no eye at + // all, since the toggle could only ever display the sentinel as text. + const masked = isMaskedValue(env.value); + const showAsText = masked ? shownKeys.has(env.key) : env.visible; + const displayValue = + masked && Object.hasOwn(revealedValues, env.key) + ? revealedValues[env.key] + : env.value; + const canToggleValue = !masked || Boolean(onReveal); return (
{resolution && ( @@ -715,19 +787,6 @@ const EnvironmentVariables: React.FC = ({ } ${inputStateClass}`} />
- {(() => { - // #336: masked rows display the revealed overlay value when - // shown; the underlying state stays the sentinel until edited - // (see revealedValues). Real/typed rows display their own value - // unchanged. Masked-row visibility is the local `shownKeys` - // overlay; plaintext rows keep their own `visible` flag. - const masked = isMaskedValue(env.value); - const showAsText = masked ? shownKeys.has(env.key) : env.visible; - const displayValue = - masked && revealedValues && env.key in revealedValues - ? revealedValues[env.key] - : env.value; - return ( = ({ !isEditingMode ? 'cursor-default bg-muted/20' : 'bg-muted/30' } ${inputStateClass}`} /> - ); - })()} - + {canToggleValue && ( + + )}
{showEditControls && isEditingMode && ( diff --git a/apps/dashboard/src/components/infra/InfraFleetCard.render.test.tsx b/apps/dashboard/src/components/infra/InfraFleetCard.render.test.tsx new file mode 100644 index 000000000..0730405ed --- /dev/null +++ b/apps/dashboard/src/components/infra/InfraFleetCard.render.test.tsx @@ -0,0 +1,188 @@ +// No DOM needed: renderToStaticMarkup runs no effects, and the card is pure. +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nProvider } from "@/components/i18n-provider"; +import type { ContainerApplyActive, ContainerApplyStep } from "@/lib/api/system"; +import type { ApplyOutcome } from "@/lib/infra-apply-status"; +import { InfraFleetCard } from "./InfraFleetCard"; + +type Counts = { + attention: number; + updates: number; + healthy: number; + stopped: number; + behind: number; + applying: number; +}; + +const IDLE: Counts = { attention: 0, updates: 0, healthy: 3, stopped: 0, behind: 0, applying: 0 }; + +function steps(running: ContainerApplyStep["id"] | null): ContainerApplyStep[] { + return (["pull", "recreate", "verify"] as const).map((id, i) => ({ + id, + label: id, + status: + running === null + ? "pending" + : i < (["pull", "recreate", "verify"] as const).indexOf(running) + ? "done" + : id === running + ? "running" + : "pending", + })); +} + +function target(over: Partial = {}): ContainerApplyActive { + return { + serverId: "srv_1", + serverName: "web-1", + component: "edge", + state: "running", + intent: "update", + sessionId: "capp_1", + steps: steps("pull"), + ...over, + }; +} + +function render( + over: { + counts?: Partial; + active?: ContainerApplyActive[]; + outcome?: ApplyOutcome | null; + onViewLogs?: (t: ContainerApplyActive) => void; + } = {}, +) { + return renderToStaticMarkup( + + {}} + onApply={() => {}} + onViewLogs={over.onViewLogs ?? (() => {})} + /> + , + ); +} + +function text(html: string) { + return html + .replace(/<[^>]+>/g, " ") + .replace(/'|'/g, "'") + .replace(/"/g, '"') + .replace(/&/g, "&") + .replace(/\s+/g, " ") + .trim(); +} + +describe("resting state", () => { + it("says everything is up to date when there is nothing to do", () => { + const out = text(render()); + expect(out).toContain("All components up to date"); + expect(out).not.toContain("Updating"); + }); + + it("offers the bulk actions for work that is not underway", () => { + const out = text( + render({ counts: { updates: 1, behind: 2, stopped: 1, healthy: 2, attention: 1 } }), + ); + expect(out).toContain("Update all (2)"); + expect(out).toContain("Restart stopped (1)"); + expect(out).toContain("1 with updates"); + }); + + it("agrees with itself at one — never '1 need attention'", () => { + const out = text(render({ counts: { attention: 1, healthy: 2 } })); + expect(out).toContain("1 needs attention"); + expect(out).not.toContain("1 need attention"); + }); +}); + +describe("in flight", () => { + it("reports the run, its phase and its percentage", () => { + const out = text(render({ counts: { applying: 1 }, active: [target()] })); + expect(out).toContain("Updating 1 component"); + expect(out).toContain("Pulling image"); + expect(out).toContain("17%"); + expect(out).toContain("web-1"); + }); + + it("does not claim everything is up to date while a swap is running", () => { + const out = text(render({ counts: { applying: 1 }, active: [target()] })); + expect(out).not.toContain("All components up to date"); + }); + + it("follows the step model", () => { + expect(text(render({ active: [target({ steps: steps("recreate") })] }))).toContain("Recreating"); + expect(text(render({ active: [target({ steps: steps("verify") })] }))).toContain("Verifying"); + }); + + it("calls a restart a restart, and never narrates a pull for one", () => { + const out = text( + render({ active: [target({ intent: "repair", steps: steps(null) })] }), + ); + expect(out).toContain("Restarting 1 component"); + expect(out).not.toContain("Pulling image"); + }); + + it("shows a queued target as queued, with no log to open", () => { + const out = render({ + active: [target({ state: "queued", sessionId: undefined, steps: undefined })], + }); + expect(text(out)).toContain("Queued"); + expect(text(out)).not.toContain("View logs"); + }); + + it("offers the live log for a running target", () => { + expect(text(render({ active: [target()] }))).toContain("View logs"); + }); + + it("collapses a long queue instead of growing the card", () => { + const many = ["srv_1", "srv_2", "srv_3", "srv_4", "srv_5"].map((id) => + target({ serverId: id, serverName: id }), + ); + const out = text(render({ active: many })); + expect(out).toContain("Updating 5 components"); + expect(out).toContain("+2 more"); + expect(out).not.toContain("srv_5"); + }); + + it("keeps the counts for work it is NOT doing, and disables the actions", () => { + const html = render({ + counts: { attention: 2, updates: 1, applying: 1, behind: 1 }, + active: [target()], + }); + expect(text(html)).toContain("2 need attention"); + expect(text(html)).toContain("Updating 1 component"); + // A second bulk while one is running re-queues containers already accepted. + expect(html).toContain("disabled"); + }); +}); + +describe("settled", () => { + it("reports the finish the counts can never carry", () => { + const out = text(render({ outcome: { done: 1, failed: 0 } })); + expect(out).toContain("1 component updated"); + expect(out).not.toContain("All components up to date"); + }); + + it("names a failure and its reason", () => { + const out = text( + render({ + counts: { updates: 1, behind: 1 }, + outcome: { done: 0, failed: 1, error: "could not pull the image" }, + }), + ); + expect(out).toContain("1 component didn't finish"); + expect(out).toContain("could not pull the image"); + }); + + it("stays quiet about the last run while a new one is going", () => { + const out = text(render({ active: [target()], outcome: { done: 1, failed: 0 } })); + expect(out).not.toContain("1 component updated"); + }); +}); diff --git a/apps/dashboard/src/components/infra/InfraFleetCard.tsx b/apps/dashboard/src/components/infra/InfraFleetCard.tsx index 74b24ac0c..b06182d49 100644 --- a/apps/dashboard/src/components/infra/InfraFleetCard.tsx +++ b/apps/dashboard/src/components/infra/InfraFleetCard.tsx @@ -7,42 +7,74 @@ import { CheckCircle2, Loader2, RefreshCw, + ScrollText, ShieldAlert, + TriangleAlert, Wrench, } from "lucide-react"; import { useI18n, interpolate } from "@/components/i18n-provider"; -import type { ContainerApplyIntent } from "@/lib/api/system"; +import type { ContainerApplyActive, ContainerApplyIntent } from "@/lib/api/system"; +import { applyIntentOf, applyPercent, applyPhase, type ApplyOutcome } from "@/lib/infra-apply-status"; /** Header controls — shared so the scan button and the tracker link sit level. */ const ICON_BUTTON = "inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted/50 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"; +/** Per-target lines shown in full before the rest collapse into a "+N more". */ +const TARGET_LINES = 3; + /** * Servers tab → right column: the fleet roll-up for managed containers (edge / - * mail) plus the two bulk actions. Presentational — the counts and both callbacks - * come from {@link useInfraFleet}, which drives the same endpoints a single row - * uses, so a bulk apply is just many of the per-server sessions started at once. + * mail), the two bulk actions, and the LIVE state of anything being applied. + * Presentational — the counts, the in-flight set and both callbacks come from + * {@link useInfraFleet}, which drives the same endpoints a single row uses, so a + * bulk apply is just many of the per-server sessions started at once. * - * Buttons appear only when they have something to act on, and the whole body - * collapses to one "all up to date" line when the fleet is clean. + * The three states are deliberately separate lines rather than one mode switch: + * counts describe what is left to do (work already underway is excluded from them, + * so nothing here offers the same swap twice), the progress block describes what is + * happening, and the settled line reports how the last run ended — which is the one + * beat the drift counts can never carry, because a component that lands clears its + * update and its in-progress mark in the same write and simply disappears. */ export function InfraFleetCard({ counts, scanning, applying, + active, + outcome, onScan, onApply, + onViewLogs, }: { - counts: { attention: number; updates: number; healthy: number; stopped: number; behind: number }; + counts: { + attention: number; + updates: number; + healthy: number; + stopped: number; + behind: number; + applying: number; + }; scanning: boolean; applying: ContainerApplyIntent | null; + /** Components mid-apply, queued ones included. Empty when the fleet is idle. */ + active: ContainerApplyActive[]; + /** How the run that just finished ended, for a few seconds after it did. */ + outcome: ApplyOutcome | null; onScan: () => void; onApply: (intent: ContainerApplyIntent) => void; + /** Open the live log for one in-flight component (re-attaches, never restarts). */ + onViewLogs?: (target: ContainerApplyActive) => void; }) { const { t } = useI18n(); const c = t.servers.list.infra; + const inFlight = active.length > 0; + // Counts exclude in-flight work, so "nothing left to report" can be true while a + // swap is still running — the progress block is what speaks then. const clean = counts.attention === 0 && counts.updates === 0; + const canAct = counts.behind > 0 || counts.stopped > 0; + const busy = applying !== null || inFlight; return (
@@ -73,81 +105,213 @@ export function InfraFleetCard({
- {clean ? ( + {!clean && ( +
+ {counts.attention > 0 && ( + + {interpolate(counts.attention === 1 ? c.attentionOne : c.attentionMany, { + n: String(counts.attention), + })} + + )} + {counts.attention > 0 && counts.updates > 0 && ( + · + )} + {counts.updates > 0 && ( + + {interpolate(c.updates, { n: String(counts.updates) })} + + )} + {counts.healthy > 0 && ( + <> + · + + {interpolate(c.healthy, { n: String(counts.healthy) })} + + + )} +
+ )} + + {inFlight && } + + {/* The settled beat, once nothing is left running. */} + {!inFlight && outcome && ( +
+ {outcome.done > 0 && ( +

+ + {interpolate(outcome.done === 1 ? c.doneOne : c.doneMany, { + n: String(outcome.done), + })} +

+ )} + {outcome.failed > 0 && ( + <> +

+ + {interpolate(outcome.failed === 1 ? c.failedOne : c.failedMany, { + n: String(outcome.failed), + })} +

+ {outcome.error && ( +

{outcome.error}

+ )} + + )} +
+ )} + + {/* Nothing to report and nothing happening — the resting state. */} + {clean && !inFlight && !outcome && (

{c.allHealthy}

- ) : ( - <> -
- {counts.attention > 0 && ( - - {interpolate(c.attention, { n: String(counts.attention) })} - - )} - {counts.attention > 0 && counts.updates > 0 && ( - · - )} - {counts.updates > 0 && ( - - {interpolate(c.updates, { n: String(counts.updates) })} - - )} - {counts.healthy > 0 && ( - <> - · - - {interpolate(c.healthy, { n: String(counts.healthy) })} - - - )} -
- - {(counts.behind > 0 || counts.stopped > 0) && ( -
- {counts.behind > 0 && ( - + )} + + {canAct && ( +
+ {counts.behind > 0 && ( + + {interpolate(c.updateAll, { n: String(counts.behind) })} + + )} + {counts.stopped > 0 && ( +
+ {interpolate(c.restartStopped, { n: String(counts.stopped) })} + )} +
+ )} - {/* Attention with nothing bulk-safe left (absent edge, gone container): - the fix is on the server's own page, so say so instead of offering - a button that would skip everything. */} - {counts.attention > 0 && counts.behind === 0 && counts.stopped === 0 && ( -

{c.openServerHint}

- )} - + {/* Attention with nothing bulk-safe left (absent edge, gone container): + the fix is on the server's own page, so say so instead of offering + a button that would skip everything. */} + {counts.attention > 0 && !canAct && !inFlight && ( +

{c.openServerHint}

)}
); } + +/** + * The live block: one headline for the whole run, a bar, and a line per component. + * + * Progress is the bar's WIDTH; the sweep only says "still moving", so a long image + * pull doesn't read as a hung bar. "View logs" re-attaches to a running session + * rather than starting anything — it is offered only for a component that has one + * (a queued target has nothing to show yet). + */ +function ApplyingBlock({ + active, + onViewLogs, +}: { + active: ContainerApplyActive[]; + onViewLogs?: (target: ContainerApplyActive) => void; +}) { + const { t } = useI18n(); + const c = t.servers.list.infra; + const percent = applyPercent(active); + const restart = applyIntentOf(active) === "repair"; + const head = restart + ? active.length === 1 + ? c.restartingOne + : c.restartingMany + : active.length === 1 + ? c.applyingOne + : c.applyingMany; + const phaseLabel: Record, string> = { + queued: c.stateQueued, + starting: t.servers.containers.starting, + pull: c.stepPull, + recreate: c.stepRecreate, + verify: c.stepVerify, + }; + const shown = active.slice(0, TARGET_LINES); + const hidden = active.length - shown.length; + const watchable = active.find((target) => target.sessionId); + + return ( +
+
+ + + {interpolate(head, { n: String(active.length) })} + + + {percent}% + +
+ +
+
+ +
+
+ +
    + {shown.map((target) => ( +
  • + + + {target.component === "mail" + ? t.servers.containers.componentMail + : t.servers.containers.componentEdge} + {" · "} + {target.serverName} + + {phaseLabel[applyPhase(target)]} +
  • + ))} + {hidden > 0 && ( +
  • + {interpolate(c.moreTargets, { n: String(hidden) })} +
  • + )} +
+ + {watchable && onViewLogs && ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/components/migration/ServerMigrationWizard.tsx b/apps/dashboard/src/components/migration/ServerMigrationWizard.tsx index 20ace65ed..969ddaba0 100644 --- a/apps/dashboard/src/components/migration/ServerMigrationWizard.tsx +++ b/apps/dashboard/src/components/migration/ServerMigrationWizard.tsx @@ -2200,7 +2200,9 @@ export function ServerMigrationWizard({ {m.wizard.targetLabel} - setTargetId(s?.id ?? null)} compact /> + {/* dropUp: this card is `overflow-hidden` and the picker sits at its + bottom, so a down-opening menu is hard-clipped. */} + setTargetId(s?.id ?? null)} compact dropUp />