feat(cli): merge oxicloud binary and cli

this feature to simplify the creation of only 1 binary for multiple architecture
This commit is contained in:
Edouard Vanbelle
2026-08-28 20:37:36 +02:00
parent 811c356cde
commit 390aa31443
25 changed files with 769 additions and 571 deletions
+18 -15
View File
@@ -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"
+23 -26
View File
@@ -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 <subcommand>` 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 <subcommand>` rather than as standalone helper bins:
#
# docker run --rm <image> oxicloud opaque setup # print OPAQUE ServerSetup
# docker exec <container> 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 <container> 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 <image> 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
+3 -3
View File
@@ -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 <name>` on the command line
switched via `oxicloud storage select <name>` 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 <blob>`:
Fingerprints are rendered the same colon-hex form (`15:f3:…:50`)
everywhere they appear: boot log, admin panel pair chain, `xxd`
inspection, `oxicloud --fingerprint <base64>` CLI, and the rotate /
inspection, `oxicloud storage fingerprint <base64>` 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 <target>`.
via `oxicloud storage select <target>`.
---
+1 -1
View File
@@ -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 <name>
oxicloud storage select <name>
```
This one-shot repair command re-runs the same env-parse the server does at boot, verifies `<name>` 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.
+3 -3
View File
@@ -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.
+2 -2
View File
@@ -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 `<N>` 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 <name>` — 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 <name>` — verify + UPDATE DB + exit.
**Example** — two entries, local disk plus an S3 target for planned migration:
+2 -2
View File
@@ -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 <one-of-the-available-names>
oxicloud storage select <one-of-the-available-names>
```
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.
@@ -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/`
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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 |
+5 -5
View File
@@ -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 <name>
oxicloud storage select <name>
```
Behaviour: parse `.env`, verify `<name>` 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=<name>` 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 <name>` 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 <name>` 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 <name>` (with the available names filled in).
Re-run the binary with `--select-storage local_main` → verifies + updates
`oxicloud storage select <name>` (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_<N>_ENCRYPTION_KEY=badbase64`
→ boot aborts with entry name + reason (not valid base64 / wrong length).
+1 -1
View File
@@ -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
-278
View File
@@ -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 <domain> <action> [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<String>,
/// 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<String>, 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 <id> 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)
}
}
@@ -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<dyn std::error::Error>> {
let args: Vec<String> = 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<dyn std::error::Error>> {
);
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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
"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<dyn std::error::Error>> {
&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<dyn std::error::Error>> {
)
.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 {
if let Err(e) =
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?;
.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<dyn std::error::Error>> {
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<Vec<FileRow>, Box<dyn std::error::Error>> {
+102
View File
@@ -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 <domain> <action> [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,
}
})
}
+223
View File
@@ -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 <action>` instead of `oxicloud-cli opaque <action>`).
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<String>,
/// 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<String>, 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 <id> 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
}
+157
View File
@@ -0,0 +1,157 @@
//! `storage` subcommand domain — storage-config repair + crypto helpers.
//!
//! Two actions today:
//! * `select <name>` — 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 <base64key|->` — 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 `<key_fp>` field embedded in every v1 blob header — so
//! an admin can pair a key in `OXICLOUD_STORAGE_<N>_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 = <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 `<key_fp>` field embedded in every v1
/// blob header. Used to identify which key in
/// `OXICLOUD_STORAGE_<N>_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 '<base64>' | 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::<Vec<_>>()
.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
}
}
}
+3 -3
View File
@@ -512,7 +512,7 @@ impl KeyPair {
/// truncation as the v1 header's `<key_fp>` 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 <base64key>`
/// report or the CLI's `oxicloud storage fingerprint <base64key>`
/// 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<Vec<Key
/// with the same base64 / length validation the pair-list parser
/// uses, so callers don't have to reimplement it.
///
/// Used by the `oxicloud --fingerprint <base64>` CLI subcommand so
/// Used by the `oxicloud storage fingerprint <base64>` 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
// `<key_fp>` 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();
+1 -1
View File
@@ -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 <name>` to pin.",
the admin storage tab or `oxicloud storage select <name>` to pin.",
first.name,
);
first
@@ -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}`."
),
};
}
+1 -1
View File
@@ -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 <one-of-the-available-names>"
oxicloud storage select <one-of-the-available-names>"
))
}
},
+7
View File
@@ -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)]
+61 -157
View File
@@ -126,6 +126,29 @@ fn make_socket(addr: &SocketAddr, reuse_port: bool) -> std::io::Result<Socket> {
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// ── 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 <path> Load env from this file. When given, the default
@@ -133,16 +156,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// use this to isolate from a developer's repo-root
// `.env`, and operators get a reproducible "this
// file and nothing else" boot.
// --select-storage <name> 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 <name>` and `--fingerprint <key>` moved to
// subcommands in v0.9.0 as `oxicloud storage select <name>` and
// `oxicloud storage fingerprint <key>` 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<String> = None;
let mut select_storage: Option<String> = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"--version" | "-V" => {
@@ -161,57 +181,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
};
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 `<key_fp>` 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 <base64key>` 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 '<base64>' | 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<dyn std::error::Error>> {
// `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 <path>] Boot the server. This is the normal");
println!(" invocation for a docker/systemd unit.");
println!();
println!(" oxicloud --select-storage <name> One-shot repair — set the active");
println!(" storage entry in the DB and exit.");
println!();
println!(" oxicloud --fingerprint <base64key|-> 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 <subcommand> [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 <action> 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 <email|username> | --all, plus --dry-run.");
println!();
println!(" migrate <action> 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 <action> Storage-config repair + crypto helpers.");
println!(" select <name> 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 <name>` before v0.9.0.");
println!(" fingerprint <k|-> Print the SSH-style colon-hex fingerprint of a base64");
println!(" AES-256 key. Same shape as the v1 blob header's");
println!(" <key_fp> field and the `backend_rotate` completion");
println!(" summary. Read stdin with `-` to keep keys out of shell");
println!(" history. Was `--fingerprint <k|->` 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 <path>");
println!(" Load environment variables from <path> 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 <name>");
println!(" Verify <name> is declared in `OXICLOUD_STORAGE_ENTRIES`, then set");
println!(" `admin_settings.storage.active_backend_name = <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 <base64key | ->");
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 <key_fp> field embedded in every v1 blob header. Used");
println!(" to identify which key in `OXICLOUD_STORAGE_<N>_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 '<base64>' | 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<dyn std::error::Error>> {
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::<Vec<_>>()
.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.
///
+4 -4
View File
@@ -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..."
+4 -3
View File
@@ -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