From 33556692747f1680a6a725d7d9101a9805941f44 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Thu, 2 Apr 2026 13:05:35 +0000 Subject: [PATCH] packages/ak-common/config: init (#21256) --- .cargo/deny.toml | 2 + Cargo.lock | 199 ++++++++++- Cargo.toml | 8 + authentik/lib/default.yml | 14 + packages/ak-common/Cargo.toml | 10 +- packages/ak-common/src/arbiter.rs | 10 +- packages/ak-common/src/config/mod.rs | 429 ++++++++++++++++++++++++ packages/ak-common/src/config/schema.rs | 90 +++++ packages/ak-common/src/lib.rs | 1 + 9 files changed, 749 insertions(+), 14 deletions(-) create mode 100644 packages/ak-common/src/config/mod.rs create mode 100644 packages/ak-common/src/config/schema.rs diff --git a/.cargo/deny.toml b/.cargo/deny.toml index 485f23500d..a8bcc1b1d2 100644 --- a/.cargo/deny.toml +++ b/.cargo/deny.toml @@ -2,12 +2,14 @@ allow = [ "Apache-2.0", "BSD-3-Clause", + "CC0-1.0", "CDLA-Permissive-2.0", "ISC", "MIT", "MPL-2.0", "OpenSSL", "Unicode-3.0", + "Zlib", ] [licenses.private] diff --git a/Cargo.lock b/Cargo.lock index 238ae9c880..2f259e4f00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "async-trait" version = "0.1.89" @@ -115,12 +121,20 @@ dependencies = [ name = "authentik-common" version = "2026.5.0-rc1" dependencies = [ + "arc-swap", "axum-server", + "config", "eyre", + "glob", "nix", + "notify", + "serde", + "serde_json", + "tempfile", "tokio", "tokio-util", "tracing", + "url", ] [[package]] @@ -201,7 +215,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cexpr", "clang-sys", "itertools", @@ -215,6 +229,12 @@ dependencies = [ "syn", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -367,6 +387,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "config" +version = "0.15.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +dependencies = [ + "async-trait", + "pathdiff", + "serde_core", + "winnow", + "yaml-rust2", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -477,6 +510,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -520,6 +559,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -660,6 +708,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -907,6 +964,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1008,6 +1085,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1030,6 +1127,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1092,6 +1195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1102,7 +1206,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -1118,6 +1222,33 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -1174,6 +1305,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1347,7 +1484,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1459,6 +1596,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1577,7 +1727,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1773,7 +1923,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1788,6 +1938,19 @@ dependencies = [ "libc", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1949,7 +2112,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -2219,7 +2382,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -2520,6 +2683,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2578,7 +2750,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -2614,6 +2786,17 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yaml-rust2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index e15625cacd..9c9e504cc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,13 +18,20 @@ license-file = "LICENSE" publish = false [workspace.dependencies] +arc-swap = "= 1.9.0" axum-server = { version = "= 0.8.0", features = ["tls-rustls-no-provider"] } aws-lc-rs = { version = "= 1.16.2", features = ["fips"] } clap = { version = "= 4.6.0", features = ["derive", "env"] } colored = "= 3.1.1" +config-rs = { package = "config", version = "= 0.15.22", default-features = false, features = [ + "yaml", + "async", +] } dotenvy = "= 0.15.7" eyre = "= 0.6.12" +glob = "= 0.3.3" nix = { version = "= 0.31.2", features = ["signal"] } +notify = "= 8.2.0" regex = "= 1.12.3" reqwest = { version = "= 0.13.2", features = [ "form", @@ -48,6 +55,7 @@ serde_repr = "= 0.1.20" serde_with = { version = "= 3.18.0", default-features = false, features = [ "base64", ] } +tempfile = "= 3.27.0" tokio = { version = "= 1.50.0", features = ["full", "tracing"] } tokio-util = { version = "= 0.7.18", features = ["full"] } tracing = "= 0.1.44" diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index c730502bab..cfbb9c68ba 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -47,6 +47,7 @@ listen: - "[::]:9300" debug: 0.0.0.0:9900 debug_py: 0.0.0.0:9901 + debug_tokio: "[::]:6669" trusted_proxy_cidrs: - 127.0.0.0/8 - 10.0.0.0/8 @@ -73,6 +74,19 @@ log_level: info log: http_headers: - User-Agent + rust_log: + "console_subscriber": info + "h2": info + "hyper_util": warn + "mio": info + "notify": info + "reqwest": info + "runtime": info + "rustls": info + "sqlx": info + "sqlx_postgres": info + "tokio": info + "tungstenite": info sessions: unauthenticated_age: days=1 diff --git a/packages/ak-common/Cargo.toml b/packages/ak-common/Cargo.toml index 399a9f007f..e27f3371e1 100644 --- a/packages/ak-common/Cargo.toml +++ b/packages/ak-common/Cargo.toml @@ -10,14 +10,22 @@ license-file.workspace = true publish.workspace = true [dependencies] +arc-swap.workspace = true axum-server.workspace = true +config-rs.workspace = true eyre.workspace = true -tokio.workspace = true +glob.workspace = true +notify.workspace = true +serde.workspace = true +serde_json.workspace = true tokio-util.workspace = true +tokio.workspace = true tracing.workspace = true +url.workspace = true [dev-dependencies] nix.workspace = true +tempfile.workspace = true [lints] workspace = true diff --git a/packages/ak-common/src/arbiter.rs b/packages/ak-common/src/arbiter.rs index 03648829e3..43b571b8f9 100644 --- a/packages/ak-common/src/arbiter.rs +++ b/packages/ak-common/src/arbiter.rs @@ -293,8 +293,8 @@ impl Arbiter { pub enum Event { /// A signal has been received. Signal(SignalKind), - #[cfg(test)] - Noop, + /// The configuration has been reloaded from sources. + ConfigChanged, } impl From for Event { @@ -414,15 +414,15 @@ mod tests { let mut events_rx1 = arbiter.events_subscribe(); let mut events_rx2 = arbiter.events_subscribe(); - let _ = arbiter.send_event(Event::Noop); + let _ = arbiter.send_event(Event::ConfigChanged); assert_eq!( events_rx1.recv().await.expect("failed to receive event"), - Event::Noop, + Event::ConfigChanged, ); assert_eq!( events_rx2.recv().await.expect("failed to receive event"), - Event::Noop, + Event::ConfigChanged, ); } } diff --git a/packages/ak-common/src/config/mod.rs b/packages/ak-common/src/config/mod.rs new file mode 100644 index 0000000000..e63641e320 --- /dev/null +++ b/packages/ak-common/src/config/mod.rs @@ -0,0 +1,429 @@ +use std::{ + env, + fs::{self, read_to_string}, + path::PathBuf, + sync::{Arc, OnceLock}, +}; + +use arc_swap::ArcSwap; +use eyre::Result; +use notify::{RecommendedWatcher, Watcher as _}; +use serde_json::{Map, Value}; +use tokio::sync::mpsc; +use tracing::{error, info, warn}; +use url::Url; + +pub mod schema; +pub use schema::Config; + +use crate::arbiter::{Arbiter, Event, Tasks}; + +static DEFAULT_CONFIG: &str = include_str!("../../../../authentik/lib/default.yml"); +static CONFIG_MANAGER: OnceLock = OnceLock::new(); + +/// List of paths from where to read YAML configuration. +fn config_paths() -> Vec { + let mut config_paths = vec![ + PathBuf::from("/etc/authentik/config.yml"), + PathBuf::from(""), + ]; + if let Ok(workspace) = env::var("WORKSPACE_DIR") { + let _ = env::set_current_dir(workspace); + } + + if let Ok(paths) = glob::glob("/etc/authentik/config.d/*.yml") { + config_paths.extend(paths.filter_map(Result::ok)); + } + + let environment = env::var("AUTHENTIK_ENV").unwrap_or_else(|_| "local".to_owned()); + + let mut computed_paths = Vec::new(); + + for path in config_paths { + if let Ok(metadata) = fs::metadata(&path) { + if !metadata.is_dir() { + computed_paths.push(path); + } + } else { + let env_paths = vec![ + path.join(format!("{environment}.yml")), + path.join(format!("{environment}.env.yml")), + ]; + for env_path in env_paths { + if let Ok(metadata) = fs::metadata(&env_path) + && !metadata.is_dir() + { + computed_paths.push(env_path); + } + } + } + } + + computed_paths +} + +impl Config { + /// Load the configuration from files and environment into a [`Value`], allowing for extra + /// processing later. + fn load_raw(config_paths: &[PathBuf]) -> Result { + let mut builder = config_rs::Config::builder().add_source(config_rs::File::from_str( + DEFAULT_CONFIG, + config_rs::FileFormat::Yaml, + )); + for path in config_paths { + builder = builder.add_source( + config_rs::File::from(path.as_path()).format(config_rs::FileFormat::Yaml), + ); + } + builder = builder.add_source( + config_rs::Environment::with_prefix("AUTHENTIK") + .prefix_separator("_") + .separator("__"), + ); + let config = builder.build()?; + let raw = config.try_deserialize::()?; + Ok(raw) + } + + /// Expand a value if it matches an env:// or file:// protocol. + /// + /// If expanded from a file, returns the file path for it to be watched later. + fn expand_value(value: &str) -> (String, Option) { + let value = value.trim(); + if let Ok(uri) = Url::parse(value) { + let fallback = uri.query().unwrap_or("").to_owned(); + match uri.scheme() { + "file" => { + let path = uri.path(); + match read_to_string(path).map(|s| s.trim().to_owned()) { + Ok(value) => return (value, Some(PathBuf::from(path))), + Err(err) => { + error!("failed to read config value from {path}: {err}"); + return (fallback, Some(PathBuf::from(path))); + } + } + } + "env" => { + if let Some(var) = uri.host_str() { + if let Ok(value) = env::var(var) { + return (value, None); + } + return (fallback, None); + } + } + _ => {} + } + } + + (value.to_owned(), None) + } + + /// Expand the configuration for env:// and file:// values. + /// + /// Returns the expanded configuration and a list of file paths for which to watch changes. + fn expand(mut raw: Value) -> (Value, Vec) { + let mut file_paths = Vec::new(); + let value = match &mut raw { + Value::String(s) => { + let (v, path) = Self::expand_value(s); + if let Some(path) = path { + file_paths.push(path); + } + Value::String(v) + } + Value::Array(arr) => { + let mut res = Vec::with_capacity(arr.len()); + for v in arr { + let (expanded, paths) = Self::expand(v.clone()); + file_paths.extend(paths); + res.push(expanded); + } + Value::Array(res) + } + Value::Object(map) => { + let mut res = Map::with_capacity(map.len()); + for (k, v) in map { + let (expanded, paths) = Self::expand(v.clone()); + file_paths.extend(paths); + res.insert(k.clone(), expanded); + } + Value::Object(res) + } + _ => raw, + }; + (value, file_paths) + } + + /// Load the configuration. + fn load(config_paths: &[PathBuf]) -> Result<(Self, Vec)> { + let raw = Self::load_raw(config_paths)?; + let (expanded, file_paths) = Self::expand(raw); + let config: Self = serde_json::from_value(expanded)?; + Ok((config, file_paths)) + } +} + +/// Manager of the config. Handles reloading when changed on disk. +struct ConfigManager { + config: ArcSwap, + config_paths: Vec, + watch_paths: Vec, +} + +/// Initialize the configuration. It relies on a global [`OnceLock`] and must be called before +/// other methods are called. +pub fn init() -> Result<()> { + info!("loading config"); + let config_paths = config_paths(); + init_with_paths(config_paths)?; + info!("config loaded"); + Ok(()) +} + +/// Initialize the configuration from a list of specific paths to read if from. +fn init_with_paths(config_paths: Vec) -> Result<()> { + let (config, mut other_paths) = Config::load(&config_paths)?; + let mut watch_paths = config_paths.clone(); + watch_paths.append(&mut other_paths); + let manager = ConfigManager { + config: ArcSwap::from_pointee(config), + config_paths, + watch_paths, + }; + CONFIG_MANAGER.get_or_init(|| manager); + Ok(()) +} + +/// Watch for configuration changes, reload the configuration in memory and send events. +/// +/// [`init`] must be called before this is used. +async fn watch_config(arbiter: Arbiter) -> Result<()> { + let (tx, mut rx) = mpsc::channel(100); + let mut watcher = RecommendedWatcher::new( + move |res: notify::Result| { + if let Ok(event) = res + && let notify::EventKind::Modify(_) = &event.kind + { + let _ = tx.blocking_send(()); + } + }, + notify::Config::default(), + )?; + let watch_paths = &CONFIG_MANAGER + .get() + .expect("failed to get config, has it been initialized?") + .watch_paths; + for path in watch_paths { + watcher.watch(path.as_ref(), notify::RecursiveMode::NonRecursive)?; + } + + let _ = arbiter.send_event(Event::ConfigChanged); + info!("config file watcher started on paths: {:?}", watch_paths); + + loop { + tokio::select! { + res = rx.recv() => { + info!("a configuration file changed, reloading config"); + if res.is_none() { + break; + } + let manager = CONFIG_MANAGER.get().expect("failed to get config, has it been initialized?"); + match tokio::task::spawn_blocking(|| Config::load(&manager.config_paths)).await? { + Ok((new_config, _)) => { + info!("configuration reloaded"); + manager.config.store(Arc::new(new_config)); + if let Err(err) = arbiter.send_event(Event::ConfigChanged) { + warn!("failed to notify of config change, aborting: {err:?}"); + break; + } + } + Err(err) => { + warn!("failed to reload config, continuing with previous config: {err:?}"); + } + } + }, + () = arbiter.shutdown() => break, + } + } + + info!("stopping config file watcher"); + + Ok(()) +} + +/// Start the configuration watcher. +/// +/// [`init`] must be called before this is used. +pub fn run(tasks: &mut Tasks) -> Result<()> { + info!("starting config file watcher"); + let arbiter = tasks.arbiter(); + tasks + .build_task() + .name(&format!("{}::watch_config", module_path!())) + .spawn(watch_config(arbiter))?; + Ok(()) +} + +/// Get the currently stored configuration. +/// +/// [`init`] must be called before this is used. +pub fn get() -> arc_swap::Guard> { + let manager = CONFIG_MANAGER + .get() + .expect("failed to get config, has it been initialized?"); + manager.config.load() +} + +#[cfg(test)] +mod tests { + use std::{env, fs::File, io::Write as _, path::PathBuf}; + + use tempfile::tempdir; + + use crate::arbiter::{Event, Tasks}; + + #[test] + fn default_config() { + let (config, _) = super::Config::load(&[]).expect("default config doesn't load"); + assert_eq!(config.secret_key, ""); + } + + #[test] + fn config_paths() { + let temp_dir = tempdir().expect("failed to create temp dir"); + for f in &[ + "local.env.yml", + "local.env.yaml", + "test_config_paths.yml", + "test_config_paths.env.yml", + "test_config_paths.env.yaml", + ] { + File::create(temp_dir.path().join(f)).expect("failed to create file"); + } + #[expect(unsafe_code, reason = "testing")] + // SAFETY: testing + unsafe { + env::set_var("WORKSPACE_DIR", temp_dir.path()); + env::set_var("AUTHENTIK_ENV", "test_config_paths"); + } + + let paths = super::config_paths(); + + assert_eq!( + &paths, + &[ + PathBuf::from("test_config_paths.yml"), + PathBuf::from("test_config_paths.env.yml"), + ] + ); + } + + #[test] + fn expand() { + let temp_dir = tempdir().expect("failed to create temp dir"); + + let secret_key_path = temp_dir.path().join("secret_key"); + let mut secret_key_file = File::create(&secret_key_path).expect("failed to create file"); + write!(secret_key_file, "my_secret_key").expect("failed to write to file"); + + let config_file_path = temp_dir.path().join("config"); + let mut config_file = File::create(&config_file_path).expect("failed to create file"); + writeln!( + config_file, + "secret_key: file://{}\npostgresql:\n password: env://TEST_CONFIG_POSTGRES_PASS", + secret_key_path.display() + ) + .expect("failed to write to file"); + + #[expect(unsafe_code, reason = "testing")] + // SAFETY: testing + unsafe { + env::set_var("TEST_CONFIG_POSTGRES_PASS", "my_postgres_pass"); + } + + let (config, config_paths) = + super::Config::load(&[config_file_path]).expect("failed to load config"); + + assert_eq!(config.secret_key, "my_secret_key"); + assert_eq!(config.postgresql.password, "my_postgres_pass"); + assert_eq!(config_paths, &[secret_key_path]); + } + + #[test] + fn init() { + super::init_with_paths(vec![]).expect("failed to init config"); + } + + #[tokio::test] + async fn watcher() { + let temp_dir = tempdir().expect("failed to create temp dir"); + + let secret_key_path = temp_dir.path().join("secret_key"); + let mut secret_key_file = File::create(&secret_key_path).expect("failed to create file"); + write!(secret_key_file, "my_secret_key").expect("failed to write to file"); + drop(secret_key_file); + + let config_file_path = temp_dir.path().join("config"); + let mut config_file = File::create(&config_file_path).expect("failed to create file"); + writeln!( + config_file, + "secret_key: file://{}\npostgresql:\n password: my_postgres_pass", + secret_key_path.display() + ) + .expect("failed to write to file"); + drop(config_file); + + super::init_with_paths(vec![config_file_path.clone()]).expect("failed to init config"); + + let mut tasks = Tasks::new().expect("failed to create tasks"); + let arbiter = tasks.arbiter(); + let mut events_rx = arbiter.events_subscribe(); + + super::run(&mut tasks).expect("failed to start watcher"); + + assert_eq!(super::get().secret_key, "my_secret_key"); + assert_eq!(super::get().postgresql.password, "my_postgres_pass"); + + let _ = events_rx.recv().await; + let mut secret_key_file = File::create(&secret_key_path).expect("failed to open file"); + write!(secret_key_file, "my_other_secret_key").expect("failed to write to file"); + drop(secret_key_file); + + assert_eq!( + events_rx.recv().await.expect("failed to receive event"), + Event::ConfigChanged, + ); + while !events_rx.is_empty() { + assert_eq!( + events_rx.recv().await.expect("failed to receive event"), + Event::ConfigChanged, + ); + } + + assert_eq!(super::get().secret_key, "my_other_secret_key"); + assert_eq!(super::get().postgresql.password, "my_postgres_pass"); + + let mut config_file = File::create(&config_file_path).expect("failed to open file"); + writeln!( + config_file, + "secret_key: file://{}\npostgresql:\n password: my_new_postgres_pass", + secret_key_path.display() + ) + .expect("failed to write to file"); + drop(config_file); + + assert_eq!( + events_rx.recv().await.expect("failed to receive event"), + Event::ConfigChanged, + ); + while !events_rx.is_empty() { + assert_eq!( + events_rx.recv().await.expect("failed to receive event"), + Event::ConfigChanged, + ); + } + + assert_eq!(super::get().secret_key, "my_other_secret_key"); + assert_eq!(super::get().postgresql.password, "my_new_postgres_pass"); + } +} diff --git a/packages/ak-common/src/config/schema.rs b/packages/ak-common/src/config/schema.rs new file mode 100644 index 0000000000..29b945c27e --- /dev/null +++ b/packages/ak-common/src/config/schema.rs @@ -0,0 +1,90 @@ +use std::{collections::HashMap, net::SocketAddr, num::NonZeroUsize}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub postgresql: PostgreSQLConfig, + + pub listen: ListenConfig, + + pub debug: bool, + #[serde(default)] + pub secret_key: String, + + pub log_level: String, + pub log: LogConfig, + + pub error_reporting: ErrorReportingConfig, + + pub compliance: ComplianceConfig, + + pub web: WebConfig, + + pub worker: WorkerConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostgreSQLConfig { + pub host: String, + pub port: u16, + pub user: String, + pub password: String, + pub name: String, + + pub sslmode: String, + pub sslrootcert: Option, + pub sslcert: Option, + pub sslkey: Option, + + pub conn_max_age: Option, + pub conn_health_checks: bool, + + pub default_schema: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListenConfig { + pub http: Vec, + pub metrics: Vec, + pub debug_tokio: SocketAddr, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogConfig { + pub http_headers: Vec, + pub rust_log: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorReportingConfig { + pub enabled: bool, + pub sentry_dsn: Option, + pub environment: String, + pub send_pii: bool, + pub sample_rate: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComplianceConfig { + pub fips: ComplianceFipsConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComplianceFipsConfig { + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebConfig { + pub path: String, + pub timeout_http_read_header: String, + pub timeout_http_read: String, + pub timeout_http_write: String, + pub timeout_http_idle: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkerConfig { + pub processes: NonZeroUsize, +} diff --git a/packages/ak-common/src/lib.rs b/packages/ak-common/src/lib.rs index 2e16a8890c..a8997c43e0 100644 --- a/packages/ak-common/src/lib.rs +++ b/packages/ak-common/src/lib.rs @@ -2,6 +2,7 @@ pub mod arbiter; pub use arbiter::{Arbiter, Event, Tasks}; +pub mod config; pub const VERSION: &str = env!("CARGO_PKG_VERSION");