diff --git a/Cargo.lock b/Cargo.lock index 1127530886..3f096e7b96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,7 @@ name = "authentik-common" version = "2026.5.0-rc1" dependencies = [ "arc-swap", + "authentik-client", "aws-lc-rs", "axum-server", "config", @@ -189,6 +190,8 @@ dependencies = [ "nix 0.31.2", "notify", "pin-project-lite", + "reqwest", + "reqwest-middleware", "rustls", "sentry", "serde", @@ -198,6 +201,7 @@ dependencies = [ "thiserror 2.0.18", "time", "tokio", + "tokio-retry2", "tokio-util", "tracing", "tracing-error", @@ -3621,6 +3625,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-retry2" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0266d56e57e6b29becbfce5daa6add8730941ca8192ddd7c24d25bf90c32a743" +dependencies = [ + "pin-project", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/Cargo.toml b/Cargo.toml index 45f2b4f2be..5ccd2319f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ tempfile = "= 3.27.0" thiserror = "= 2.0.18" time = { version = "= 0.3.47", features = ["macros"] } tokio = { version = "= 1.51.1", features = ["full", "tracing"] } +tokio-retry2 = "= 0.9.1" tokio-rustls = "= 0.26.4" tokio-util = { version = "= 0.7.18", features = ["full"] } tower = "= 0.5.3" @@ -105,6 +106,7 @@ tracing-subscriber = { version = "= 0.3.23", features = [ url = "= 2.5.8" uuid = { version = "= 1.23.0", features = ["serde", "v4"] } +ak-client = { package = "authentik-client", version = "2026.5.0-rc1", path = "./packages/client-rust" } ak-common = { package = "authentik-common", version = "2026.5.0-rc1", path = "./packages/ak-common", default-features = false } [profile.dev.package.backtrace] diff --git a/packages/ak-common/Cargo.toml b/packages/ak-common/Cargo.toml index 33e995c320..771356ae8d 100644 --- a/packages/ak-common/Cargo.toml +++ b/packages/ak-common/Cargo.toml @@ -15,6 +15,7 @@ core = ["dep:sqlx"] proxy = [] [dependencies] +ak-client.workspace = true arc-swap.workspace = true aws-lc-rs.workspace = true axum-server.workspace = true @@ -26,6 +27,8 @@ ipnet.workspace = true json-subscriber.workspace = true notify.workspace = true pin-project-lite.workspace = true +reqwest.workspace = true +reqwest-middleware.workspace = true rustls.workspace = true sentry.workspace = true serde.workspace = true @@ -33,6 +36,7 @@ serde_json.workspace = true sqlx = { workspace = true, optional = true } thiserror.workspace = true time.workspace = true +tokio-retry2.workspace = true tokio-util.workspace = true tokio.workspace = true tracing-error.workspace = true diff --git a/packages/ak-common/src/api.rs b/packages/ak-common/src/api.rs new file mode 100644 index 0000000000..0bd9fcb783 --- /dev/null +++ b/packages/ak-common/src/api.rs @@ -0,0 +1,193 @@ +//! Utilities for working with the authentik API client. + +use ak_client::apis::configuration::Configuration; +use eyre::{Result, eyre}; +use url::Url; + +use crate::{config, user_agent_outpost}; + +pub struct ServerConfig { + pub host: Url, + pub token: String, + pub insecure: bool, +} + +impl ServerConfig { + pub fn new() -> Result { + let host = config::get() + .host + .clone() + .ok_or_else(|| eyre!("environment variable `AUTHENTIK_HOST` not set"))?; + let mut host: Url = host.parse()?; + let token = config::get() + .token + .clone() + .ok_or_else(|| eyre!("environment variable `AUTHENTIK_TOKEN` not set"))?; + let insecure = config::get().insecure.unwrap_or(false); + + if !host.path().ends_with('/') { + host.path_segments_mut() + .map_err(|()| eyre!("URL cannot be a base"))? + .push(""); + } + + Ok(Self { + host, + token, + insecure, + }) + } +} + +/// Return a [`Configuration`] object based on external environment variables. +pub fn make_config() -> Result { + let server_config = ServerConfig::new()?; + + let base_path = server_config.host.join("api/v3")?.into(); + + let client = reqwest::ClientBuilder::new() + .tls_danger_accept_invalid_hostnames(server_config.insecure) + .tls_danger_accept_invalid_certs(server_config.insecure) + .build()?; + let client = reqwest_middleware::ClientBuilder::new(client).build(); + + Ok(Configuration { + base_path, + client, + bearer_access_token: Some(server_config.token), + user_agent: Some(user_agent_outpost()), + ..Default::default() + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{ServerConfig, make_config}; + use crate::config; + + #[test] + fn server_config_no_trailing_slash() { + config::init().expect("failed to init config"); + config::set(json!({ + "host": "http://localhost:9000", + "token": "token", + })) + .expect("failed to set config"); + + let server_config = ServerConfig::new().expect("failed to create server config"); + + assert_eq!(server_config.host.as_str(), "http://localhost:9000/"); + } + + #[test] + fn server_config_with_trailing_slash() { + config::init().expect("failed to init config"); + config::set(json!({ + "host": "http://localhost:9000/", + "token": "token", + })) + .expect("failed to set config"); + + let server_config = ServerConfig::new().expect("failed to create server config"); + + assert_eq!(server_config.host.as_str(), "http://localhost:9000/"); + } + + #[test] + fn server_config_with_path_no_trailing_slash() { + config::init().expect("failed to init config"); + config::set(json!({ + "host": "http://localhost:9000/authentik", + "token": "token", + })) + .expect("failed to set config"); + + let server_config = ServerConfig::new().expect("failed to create server config"); + + assert_eq!( + server_config.host.as_str(), + "http://localhost:9000/authentik/" + ); + } + + #[test] + fn server_config_with_path_and_trailing_slash() { + config::init().expect("failed to init config"); + config::set(json!({ + "host": "http://localhost:9000/authentik/", + "token": "token", + })) + .expect("failed to set config"); + + let server_config = ServerConfig::new().expect("failed to create server config"); + + assert_eq!( + server_config.host.as_str(), + "http://localhost:9000/authentik/" + ); + } + + #[test] + fn make_config_no_trailing_slash() { + config::init().expect("failed to init config"); + config::set(json!({ + "host": "http://localhost:9000", + "token": "token", + })) + .expect("failed to set config"); + + let api_config = make_config().expect("failed to make config"); + + assert_eq!(api_config.base_path, "http://localhost:9000/api/v3"); + } + + #[test] + fn make_config_with_trailing_slash() { + config::init().expect("failed to init config"); + config::set(json!({ + "host": "http://localhost:9000/", + "token": "token", + })) + .expect("failed to set config"); + + let api_config = make_config().expect("failed to make config"); + + assert_eq!(api_config.base_path, "http://localhost:9000/api/v3"); + } + + #[test] + fn make_config_with_path_no_trailing_slash() { + config::init().expect("failed to init config"); + config::set(json!({ + "host": "http://localhost:9000/authentik", + "token": "token", + })) + .expect("failed to set config"); + + let api_config = make_config().expect("failed to make config"); + + assert_eq!( + api_config.base_path, + "http://localhost:9000/authentik/api/v3" + ); + } + + #[test] + fn make_config_with_path_and_trailing_slash() { + config::init().expect("failed to init config"); + config::set(json!({ + "host": "http://localhost:9000/authentik/", + "token": "token", + })) + .expect("failed to set config"); + + let api_config = make_config().expect("failed to make config"); + + assert_eq!( + api_config.base_path, + "http://localhost:9000/authentik/api/v3" + ); + } +} diff --git a/packages/ak-common/src/config/schema.rs b/packages/ak-common/src/config/schema.rs index 826988782a..896a40e100 100644 --- a/packages/ak-common/src/config/schema.rs +++ b/packages/ak-common/src/config/schema.rs @@ -23,6 +23,11 @@ pub struct Config { pub web: WebConfig, pub worker: WorkerConfig, + + // Outpost specific fields + pub host: Option, + pub token: Option, + pub insecure: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/packages/ak-common/src/lib.rs b/packages/ak-common/src/lib.rs index feabf41bac..09d67b87b4 100644 --- a/packages/ak-common/src/lib.rs +++ b/packages/ak-common/src/lib.rs @@ -1,5 +1,6 @@ //! Various utilities used by other crates +pub mod api; pub mod arbiter; pub use arbiter::{Arbiter, Event, Tasks}; pub mod config; @@ -26,6 +27,10 @@ pub fn authentik_full_version() -> String { } } +pub fn user_agent_outpost() -> String { + format!("goauthentik.io/outpost/{}", authentik_full_version()) +} + pub fn authentik_user_agent() -> String { format!("authentik@{}", authentik_full_version()) } diff --git a/packages/ak-common/src/tracing.rs b/packages/ak-common/src/tracing.rs index 3902dee2ea..4f51b254ac 100644 --- a/packages/ak-common/src/tracing.rs +++ b/packages/ak-common/src/tracing.rs @@ -128,33 +128,91 @@ mod json { /// Utilities for Sentry pub mod sentry { - use std::str::FromStr as _; + use std::{str::FromStr as _, time::Duration}; - use tracing::trace; + use ak_client::apis::root_api::root_config_retrieve; + use eyre::{Error, Result}; + use tokio_retry2::{Retry, RetryError, strategy::FixedInterval}; + use tracing::{error, trace}; - use crate::{VERSION, authentik_user_agent, config}; + use crate::{ + Mode, VERSION, api, authentik_user_agent, + config::{self, schema::ErrorReportingConfig}, + }; + + fn get_config() -> Result { + // In non-core mode, we are running an outpost and need to grab the error reporting + // configuration from the API. + if Mode::is_core() { + return Ok(config::get().error_reporting.clone()); + } + + let api_config = api::make_config()?; + + let config = { + let retry_strategy = FixedInterval::new(Duration::from_secs(3)); + let retrieve_config = async || { + root_config_retrieve(&api_config) + .await + .map_err(Error::new) + .map_err(RetryError::transient) + }; + let retry_notify = |err: &Error, _duration| { + error!( + ?err, + "Failed to fetch configuration from API, retrying in 3 seconds" + ); + }; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(Retry::spawn_notify( + retry_strategy, + retrieve_config, + retry_notify, + ))? + }; + + let config = config.error_reporting; + + Ok(ErrorReportingConfig { + enabled: config.enabled, + sentry_dsn: Some(config.sentry_dsn), + environment: config.environment, + send_pii: config.send_pii, + #[expect( + clippy::cast_possible_truncation, + reason = "This is fine, we'll never get big values here." + )] + #[expect( + clippy::as_conversions, + reason = "This is fine, we'll never get big values here." + )] + sample_rate: config.traces_sample_rate as f32, + }) + } /// Install the sentry client. This must happen before [`super::install`] is called. - pub fn install() -> sentry::ClientInitGuard { + pub fn install() -> Result> { + let config = get_config()?; + if !config.enabled { + return Ok(None); + } trace!("setting up sentry"); - let config = config::get(); - sentry::init(sentry::ClientOptions { - dsn: config.error_reporting.sentry_dsn.clone().map(|dsn| { + let debug = config::get().debug; + Ok(Some(sentry::init(sentry::ClientOptions { + dsn: config.sentry_dsn.clone().map(|dsn| { sentry::types::Dsn::from_str(&dsn).expect("Failed to create sentry DSN") }), release: Some(format!("authentik@{VERSION}").into()), - environment: Some(config.error_reporting.environment.clone().into()), + environment: Some(config.environment.clone().into()), attach_stacktrace: true, - send_default_pii: config.error_reporting.send_pii, - sample_rate: config.error_reporting.sample_rate, - traces_sample_rate: if config.debug { - 1.0 - } else { - config.error_reporting.sample_rate - }, + send_default_pii: config.send_pii, + sample_rate: config.sample_rate, + traces_sample_rate: if debug { 1.0 } else { config.sample_rate }, user_agent: authentik_user_agent().into(), ..sentry::ClientOptions::default() - }) + }))) } }