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:
@@ -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 {
|
||||
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<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
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}`."
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user