diff --git a/Cargo.lock b/Cargo.lock index 7cb3730..ad553b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,8 +61,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "amnezia-fellow" -version = "0.1.4" +version = "0.1.5" dependencies = [ + "async-trait", "base64 0.22.1", "cot", "curve25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 12a7bc3..518cbff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "amnezia-fellow" -version = "0.1.5" +version = "0.1.6" edition = "2024" -description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync" +description = "Amnezia VPN client manager with SSO, SQLite/PostgreSQL, and Kubernetes Secret sync" [dependencies] -cot = { version = "0.6.0", default-features = false, features = ["sqlite", "json", "openapi", "swagger-ui"] } +async-trait = "0.1" +cot = { version = "0.6.0", default-features = false, features = ["sqlite", "postgres", "json", "openapi", "swagger-ui"] } schemars = { version = "0.9", features = ["derive"] } serde = { version = "1", features = ["derive"] } openidconnect = "4.0" diff --git a/README.md b/README.md index 6553770..51deb09 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Amnezia VPN client manager written in Rust on top of the public [`cot`](https://cot.rs) framework. -The app uses SQLite as the source of truth, authenticates users through OIDC/SSO, renders AmneziaWG client peers into a Kubernetes Secret, and avoids updating that Secret when the rendered content is byte-for-byte identical. +The app uses SQLite or PostgreSQL as the source of truth, authenticates users through OIDC/SSO, renders AmneziaWG client peers into a Kubernetes Secret, and avoids updating that Secret when the rendered content is byte-for-byte identical. ## Quick Start @@ -53,7 +53,7 @@ The OIDC groups claim is expected to be `groups`. ## VPN Data Model -SQLite stores all client data needed to restore configs: +The database stores all client data needed to restore configs: - owner user id - display name @@ -95,7 +95,8 @@ All settings use `AMNEZIA_FELLOW_` prefix. Priority is: | Variable | Description | Default | | --- | --- | --- | -| `AMNEZIA_FELLOW_DATABASE_URL` | SQLite connection URL | `sqlite://amnezia-fellow.sqlite3?mode=rwc` | +| `AMNEZIA_FELLOW_DATABASE_URL` | SQLite or PostgreSQL connection URL. PostgreSQL URLs must start with `postgresql://`. | `sqlite://amnezia-fellow.sqlite3?mode=rwc` | +| `AMNEZIA_FELLOW_MIGRATE_SQLITE` | Optional SQLite path/URL imported into PostgreSQL once on startup | empty | | `AMNEZIA_FELLOW_LOG_LEVEL` | Tracing filter | `info` | | `AMNEZIA_FELLOW_AUTH_PASSWORD_ENABLED` | Enable password login | `true` | | `AMNEZIA_FELLOW_AUTH_SSO_ENABLED` | Enable OIDC login | `false` | @@ -118,6 +119,23 @@ All settings use `AMNEZIA_FELLOW_` prefix. Priority is: | `AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME` | Telegram bot username, with or without `@` | empty | | `AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN` | Telegram bot token used to verify Web App `initData` | empty | +## PostgreSQL Migration + +To move from SQLite to PostgreSQL, start the app with a PostgreSQL database URL +and point `AMNEZIA_FELLOW_MIGRATE_SQLITE` at the existing SQLite file: + +```bash +AMNEZIA_FELLOW_DATABASE_URL=postgresql://user:pass@postgres:5432/amnezia_fellow +AMNEZIA_FELLOW_MIGRATE_SQLITE=/data/amnezia-fellow.sqlite3 +``` + +On startup, the app runs its migrations on PostgreSQL, opens the SQLite source +read/write, applies any missing app migrations there, copies config entries, +database sessions, users, OIDC links, Telegram link state, and VPN clients, +then writes an import marker into PostgreSQL. If the marker already exists, the +import is skipped. If PostgreSQL already contains app data but has no marker, +startup fails instead of merging two databases implicitly. + ## Telegram Bot Configure the bot through the admin Settings page or the matching environment variables: diff --git a/src/admin/views.rs b/src/admin/views.rs index eb802b0..63f8d77 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -84,6 +84,11 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec Self { Self { key, value } } + + pub fn key_str(&self) -> &str { + &self.key + } + + pub async fn get_by_key(db: &Database, key: &str) -> cot::db::Result> { + let key = key.to_owned(); + cot::db::query!(ConfigEntry, $key == key).get(db).await + } } // --------------------------------------------------------------------------- @@ -87,6 +96,7 @@ pub mod db_migrations { pub struct ConfigSources { pub database_url: ConfigSource, + pub migrate_sqlite: ConfigSource, pub oidc_issuer: ConfigSource, pub oidc_client_id: ConfigSource, pub oidc_client_secret: ConfigSource, @@ -116,6 +126,7 @@ impl Default for ConfigSources { fn default() -> Self { Self { database_url: ConfigSource::Default, + migrate_sqlite: ConfigSource::Default, oidc_issuer: ConfigSource::Default, oidc_client_id: ConfigSource::Default, oidc_client_secret: ConfigSource::Default, @@ -194,8 +205,10 @@ macro_rules! impl_env_overrides { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { - /// SQLite connection URL. + /// SQLite or PostgreSQL connection URL. pub database_url: String, + /// Optional SQLite database path/URL copied into PostgreSQL on startup. + pub migrate_sqlite: String, /// OIDC issuer URL. pub oidc_issuer: String, /// OIDC client ID. @@ -248,6 +261,7 @@ impl Default for AppConfig { fn default() -> Self { Self { database_url: "sqlite://amnezia-fellow.sqlite3?mode=rwc".into(), + migrate_sqlite: String::new(), oidc_issuer: String::new(), oidc_client_id: String::new(), oidc_client_secret: String::new(), @@ -277,6 +291,7 @@ impl Default for AppConfig { impl_env_overrides!( database_url, + migrate_sqlite, oidc_issuer, oidc_client_id, oidc_client_secret, @@ -355,6 +370,7 @@ impl AppConfig { } apply_db_field!(database_url); + apply_db_field!(migrate_sqlite); apply_db_field!(oidc_issuer); apply_db_field!(oidc_client_id); apply_db_field!(oidc_client_secret); diff --git a/src/main.rs b/src/main.rs index 86a931a..97a4403 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,12 +4,14 @@ mod auth; mod config; mod i18n; mod oidc; +mod sqlite_migration; mod telegram; mod user; mod vpn; use std::sync::Arc; +use async_trait::async_trait; use cot::auth::PasswordVerificationResult; use cot::cli::CliMetadata; use cot::common_types::Password; @@ -21,7 +23,7 @@ use cot::db::Database; use cot::form::{Form, FormResult}; use cot::html::Html; use cot::middleware::SessionMiddleware; -use cot::project::RegisterAppsContext; +use cot::project::{ProjectContext, RegisterAppsContext}; use cot::request::extractors::{RequestForm, UrlQuery}; use cot::response::IntoResponse; use cot::router::method::get; @@ -181,11 +183,19 @@ struct AmneziaFellowApp { config: Arc, } +#[async_trait] impl App for AmneziaFellowApp { fn name(&self) -> &'static str { env!("CARGO_PKG_NAME") } + async fn init(&self, context: &mut ProjectContext) -> cot::Result<()> { + if let Some(db) = context.try_database() { + sqlite_migration::run_if_requested(&self.config, db).await?; + } + Ok(()) + } + fn router(&self) -> Router { Router::with_urls([ Route::with_handler_and_name( @@ -296,8 +306,11 @@ impl Project for AmneziaFellowProject { " Priority: env var > DB override > compiled default.\n", "\n", " Database (required for most features):\n", - " AMNEZIA_FELLOW_DATABASE_URL SQLite connection URL\n", + " AMNEZIA_FELLOW_DATABASE_URL SQLite or PostgreSQL connection URL\n", " Example: sqlite:///data/amnezia-fellow.sqlite3?mode=rwc\n", + " Example: postgresql://user:pass@postgres:5432/amnezia_fellow\n", + " AMNEZIA_FELLOW_MIGRATE_SQLITE SQLite path/URL to import into PostgreSQL once\n", + " Example: /data/amnezia-fellow.sqlite3\n", "\n", " Server:\n", " AMNEZIA_FELLOW_LOG_LEVEL Tracing filter (default: info)\n", diff --git a/src/sqlite_migration.rs b/src/sqlite_migration.rs new file mode 100644 index 0000000..b009f4b --- /dev/null +++ b/src/sqlite_migration.rs @@ -0,0 +1,186 @@ +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use cot::db::migrations::{self, MigrationEngine, SyncDynMigration}; +use cot::db::{Database, Model}; +use cot::session::db::Session as CotSession; + +use crate::config::{AppConfig, ConfigEntry}; +use crate::user::{OidcLink, User}; +use crate::vpn::VpnClient; + +const MARKER_KEY: &str = "sqlite_migration_completed_at"; +const SOURCE_KEY: &str = "sqlite_migration_source"; + +pub async fn run_if_requested(config: &AppConfig, target_db: &Database) -> cot::Result<()> { + let source = config.migrate_sqlite.trim(); + if source.is_empty() { + return Ok(()); + } + + if !is_postgres_url(&config.database_url) { + tracing::warn!( + "AMNEZIA_FELLOW_MIGRATE_SQLITE is set but the active database is not PostgreSQL; skipping SQLite import" + ); + return Ok(()); + } + + if ConfigEntry::get_by_key(target_db, MARKER_KEY) + .await + .map_err(internal)? + .is_some() + { + tracing::info!("SQLite import marker exists; skipping SQLite import"); + return Ok(()); + } + + if target_has_app_data(target_db).await? { + return Err(cot::Error::internal( + "PostgreSQL target already contains amnezia-fellow data and has no SQLite import marker; refusing to merge automatically", + )); + } + + let source_url = sqlite_source_url(source)?; + tracing::info!("Importing amnezia-fellow data from SQLite into PostgreSQL"); + + let source_db = Database::new(source_url) + .await + .map_err(|e| cot::Error::internal(format!("failed to open SQLite source: {e}")))?; + + run_app_migrations(&source_db).await?; + copy_app_data(&source_db, target_db).await?; + reset_postgres_sequences(target_db).await?; + write_marker(target_db, source).await?; + + tracing::info!("SQLite import into PostgreSQL completed"); + Ok(()) +} + +fn is_postgres_url(url: &str) -> bool { + url.starts_with("postgresql:") +} + +fn sqlite_source_url(source: &str) -> cot::Result { + if source.starts_with("sqlite:") { + return Ok(source.to_owned()); + } + + if !Path::new(source).exists() { + return Err(cot::Error::internal(format!( + "SQLite import source does not exist: {source}" + ))); + } + + let prefix = if Path::new(source).is_absolute() { + "sqlite://" + } else { + "sqlite:" + }; + Ok(format!("{prefix}{source}?mode=rw")) +} + +async fn run_app_migrations(db: &Database) -> cot::Result<()> { + let engine = MigrationEngine::new(app_migrations()) + .map_err(|e| cot::Error::internal(format!("failed to build migration engine: {e}")))?; + engine + .run(db) + .await + .map_err(|e| cot::Error::internal(format!("failed to migrate SQLite source: {e}"))) +} + +fn app_migrations() -> Vec> { + let mut all = migrations::wrap_migrations(cot::session::db::migrations::MIGRATIONS); + all.extend(migrations::wrap_migrations( + crate::config::db_migrations::MIGRATIONS, + )); + all.extend(migrations::wrap_migrations( + crate::user::db_migrations::MIGRATIONS, + )); + all.extend(migrations::wrap_migrations( + crate::vpn::db_migrations::MIGRATIONS, + )); + all +} + +async fn target_has_app_data(db: &Database) -> cot::Result { + let config_entries = ConfigEntry::objects().count(db).await.map_err(internal)?; + let users = User::objects().count(db).await.map_err(internal)?; + let oidc_links = OidcLink::objects().count(db).await.map_err(internal)?; + let clients = VpnClient::objects().count(db).await.map_err(internal)?; + Ok(config_entries > 0 || users > 0 || oidc_links > 0 || clients > 0) +} + +async fn copy_app_data(source_db: &Database, target_db: &Database) -> cot::Result<()> { + for mut entry in ConfigEntry::objects().all(source_db).await.map_err(internal)? { + if matches!( + entry.key_str(), + "database_url" | "migrate_sqlite" | MARKER_KEY | SOURCE_KEY + ) { + continue; + } + entry.save(target_db).await.map_err(internal)?; + } + + for mut session in CotSession::objects() + .all(source_db) + .await + .map_err(internal)? + { + session.save(target_db).await.map_err(internal)?; + } + + for mut user in User::list_all(source_db).await.map_err(internal)? { + user.save(target_db).await.map_err(internal)?; + } + + for mut link in OidcLink::objects().all(source_db).await.map_err(internal)? { + link.save(target_db).await.map_err(internal)?; + } + + for mut client in VpnClient::list_all(source_db).await.map_err(internal)? { + client.save(target_db).await.map_err(internal)?; + } + + Ok(()) +} + +async fn reset_postgres_sequences(db: &Database) -> cot::Result<()> { + for table in [ + "cot_session__session", + "amnezia_fellow__user", + "amnezia_fellow__oidc_link", + "amnezia_fellow__vpn_client", + ] { + let sql = format!( + "SELECT setval(pg_get_serial_sequence('{table}', 'id'), \ + COALESCE((SELECT MAX(id) FROM {table}), 1), \ + (SELECT COUNT(*) > 0 FROM {table}))" + ); + db.raw(&sql).await.map_err(internal)?; + } + Ok(()) +} + +async fn write_marker(db: &Database, source: &str) -> cot::Result<()> { + let mut marker = ConfigEntry::new(MARKER_KEY.to_owned(), now_unix_seconds().to_string()); + marker.save(db).await.map_err(internal)?; + + let mut source_entry = ConfigEntry::new(SOURCE_KEY.to_owned(), source.to_owned()); + source_entry.save(db).await.map_err(internal)?; + + let mut migrate_entry = ConfigEntry::new("migrate_sqlite".to_owned(), String::new()); + migrate_entry.save(db).await.map_err(internal)?; + Ok(()) +} + +fn now_unix_seconds() -> i64 { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + i64::try_from(seconds).unwrap_or(i64::MAX) +} + +fn internal(error: impl std::fmt::Display) -> cot::Error { + cot::Error::internal(error.to_string()) +} diff --git a/templates/configs.html b/templates/configs.html index ca2b9e2..1e7b801 100644 --- a/templates/configs.html +++ b/templates/configs.html @@ -3,9 +3,11 @@ {% block title %}{{ t.configs_heading }} | {{ t.site_name }}{% endblock title %} {% block head_extra %} +