From 58eec1ce0fa6bbf0b235534f8f0f80754c4059dc Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:55:51 +0530 Subject: [PATCH 01/44] deps: add platform-specific dependencies for OS service management Add libc (unix) for effective-uid check in the service elevation guard, and windows-service for native Service Control Manager integration. --- Cargo.lock | 19 +++++++++++++++++++ apps/backend/Cargo.toml | 8 ++++++++ 2 files changed, 27 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ac72d5f2..08729ca6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -808,6 +808,7 @@ dependencies = [ "http-body-util", "image", "json-patch", + "libc", "mime_guess", "object_store", "prost", @@ -846,6 +847,7 @@ dependencies = [ "utoipa", "utoipa-scalar", "uuid", + "windows-service", "wiremock", "zstd", ] @@ -5475,6 +5477,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -5567,6 +5575,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-service" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "windows-strings" version = "0.5.1" diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index 137c47a0..a25c2f99 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -77,6 +77,14 @@ tar = "0.4" croner = "3" tantivy = "0.26" +# Unix: effective-uid check for the `service` elevation guard. +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +# Windows: native Service Control Manager integration for `vcms service`. +[target.'cfg(windows)'.dependencies] +windows-service = "0.7" + [build-dependencies] tonic-build = "0.14" tonic-prost-build = "0.14" From 8366b60b49e6051ccbed8983067efdb5866f08a0 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:56:03 +0530 Subject: [PATCH 02/44] refactor(grpc): add shutdown future parameter for graceful drain Accept an injected shutdown future in start_grpc_server and spawn_grpc_server, switching from serve() to serve_with_shutdown(). This enables the server bootstrap to be shared between the foreground serve command and OS service runners that provide their own shutdown trigger (e.g. Windows SCM stop control). --- apps/backend/src/grpc/server.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/grpc/server.rs b/apps/backend/src/grpc/server.rs index 87a26ba7..88ff63bb 100644 --- a/apps/backend/src/grpc/server.rs +++ b/apps/backend/src/grpc/server.rs @@ -26,6 +26,7 @@ pub async fn start_grpc_server( config: Arc, storage_registry: Arc, grpc_addr: SocketAddr, + shutdown: impl Future + Send + 'static, ) -> Result<(), Box> { let collection_svc = CollectionServiceImpl::new(services.collection.clone(), repository.clone()); let entry_svc = EntryServiceImpl::new(services.entry.clone(), repository.clone()); @@ -90,7 +91,7 @@ pub async fn start_grpc_server( .add_service(file_svc) .add_service(site_svc) .add_service(webhook_svc) - .serve(grpc_addr) + .serve_with_shutdown(grpc_addr, shutdown) .await?; Ok(()) @@ -102,6 +103,7 @@ pub fn spawn_grpc_server( config: Arc, storage_registry: Arc, grpc_addr: SocketAddr, + shutdown: Pin + Send>>, ) -> GrpcServerFuture { Box::pin(start_grpc_server( services, @@ -109,5 +111,6 @@ pub fn spawn_grpc_server( config, storage_registry, grpc_addr, + shutdown, )) } From 12cc20c3f42b7c9cd00ab6509c32273cfb8ad6c2 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:56:17 +0530 Subject: [PATCH 03/44] refactor: extract server bootstrap into reusable server module Move the full startup sequence (home dir, secrets, config, migrations, REST + gRPC servers, search indexer, backup scheduler) from main.rs into a new server module with a run() function that accepts an injected shutdown future. This allows both the foreground serve command and OS service runners (systemd, launchd, Windows SCM) to share the exact same bootstrap path. Also loads .env from /.env in addition to cwd, so installed services and mcp stdio (which run from an arbitrary cwd) pick up persisted secrets. The home .env is second-wins so real env vars and the cwd file keep precedence. --- apps/backend/src/lib.rs | 2 + apps/backend/src/main.rs | 260 +++----------------------------- apps/backend/src/server.rs | 299 +++++++++++++++++++++++++++++++++++++ 3 files changed, 321 insertions(+), 240 deletions(-) create mode 100644 apps/backend/src/server.rs diff --git a/apps/backend/src/lib.rs b/apps/backend/src/lib.rs index c795463d..cbce407a 100644 --- a/apps/backend/src/lib.rs +++ b/apps/backend/src/lib.rs @@ -8,6 +8,8 @@ pub mod graphql; pub mod mcp; pub mod paths; pub mod secrets; +pub mod server; +pub mod service; pub mod signed_upload; pub mod test_helpers; pub mod tracing; diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 653c364d..5d9998e8 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -2,21 +2,31 @@ use clap::Parser; use cms::cli::{AdminAction, BackupAction, Cli, Command, ConfigAction, McpTransport}; use cms::config::{self, Config}; use cms::database::{connect_db_without_migrations, init_db_with_config}; -use cms::grpc::server::spawn_grpc_server; use cms::middleware::auth::Actor; use cms::repository::Repository; -use cms::router::create_router; use cms::services::Services; -use cms::storage::{self, STORAGE_KIND_FILESYSTEM, STORAGE_KIND_S3, StorageRegistry}; use std::error::Error; -use std::net::SocketAddr; use std::sync::Arc; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; #[tokio::main] async fn main() { + // Load env from the cwd `.env` (dev) first, then `$VCMS_HOME/.env` (installed + // services + `mcp stdio`, which run from an arbitrary cwd). dotenvy is + // first-wins, so real env vars and the cwd file keep precedence over the home + // file. VCMS_HOME must be a real env var — resolved here before the home `.env` + // loads — never set inside that file (chicken-and-egg). dotenvy::dotenv().ok(); + let home_env = cms::paths::home().join(".env"); + match dotenvy::from_path(&home_env) { + Ok(()) => {} + Err(e) if e.not_found() => {} // missing file is fine + Err(e) => { + eprintln!("Error: cannot load {}: {e}", home_env.display()); + std::process::exit(1); + } + } let cli = Cli::parse(); @@ -31,10 +41,11 @@ async fn main() { import_as_new, yes, }) => run_restore(file, scope, site, *import_as_new, *yes, &cli).await, + Some(Command::Service { action }) => cms::service::run_service(action, &cli).await, Some(Command::Mcp { transport: McpTransport::Stdio, }) => run_mcp_stdio(&cli).await, - Some(Command::Serve) | None => run_serve(&cli).await, + Some(Command::Serve) | None => cms::server::run(&cli, cms::server::shutdown_signal()).await, }; if let Err(e) = result { @@ -43,128 +54,6 @@ async fn main() { } } -async fn run_serve(cli: &Cli) -> Result<(), Box> { - cms::paths::ensure()?; - cms::secrets::ensure()?; - let config = Config::load(cli)?; - - let _guard = cms::tracing::init_tracing(&config); - - if let Err(e) = config.validate_security() { - return Err(format!("Invalid production security configuration: {e}").into()); - } - - let pool = init_db_with_config(&config).await?; - - let repository = Repository::new(&pool); - - seed_admin(&repository).await; - - let storage_registry = initialize_storage(&config); - // Build these once and share them: the REST router and the gRPC server use the - // same `Services` so the single-writer search index is opened only once. - let repository_arc = Arc::new(repository.clone()); - let config_arc = Arc::new(config.clone()); - let services = Services::new(repository_arc.clone(), &pool, &config); - - let backup_destination = cms::services::backup::build_backup_destination(&config) - .map_err(|e| format!("Failed to initialize backup destination: {e}"))?; - let backup_service = Arc::new(cms::services::backup::BackupService::new( - pool.clone(), - storage_registry.clone(), - backup_destination, - &config, - )); - - let app = create_router( - repository.clone(), - config.clone(), - storage_registry.clone(), - services.clone(), - backup_service.clone(), - ); - - // Reconcile backups/restore jobs left mid-flight by a previous process: any - // running/pending row at startup is orphaned (backups only run in-process). - match cms::services::backup::meta::fail_orphaned(&pool, &cms::services::backup::now_iso()).await { - Ok(n) if n > 0 => info!("Reconciled {n} interrupted backup job(s) to failed"), - Ok(_) => {} - Err(e) => tracing::error!("Failed to reconcile interrupted backups: {e}"), - } - - if config.backup_enabled { - let scheduler_service = backup_service.clone(); - tokio::spawn(async move { - cms::services::backup::scheduler::run(scheduler_service).await; - }); - info!("Backup scheduler started"); - } - - // The search indexer is the single writer/consumer: it rebuilds the index when - // empty (first run / wiped), then drains the cross-process queue forever. - if let (Some(search), Some(queue)) = (services.search.clone(), services.search_queue.clone()) { - let repo = repository_arc.clone(); - tokio::spawn(async move { - if search.is_empty() { - info!("Search index is empty; building from database..."); - match search.rebuild_all(&repo).await { - Ok(n) => info!("Search index built: {} entries", n), - Err(e) => tracing::error!("Search index build failed: {}", e), - } - } - cms::services::search::indexer::run(search, queue, repo).await; - }); - } - - let addr: SocketAddr = config.bind_address.parse().expect("Invalid BIND_ADDRESS"); - info!("Dashboard UI available at http://{}/dashboard", addr); - info!("REST API server running on http://{}", addr); - info!("GraphQL endpoint at http://{}/api/graphql", addr); - if config.mcp_enabled { - info!("MCP HTTP endpoint at http://{}/mcp", addr); - } - - let grpc_addr: SocketAddr = config.grpc_bind_address.parse().expect("Invalid GRPC_BIND_ADDRESS"); - info!("gRPC server running on {}", grpc_addr); - - let rest_handle = tokio::spawn(async move { - let listener = tokio::net::TcpListener::bind(addr) - .await - .expect("Failed to bind address"); - - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(shutdown_signal()) - .await - .expect("Server error"); - }); - - let grpc_handle = tokio::spawn(spawn_grpc_server( - services.clone(), - repository_arc.clone(), - config_arc.clone(), - storage_registry.clone(), - grpc_addr, - )); - - tokio::select! { - result = rest_handle => { - if let Err(e) = result { - tracing::error!("REST server error: {}", e); - } - } - result = grpc_handle => { - if let Err(e) = result { - tracing::error!("gRPC server error: {}", e); - } - } - } - - Ok(()) -} - async fn run_mcp_stdio(cli: &Cli) -> Result<(), Box> { // Read-only: never create the home dir, database, or secrets file here. The // persisted secrets written by `vcms serve` are what make this process verify @@ -209,7 +98,7 @@ async fn run_mcp_stdio(cli: &Cli) -> Result<(), Box> { Actor::User(_) => return Err("MCP stdio requires a CMS site access token".into()), } - let storage_registry = initialize_storage(&config); + let storage_registry = cms::server::initialize_storage(&config); let repository = Arc::new(repository); let config = Arc::new(config); // Read-only search: stdio can run alongside the server without contending for @@ -225,51 +114,6 @@ async fn run_mcp_stdio(cli: &Cli) -> Result<(), Box> { result } -fn initialize_storage(config: &Config) -> Arc { - let mut storage_registry = StorageRegistry::new(); - - // Use an explicit filesystem path if set; otherwise default to ~/.vcms/storage - // so uploads work out of the box — unless S3 is configured and takes over. - let fs_path = match (&config.storage_fs_path, config.has_s3()) { - (Some(path), _) => Some(path.clone()), - (None, false) => Some(cms::paths::storage_dir().to_string_lossy().into_owned()), - (None, true) => None, - }; - - if let Some(fs_path) = fs_path { - match storage::FileSystemStorage::new(&fs_path) { - Ok(fs) => { - storage_registry.register(STORAGE_KIND_FILESYSTEM, Arc::new(fs)); - info!("Filesystem storage initialized at {}", fs_path); - } - Err(error) => warn!("Failed to initialize filesystem storage: {}", error), - } - } - - if config.has_s3() { - match storage::S3Storage::new( - config.s3_access_key_id.as_deref().unwrap(), - config.s3_secret_access_key.as_deref().unwrap(), - config.s3_bucket.as_deref().unwrap(), - config.s3_region.as_deref().unwrap_or("us-east-1"), - config.s3_endpoint.as_deref(), - config.s3_public_url.as_deref(), - ) { - Ok(s3) => { - storage_registry.register(STORAGE_KIND_S3, Arc::new(s3)); - info!("S3 storage initialized"); - } - Err(error) => warn!("Failed to initialize S3 storage: {}", error), - } - } - - if storage_registry.get(STORAGE_KIND_FILESYSTEM).is_none() && storage_registry.get(STORAGE_KIND_S3).is_none() { - warn!("No storage providers configured. Set STORAGE_FS_PATH or S3_* env vars."); - } - - Arc::new(storage_registry) -} - fn run_config(action: &ConfigAction, cli: &Cli) -> Result<(), Box> { match action { ConfigAction::Init { force, path } => { @@ -337,7 +181,7 @@ async fn run_backup(action: &BackupAction, cli: &Cli) -> Result<(), Box) -> Result Err(format!("unknown scope '{other}' (use instance|site)").into()), } } - -/// Email identity of the default admin account. Login is by email, so the seed -/// gate keys on this (non-unique display name "admin" would be the wrong column). -const ADMIN_EMAIL: &str = "admin@cms.local"; - -async fn seed_admin(repository: &Repository) { - debug!("Checking if admin user needs to be seeded"); - if !repository.user.exists(ADMIN_EMAIL).await.unwrap_or(false) { - info!("Seeding default admin user"); - let id = uuid::Uuid::now_v7().to_string(); - let password_hash = bcrypt::hash("admin", bcrypt::DEFAULT_COST).expect("Failed to hash password"); - repository - .user - .create(&id, "admin", ADMIN_EMAIL, &password_hash) - .await - .expect("Failed to seed admin user"); - repository - .user - .set_instance_role(&id, Some("instance_owner")) - .await - .expect("Failed to assign instance owner"); - repository - .user - .update_password(&id, &password_hash, true) - .await - .expect("Failed to require password change"); - - warn!("Seeded default admin user (admin@cms.local / admin) — CHANGE THE PASSWORD IMMEDIATELY!"); - eprintln!( - "\n\ - ============================ SECURITY WARNING ============================\n\ - A default admin account was created: email 'admin@cms.local' password 'admin'\n\ - Sign in with the email and password. Anyone who can reach this server can log\n\ - in until you change it. Run:\n\ - \n vcms admin reset-password --email admin@cms.local --password \n\n\ - or change it from the dashboard now. Do NOT expose this server until done.\n\ - =========================================================================\n" - ); - } else { - debug!("Admin user already exists, skipping seeding"); - } -} - -async fn shutdown_signal() { - let ctrl_c = async { - tokio::signal::ctrl_c().await.expect("failed to install Ctrl+C handler"); - }; - - #[cfg(unix)] - let terminate = async { - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("failed to install signal handler") - .recv() - .await; - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => info!("Received Ctrl+C, shutting down..."), - _ = terminate => info!("Received SIGTERM, shutting down..."), - } -} diff --git a/apps/backend/src/server.rs b/apps/backend/src/server.rs new file mode 100644 index 00000000..cc63cea3 --- /dev/null +++ b/apps/backend/src/server.rs @@ -0,0 +1,299 @@ +//! Server bootstrap shared by `vcms serve` and the platform service runners. +//! +//! The full startup sequence (home dir, secrets, config, migrations, REST + gRPC) +//! lives here rather than in `main.rs` so that the Windows Service Control Manager +//! entry point — which lives in the library — can host the exact same server. The +//! caller injects a *shutdown future*; whatever resolves it (Ctrl+C / SIGTERM on a +//! normal run, an SCM stop control on Windows) triggers one graceful drain of both +//! the REST and gRPC servers. + +use std::error::Error; +use std::future::Future; +use std::net::SocketAddr; +use std::sync::Arc; + +use tracing::{debug, info, warn}; + +use crate::cli::Cli; +use crate::config::Config; +use crate::database::init_db_with_config; +use crate::grpc::server::spawn_grpc_server; +use crate::repository::Repository; +use crate::router::create_router; +use crate::services::Services; +use crate::storage::{self, STORAGE_KIND_FILESYSTEM, STORAGE_KIND_S3, StorageRegistry}; + +/// Email identity of the default admin account. Login is by email, so the seed +/// gate keys on this (non-unique display name "admin" would be the wrong column). +const ADMIN_EMAIL: &str = "admin@cms.local"; + +/// Boot the full server and run until `shutdown` resolves. +/// +/// Shared by the foreground `serve` command and the OS service runners. The +/// `shutdown` future is the single trigger that gracefully drains both the REST +/// and gRPC listeners. +pub async fn run(cli: &Cli, shutdown: impl Future + Send + 'static) -> Result<(), Box> { + crate::paths::ensure()?; + crate::secrets::ensure()?; + let config = Config::load(cli)?; + + let _guard = crate::tracing::init_tracing(&config); + + if let Err(e) = config.validate_security() { + return Err(format!("Invalid production security configuration: {e}").into()); + } + + let pool = init_db_with_config(&config).await?; + + let repository = Repository::new(&pool); + + seed_admin(&repository).await; + + let storage_registry = initialize_storage(&config); + // Build these once and share them: the REST router and the gRPC server use the + // same `Services` so the single-writer search index is opened only once. + let repository_arc = Arc::new(repository.clone()); + let config_arc = Arc::new(config.clone()); + let services = Services::new(repository_arc.clone(), &pool, &config); + + let backup_destination = crate::services::backup::build_backup_destination(&config) + .map_err(|e| format!("Failed to initialize backup destination: {e}"))?; + let backup_service = Arc::new(crate::services::backup::BackupService::new( + pool.clone(), + storage_registry.clone(), + backup_destination, + &config, + )); + + let app = create_router( + repository.clone(), + config.clone(), + storage_registry.clone(), + services.clone(), + backup_service.clone(), + ); + + // Reconcile backups/restore jobs left mid-flight by a previous process: any + // running/pending row at startup is orphaned (backups only run in-process). + match crate::services::backup::meta::fail_orphaned(&pool, &crate::services::backup::now_iso()).await { + Ok(n) if n > 0 => info!("Reconciled {n} interrupted backup job(s) to failed"), + Ok(_) => {} + Err(e) => tracing::error!("Failed to reconcile interrupted backups: {e}"), + } + + if config.backup_enabled { + let scheduler_service = backup_service.clone(); + tokio::spawn(async move { + crate::services::backup::scheduler::run(scheduler_service).await; + }); + info!("Backup scheduler started"); + } + + // The search indexer is the single writer/consumer: it rebuilds the index when + // empty (first run / wiped), then drains the cross-process queue forever. + if let (Some(search), Some(queue)) = (services.search.clone(), services.search_queue.clone()) { + let repo = repository_arc.clone(); + tokio::spawn(async move { + if search.is_empty() { + info!("Search index is empty; building from database..."); + match search.rebuild_all(&repo).await { + Ok(n) => info!("Search index built: {} entries", n), + Err(e) => tracing::error!("Search index build failed: {}", e), + } + } + crate::services::search::indexer::run(search, queue, repo).await; + }); + } + + let addr: SocketAddr = config + .bind_address + .parse() + .map_err(|e| format!("Invalid BIND_ADDRESS '{}': {e}", config.bind_address))?; + info!("Dashboard UI available at http://{}/dashboard", addr); + info!("REST API server running on http://{}", addr); + info!("GraphQL endpoint at http://{}/api/graphql", addr); + if config.mcp_enabled { + info!("MCP HTTP endpoint at http://{}/mcp", addr); + } + + let grpc_addr: SocketAddr = config + .grpc_bind_address + .parse() + .map_err(|e| format!("Invalid GRPC_BIND_ADDRESS '{}': {e}", config.grpc_bind_address))?; + info!("gRPC server running on {}", grpc_addr); + + // One shutdown signal fans out to both listeners via a watch channel: the + // injected `shutdown` future flips it, and both servers drain on the change. + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let shutdown_tx2 = shutdown_tx.clone(); + tokio::spawn(async move { + shutdown.await; + let _ = shutdown_tx.send(true); + }); + + let rest_rx = shutdown_rx.clone(); + let rest_handle = tokio::spawn(async move { + let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { + tracing::error!("Failed to bind REST address {addr}: {e}"); + e + })?; + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(wait_for_shutdown(rest_rx)) + .await + .map_err(|e| { + tracing::error!("REST server error: {e}"); + e + }) + }); + + let grpc_handle = tokio::spawn(spawn_grpc_server( + services.clone(), + repository_arc.clone(), + config_arc.clone(), + storage_registry.clone(), + grpc_addr, + Box::pin(wait_for_shutdown(shutdown_rx)), + )); + + tokio::select! { + result = rest_handle => { + let _ = shutdown_tx2.send(true); + match result { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(e.into()), + Err(e) => return Err(e.into()), + } + } + result = grpc_handle => { + let _ = shutdown_tx2.send(true); + match result { + Ok(Ok(())) => {} + // gRPC's error is `Box`; format it so the + // conversion to this fn's `Box` is unambiguous. + Ok(Err(e)) => return Err(format!("gRPC server error: {e}").into()), + Err(e) => return Err(format!("gRPC server task panicked: {e}").into()), + } + } + } + + Ok(()) +} + +/// Resolve once the watch channel reports a shutdown was requested. +async fn wait_for_shutdown(mut rx: tokio::sync::watch::Receiver) { + if *rx.borrow_and_update() { + return; + } + let _ = rx.changed().await; +} + +/// Wait for a Ctrl+C or (on unix) SIGTERM — the foreground `serve` shutdown trigger. +pub async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c().await.expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => info!("Received Ctrl+C, shutting down..."), + _ = terminate => info!("Received SIGTERM, shutting down..."), + } +} + +/// Register the configured storage backends (filesystem and/or S3). +pub fn initialize_storage(config: &Config) -> Arc { + let mut storage_registry = StorageRegistry::new(); + + // Use an explicit filesystem path if set; otherwise default to ~/.vcms/storage + // so uploads work out of the box — unless S3 is configured and takes over. + let fs_path = match (&config.storage_fs_path, config.has_s3()) { + (Some(path), _) => Some(path.clone()), + (None, false) => Some(crate::paths::storage_dir().to_string_lossy().into_owned()), + (None, true) => None, + }; + + if let Some(fs_path) = fs_path { + match storage::FileSystemStorage::new(&fs_path) { + Ok(fs) => { + storage_registry.register(STORAGE_KIND_FILESYSTEM, Arc::new(fs)); + info!("Filesystem storage initialized at {}", fs_path); + } + Err(error) => warn!("Failed to initialize filesystem storage: {}", error), + } + } + + if config.has_s3() { + match storage::S3Storage::new( + config.s3_access_key_id.as_deref().unwrap(), + config.s3_secret_access_key.as_deref().unwrap(), + config.s3_bucket.as_deref().unwrap(), + config.s3_region.as_deref().unwrap_or("us-east-1"), + config.s3_endpoint.as_deref(), + config.s3_public_url.as_deref(), + ) { + Ok(s3) => { + storage_registry.register(STORAGE_KIND_S3, Arc::new(s3)); + info!("S3 storage initialized"); + } + Err(error) => warn!("Failed to initialize S3 storage: {}", error), + } + } + + if storage_registry.get(STORAGE_KIND_FILESYSTEM).is_none() && storage_registry.get(STORAGE_KIND_S3).is_none() { + warn!("No storage providers configured. Set STORAGE_FS_PATH or S3_* env vars."); + } + + Arc::new(storage_registry) +} + +async fn seed_admin(repository: &Repository) { + debug!("Checking if admin user needs to be seeded"); + if !repository.user.exists(ADMIN_EMAIL).await.unwrap_or(false) { + info!("Seeding default admin user"); + let id = uuid::Uuid::now_v7().to_string(); + let password_hash = bcrypt::hash("admin", bcrypt::DEFAULT_COST).expect("Failed to hash password"); + repository + .user + .create(&id, "admin", ADMIN_EMAIL, &password_hash) + .await + .expect("Failed to seed admin user"); + repository + .user + .set_instance_role(&id, Some("instance_owner")) + .await + .expect("Failed to assign instance owner"); + repository + .user + .update_password(&id, &password_hash, true) + .await + .expect("Failed to require password change"); + + warn!("Seeded default admin user (admin@cms.local / admin) — CHANGE THE PASSWORD IMMEDIATELY!"); + eprintln!( + "\n\ + ============================ SECURITY WARNING ============================\n\ + A default admin account was created: email 'admin@cms.local' password 'admin'\n\ + Sign in with the email and password. Anyone who can reach this server can log\n\ + in until you change it. Run:\n\ + \n vcms admin reset-password --email admin@cms.local --password \n\n\ + or change it from the dashboard now. Do NOT expose this server until done.\n\ + =========================================================================\n" + ); + } else { + debug!("Admin user already exists, skipping seeding"); + } +} From 6c79f6bd1cdc72eb4f8700c3a803db95934fb44e Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:56:34 +0530 Subject: [PATCH 04/44] feat: add cross-platform OS service management Add 'vcms service' subcommand with install, uninstall, status, start, and stop actions for managing the server as a native OS background service: - Linux: systemd unit (install/enable/start via systemctl) - macOS: launchd LaunchDaemon (bootstrap/kickstart via launchctl) - Windows: Service Control Manager via windows-service crate (auto-start, LocalSystem, ACL-hardened ProgramData home) Shared logic (unit file rendering, username validation, home directory resolution, secure .env creation) lives in service/mod.rs so it compiles and tests on every platform. Platform-specific side effects are behind cfg-gated submodules. Security: the service refuses to run as root on unix; Windows uses LocalSystem. The .env file is created with mode 0600, symlink attacks are rejected, and Windows ACLs are locked to SYSTEM + Administrators only. --- apps/backend/src/cli.rs | 80 +++++++ apps/backend/src/service/linux.rs | 125 +++++++++++ apps/backend/src/service/macos.rs | 123 +++++++++++ apps/backend/src/service/mod.rs | 309 ++++++++++++++++++++++++++++ apps/backend/src/service/windows.rs | 264 ++++++++++++++++++++++++ 5 files changed, 901 insertions(+) create mode 100644 apps/backend/src/service/linux.rs create mode 100644 apps/backend/src/service/macos.rs create mode 100644 apps/backend/src/service/mod.rs create mode 100644 apps/backend/src/service/windows.rs diff --git a/apps/backend/src/cli.rs b/apps/backend/src/cli.rs index 5a0eff60..b8f7b8f2 100644 --- a/apps/backend/src/cli.rs +++ b/apps/backend/src/cli.rs @@ -69,6 +69,11 @@ pub enum Command { #[command(subcommand)] action: BackupAction, }, + /// Manage the OS background service (systemd / launchd / Windows SCM). + Service { + #[command(subcommand)] + action: ServiceAction, + }, /// Restore a backup artifact (runs offline; destructive — replaces data in scope). Restore { /// Path to the backup artifact (`.cmsbak`). @@ -114,6 +119,37 @@ pub enum BackupAction { List, } +#[derive(Subcommand, Debug)] +pub enum ServiceAction { + /// Install the service, enable it at boot, and start it now (requires root/admin). + /// + /// The service runs `vcms serve` as the chosen OS account; runtime files live + /// under that account's `$VCMS_HOME` (default `~/.vcms`). + Install { + /// OS account the service runs as. + /// + /// Defaults to the real invoking user (`$SUDO_USER` when run via sudo). The + /// service never runs as root. Ignored on Windows (runs as LocalSystem). + #[arg(long, value_name = "NAME")] + user: Option, + }, + /// Stop, disable, and remove the service (requires root/admin). + Uninstall, + /// Show whether the service is installed, enabled at boot, and running. + Status, + /// Start the installed service. + Start, + /// Stop the running service. + Stop, + /// Internal entry point invoked by the Windows Service Control Manager. + /// + /// Not for direct use — the SCM launches this to host the server inside a + /// Windows service. Hidden from `--help`. + #[cfg(windows)] + #[command(hide = true)] + Run, +} + #[derive(Subcommand, Debug)] pub enum McpTransport { /// Run MCP over stdin/stdout. @@ -163,4 +199,48 @@ mod tests { }) )); } + + #[test] + fn parses_service_install_with_user() { + use super::ServiceAction; + let cli = + Cli::try_parse_from(["vcms", "service", "install", "--user", "deploy"]).expect("command should parse"); + match cli.command { + Some(Command::Service { + action: ServiceAction::Install { user }, + }) => assert_eq!(user.as_deref(), Some("deploy")), + other => panic!("unexpected command: {other:?}"), + } + } + + #[test] + fn parses_service_lifecycle_subcommands() { + use super::ServiceAction; + for (args, ok) in [ + ( + ["service", "status"], + matches!(parse_action(&["service", "status"]), ServiceAction::Status), + ), + ( + ["service", "start"], + matches!(parse_action(&["service", "start"]), ServiceAction::Start), + ), + ( + ["service", "stop"], + matches!(parse_action(&["service", "stop"]), ServiceAction::Stop), + ), + ] { + assert!(ok, "{args:?} did not parse to the expected ServiceAction"); + } + } + + #[cfg(test)] + fn parse_action(args: &[&str]) -> super::ServiceAction { + let mut full = vec!["vcms"]; + full.extend_from_slice(args); + match Cli::try_parse_from(full).expect("parse").command { + Some(Command::Service { action }) => action, + other => panic!("unexpected: {other:?}"), + } + } } diff --git a/apps/backend/src/service/linux.rs b/apps/backend/src/service/linux.rs new file mode 100644 index 00000000..eb2dabd3 --- /dev/null +++ b/apps/backend/src/service/linux.rs @@ -0,0 +1,125 @@ +//! systemd integration for `vcms service` on Linux. + +use std::path::Path; +use std::process::Command; + +use super::{InstallOptions, SERVICE_NAME, require_root, resolve_run_user, systemd_unit, user_home}; +use crate::cli::{Cli, ServiceAction}; + +/// Where the generated unit file is written. +const UNIT_PATH: &str = "/etc/systemd/system/vcms.service"; + +pub fn dispatch(action: &ServiceAction, _cli: &Cli) -> Result<(), Box> { + match action { + ServiceAction::Install { user } => install(user.as_deref()), + ServiceAction::Uninstall => uninstall(), + ServiceAction::Status => status(), + ServiceAction::Start => start(), + ServiceAction::Stop => stop(), + } +} + +fn install(user: Option<&str>) -> Result<(), Box> { + require_root("install")?; + if !Path::new("/run/systemd/system").exists() { + return Err("systemd was not detected (no /run/systemd/system). Only systemd-based \ + Linux distributions are supported by `vcms service`." + .into()); + } + + let user = resolve_run_user(user)?; + let vcms_home = user_home(&user)?.join(".vcms"); + let exe_path = std::env::current_exe()?; + let opts = InstallOptions { + user: user.clone(), + exe_path, + vcms_home, + }; + + prepare_home(&opts)?; + + std::fs::write(UNIT_PATH, systemd_unit(&opts))?; + println!("Wrote {UNIT_PATH}"); + + run(Command::new("systemctl").arg("daemon-reload"))?; + run(Command::new("systemctl").args(["enable", "--now", SERVICE_NAME]))?; + + println!("Service '{SERVICE_NAME}' installed, enabled at boot, and started (running as {user})."); + println!("Secrets (Postgres/S3 only) go in {}", opts.env_file().display()); + println!("Check it with: vcms service status"); + Ok(()) +} + +fn uninstall() -> Result<(), Box> { + require_root("uninstall")?; + // Best-effort: the service may already be stopped/disabled. + let _ = run(Command::new("systemctl").args(["disable", "--now", SERVICE_NAME])); + if Path::new(UNIT_PATH).exists() { + std::fs::remove_file(UNIT_PATH)?; + println!("Removed {UNIT_PATH}"); + } + run(Command::new("systemctl").arg("daemon-reload"))?; + println!("Service '{SERVICE_NAME}' uninstalled. Your data under ~/.vcms was left intact."); + Ok(()) +} + +fn start() -> Result<(), Box> { + require_root("start")?; + run(Command::new("systemctl").args(["start", SERVICE_NAME]))?; + println!("Service '{SERVICE_NAME}' started."); + Ok(()) +} + +fn stop() -> Result<(), Box> { + require_root("stop")?; + run(Command::new("systemctl").args(["stop", SERVICE_NAME]))?; + println!("Service '{SERVICE_NAME}' stopped."); + Ok(()) +} + +fn status() -> Result<(), Box> { + let enabled = output_trim(Command::new("systemctl").args(["is-enabled", SERVICE_NAME])); + let active = output_trim(Command::new("systemctl").args(["is-active", SERVICE_NAME])); + println!("Service: {SERVICE_NAME}"); + println!(" enabled at boot: {}", enabled.as_deref().unwrap_or("unknown")); + println!(" running: {}", active.as_deref().unwrap_or("unknown")); + // Detailed view (best-effort; ignored if the unit is absent). + let _ = Command::new("systemctl") + .args(["status", SERVICE_NAME, "--no-pager"]) + .status(); + Ok(()) +} + +/// Create the run-as user's `~/.vcms` + optional `.env` (0600) and hand ownership +/// to that account so the daemon can read and write its home. +fn prepare_home(opts: &InstallOptions) -> Result<(), Box> { + super::reject_symlink(&opts.vcms_home)?; + std::fs::create_dir_all(&opts.vcms_home)?; + let env_file = opts.env_file(); + super::create_secure_env_file(&env_file)?; + set_mode_600(&env_file)?; + run(Command::new("chown") + .arg("-R") + .arg(format!("{}:", opts.user)) + .arg(&opts.vcms_home))?; + Ok(()) +} + +fn set_mode_600(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) +} + +fn run(cmd: &mut Command) -> Result<(), Box> { + let status = cmd.status()?; + if !status.success() { + return Err(format!("command {cmd:?} failed with {status}").into()); + } + Ok(()) +} + +fn output_trim(cmd: &mut Command) -> Option { + let out = cmd.output().ok()?; + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } +} diff --git a/apps/backend/src/service/macos.rs b/apps/backend/src/service/macos.rs new file mode 100644 index 00000000..0b1ba80c --- /dev/null +++ b/apps/backend/src/service/macos.rs @@ -0,0 +1,123 @@ +//! launchd integration for `vcms service` on macOS (system LaunchDaemon). + +use std::path::Path; +use std::process::Command; + +use super::{InstallOptions, LAUNCHD_LABEL, launchd_plist, require_root, resolve_run_user, user_home}; +use crate::cli::{Cli, ServiceAction}; + +/// Where the generated LaunchDaemon plist is written. +const PLIST_PATH: &str = "/Library/LaunchDaemons/local.vcms.plist"; + +/// Modern launchctl service target (`system/