diff --git a/Cargo.toml b/Cargo.toml index 6f3d85ba..33f60fa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -168,6 +168,12 @@ faces-onnx = ["dep:ort", "dep:ndarray"] # `examples/` can measure them. Off by default — adds nothing to prod builds. # Run with: `cargo bench --features bench` / `cargo run --release --features bench --example bench_thumbnails_mem`. bench = [] +# Empty marker feature that gates the `generate-openapi` binary out of the +# default release build set. `just openapi` flips it when the SPA needs a +# regenerated openapi.json; end-user release builds never do. Kept separate +# from `test_utils` for the same reason `load_seed_bin` is — enabling +# `dev_tools` on the CLI must not perturb the oxicloud dependency graph. +dev_tools = [] [dev-dependencies] criterion = "0.5" @@ -186,19 +192,12 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] } [[bin]] name = "generate-openapi" path = "src/bin/generate-openapi.rs" - -[[bin]] -name = "migrate-nfc-filenames" -path = "src/bin/migrate-nfc-filenames.rs" - -[[bin]] -name = "oxicloud-cli" -path = "src/bin/oxicloud-cli.rs" -# Operator toolbox — subcommand-driven CLI for tasks that don't belong -# in the main server. Currently: `oxicloud-cli opaque {setup,reset}`. -# Ships in the release Dockerfile as the single operator-facing helper -# (replaces the earlier per-task `opaque-setup` bin, which was folded -# into `oxicloud-cli opaque setup`). +# Dev-only: regenerates `resources/gen/openapi.json` from the utoipa +# `#[utoipa::path]` annotations in the API handlers. Gated behind the +# `dev_tools` feature so `cargo build --release --bins` (and the prod +# Dockerfile) skip it entirely — end users have no reason to run it. +# Invoked by `just openapi`, which passes `--features dev_tools`. +required-features = ["dev_tools"] [[bin]] name = "opaque-hurl-helper" @@ -207,7 +206,10 @@ path = "src/bin/opaque-hurl-helper.rs" # handshake against a running server. Invoked from tests/api/run.sh # after opaque_substrate.hurl to cover the parts Hurl can't (OPRF # blinding, AKE nonces are per-attempt-random). Not shipped in the -# release Dockerfile (nothing outside tests/ calls it). +# release Dockerfile (nothing outside tests/ calls it). Gated behind +# `test_utils` so `cargo build --release --bins` skips it; `run.sh` +# enables the feature explicitly when building the helper on demand. +required-features = ["test_utils"] [[bin]] name = "dpop-hurl-helper" @@ -216,7 +218,8 @@ path = "src/bin/dpop-hurl-helper.rs" # the DPoP-Nonce challenge/retry loop, and covers the wire-protocol # scenarios Hurl can't express (per-request fresh jti/iat, replay # detection, malformed proofs, wrong htm/htu/alg/typ). Same -# no-ship-in-release status as opaque-hurl-helper. +# no-ship-in-release status as opaque-hurl-helper — gated identically. +required-features = ["test_utils"] [[bin]] name = "load-seed" diff --git a/Dockerfile b/Dockerfile index 0f86c094..39c1f1a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,11 +42,8 @@ COPY build.rs ./ # Create a minimal project to download and cache dependencies RUN mkdir -p src/bin && \ echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \ - echo 'fn main() {}' > src/bin/generate-openapi.rs && \ - echo 'fn main() {}' > src/bin/migrate-nfc-filenames.rs && \ - echo 'fn main() {}' > src/bin/oxicloud-cli.rs && \ echo 'fn main() {}' > src/bin/opaque-hurl-helper.rs && \ - cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin oxicloud-cli && \ + cargo build --release --bin oxicloud && \ rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-* # ─── Stage 3: Build the application ────────────────────────────────────────── @@ -86,7 +83,7 @@ RUN DATABASE_URL="${DATABASE_URL}" \ GITHUB_SHA="${GITHUB_SHA}" \ GITHUB_REF_NAME="${GITHUB_REF_NAME}" \ GITHUB_HEAD_REF="${GITHUB_HEAD_REF}" \ - cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin oxicloud-cli + cargo build --release --bin oxicloud # The SPA is built by the Vite frontend stage; bring it in for the runtime copy # below (build.rs has no asset pipeline — it only injects git metadata). COPY --from=frontend /static-dist ./static-dist @@ -126,21 +123,24 @@ RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharin GITHUB_HEAD_REF="${GITHUB_HEAD_REF}" \ cargo build --release && \ mkdir -p /app/bin && \ - cp target/release/oxicloud /app/bin/oxicloud && \ - cp target/release/migrate-nfc-filenames /app/bin/migrate-nfc-filenames && \ - cp target/release/oxicloud-cli /app/bin/oxicloud-cli + cp target/release/oxicloud /app/bin/oxicloud # ─── Stage 3c: Select the builder & normalise the binary path ───────────────── # FROM expands the global ${BUILDER} arg to alias the chosen builder stage # (`builder` for CI/release, `builder-cache` for the e2e image). It then copies -# the two shipped binaries from the builder-specific ${BIN_DIR} into a single -# stable path (/app/release) so the runtime stage's COPYs are independent of -# which builder ran. `static-dist` already lives at /app/static-dist in both +# the shipped binary from the builder-specific ${BIN_DIR} into a single stable +# path (/app/release) so the runtime stage's COPY is independent of which +# builder ran. `static-dist` already lives at /app/static-dist in both # builders, so it needs no normalisation. +# +# Single `oxicloud` binary since v0.9.0 — the operator toolbox +# (`opaque setup`, `migrate nfc-filenames`, …) now lives under +# `oxicloud ` rather than in standalone `oxicloud-cli` / +# `migrate-nfc-filenames` bins. See docs/plan/bundled-binary.md § 1b. FROM ${BUILDER} AS app ARG BIN_DIR RUN mkdir -p /app/release && \ - cp "${BIN_DIR}/oxicloud" "${BIN_DIR}/migrate-nfc-filenames" "${BIN_DIR}/oxicloud-cli" /app/release/ + cp "${BIN_DIR}/oxicloud" /app/release/ # ─── Stage 4: Minimal runtime image ────────────────────────────────────────── FROM alpine:3.24.0 @@ -163,21 +163,18 @@ RUN apk --no-cache upgrade && \ addgroup -g 1001 -S oxicloud && \ adduser -u 1001 -S oxicloud -G oxicloud -# Copy the compiled binary and entrypoint (--chmod avoids extra RUN chmod layers) +# Copy the compiled binary and entrypoint (--chmod avoids extra RUN chmod layers). +# +# Single `oxicloud` binary — since v0.9.0 the operator toolbox lives +# under `oxicloud ` rather than as standalone helper bins: +# +# docker run --rm oxicloud opaque setup # print OPAQUE ServerSetup +# docker exec oxicloud migrate nfc-filenames --dry-run +# # NFC-normalize storage.files.name (pre-June-2026 dbs; safe on new installs) +# +# Bare `oxicloud` (Docker CMD default) still starts the server — backwards +# compat preserved. See docs/plan/bundled-binary.md § 1b. COPY --from=app --chmod=755 /app/release/oxicloud /usr/local/bin/ -# Ship the NFC filename migration binary alongside the server so -# operators can run it inside the container without a separate Rust -# toolchain — `docker exec migrate-nfc-filenames --dry-run` -# to preview, drop `--dry-run` to execute. One-shot tool, safe to -# ship; it only mutates `storage.files` rows whose name ≠ NFC(name). -COPY --from=app --chmod=755 /app/release/migrate-nfc-filenames /usr/local/bin/ -# Ship the OPAQUE server-setup generator alongside the server so operators -# can generate their `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` value inside the -# container without a separate Rust toolchain: -# docker run --rm oxicloud-cli opaque setup # prints the base64 value -# One-shot, side-effect-free — safe to include; the runtime doesn't -# invoke it, admins do (see docs/config/authentication.md §OPAQUE). -COPY --from=app --chmod=755 /app/release/oxicloud-cli /usr/local/bin/ COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \ chmod 755 /usr/local/bin/entrypoint.sh diff --git a/docs/architecture/backend-storage.md b/docs/architecture/backend-storage.md index cf22ff17..a7b1d1b1 100644 --- a/docs/architecture/backend-storage.md +++ b/docs/architecture/backend-storage.md @@ -26,7 +26,7 @@ probe, and lifecycle behaviour are uniform. Every entry is declared in `OXICLOUD_STORAGE_ENTRIES` (comma-separated list of names). The active entry is stored in `admin_settings` and -switched via `oxicloud --select-storage ` on the command line +switched via `oxicloud storage select ` on the command line or automatically at the end of a successful `backend_migration`. Non-active entries stay reachable through the multi-entry API (test, audit, migrate-into). @@ -119,7 +119,7 @@ Rendered visually via `xxd -l 15 `: Fingerprints are rendered the same colon-hex form (`15:f3:…:50`) everywhere they appear: boot log, admin panel pair chain, `xxd` -inspection, `oxicloud --fingerprint ` CLI, and the rotate / +inspection, `oxicloud storage fingerprint ` CLI, and the rotate / migration audit lines. That means an admin can cross-reference by eye — same string means same key. @@ -336,7 +336,7 @@ readonly (source stays active — writes safe there), and returns `RunOutcome::Failed`. Operator inspects findings, then either retries (walk short-circuits on head-format matches → cheap re-attempt), fixes the source, or explicitly accepts the partial -via `oxicloud --select-storage `. +via `oxicloud storage select `. --- diff --git a/docs/config/admin-settings.md b/docs/config/admin-settings.md index 0dc1f082..589eac3a 100644 --- a/docs/config/admin-settings.md +++ b/docs/config/admin-settings.md @@ -93,7 +93,7 @@ Runs are recoverable — status, cursor, and per-blob failure findings all live If an entry is renamed or removed from `.env` while the DB pointer still names the old one, boot aborts with a clear error pointing at: ``` -oxicloud --select-storage +oxicloud storage select ``` This one-shot repair command re-runs the same env-parse the server does at boot, verifies `` is declared in `OXICLOUD_STORAGE_ENTRIES`, updates `admin_settings.storage.active_backend_name` in the DB, and exits. Operator then restarts normally. See [Environment Variables — Storage Entries](/config/env#storage-entries-multi-entry-recommended) for the model, and [`oxicloud --help`](https://github.com/oxicloud/oxicloud/blob/main/src/main.rs) for the full flag list. diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 967bc0b7..1b8b8e97 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -135,12 +135,12 @@ Password-using deployments will opt in via three env vars: 2. **`OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`** — generated once and persisted like your JWT secret. Rotating this invalidates every user's registration; treat it as one of the crown jewels. Two ways to generate: ```bash # Docker (recommended in production — no toolchain needed): - docker run --rm ghcr.io/atalayalabs/oxicloud:latest oxicloud-cli opaque setup + docker run --rm ghcr.io/atalayalabs/oxicloud:latest oxicloud opaque setup # From a source checkout: - cargo run --bin oxicloud-cli -- opaque setup + cargo run --bin oxicloud -- opaque setup ``` - Both print the base64 value on stdout (with guidance on stderr, so shell pipelines like `$(docker run ... oxicloud-cli opaque setup)` capture cleanly). + Both print the base64 value on stdout (with guidance on stderr, so shell pipelines like `$(docker run ... oxicloud opaque setup)` capture cleanly). 3. **`OXICLOUD_AUTH_OPAQUE_KSF_*`** — client-side Argon2id key-stretching cost. Defaults (46 MiB / 1 iter / 1 lane) match OWASP's interactive-auth recommendation. See the next section for the rationale + when to bump. The `OXICLOUD_HASH_*` variables (server-side legacy Argon2) and `OXICLOUD_AUTH_OPAQUE_KSF_*` (client-side OPAQUE Argon2) are intentionally separate: the server-side path is RAM-bounded by concurrent-login traffic and needs to stay modest; the client-side path is single-user per attempt and can be tuned independently. Tuning them together would force a bad compromise in one direction or the other. diff --git a/docs/config/env.md b/docs/config/env.md index ffc09e5f..c79dcee2 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -58,7 +58,7 @@ OPAQUE (RFC 9807) is a zero-knowledge password-authenticated key exchange: the p | Variable | Default | Description | |---|---|---| | `OXICLOUD_AUTH_OPAQUE_MODE` | `off` | Runtime mode. `off` = endpoints 404 (default). `migrate` = endpoints live, legacy `POST /api/auth/login` still accepted. `opaque_only` = endpoints live, legacy refused for users with an envelope. **Effective-mode cross-check**: when `password` is not in `OXICLOUD_AUTH_METHODS`, the mode is auto-downgraded to `off` with an audit-channel INFO line (OPAQUE only replaces the password path — nothing to shadow in an OIDC-only or magic-link-only deployment). So OIDC / magic-link-only operators can safely ignore every `OXICLOUD_AUTH_OPAQUE_*` variable. | -| `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` | — | Base64-encoded `ServerSetup` blob. **Required** when `OXICLOUD_AUTH_OPAQUE_MODE != off` AND password is enabled — the server refuses to start with a helpful error otherwise. Generate once with the `oxicloud-cli opaque setup` subcommand and persist the value like your JWT secret. **Never rotate** — rotating invalidates every user's envelope (they'd all need to reset their passphrase). | +| `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP` | — | Base64-encoded `ServerSetup` blob. **Required** when `OXICLOUD_AUTH_OPAQUE_MODE != off` AND password is enabled — the server refuses to start with a helpful error otherwise. Generate once with the `oxicloud opaque setup` subcommand and persist the value like your JWT secret. **Never rotate** — rotating invalidates every user's envelope (they'd all need to reset their passphrase). | | `OXICLOUD_AUTH_OPAQUE_KSF_MEMORY_KIB` | `47104` | Client-side Argon2id memory cost in KiB (46 MiB — matches OWASP interactive-auth recommendation). Runs on the user's device during OPAQUE login/registration, TWICE per login. Distinct from `OXICLOUD_HASH_MEMORY_COST` (server-side legacy path). Bumping raises brute-force cost after a hypothetical envelope leak but also raises login latency and risks WASM heap OOM on low-memory devices — see `authentication.md § OPAQUE — KSF parameters` for the full rationale + per-device latency table. | | `OXICLOUD_AUTH_OPAQUE_KSF_ITERATIONS` | `1` | Client-side Argon2id iteration count (OWASP interactive-auth recommendation). | | `OXICLOUD_AUTH_OPAQUE_KSF_PARALLELISM` | `1` | Client-side Argon2id parallelism lanes (OWASP recommendation). Higher only helps on multi-core hardware and can hurt single-core / older mobile devices. | @@ -131,7 +131,7 @@ Each declared name `` then reads its own set of per-entry variables: - A declared name whose required per-entry fields are missing (`_BACKEND` never set, S3 with no `_S3_BUCKET`, Azure with no `_AZURE_CONTAINER`). - Setting `OXICLOUD_STORAGE_ENTRIES` alongside any of the legacy flat vars below (`OXICLOUD_STORAGE_BACKEND`, `OXICLOUD_S3_*`, `OXICLOUD_AZURE_*`, `OXICLOUD_STORAGE_ENCRYPTION_*`). Pick one mode; the error lists every conflicting var to remove. -- A DB pointer (`admin_settings.storage.active_backend_name`) that names an entry not in the current `_ENTRIES`. The error points at the repair flag `oxicloud --select-storage ` — verify + UPDATE DB + exit. +- A DB pointer (`admin_settings.storage.active_backend_name`) that names an entry not in the current `_ENTRIES`. The error points at the repair flag `oxicloud storage select ` — verify + UPDATE DB + exit. **Example** — two entries, local disk plus an S3 target for planned migration: diff --git a/docs/guide/backend-storage.md b/docs/guide/backend-storage.md index 19d04bdd..07bafce2 100644 --- a/docs/guide/backend-storage.md +++ b/docs/guide/backend-storage.md @@ -167,13 +167,13 @@ If you rename or remove a backend from `.env` while it was still the active one, ``` active_backend_name = `s3_prod`, but no entry with that name is declared in OXICLOUD_STORAGE_ENTRIES. Available: [local_main]. […] -oxicloud --select-storage +oxicloud storage select ``` Run the command it suggests to pick a still-declared backend and the server will boot again on the next start: ``` -oxicloud --select-storage local_main +oxicloud storage select local_main ``` This just updates which backend OxiCloud considers active — it doesn't move any data. diff --git a/docs/plan/benchmake-and-performance-tracking.md b/docs/plan/benchmake-and-performance-tracking.md index 46f40f17..d4bcdc39 100644 --- a/docs/plan/benchmake-and-performance-tracking.md +++ b/docs/plan/benchmake-and-performance-tracking.md @@ -41,7 +41,9 @@ Mirrors the `tests/api/` shell pattern (run.sh, test.env, separate server port). ### 2. Rust bulk seeder — `src/bin/load-seed.rs` -New binary registered in `Cargo.toml` alongside `generate-openapi` and `migrate-nfc-filenames`. +New binary registered in `Cargo.toml` alongside `generate-openapi` +(the historical `migrate-nfc-filenames` bin has since been folded into +`oxicloud migrate nfc-filenames` — see `docs/plan/bundled-binary.md` § 1b). **CLI:** ``` @@ -158,7 +160,7 @@ Matches existing recipe naming (`test-*`, `front-*`, `api-test`). - `.github/workflows/load-nightly.yml`, `load-smoke.yml` **Modify:** -- `Cargo.toml` — add `[[bin]] name = "load-seed" path = "src/bin/load-seed.rs"` after the `migrate-nfc-filenames` entry +- `Cargo.toml` — add `[[bin]] name = "load-seed" path = "src/bin/load-seed.rs"` after the `generate-openapi` entry (the `migrate-nfc-filenames` bin referenced in earlier drafts has been folded into `oxicloud migrate nfc-filenames`) - `justfile` — append four `load*` recipes - `.gitignore` — add `tests/load/results/*.json` and `tests/load/storage/` diff --git a/docs/plan/oidc-account-linking.md b/docs/plan/oidc-account-linking.md index 86640442..c025ea6a 100644 --- a/docs/plan/oidc-account-linking.md +++ b/docs/plan/oidc-account-linking.md @@ -330,7 +330,7 @@ authenticated session): **Deferred:** - Step-up auth before link start -- Admin-mediated link/unlink via `oxicloud-cli federation` (proper for +- Admin-mediated link/unlink via `oxicloud federation` (proper for "user changed IdP email" recovery scenario) - OCM link (same shape, different kind) - Multi-federation (multiple linked identities per user — see diff --git a/docs/plan/opaque-only.md b/docs/plan/opaque-only.md index 7d8954d6..de0c2d3b 100644 --- a/docs/plan/opaque-only.md +++ b/docs/plan/opaque-only.md @@ -62,7 +62,7 @@ and ordering are the load-bearing decisions here. ## Preconditions before we start the wipe Every one of these MUST hold. Adding a pre-flight check in -`oxicloud-cli opaque wipe-legacy` (proposed below) that refuses to run +`oxicloud opaque wipe-legacy` (proposed below) that refuses to run otherwise. 1. **`OXICLOUD_AUTH_OPAQUE_MODE=opaque_only`** on the deployment for at @@ -174,7 +174,7 @@ it can't, since login-link users just clicked email — no proof-of-current). ### The wipe migration -Delivered as `oxicloud-cli opaque wipe-legacy` — a dedicated subcommand, +Delivered as `oxicloud opaque wipe-legacy` — a dedicated subcommand, NOT a schema migration. Reasons: - Idempotent (won't re-wipe already-nulled rows) - Pre-flight refuses when preconditions aren't met (unlike a migration @@ -207,7 +207,7 @@ UPDATE auth.users Output: `N password_hash columns nulled. M users still have password_hash because they don't meet the OPAQUE-migrated preconditions — inspect via -`oxicloud-cli opaque wipe-legacy --dry-run` and address separately.` +`oxicloud opaque wipe-legacy --dry-run` and address separately.` The `WHERE` clause is intentionally strict: OIDC users, externals, and under-migrated users are ALL left alone. The strict version is safer than @@ -232,7 +232,7 @@ can drop the legacy password code: 6. `has_password` field on `UserDto` / `AdminUserSummaryDto`: delete (always false, meaningless signal) 7. `admin`-badge `password` chip: delete (same reason) -8. `oxicloud-cli opaque reset --user X` for legacy-recovery: still useful +8. `oxicloud opaque reset --user X` for legacy-recovery: still useful as an emergency lever (envelope somehow corrupted, need to force re-registration via recovery-magic-link), but its "silent-migration handles the recovery" semantics become "recovery-magic-link handles the @@ -269,7 +269,7 @@ running smoothly for the indicated period." | G1 | Land task #31: change_password OPAQUE-lockout fix + hybrid-user password gate | Days | | G2 | Land recovery-magic-link admin reset flow | Weeks | | G3 | Land OPAQUE-verify-current + change_password redesign that COMPOSES the two (Argon2-verify AND OPAQUE-verify both work; use whichever the user has) | Weeks | -| G4 | Ship `oxicloud-cli opaque wipe-legacy` (dry-run only initially, no destructive flag) | Days | +| G4 | Ship `oxicloud opaque wipe-legacy` (dry-run only initially, no destructive flag) | Days | | G5 | Add admin-dashboard metric: "N users still on legacy (`password_hash IS NOT NULL AND !opaque_migrated`)" | Days | | G6 | Operator switches deployment to `opaque_only` mode | ✅ already possible | | G7 | Wait 90+ days at `opaque_only`, watch the metric drop to 0 | Months | diff --git a/docs/plan/storage-multi-entry.md b/docs/plan/storage-multi-entry.md index 88bb9a6c..00c91046 100644 --- a/docs/plan/storage-multi-entry.md +++ b/docs/plan/storage-multi-entry.md @@ -324,7 +324,7 @@ at), boot fails fast with a clear error. Operator has two ways out: restart. 2. **CLI repair flag on the `oxicloud` binary itself**: ``` - oxicloud --select-storage + oxicloud storage select ``` Behaviour: parse `.env`, verify `` exists in `_ENTRIES` (fail-fast with the available names listed if not), connect to DB, UPDATE @@ -334,7 +334,7 @@ at), boot fails fast with a clear error. Operator has two ways out: The bare-flag on the shipped binary is chosen over a separate `just` recipe or auxiliary bin because: -- **Docker-friendly**: `docker exec oxicloud oxicloud --select-storage foo` +- **Docker-friendly**: `docker exec oxicloud oxicloud storage select foo` — no need to install extra tooling in the container. - **Systemd-friendly**: can be run as a `ExecStartPre=` one-shot before the main service unit. @@ -377,7 +377,7 @@ foundational; the rest layer on top independently within reason. | 5 | Cutover state machine: on migration `Completed`, write `active_backend_name = target_name`, keep read-only on. Boot on new backend after operator restart. | 4 | ~half day | | 6 | Admin storage tab rewrite: list entries, show active, migrate dropdown, read-only banner. Delete Save form + S3 field editors + .env cutover hint. | 1, 3, 4 | 1 day | | 7 | `?storage=` on `blobs_consistency` + `backend_consistency`. `JobRunArgs.storage` plumbing, `TriggerJobQuery.storage`, entry-resolver at run start, params records probed name. Retire `verify_migration` + its DTO + its route + its handler. | 1, 3 | 1 day | -| 8 | `oxicloud --select-storage ` bare-flag repair command on the main binary. Parses `.env`, verifies entry exists, UPDATEs DB, exits. Boot-time missing-entry error message points at it. See §Fallback. | 2 | ~quarter day | +| 8 | `oxicloud storage select ` bare-flag repair command on the main binary. Parses `.env`, verifies entry exists, UPDATEs DB, exits. Boot-time missing-entry error message points at it. See §Fallback. | 2 | ~quarter day | **Total: ~5-6 days end to end.** Slices 6 and 7 can proceed in parallel with each other once 1-5 land. Slice 8 is an ops nicety, could ship whenever. @@ -414,8 +414,8 @@ Per slice, plus these end-to-end scenarios in Hurl: → 400 with known-names list. No run row created. 9. **Missing entry at boot**: `active_backend_name = "gone"` but `_ENTRIES` doesn't include it → boot aborts with the specific message pointing at - `oxicloud --select-storage ` (with the available names filled in). - Re-run the binary with `--select-storage local_main` → verifies + updates + `oxicloud storage select ` (with the available names filled in). + Re-run the binary with `storage select local_main` → verifies + updates DB + exits 0. Restart the server → boots cleanly on `local_main`. 10. **Encryption key invalid**: `OXICLOUD_STORAGE__ENCRYPTION_KEY=badbase64` → boot aborts with entry name + reason (not valid base64 / wrong length). diff --git a/justfile b/justfile index c958cc61..c51a91b0 100644 --- a/justfile +++ b/justfile @@ -192,7 +192,7 @@ audit: cargo audit openapi: - cargo run --bin generate-openapi + cargo run --features dev_tools --bin generate-openapi db: docker compose up -d postgres diff --git a/src/bin/oxicloud-cli.rs b/src/bin/oxicloud-cli.rs deleted file mode 100644 index d4b8e135..00000000 --- a/src/bin/oxicloud-cli.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! `oxicloud-cli` — operator toolbox for the OxiCloud deployment. -//! -//! Single binary with subcommand tree, shipped alongside the `oxicloud` -//! server binary. Replaces the per-task one-off bins (previously -//! `opaque-setup`, and any future `opaque-reset` etc.) with a -//! discoverable `--help`-driven surface so the container ships one -//! toolbox binary rather than N one-off ones. -//! -//! ## Layout -//! -//! ```text -//! oxicloud-cli [flags] -//! -//! Domains: -//! opaque OPAQUE aPAKE substrate management -//! setup Print a fresh ServerSetup value for OXICLOUD_AUTH_OPAQUE_SERVER_SETUP -//! reset Clear envelope(s) so silent-migration re-mints under current KSF -//! ``` -//! -//! Growth pattern: each new domain gets its own module below (e.g. -//! `mod opaque`) with a `#[derive(Subcommand)]` enum for its actions -//! and a `run(args) -> ExitCode` entrypoint. Keep each module -//! self-contained so a future extraction is a file move. -//! -//! ## Environment -//! -//! * `DATABASE_URL` — required by any subcommand that talks to the DB -//! (`opaque reset`); not needed for pure primitive helpers -//! (`opaque setup`). Each subcommand documents its own dependencies. - -use std::process::ExitCode; - -use clap::{Parser, Subcommand}; - -#[derive(Parser)] -#[command( - name = "oxicloud-cli", - version, - about = "OxiCloud operator toolbox", - long_about = "OxiCloud operator toolbox — subcommand entrypoint for operational \ - tasks that don't belong in the main server binary." -)] -struct Cli { - #[command(subcommand)] - domain: Domain, -} - -#[derive(Subcommand)] -enum Domain { - /// OPAQUE aPAKE substrate management (setup, reset). - Opaque { - #[command(subcommand)] - action: opaque::Action, - }, -} - -#[tokio::main(flavor = "current_thread")] -async fn main() -> ExitCode { - let cli = Cli::parse(); - match cli.domain { - Domain::Opaque { action } => opaque::run(action).await, - } -} - -// ── opaque domain ────────────────────────────────────────────────────── - -mod opaque { - use std::env; - use std::process::ExitCode; - - use clap::Subcommand; - use oxicloud::infrastructure::services::opaque_service::OpaqueService; - use sqlx::{PgPool, Row}; - - #[derive(Subcommand)] - pub enum Action { - /// Generate a fresh OPAQUE ServerSetup and print its base64 - /// encoding to stdout. Guidance goes to stderr so shell - /// pipelines capture cleanly. - /// - /// Run ONCE per deployment; persist the printed value as - /// `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`. Rotating this value - /// invalidates every user's OPAQUE registration — treat it - /// like your JWT secret. - Setup, - - /// Clear the OPAQUE envelope for one user or all users - /// WITHOUT touching password or setting force_password_change. - /// - /// Use case: KSF rotation. If you change - /// OXICLOUD_AUTH_OPAQUE_KSF_* values, existing envelopes - /// become cryptographically incompatible with the newly - /// published KSF — logins fail with InvalidCredentials. - /// Nulling the envelope columns forces the SPA's `/lookup` - /// to report `hasOpaque: false`, which routes the next login - /// through legacy `/api/auth/login`; silent-migration then - /// mints a fresh envelope under the CURRENT KSF. Passwords - /// are unchanged. - /// - /// NOT for forgotten-passphrase recovery — use the admin - /// password-reset endpoint (`PUT /api/admin/users/{id}/password`) - /// which sets a temp password + force_change flag in one shot. - Reset { - /// Email OR username to reset (dispatched on `@` presence, - /// same rule as `POST /api/auth/login`). - #[arg(long, conflicts_with = "all")] - user: Option, - - /// Reset every user with an OPAQUE envelope. - #[arg(long, conflicts_with = "user")] - all: bool, - - /// Print what would change without touching the DB. - #[arg(long)] - dry_run: bool, - }, - } - - pub async fn run(action: Action) -> ExitCode { - match action { - Action::Setup => run_setup(), - Action::Reset { user, all, dry_run } => run_reset(user, all, dry_run).await, - } - } - - fn run_setup() -> ExitCode { - // Match the legacy `opaque-setup` bin's contract: - // - value on stdout, no trailing commentary (pipeline-safe) - // - guidance on stderr - let b64 = OpaqueService::generate_server_setup_b64(); - println!("{b64}"); - eprintln!(); - eprintln!("=== OPAQUE server setup generated. ==="); - eprintln!("Persist the line above in OXICLOUD_AUTH_OPAQUE_SERVER_SETUP."); - eprintln!("NEVER rotate: rotating invalidates every user's registration."); - eprintln!("Treat this value like your JWT secret."); - ExitCode::from(0) - } - - async fn run_reset(user: Option, all: bool, dry_run: bool) -> ExitCode { - // clap enforces `conflicts_with`, but not "at least one of". - // Belt-and-braces check here so the failure is explicit. - if user.is_none() && !all { - eprintln!("opaque reset: pass either --user or --all"); - return ExitCode::from(2); - } - - let database_url = match env::var("DATABASE_URL") { - Ok(v) => v, - Err(_) => { - eprintln!("opaque reset: DATABASE_URL not set"); - return ExitCode::from(2); - } - }; - let pool = match PgPool::connect(&database_url).await { - Ok(p) => p, - Err(e) => { - eprintln!("opaque reset: failed to connect to database: {e}"); - return ExitCode::from(1); - } - }; - - // Preview the affected row set before writing. Doubles as - // dry-run output and as diagnostics when --user matches nothing. - // Envelope-presence bool lets the operator see which rows had - // an envelope vs which only carry a stale migration mark. - let select_sql = if all { - r#" - SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope - FROM auth.users - WHERE opaque_envelope IS NOT NULL - OR opaque_migrated_at IS NOT NULL - ORDER BY email - "# - } else { - r#" - SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope - FROM auth.users - WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END - "# - }; - let rows_result = if all { - sqlx::query(select_sql).fetch_all(&pool).await - } else { - let ident = user.as_deref().unwrap(); - sqlx::query(select_sql).bind(ident).fetch_all(&pool).await - }; - let rows = match rows_result { - Ok(r) => r, - Err(e) => { - eprintln!("opaque reset: query failed: {e}"); - return ExitCode::from(1); - } - }; - if rows.is_empty() { - if all { - println!("opaque reset: no users have an OPAQUE envelope — nothing to do."); - return ExitCode::from(0); - } else { - eprintln!( - "opaque reset: no user matches --user {} — nothing changed.", - user.as_deref().unwrap_or("") - ); - return ExitCode::from(1); - } - } - - println!( - "opaque reset ({}): {} row(s) to affect", - if dry_run { - "DRY RUN — no writes" - } else { - "EXECUTING" - }, - rows.len() - ); - for row in &rows { - let id: uuid::Uuid = row.get("id"); - let email: String = row.get("email"); - let had_envelope: bool = row.get("had_envelope"); - println!( - " {} {} {}", - id, - email, - if had_envelope { - "had-envelope" - } else { - "no-envelope-had-migrated-mark" - } - ); - } - if dry_run { - return ExitCode::from(0); - } - - // Actual UPDATE. Kept identical in shape to the SELECT above so - // the planner sees the same query pattern for both. We - // DELIBERATELY do NOT touch password_hash or - // force_password_change_at_next_login — this tool is scoped - // to "the passwords are fine, the envelopes are stale." - let update_sql_all = r#" - UPDATE auth.users - SET opaque_envelope = NULL, - opaque_ciphersuite_version = NULL, - opaque_registered_at = NULL, - opaque_migrated_at = NULL - WHERE opaque_envelope IS NOT NULL - OR opaque_migrated_at IS NOT NULL - "#; - let update_sql_one = r#" - UPDATE auth.users - SET opaque_envelope = NULL, - opaque_ciphersuite_version = NULL, - opaque_registered_at = NULL, - opaque_migrated_at = NULL - WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END - "#; - let write_result = if all { - sqlx::query(update_sql_all).execute(&pool).await - } else { - let ident = user.as_deref().unwrap(); - sqlx::query(update_sql_one).bind(ident).execute(&pool).await - }; - let affected = match write_result { - Ok(r) => r.rows_affected(), - Err(e) => { - eprintln!("opaque reset: update failed: {e}"); - return ExitCode::from(1); - } - }; - println!( - "opaque reset: cleared envelope columns on {affected} row(s). \ - Users log in with their existing password; silent-migration \ - re-mints envelopes under the current KSF on next login." - ); - ExitCode::from(0) - } -} diff --git a/src/bin/migrate-nfc-filenames.rs b/src/cli/migrate.rs similarity index 58% rename from src/bin/migrate-nfc-filenames.rs rename to src/cli/migrate.rs index 270eddb7..2ff35f4d 100644 --- a/src/bin/migrate-nfc-filenames.rs +++ b/src/cli/migrate.rs @@ -1,44 +1,64 @@ -//! `migrate-nfc-filenames` — one-shot CLI to NFC-normalize -//! `storage.files.name` across an OxiCloud instance. +//! `migrate` subcommand domain — one-time data migrations. //! -//! Why: PostgreSQL compares bytes literally and the `UNIQUE` -//! index on `(folder_id, name, user_id) WHERE NOT is_trashed` -//! does not catch Unicode normalization differences. macOS APFS -//! stores filenames in NFD; browsers post NFC. A file uploaded -//! from the web ("café.txt", NFC) and the same name re-uploaded -//! from a NextCloud desktop client on macOS (round-tripped to -//! NFD: `e` + combining acute) lands as two distinct rows, both -//! visible in the listing, both pointing at the same blob. +//! Sqlx schema migrations run automatically at boot via +//! `sqlx::migrate!()` — this domain is reserved for **data** migrations +//! that need explicit operator invocation (data-loss ambiguity, long +//! runtime, or historical schema-drift cleanup). //! -//! What this does: +//! Currently ships one action: `nfc-filenames` — cleans up NFD/NFC +//! filename collisions in databases populated before the June 2026 +//! write-time fix at `src/domain/services/path_service.rs::normalize_storage_name` +//! (called from `src/infrastructure/repositories/pg/file_blob_read_repository.rs` +//! during file operations). New installs never need this migration; +//! only pre-June-2026 databases do. //! -//! 1. Scans every non-trashed file row. -//! 2. For each row whose name ≠ NFC(name): -//! - If no other row in the same `(folder_id, user_id)` already -//! holds the NFC form → UPDATE the row's name to NFC. -//! - If a collision exists with **same blob_hash**: trash the -//! newer of the two (`is_trashed = true`, `trashed_at = NOW()`). -//! User can restore from the trash UI if needed. -//! - If a collision exists with **different blob_hash**: rename -//! the newer row to `{nfc_name}.duplicate`, incrementing the -//! suffix (`.duplicate-1`, `.duplicate-2`, …) until a free name -//! is found. Preserves both files; user can inspect and resolve. -//! - In both collision cases, the surviving (older) row's name -//! is also normalized to NFC. +//! Previously lived in a standalone `migrate-nfc-filenames` binary +//! before the v0.9.0 CLI/server merge — see docs/plan/bundled-binary.md § 1b. +//! The 149-line body of `main()` moved here as `run_nfc_filenames()` +//! with `env::args()` parsing replaced by clap. //! -//! Run: -//! `cargo run --bin migrate-nfc-filenames -- --dry-run` -//! `cargo run --bin migrate-nfc-filenames` -//! -//! Folder rows are NOT touched in this pass — trashing a folder -//! affects descendants; that pass is deferred to a follow-up. +//! Future removal target: v1.0. Databases upgraded through v0.9.0 +//! will have run this migration (or been unaffected because they were +//! post-fix installs); by v1.0 no user should still need it. Drop +//! the `NfcFilenames` variant + this module's `run_nfc_filenames()` +//! function together at that point. + +use std::env; use chrono::{DateTime, Utc}; +use clap::Subcommand; use sqlx::{PgPool, Row}; -use std::env; use uuid::Uuid; -use oxicloud::domain::services::path_service::normalize_storage_name; +use crate::domain::services::path_service::normalize_storage_name; + +#[derive(Subcommand)] +pub enum Action { + /// NFC-normalize storage.files.name across the instance. + /// + /// Historical cleanup for databases populated before June 2026. + /// New installs (post-`normalize_storage_name` write-time fix) + /// never need this — file operations already write NFC form. + /// + /// Collision handling: + /// * No collision → UPDATE row name to NFC. + /// * Same blob content → trash the newer row. + /// * Different content → rename the newer to `{name}.duplicate[-N]`. + /// + /// In all collision cases, the surviving (older) row's name is + /// also normalized to NFC. + NfcFilenames { + /// Print what would change without touching the DB. + #[arg(long)] + dry_run: bool, + }, +} + +pub async fn run(action: Action) -> u8 { + match action { + Action::NfcFilenames { dry_run } => run_nfc_filenames(dry_run).await, + } +} #[derive(Debug, Clone)] struct FileRow { @@ -59,15 +79,22 @@ struct Stats { renamed_duplicate: u64, } -#[tokio::main] -async fn main() -> Result<(), Box> { - let args: Vec = env::args().collect(); - let dry_run = args.iter().any(|a| a == "--dry-run"); +async fn run_nfc_filenames(dry_run: bool) -> u8 { + let database_url = match env::var("DATABASE_URL") { + Ok(v) => v, + Err(_) => { + eprintln!("migrate nfc-filenames: DATABASE_URL not set"); + return 2; + } + }; - let database_url = - env::var("DATABASE_URL").expect("DATABASE_URL must be set in the environment"); - - let pool = PgPool::connect(&database_url).await?; + let pool = match PgPool::connect(&database_url).await { + Ok(p) => p, + Err(e) => { + eprintln!("migrate nfc-filenames: failed to connect to database: {e}"); + return 1; + } + }; println!( "=== NFC filename migration ({}) ===", @@ -79,7 +106,13 @@ async fn main() -> Result<(), Box> { ); println!(); - let rows = load_non_trashed_files(&pool).await?; + let rows = match load_non_trashed_files(&pool).await { + Ok(r) => r, + Err(e) => { + eprintln!("migrate nfc-filenames: initial scan failed: {e}"); + return 1; + } + }; println!("Loaded {} non-trashed file rows", rows.len()); println!(); @@ -98,7 +131,16 @@ async fn main() -> Result<(), Box> { // Row is in non-NFC form. Look for a collision in the same // (folder_id, user_id) scope, including rows that may also // be non-NFC but happen to normalize to the same NFC value. - let collision = find_collision(&pool, row, &nfc_name).await?; + let collision = match find_collision(&pool, row, &nfc_name).await { + Ok(c) => c, + Err(e) => { + eprintln!( + "migrate nfc-filenames: collision query failed for {}: {e}", + row.id + ); + return 1; + } + }; match collision { None => { @@ -106,12 +148,15 @@ async fn main() -> Result<(), Box> { "NORMALIZE {} user={} '{}' → '{}'", row.id, row.user_id, row.name, nfc_name ); - if !dry_run { - sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2") + if !dry_run + && let Err(e) = sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2") .bind(&nfc_name) .bind(row.id) .execute(&pool) - .await?; + .await + { + eprintln!("migrate nfc-filenames: rename failed for {}: {e}", row.id); + return 1; } stats.normalized_in_place += 1; } @@ -134,7 +179,7 @@ async fn main() -> Result<(), Box> { &older.blob_hash[..16.min(older.blob_hash.len())] ); if !dry_run { - sqlx::query( + if let Err(e) = sqlx::query( "UPDATE storage.files SET is_trashed = TRUE, trashed_at = NOW() @@ -142,25 +187,60 @@ async fn main() -> Result<(), Box> { ) .bind(newer.id) .execute(&pool) - .await?; - normalize_survivor_name(&pool, older, &nfc_name).await?; + .await + { + eprintln!("migrate nfc-filenames: trash failed for {}: {e}", newer.id); + return 1; + } + if let Err(e) = normalize_survivor_name(&pool, older, &nfc_name).await { + eprintln!( + "migrate nfc-filenames: survivor rename failed for {}: {e}", + older.id + ); + return 1; + } } stats.deduped_same_content += 1; } else { // Different content → rename newer to a free // `{nfc_name}.duplicate[-N]`; promote older to NFC. - let disambiguated = find_free_duplicate_name(&pool, newer, &nfc_name).await?; + let disambiguated = match find_free_duplicate_name(&pool, newer, &nfc_name) + .await + { + Ok(n) => n, + Err(e) => { + eprintln!( + "migrate nfc-filenames: duplicate-name search failed for {}: {e}", + newer.id + ); + return 1; + } + }; println!( "RENAME newer={} (different blob) older={} '{}' → '{}'", newer.id, older.id, newer.name, disambiguated ); if !dry_run { - sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2") - .bind(&disambiguated) - .bind(newer.id) - .execute(&pool) - .await?; - normalize_survivor_name(&pool, older, &nfc_name).await?; + if let Err(e) = + sqlx::query("UPDATE storage.files SET name = $1 WHERE id = $2") + .bind(&disambiguated) + .bind(newer.id) + .execute(&pool) + .await + { + eprintln!( + "migrate nfc-filenames: disambiguation rename failed for {}: {e}", + newer.id + ); + return 1; + } + if let Err(e) = normalize_survivor_name(&pool, older, &nfc_name).await { + eprintln!( + "migrate nfc-filenames: survivor rename failed for {}: {e}", + older.id + ); + return 1; + } } stats.renamed_duplicate += 1; } @@ -192,7 +272,7 @@ async fn main() -> Result<(), Box> { println!("DRY RUN — no rows were written. Re-run without --dry-run to apply."); } - Ok(()) + 0 } async fn load_non_trashed_files(pool: &PgPool) -> Result, Box> { diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 00000000..e8476ebe --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,102 @@ +//! Operator-tools subcommand tree. +//! +//! Dispatched from `src/main.rs` when the first positional arg matches +//! a known domain (`opaque`, `migrate`, `storage`). Bare `oxicloud` (or +//! oxicloud with legacy top-level flags like `--config`) falls through +//! to server startup — backwards compat with existing Docker CMD lines +//! and systemd units. +//! +//! History: this tree previously lived in a standalone `oxicloud-cli` +//! binary. Folded into the main `oxicloud` binary in v0.9.0 so the +//! release tarball ships one executable. Growth pattern preserved from +//! the old bin's header — see docs/plan/bundled-binary.md § 1b. +//! +//! ## Layout +//! +//! ```text +//! oxicloud [flags] +//! +//! Domains: +//! opaque OPAQUE aPAKE substrate management +//! setup Print a fresh ServerSetup value for +//! OXICLOUD_AUTH_OPAQUE_SERVER_SETUP +//! reset Clear envelope(s) so silent-migration +//! re-mints under current KSF +//! migrate One-time data migrations +//! nfc-filenames NFC-normalize storage.files.name +//! (pre-June-2026 databases) +//! storage Storage-config repair + crypto helpers (was --select-storage +//! and --fingerprint before v0.9.0 CLI harmonization). +//! select Set the active storage-entry backend in DB +//! fingerprint Print SSH-style fingerprint of an AES-256 key +//! ``` +//! +//! Growth pattern: each new domain gets its own module below (e.g. +//! `mod opaque`, `mod migrate`) with a `#[derive(Subcommand)]` enum +//! for its actions and a `run(action) -> u8` entrypoint. Keep +//! each module self-contained so a future extraction is a file move. +//! +//! ## Environment +//! +//! * `DATABASE_URL` — required by any subcommand that talks to the DB +//! (`opaque reset`, `migrate nfc-filenames`); not needed for pure +//! primitive helpers (`opaque setup`). Each subcommand documents its +//! own dependencies. + +use clap::{Parser, Subcommand}; + +pub mod migrate; +pub mod opaque; +pub mod storage; + +#[derive(Parser)] +#[command( + name = "oxicloud", + version, + about = "OxiCloud operator toolbox — subcommand entrypoint for \ + operational tasks that don't belong in the main server \ + binary. Run `oxicloud` (with no subcommand) to start the \ + server." +)] +struct Cli { + #[command(subcommand)] + domain: Domain, +} + +#[derive(Subcommand)] +enum Domain { + /// OPAQUE aPAKE substrate management (setup, reset). + Opaque { + #[command(subcommand)] + action: opaque::Action, + }, + /// One-time data migrations (historical schema/data fixes). + Migrate { + #[command(subcommand)] + action: migrate::Action, + }, + /// Storage-config repair + crypto helpers. + Storage { + #[command(subcommand)] + action: storage::Action, + }, +} + +/// Entrypoint called from `src/main.rs` after it detects a subcommand +/// on argv[1]. Builds a single-threaded tokio runtime — the operator +/// tools don't need multi-thread scheduling and starting a smaller +/// runtime keeps CLI invocations cheap. +pub fn run() -> u8 { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime for CLI"); + rt.block_on(async { + let cli = Cli::parse(); + match cli.domain { + Domain::Opaque { action } => opaque::run(action).await, + Domain::Migrate { action } => migrate::run(action).await, + Domain::Storage { action } => storage::run(action).await, + } + }) +} diff --git a/src/cli/opaque.rs b/src/cli/opaque.rs new file mode 100644 index 00000000..95c61d4a --- /dev/null +++ b/src/cli/opaque.rs @@ -0,0 +1,223 @@ +//! `opaque` subcommand domain — OPAQUE aPAKE substrate management. +//! +//! Two actions today: +//! * `setup` — mint a fresh ServerSetup for +//! `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`. Deployment-time one-off. +//! * `reset` — clear envelope columns so silent-migration re-mints them +//! under the current KSF. Used after KSF rotation. +//! +//! Previously lived in `src/bin/oxicloud-cli.rs::mod opaque` before the +//! v0.9.0 CLI/server merge — see docs/plan/bundled-binary.md § 1b. +//! Behaviour is identical; the only change is the invocation form +//! (`oxicloud opaque ` instead of `oxicloud-cli opaque `). + +use std::env; + +use clap::Subcommand; +use sqlx::{PgPool, Row}; + +use crate::infrastructure::services::opaque_service::OpaqueService; + +#[derive(Subcommand)] +pub enum Action { + /// Generate a fresh OPAQUE ServerSetup and print its base64 + /// encoding to stdout. Guidance goes to stderr so shell + /// pipelines capture cleanly. + /// + /// Run ONCE per deployment; persist the printed value as + /// `OXICLOUD_AUTH_OPAQUE_SERVER_SETUP`. Rotating this value + /// invalidates every user's OPAQUE registration — treat it + /// like your JWT secret. + Setup, + + /// Clear the OPAQUE envelope for one user or all users + /// WITHOUT touching password or setting force_password_change. + /// + /// Use case: KSF rotation. If you change + /// OXICLOUD_AUTH_OPAQUE_KSF_* values, existing envelopes + /// become cryptographically incompatible with the newly + /// published KSF — logins fail with InvalidCredentials. + /// Nulling the envelope columns forces the SPA's `/lookup` + /// to report `hasOpaque: false`, which routes the next login + /// through legacy `/api/auth/login`; silent-migration then + /// mints a fresh envelope under the CURRENT KSF. Passwords + /// are unchanged. + /// + /// NOT for forgotten-passphrase recovery — use the admin + /// password-reset endpoint (`PUT /api/admin/users/{id}/password`) + /// which sets a temp password + force_change flag in one shot. + Reset { + /// Email OR username to reset (dispatched on `@` presence, + /// same rule as `POST /api/auth/login`). + #[arg(long, conflicts_with = "all")] + user: Option, + + /// Reset every user with an OPAQUE envelope. + #[arg(long, conflicts_with = "user")] + all: bool, + + /// Print what would change without touching the DB. + #[arg(long)] + dry_run: bool, + }, +} + +pub async fn run(action: Action) -> u8 { + match action { + Action::Setup => run_setup(), + Action::Reset { user, all, dry_run } => run_reset(user, all, dry_run).await, + } +} + +fn run_setup() -> u8 { + // Match the legacy `opaque-setup` bin's contract: + // - value on stdout, no trailing commentary (pipeline-safe) + // - guidance on stderr + let b64 = OpaqueService::generate_server_setup_b64(); + println!("{b64}"); + eprintln!(); + eprintln!("=== OPAQUE server setup generated. ==="); + eprintln!("Persist the line above in OXICLOUD_AUTH_OPAQUE_SERVER_SETUP."); + eprintln!("NEVER rotate: rotating invalidates every user's registration."); + eprintln!("Treat this value like your JWT secret."); + 0 +} + +async fn run_reset(user: Option, all: bool, dry_run: bool) -> u8 { + // clap enforces `conflicts_with`, but not "at least one of". + // Belt-and-braces check here so the failure is explicit. + if user.is_none() && !all { + eprintln!("opaque reset: pass either --user or --all"); + return 2; + } + + let database_url = match env::var("DATABASE_URL") { + Ok(v) => v, + Err(_) => { + eprintln!("opaque reset: DATABASE_URL not set"); + return 2; + } + }; + let pool = match PgPool::connect(&database_url).await { + Ok(p) => p, + Err(e) => { + eprintln!("opaque reset: failed to connect to database: {e}"); + return 1; + } + }; + + // Preview the affected row set before writing. Doubles as + // dry-run output and as diagnostics when --user matches nothing. + // Envelope-presence bool lets the operator see which rows had + // an envelope vs which only carry a stale migration mark. + let select_sql = if all { + r#" + SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope + FROM auth.users + WHERE opaque_envelope IS NOT NULL + OR opaque_migrated_at IS NOT NULL + ORDER BY email + "# + } else { + r#" + SELECT id, email, (opaque_envelope IS NOT NULL) AS had_envelope + FROM auth.users + WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END + "# + }; + let rows_result = if all { + sqlx::query(select_sql).fetch_all(&pool).await + } else { + let ident = user.as_deref().unwrap(); + sqlx::query(select_sql).bind(ident).fetch_all(&pool).await + }; + let rows = match rows_result { + Ok(r) => r, + Err(e) => { + eprintln!("opaque reset: query failed: {e}"); + return 1; + } + }; + if rows.is_empty() { + if all { + println!("opaque reset: no users have an OPAQUE envelope — nothing to do."); + return 0; + } else { + eprintln!( + "opaque reset: no user matches --user {} — nothing changed.", + user.as_deref().unwrap_or("") + ); + return 1; + } + } + + println!( + "opaque reset ({}): {} row(s) to affect", + if dry_run { + "DRY RUN — no writes" + } else { + "EXECUTING" + }, + rows.len() + ); + for row in &rows { + let id: uuid::Uuid = row.get("id"); + let email: String = row.get("email"); + let had_envelope: bool = row.get("had_envelope"); + println!( + " {} {} {}", + id, + email, + if had_envelope { + "had-envelope" + } else { + "no-envelope-had-migrated-mark" + } + ); + } + if dry_run { + return 0; + } + + // Actual UPDATE. Kept identical in shape to the SELECT above so + // the planner sees the same query pattern for both. We + // DELIBERATELY do NOT touch password_hash or + // force_password_change_at_next_login — this tool is scoped + // to "the passwords are fine, the envelopes are stale." + let update_sql_all = r#" + UPDATE auth.users + SET opaque_envelope = NULL, + opaque_ciphersuite_version = NULL, + opaque_registered_at = NULL, + opaque_migrated_at = NULL + WHERE opaque_envelope IS NOT NULL + OR opaque_migrated_at IS NOT NULL + "#; + let update_sql_one = r#" + UPDATE auth.users + SET opaque_envelope = NULL, + opaque_ciphersuite_version = NULL, + opaque_registered_at = NULL, + opaque_migrated_at = NULL + WHERE CASE WHEN $1 LIKE '%@%' THEN email = $1 ELSE username = $1 END + "#; + let write_result = if all { + sqlx::query(update_sql_all).execute(&pool).await + } else { + let ident = user.as_deref().unwrap(); + sqlx::query(update_sql_one).bind(ident).execute(&pool).await + }; + let affected = match write_result { + Ok(r) => r.rows_affected(), + Err(e) => { + eprintln!("opaque reset: update failed: {e}"); + return 1; + } + }; + println!( + "opaque reset: cleared envelope columns on {affected} row(s). \ + Users log in with their existing password; silent-migration \ + re-mints envelopes under the current KSF on next login." + ); + 0 +} diff --git a/src/cli/storage.rs b/src/cli/storage.rs new file mode 100644 index 00000000..ff3d1b01 --- /dev/null +++ b/src/cli/storage.rs @@ -0,0 +1,157 @@ +//! `storage` subcommand domain — storage-config repair + crypto helpers. +//! +//! Two actions today: +//! * `select ` — set `admin_settings.storage.active_backend_name` +//! in the DB to the named entry and exit. Used to unblock boot after +//! renaming or removing a storage entry in `.env` while the DB still +//! points at the old name (the server aborts boot with a pointer to +//! this subcommand when that happens). See +//! `docs/plan/storage-multi-entry.md` § Fallback. +//! * `fingerprint ` — print the SSH-style colon-hex +//! fingerprint of a base64-encoded AES-256 key. Matches the +//! `head_key_fp` field the `backend_rotate` job reports on completion +//! and the raw `` field embedded in every v1 blob header — so +//! an admin can pair a key in `OXICLOUD_STORAGE__ENCRYPTION_KEY` +//! with the current on-disk head and safely drop any key whose +//! fingerprint does NOT match the last-successful rotate. +//! +//! Both actions previously lived as top-level flags (`--select-storage`, +//! `--fingerprint`) on the `oxicloud` binary. Moved into the subcommand +//! tree in v0.9.0 for CLI consistency — see docs/plan/bundled-binary.md +//! § 1c. Behaviour is identical. + +use std::env; +use std::io::Read; + +use clap::Subcommand; + +use crate::common::config::{AppConfig, fingerprint_from_base64_key}; +use crate::infrastructure::services::entry_backend::persist_active_backend_name; + +#[derive(Subcommand)] +pub enum Action { + /// Select the active storage-entry backend. Writes + /// `admin_settings.storage.active_backend_name = ` in the DB + /// and exits. Does NOT boot the server. Use to recover from the + /// "boot fails on missing entry" case after renaming or removing a + /// storage entry in `.env`. + /// + /// The named entry MUST appear in `OXICLOUD_STORAGE_ENTRIES` — this + /// subcommand re-parses the same env the server would parse at boot, + /// so a successful run guarantees the subsequent boot will find the + /// entry (no drift between the two code paths). + Select { + /// Storage-entry name (must appear in OXICLOUD_STORAGE_ENTRIES). + name: String, + }, + + /// Print the SSH-style colon-hex fingerprint (16-hex, 8-byte + /// truncation of sha256) of a base64-encoded AES-256 key. + /// + /// Matches the `head_key_fp` field the `backend_rotate` job reports + /// on completion, and the raw `` field embedded in every v1 + /// blob header. Used to identify which key in + /// `OXICLOUD_STORAGE__ENCRYPTION_KEY` corresponds to the current + /// on-disk head — safe to drop any key whose fingerprint does NOT + /// match the last-successful rotate's `head_key_fp`. + /// + /// Pass `-` to read the key from stdin so it never touches shell + /// history: + /// + /// ```text + /// echo -n '' | oxicloud storage fingerprint - + /// ``` + Fingerprint { + /// Base64-encoded AES-256 key, or `-` to read the key from stdin. + key: String, + }, +} + +pub async fn run(action: Action) -> u8 { + match action { + Action::Select { name } => run_select(&name).await, + Action::Fingerprint { key } => run_fingerprint(&key), + } +} + +/// Verify `name` is declared in the current env, UPDATE +/// `admin_settings.storage.active_backend_name`, exit. +/// +/// Loading AppConfig here re-runs the same env-parse the server does +/// at boot, so a successful `storage select` guarantees a subsequent +/// normal boot will find the entry — no drift between the two code +/// paths. +async fn run_select(name: &str) -> u8 { + let config = AppConfig::from_env(); + if config.storage_entries.is_empty() { + eprintln!( + "OXICLOUD_STORAGE_ENTRIES is not set (or synthesised — legacy path). \ + `storage select` needs at least one named entry to switch to." + ); + return 2; + } + if !config.storage_entries.iter().any(|e| e.name == name) { + let available = config + .storage_entries + .iter() + .map(|e| e.name.as_str()) + .collect::>() + .join(", "); + eprintln!( + "entry `{name}` is not declared in OXICLOUD_STORAGE_ENTRIES. \ + Available: [{available}]" + ); + return 2; + } + + let db_url = match env::var("DATABASE_URL") { + Ok(v) => v, + Err(_) => { + eprintln!( + "DATABASE_URL not set — `storage select` needs the same DB the server \ + would boot on" + ); + return 2; + } + }; + let pool = match sqlx::PgPool::connect(&db_url).await { + Ok(p) => p, + Err(e) => { + eprintln!("failed to connect to DATABASE_URL: {e}"); + return 1; + } + }; + + if let Err(e) = persist_active_backend_name(&pool, name).await { + eprintln!("failed to write admin_settings.storage.active_backend_name = `{name}`: {e}"); + return 1; + } + + println!( + "active_backend_name = `{name}` written to admin_settings. Restart the server to switch." + ); + 0 +} + +fn run_fingerprint(key: &str) -> u8 { + let key_b64 = if key == "-" { + let mut buf = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut buf) { + eprintln!("failed to read key from stdin: {e}"); + return 2; + } + buf.trim().to_string() + } else { + key.to_string() + }; + match fingerprint_from_base64_key(&key_b64) { + Ok(fp) => { + println!("{fp}"); + 0 + } + Err(e) => { + eprintln!("storage fingerprint: {e}"); + 2 + } + } +} diff --git a/src/common/config.rs b/src/common/config.rs index fc3b54bb..6efc7db5 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -512,7 +512,7 @@ impl KeyPair { /// truncation as the v1 header's `` field and the /// `head_key_fp` reported by `backend_rotate` on completion, so /// operators can cross-reference the boot log against a rotate - /// report or the CLI's `oxicloud --fingerprint ` + /// report or the CLI's `oxicloud storage fingerprint ` /// output without any format conversion. /// /// Returns `None` for `CipherKind::None` (nothing to @@ -723,7 +723,7 @@ pub fn parse_encryption_pair_list(entry_name: &str, raw: &str) -> Result` CLI subcommand so +/// Used by the `oxicloud storage fingerprint ` CLI subcommand so /// admins can identify which key in their `.env` corresponds to the /// `head_key_fp` a `backend_rotate` run reported on completion — /// see `docs/plan/storage-key-rotation.md`. @@ -4330,7 +4330,7 @@ mod tests { // SSH-style 8-byte colon-hex (16 hex + 7 colons = 23 chars) // so operators can cross-reference against the v1 header's // `` field + `backend_rotate`'s `head_key_fp` - // output + the `oxicloud --fingerprint` CLI. + // output + the `oxicloud storage fingerprint` CLI. let pairs = parse_encryption_pair_list("t", &format!("aes-256-gcm:{K1_B64},none:")).unwrap(); let fp0 = pairs[0].fingerprint_short().unwrap(); diff --git a/src/common/di.rs b/src/common/di.rs index 0a47d418..330134ef 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -313,7 +313,7 @@ impl AppServiceFactory { tracing::info!( "Storage: no active_backend_name set in DB — defaulting to first entry \ `{}` (declared first in OXICLOUD_STORAGE_ENTRIES). Set explicitly via \ - the admin storage tab or `oxicloud --select-storage ` to pin.", + the admin storage tab or `oxicloud storage select ` to pin.", first.name, ); first diff --git a/src/infrastructure/services/backend_migration_service.rs b/src/infrastructure/services/backend_migration_service.rs index 61dabc72..2528dde3 100644 --- a/src/infrastructure/services/backend_migration_service.rs +++ b/src/infrastructure/services/backend_migration_service.rs @@ -894,14 +894,14 @@ impl BackendMigrationService { source_missing = source_missing, "🛑 backend_migration aborted — {failed} blob(s) failed, active backend left at \ `{previous_active}`, readonly cleared. Inspect findings and retry, or accept \ - the partial migration via `oxicloud --select-storage {target_name}`." + the partial migration via `oxicloud storage select {target_name}`." ); return RunOutcome::Failed { message: format!( "{failed} blob(s) failed to migrate — active backend NOT switched \ (still `{previous_active}`). Retry the run (short-circuits on already-copied \ blobs) or accept the partial migration manually via \ - `oxicloud --select-storage {target_name}`." + `oxicloud storage select {target_name}`." ), }; } diff --git a/src/infrastructure/services/entry_backend.rs b/src/infrastructure/services/entry_backend.rs index d9f02afc..005fb68b 100644 --- a/src/infrastructure/services/entry_backend.rs +++ b/src/infrastructure/services/entry_backend.rs @@ -168,7 +168,7 @@ pub async fn resolve_active_entry<'a>( "auth.admin_settings.storage.active_backend_name = `{name}`, but no entry \ with that name is declared in OXICLOUD_STORAGE_ENTRIES. Available: [{available}]. \ Either add `{name}` back to your .env, or repair the DB pointer with:\n \ - oxicloud --select-storage " + oxicloud storage select " )) } }, diff --git a/src/lib.rs b/src/lib.rs index 7e5caccb..d13e0310 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,13 @@ pub mod domain; pub mod infrastructure; pub mod interfaces; +// Operator-tools subcommand tree, dispatched from `src/main.rs` when +// the first positional arg matches a known domain (`opaque`, `migrate`). +// Previously lived in a standalone `oxicloud-cli` binary; folded in so +// the release tarball ships one executable — see +// docs/plan/bundled-binary.md § Deliverable 1b. +pub mod cli; + // Test-only helpers for #[cfg(integration_tests)] modules across the // crate (shared pool URL guard + pre-suite cleanup OnceCell). #[cfg(integration_tests)] diff --git a/src/main.rs b/src/main.rs index 7d936460..9f74001e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,6 +126,29 @@ fn make_socket(addr: &SocketAddr, reuse_port: bool) -> std::io::Result { } fn main() -> Result<(), Box> { + // ── Operator subcommand dispatch ───────────────────────────────── + // + // If argv[1] matches a known subcommand domain, hand off to the + // clap-driven CLI tree in `src/cli/` and exit with its ExitCode. + // Bare `oxicloud` (or oxicloud with legacy top-level flags below) + // falls through to the server startup path — backwards compat with + // every existing Docker CMD line, systemd unit, and docker-compose + // entry that just runs `oxicloud` with no args. + // + // Absorbed here from the standalone `oxicloud-cli` + + // `migrate-nfc-filenames` binaries in v0.9.0 so the release tarball + // ships one executable. See docs/plan/bundled-binary.md § 1b. + if let Some(first) = std::env::args().nth(1) + && matches!(first.as_str(), "opaque" | "migrate" | "storage") + { + // `oxicloud::cli::run()` returns a plain `u8` exit-code, which + // widens exactly into `i32` for `std::process::exit`. Values are + // 0/1/2 today; the widening is loss-free by construction. + std::process::exit(i32::from(oxicloud::cli::run())); + } + + // ── Legacy top-level flags (server-startup path) ───────────────── + // // Minimal CLI: // --version Print version + branch + commit hash and exit. // --config Load env from this file. When given, the default @@ -133,16 +156,13 @@ fn main() -> Result<(), Box> { // use this to isolate from a developer's repo-root // `.env`, and operators get a reproducible "this // file and nothing else" boot. - // --select-storage One-shot repair: verify the named entry exists - // in the current .env, UPDATE - // admin_settings.storage.active_backend_name in - // the DB, and exit. Does NOT boot the server. - // Use to recover from the "boot fails on missing - // entry" case — see - // `docs/plan/storage-multi-entry.md` §Fallback. + // + // NB: `--select-storage ` and `--fingerprint ` moved to + // subcommands in v0.9.0 as `oxicloud storage select ` and + // `oxicloud storage fingerprint ` respectively — dispatched + // above via the `matches!` guard. See docs/plan/bundled-binary.md § 1c. let mut args = std::env::args().skip(1); let mut config_path: Option = None; - let mut select_storage: Option = None; while let Some(arg) = args.next() { match arg.as_str() { "--version" | "-V" => { @@ -161,57 +181,6 @@ fn main() -> Result<(), Box> { }; config_path = Some(p); } - "--select-storage" => { - let Some(name) = args.next() else { - eprintln!("--select-storage requires an entry name"); - std::process::exit(2); - }; - select_storage = Some(name); - } - "--fingerprint" => { - // One-shot helper: compute the SSH-style colon-hex - // fingerprint of a base64-encoded AES-256 key and - // print to stdout. Same truncation used by the v1 - // header's `` field + the `backend_rotate` - // completion summary — so an admin can: - // 1. Look at the `head_key_fp` reported by the - // last rotate run. - // 2. Run `oxicloud --fingerprint ` for - // each candidate in `.env`. - // 3. Match — the key that produces the reported - // fingerprint is the current head; any other - // key in `_ENCRYPTION_KEY` no longer decrypts - // any live blob and can be dropped. - // - // Also accepts `-` for stdin so keys never touch the - // shell history: - // echo -n '' | oxicloud --fingerprint - - let Some(key_b64) = args.next() else { - eprintln!("--fingerprint requires a base64 key argument (or `-` for stdin)"); - std::process::exit(2); - }; - let key_b64 = if key_b64 == "-" { - use std::io::Read; - let mut buf = String::new(); - if let Err(e) = std::io::stdin().read_to_string(&mut buf) { - eprintln!("failed to read key from stdin: {e}"); - std::process::exit(2); - } - buf.trim().to_string() - } else { - key_b64 - }; - match oxicloud::common::config::fingerprint_from_base64_key(&key_b64) { - Ok(fp) => { - println!("{fp}"); - return Ok(()); - } - Err(e) => { - eprintln!("--fingerprint: {e}"); - std::process::exit(2); - } - } - } "--help" | "-h" => { print_help(); return Ok(()); @@ -256,14 +225,6 @@ fn main() -> Result<(), Box> { // `build_runtime`. let runtime = build_runtime()?; - // Repair-flag short-circuit. `--select-storage` runs the small - // "verify entry + UPDATE pointer + exit" path and NEVER falls - // through to booting the server — the operator restarts normally - // after this exits. - if let Some(name) = select_storage { - return runtime.block_on(run_select_storage(&name)); - } - runtime.block_on(run()) } @@ -291,21 +252,44 @@ fn print_help() { println!(" oxicloud [--config ] Boot the server. This is the normal"); println!(" invocation for a docker/systemd unit."); println!(); - println!(" oxicloud --select-storage One-shot repair — set the active"); - println!(" storage entry in the DB and exit."); - println!(); - println!(" oxicloud --fingerprint One-shot helper — print the SSH-style"); - println!(" fingerprint of a base64 AES-256 key."); - println!(" Same shape used by the v1 blob header"); - println!(" + `backend_rotate` completion summary."); - println!(" Read stdin with `-` to keep keys out"); - println!(" of shell history."); + println!(" oxicloud [args...] Operator toolbox — one-shot tools that"); + println!(" exit after completing (see SUBCOMMANDS)."); println!(); println!(" oxicloud --version Print version + commit and exit."); println!(); println!(" oxicloud --help Print this help and exit."); println!(); println!(); + println!("SUBCOMMANDS:"); + println!(" opaque OPAQUE aPAKE substrate management."); + println!(" setup Print a fresh ServerSetup (persist as"); + println!(" OXICLOUD_AUTH_OPAQUE_SERVER_SETUP). Runs once per"); + println!(" deployment. Rotating invalidates every user's envelope."); + println!(" reset Clear envelope(s) so silent-migration re-mints under"); + println!(" the current KSF. Use after KSF rotation. Flags:"); + println!(" --user | --all, plus --dry-run."); + println!(); + println!(" migrate One-time data migrations (historical schema/data fixes)."); + println!(" nfc-filenames NFC-normalize storage.files.name across the instance."); + println!(" Cleanup for databases populated before the June 2026"); + println!(" write-time fix; new installs never need it. Flag:"); + println!(" --dry-run to preview without writing."); + println!(); + println!(" storage Storage-config repair + crypto helpers."); + println!(" select Set the active storage-entry backend and exit. Use to"); + println!(" unblock boot after renaming/removing an entry in `.env`"); + println!(" while the DB still points at the old name. Was"); + println!(" `--select-storage ` before v0.9.0."); + println!(" fingerprint Print the SSH-style colon-hex fingerprint of a base64"); + println!(" AES-256 key. Same shape as the v1 blob header's"); + println!(" field and the `backend_rotate` completion"); + println!(" summary. Read stdin with `-` to keep keys out of shell"); + println!(" history. Was `--fingerprint ` before v0.9.0."); + println!(); + println!(" Each subcommand has its own `--help`, e.g. `oxicloud opaque reset --help`."); + println!(" Subcommands require the same env vars as the server (DATABASE_URL etc.)."); + println!(); + println!(); println!("OPTIONS:"); println!(" --config "); println!(" Load environment variables from instead of the default `./.env`."); @@ -315,28 +299,6 @@ fn print_help() { println!(" config. Without this flag, the default `./.env` probe is"); println!(" non-overriding — shell exports win — matching dev convenience."); println!(); - println!(" --select-storage "); - println!(" Verify is declared in `OXICLOUD_STORAGE_ENTRIES`, then set"); - println!(" `admin_settings.storage.active_backend_name = ` in the DB and"); - println!(" exit. Does NOT boot the server. Use to unblock boot after renaming"); - println!(" or removing a storage entry in `.env` while the DB still points at"); - println!(" the old name (the server aborts boot with a pointer to this flag"); - println!(" when that happens). See `docs/plan/storage-multi-entry.md`"); - println!(" §Fallback for the full recovery flow."); - println!(); - println!(" --fingerprint "); - println!(" Compute the SSH-style colon-hex fingerprint (16-hex, 8-byte"); - println!(" truncation of sha256) of a base64-encoded AES-256 key. Matches the"); - println!(" `head_key_fp` field the `backend_rotate` job reports on completion,"); - println!(" and the raw field embedded in every v1 blob header. Used"); - println!(" to identify which key in `OXICLOUD_STORAGE__ENCRYPTION_KEY`"); - println!(" corresponds to the current on-disk head — safe to drop any key"); - println!(" whose fingerprint does NOT match the last-successful rotate's"); - println!(" `head_key_fp`. Pass `-` to read the key from stdin so it never"); - println!(" touches shell history:"); - println!(); - println!(" echo -n '' | oxicloud --fingerprint -"); - println!(); println!(" --version, -V"); println!(" Print the version, git branch, and commit hash. Exits 0."); println!(); @@ -346,7 +308,7 @@ fn print_help() { println!(); println!("ENVIRONMENT:"); println!(" DATABASE_URL PostgreSQL connection string (required for boot and"); - println!(" for --select-storage)."); + println!(" for `storage select`)."); println!(); println!(" OXICLOUD_SERVER_HOST Bind host (default: 127.0.0.1)."); println!(" OXICLOUD_SERVER_PORT Bind port (default: 8086)."); @@ -364,64 +326,6 @@ fn print_help() { println!("The full env-var surface is documented in `example.env` at the repo root."); } -/// Repair-flag body. Loads env config, parses entries, verifies the -/// requested name is declared, connects to PG, upserts -/// `admin_settings.storage.active_backend_name`. Never touches the -/// server — the operator restarts after this exits. -/// -/// Exit codes: -/// - `0` on success. -/// - Non-zero via `std::process::exit` on every failure path (name -/// not declared, DB unreachable, upsert failed). Printed to stderr. -async fn run_select_storage(name: &str) -> Result<(), Box> { - use common::config::AppConfig; - use infrastructure::services::entry_backend::persist_active_backend_name; - - // Parse entries + validate `name` is declared. Loading AppConfig - // here re-runs the same env-parse the server does at boot, so a - // successful --select-storage guarantees a subsequent normal - // boot will find the entry (no drift between the two code paths). - let config = AppConfig::from_env(); - if config.storage_entries.is_empty() { - eprintln!( - "OXICLOUD_STORAGE_ENTRIES is not set (or synthesised — legacy path). \ - `--select-storage` needs at least one named entry to switch to." - ); - std::process::exit(2); - } - if !config.storage_entries.iter().any(|e| e.name == name) { - let available = config - .storage_entries - .iter() - .map(|e| e.name.as_str()) - .collect::>() - .join(", "); - eprintln!( - "entry `{name}` is not declared in OXICLOUD_STORAGE_ENTRIES. Available: [{available}]" - ); - std::process::exit(2); - } - - // Connect to PG using the same DATABASE_URL the server uses. - let db_url = std::env::var("DATABASE_URL").map_err( - |_| "DATABASE_URL not set — `--select-storage` needs the same DB the server would boot on", - )?; - let pool = sqlx::PgPool::connect(&db_url) - .await - .map_err(|e| format!("failed to connect to DATABASE_URL: {e}"))?; - - persist_active_backend_name(&pool, name) - .await - .map_err(|e| { - format!("failed to write admin_settings.storage.active_backend_name = `{name}`: {e}") - })?; - - println!( - "active_backend_name = `{name}` written to admin_settings. Restart the server to switch." - ); - Ok(()) -} - /// Construct the multi-threaded Tokio runtime with explicit, CFS-quota-aware /// pool sizes. /// diff --git a/tests/api/run.sh b/tests/api/run.sh index f31a5bd7..3ff37afb 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -254,8 +254,8 @@ OPAQUE_HELPER_BIN="$REPO_ROOT/target/$BUILD_TARGET/opaque-hurl-helper" if [[ ! -x "$OPAQUE_HELPER_BIN" ]]; then log "Building opaque-hurl-helper ($BUILD_TARGET)..." case "$BUILD_TARGET" in - debug) (cd "$REPO_ROOT" && cargo build --bin opaque-hurl-helper 2>&1 | tail -n 20) || die "opaque-hurl-helper build failed" ;; - release) (cd "$REPO_ROOT" && cargo build --release --bin opaque-hurl-helper 2>&1 | tail -n 20) || die "opaque-hurl-helper build failed" ;; + debug) (cd "$REPO_ROOT" && cargo build --features test_utils --bin opaque-hurl-helper 2>&1 | tail -n 20) || die "opaque-hurl-helper build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release --features test_utils --bin opaque-hurl-helper 2>&1 | tail -n 20) || die "opaque-hurl-helper build failed" ;; esac fi log "Running OPAQUE crypto handshake helper..." @@ -278,8 +278,8 @@ DPOP_HELPER_BIN="$REPO_ROOT/target/$BUILD_TARGET/dpop-hurl-helper" if [[ ! -x "$DPOP_HELPER_BIN" ]]; then log "Building dpop-hurl-helper ($BUILD_TARGET)..." case "$BUILD_TARGET" in - debug) (cd "$REPO_ROOT" && cargo build --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;; - release) (cd "$REPO_ROOT" && cargo build --release --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;; + debug) (cd "$REPO_ROOT" && cargo build --features test_utils --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release --features test_utils --bin dpop-hurl-helper 2>&1 | tail -n 20) || die "dpop-hurl-helper build failed" ;; esac fi log "Running DPoP wire-protocol helper..." diff --git a/tests/common/server.env b/tests/common/server.env index fb9fd9d1..5c2b7018 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -148,9 +148,10 @@ OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0 # still 404 (they flip to 200 when Phase 1 lands). # # The SERVER_SETUP below is a throwaway keypair generated once for the -# test env — real deployments call `opaque-setup` and paste the output. -# Never reuse this value outside CI. Regenerate any time with: -# cargo run --bin opaque-setup +# test env — real deployments call `oxicloud opaque setup` and paste +# the output. Never reuse this value outside CI. Regenerate any time +# with: +# cargo run --bin oxicloud -- opaque setup OXICLOUD_AUTH_OPAQUE_MODE=migrate OXICLOUD_AUTH_OPAQUE_SERVER_SETUP="ZY4hAGa1MNyE7Ht+8ksLcyMmi/K2iJvxQly+DdfllUxjiH0+CjCt4hG6+9Y68jGet2L213dV0hajCbr4fXnekkWtUxqLr+butVHEksZ9NJRuZTvS6SMC73yf/yku4WUHT1NSRB2yHurAFmYn75D9wdA1VaXTuwgO/u5i1pvcsQs=" # Fast Argon2id — CI machines are underpowered vs production (256 MiB