diff --git a/packages/ak-common/src/api.rs b/packages/ak-common/src/api.rs index 0624bd3ad4..fed533bcf6 100644 --- a/packages/ak-common/src/api.rs +++ b/packages/ak-common/src/api.rs @@ -1,7 +1,6 @@ //! Utilities for working with the authentik API client. -use ak_client::apis::configuration::Configuration; -use ak_client::models::Pagination; +use ak_client::{apis::configuration::Configuration, models::Pagination}; use eyre::{Result, eyre}; use url::Url; diff --git a/src/outpost/proxy/application.rs b/src/outpost/proxy/application.rs index 84a2d239ce..b76be4a25b 100644 --- a/src/outpost/proxy/application.rs +++ b/src/outpost/proxy/application.rs @@ -1,8 +1,7 @@ -use url::Url; - use ak_client::models::ProxyOutpostConfig; use eyre::{Result, eyre}; use tracing::instrument; +use url::Url; use crate::outpost::proxy::ProxyOutpost; @@ -10,20 +9,20 @@ const REDIRECT_PARAM: &str = "rd"; const CALLBACK_SIGNATURE: &str = "X-authentik-auth-callback"; const LOGOUT_SIGNATURE: &str = "X-authentik-logout"; +#[derive(Debug)] pub(super) struct Application { pub(super) host: String, + pub(super) provider: ProxyOutpostConfig, } impl Application { #[instrument(skip_all)] - pub(super) fn new( - _existing_apps: &ProxyOutpost, - provider: &ProxyOutpostConfig, - ) -> Result { + pub(super) fn new(_existing_apps: &ProxyOutpost, provider: ProxyOutpostConfig) -> Result { let external_url = Url::parse(&provider.external_host)?; - let external_host = external_url - .host_str() - .ok_or_else(|| eyre!("no host in external host"))?; + if !external_url.has_authority() { + return Err(eyre!("no host in external host")); + } + let external_host = external_url.authority(); let _redirect_url = { let mut redirect_url = external_url.join("outpost.goauthentik.io/callback")?; @@ -33,6 +32,7 @@ impl Application { Ok(Self { host: external_host.to_owned(), + provider, }) } } diff --git a/src/outpost/proxy/handlers.rs b/src/outpost/proxy/handlers.rs index cdfe179cb6..45e737b777 100644 --- a/src/outpost/proxy/handlers.rs +++ b/src/outpost/proxy/handlers.rs @@ -1,12 +1,15 @@ -use ak_axum::extract::host::Host; -use axum::extract::State; -use axum::http::Method; -use metrics::histogram; use std::sync::Arc; -use tokio::time::Instant; -use axum::http::StatusCode; -use axum::response::IntoResponse; +use ak_axum::{error::Result, extract::host::Host}; +use axum::{ + extract::{Request, State}, + http::{Method, StatusCode, header::CONTENT_TYPE}, + response::{IntoResponse as _, Response}, +}; +use metrics::histogram; +use serde_json::json; +use tokio::time::Instant; +use tracing::{instrument, trace, warn}; use crate::outpost::proxy::ProxyOutpost; @@ -14,7 +17,7 @@ pub(super) async fn handle_ping( method: Method, Host(host): Host, State(outpost): State>, -) -> impl IntoResponse { +) -> Response { let start = Instant::now(); histogram!( "authentik_outpost_proxy_request_duration_seconds", @@ -24,9 +27,33 @@ pub(super) async fn handle_ping( "type" => "ping", ) .record(start.elapsed().as_secs_f64()); - StatusCode::NO_CONTENT + StatusCode::NO_CONTENT.into_response() } -pub(super) async fn default(State(_outpost): State>) -> impl IntoResponse { - StatusCode::NOT_FOUND +#[instrument(skip(request, outpost))] +pub(super) async fn default( + Host(host): Host, + State(outpost): State>, + request: Request, +) -> Result { + let Some(_app) = outpost.lookup_app(&host) else { + trace!(headers = ?request.headers(), "tracing headers for no hostname match"); + warn!("no app for hostname"); + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(CONTENT_TYPE, "application/json") + .body( + json!({ + "message": "no app for hostname", + "host": host, + "detail": format!("check the outpost settings and make sure '{host}' is included."), + }) + .to_string() + .into(), + ) + .expect("infallible")); + }; + trace!("passing to application"); + + Ok(StatusCode::NOT_FOUND.into_response()) } diff --git a/src/outpost/proxy/mod.rs b/src/outpost/proxy/mod.rs index 530ef2a76b..884dfbc911 100644 --- a/src/outpost/proxy/mod.rs +++ b/src/outpost/proxy/mod.rs @@ -1,17 +1,15 @@ -use ak_common::config; use std::{collections::HashMap, sync::Arc}; use ak_axum::router::wrap_router; -use ak_client::apis::outposts_api::outposts_proxy_list; -use ak_common::{Tasks, api::fetch_all}; +use ak_client::{apis::outposts_api::outposts_proxy_list, models::ProxyMode}; +use ak_common::{Tasks, api::fetch_all, config}; use arc_swap::ArcSwap; use argh::FromArgs; use axum::Router; use eyre::Result; -use tracing::{debug, error, info, instrument, warn}; +use tracing::{debug, error, info, instrument, trace, warn}; -use crate::outpost::proxy::application::Application; -use crate::outpost::{Outpost, OutpostController}; +use crate::outpost::{Outpost, OutpostController, proxy::application::Application}; mod application; mod handlers; @@ -27,7 +25,7 @@ pub(crate) struct Cli {} pub(crate) struct ProxyOutpost { controller: Arc, - applications: ArcSwap>, + apps: ArcSwap>>, } impl Outpost for ProxyOutpost { @@ -39,7 +37,7 @@ impl Outpost for ProxyOutpost { async fn new(controller: Arc) -> Result { Ok(Self { controller, - applications: ArcSwap::from_pointee(HashMap::with_capacity(0)), + apps: ArcSwap::from_pointee(HashMap::with_capacity(0)), }) } @@ -97,21 +95,18 @@ impl Outpost for ProxyOutpost { let mut apps = HashMap::with_capacity(providers.len()); for provider in providers { - let Ok(application) = Application::new(self, &provider) + let name = provider.name.clone(); + let Ok(application) = Application::new(self, provider) .inspect_err(|err| warn!(?err, "failed to setup application, skipping provider")) else { continue; }; - info!( - name = provider.name, - host = application.host, - "loaded application" - ); + info!(name, host = application.host, "loaded application"); - apps.insert(application.host.clone(), application); + apps.insert(application.host.clone(), Arc::new(application)); } - self.applications.store(Arc::new(apps)); + self.apps.store(Arc::new(apps)); Ok(()) } @@ -123,20 +118,68 @@ impl Outpost for ProxyOutpost { } } -// TODO: actually write this -fn build_static_router() -> Router { - Router::new() +impl ProxyOutpost { + #[instrument(skip(self))] + fn lookup_app(&self, host: &str) -> Option> { + let apps = self.apps.load(); + + // If we only have a single app, host name switching doesn't matter. + if apps.len() == 1 + && let Some(app) = apps.values().next() + { + debug!(app = app.provider.name, "found a single app, using it"); + return Some(Arc::clone(app)); + } + + if let Some(app) = apps.get(host) { + debug!(app = app.provider.name, "found app based direct host match"); + return Some(Arc::clone(app)); + } + + // For forward_auth_domain, we don't have a direct app to domain relationship. + // Check through all apps, and check how much of their cookie domain matches the host. + // Return the application that has the longest match. + let mut longest_match = None; + let mut longest_len = 0_usize; + + for app in apps.values() { + if app.provider.mode != Some(ProxyMode::ForwardDomain) { + continue; + } + + if let Some(cookie_domain) = app.provider.cookie_domain.as_deref() { + // Check if the cookie domain has a leading period for a wildcard. + // This will decrease the weight of a wildcard domain, but a request to example.com + // with the cookie domain set to example.com will still be routed correctly. + let domain = cookie_domain.trim_start_matches('.'); + + if host.ends_with(domain) && domain.len() > longest_len { + longest_len = domain.len(); + longest_match = Some(Arc::clone(app)); + } + // For forward_auth_domain, we need to response on the external domain too. + if app.provider.external_host == host { + debug!(app = app.provider.name, "found app based on external_host"); + return Some(Arc::clone(app)); + } + } + } + + if let Some(app) = &longest_match { + debug!(app = app.provider.name, "found app based on cookie domain"); + } + + longest_match + } } fn build_router(outpost: Arc) -> Router { - // TODO: static files wrap_router( Router::new() .nest( "/outpost.goauthentik.io/ping", Router::new().fallback(handlers::handle_ping), ) - // .nest("/outpost.goauthentik.io/static", build_static_router()) .fallback(handlers::default) .with_state(outpost), true,