mod oidc; mod saml; pub use oidc::AuthentikOidc; pub use saml::AuthentikSaml; use crate::Result; use crate::cloud::CloudOptions; use crate::config::{HazeConfig, ProxyConfig}; use crate::image::pull_image; use crate::service::ServiceTrait; use bollard::Docker; use bollard::models::{ContainerCreateBody, EndpointSettings, HostConfig, NetworkingConfig}; use bollard::query_parameters::CreateContainerOptions; use camino::Utf8PathBuf; use maplit::hashmap; use miette::{IntoDiagnostic, Report, WrapErr}; use std::fs::{create_dir_all, write}; use std::net::{IpAddr, Ipv4Addr}; const AUTHENTIK_IMAGE: &str = "ghcr.io/goauthentik/server:2026.8.0"; const POSTGRES_IMAGE: &str = "docker.io/library/postgres:16-alpine"; pub(super) const SIGNING_CERT: &str = include_str!("../../../certificates/authentik/public.crt"); pub(super) const SIGNING_KEY: &str = include_str!("../../../certificates/authentik/private.key"); pub(super) const AUTHENTIK_PORT: u16 = 9000; const AUTHENTIK_TOKEN: &str = "haze"; const BLUEPRINT: &str = include_str!("../../../blueprints/authentik.yaml"); #[derive(Debug, Clone, Eq, PartialEq)] pub struct Authentik; pub(super) async fn container_ip( docker: &Docker, container: &str, network: Option<&str>, ) -> Result { let networks = docker .inspect_container(container, None) .await .into_diagnostic()? .network_settings .and_then(|settings| settings.networks) .ok_or_else(|| Report::msg(format!("{container} is not connected to any network")))?; let endpoint = match network { Some(network) => networks.get(network).cloned(), None => networks.values().next().cloned(), }; endpoint .and_then(|endpoint| endpoint.ip_address) .ok_or_else(|| Report::msg(format!("{container} has no ip")))? .parse() .into_diagnostic() } pub(super) fn write_file(path: &Utf8PathBuf, filename: &str, content: &str) -> Result<()> { create_dir_all(path) .into_diagnostic() .wrap_err_with(|| format!("Failed to create {path}"))?; write(path.join(filename), content) .into_diagnostic() .wrap_err_with(|| format!("Failed to write {filename}")) } pub(super) fn container_name(cloud_id: &str, role: &str) -> String { if role == "server" { format!("{cloud_id}-authentik") } else { format!("{cloud_id}-authentik-{role}") } } fn domain_name(role: &str) -> String { if role == "server" { "authentik".into() } else { format!("authentik-{role}") } } async fn spawn_authentik( docker: &Docker, config: &HazeConfig, cloud_id: &str, network: &str, role: &str, ) -> Result { let domain_name = domain_name(role); let name = container_name(cloud_id, role); let haze_directory = config.work_dir.join(cloud_id).join("authentik"); write_file(&haze_directory, "public.crt", SIGNING_CERT)?; write_file(&haze_directory, "private.key", SIGNING_KEY)?; let blueprints_directory = haze_directory.join("blueprints"); write_file(&blueprints_directory, "nextcloud.yaml", BLUEPRINT)?; let mut env = vec![ "AUTHENTIK_POSTGRESQL__HOST=authentik-db".to_string(), "AUTHENTIK_POSTGRESQL__NAME=authentik".to_string(), "AUTHENTIK_POSTGRESQL__USER=authentik".to_string(), "AUTHENTIK_POSTGRESQL__PASSWORD=authentik".to_string(), "AUTHENTIK_SECRET_KEY=authentik-secret".to_string(), "AUTHENTIK_LOG_LEVEL=warning".to_string(), "AUTHENTIK_BOOTSTRAP_PASSWORD=password".to_string(), "AUTHENTIK_BOOTSTRAP_EMAIL=admin@haze.test".to_string(), format!("AUTHENTIK_BOOTSTRAP_TOKEN={AUTHENTIK_TOKEN}"), ]; if !config.proxy.address.is_empty() { let url = config.proxy.addr_with_port( &container_name(cloud_id, "server"), IpAddr::V4(Ipv4Addr::LOCALHOST), // ip arg is unused when a proxy is configured AUTHENTIK_PORT, ); env.push(format!("AUTHENTIK_WEB__BASE_URL={url}")); } let id = docker .create_container( Some(CreateContainerOptions { name: Some(name.to_string()), ..CreateContainerOptions::default() }), ContainerCreateBody { image: Some(AUTHENTIK_IMAGE.into()), cmd: Some(vec![role.into()]), env: Some(env), host_config: Some(HostConfig { network_mode: Some(network.to_string()), shm_size: Some(512 * 1024 * 1024), binds: Some(vec![ format!("{haze_directory}:/haze/authentik:ro"), format!("{blueprints_directory}:/blueprints/custom:ro"), ]), ..Default::default() }), labels: Some(hashmap! { "haze-type".to_string() => domain_name.to_string(), "haze-cloud-id".to_string() => cloud_id.to_string(), }), networking_config: Some(NetworkingConfig { endpoints_config: Some(hashmap! { network.to_string() => EndpointSettings { aliases: Some(vec![domain_name.to_string()]), ..Default::default() } }), }), ..Default::default() }, ) .await .into_diagnostic() .wrap_err_with(|| format!("Failed to create {name}"))? .id; docker .start_container(&id, None) .await .into_diagnostic() .wrap_err_with(|| format!("Failed to start {name}"))?; Ok(id) } async fn spawn_postgres(docker: &Docker, cloud_id: &str, network: &str) -> Result { let domain_name = domain_name("db"); let name = container_name(cloud_id, "db"); let id = docker .create_container( Some(CreateContainerOptions { name: Some(name.to_string()), ..CreateContainerOptions::default() }), ContainerCreateBody { image: Some(POSTGRES_IMAGE.into()), env: Some(vec![ "POSTGRES_DB=authentik".into(), "POSTGRES_USER=authentik".into(), "POSTGRES_PASSWORD=authentik".into(), ]), host_config: Some(HostConfig { network_mode: Some(network.to_string()), ..Default::default() }), labels: Some(hashmap! { "haze-type".to_string() => domain_name.to_string(), "haze-cloud-id".to_string() => cloud_id.to_string(), }), networking_config: Some(NetworkingConfig { endpoints_config: Some(hashmap! { network.to_string() => EndpointSettings { aliases: Some(vec![domain_name.to_string()]), ..Default::default() } }), }), ..Default::default() }, ) .await .into_diagnostic() .wrap_err_with(|| format!("Failed to create {name}"))? .id; docker .start_container(&id, None) .await .into_diagnostic() .wrap_err_with(|| format!("Failed to start {name}"))?; Ok(id) } #[async_trait::async_trait] impl ServiceTrait for Authentik { fn name(&self) -> &str { "authentik" } async fn spawn( &self, docker: &Docker, cloud_id: &str, network: &str, config: &HazeConfig, _options: &CloudOptions, ) -> Result> { pull_image(docker, POSTGRES_IMAGE).await?; pull_image(docker, AUTHENTIK_IMAGE).await?; Ok(vec![ spawn_postgres(docker, cloud_id, network).await?, spawn_authentik(docker, config, cloud_id, network, "worker").await?, spawn_authentik(docker, config, cloud_id, network, "server").await?, ]) } async fn post_setup( &self, docker: &Docker, cloud_id: &str, config: &HazeConfig, ) -> Result>> { let authentik_container = container_name(cloud_id, "server"); let authentik_url = config.proxy.addr_with_port( &authentik_container, container_ip(docker, &authentik_container, None).await?, AUTHENTIK_PORT, ); let nextcloud_url = config.proxy.addr( cloud_id, container_ip(docker, cloud_id, Some("haze")).await?, ); let haze_directory = config.work_dir.join(cloud_id).join("authentik"); write_file(&haze_directory, "authentik-url", &authentik_url)?; write_file(&haze_directory, "nextcloud-url", &nextcloud_url)?; Ok(vec![]) } fn container_name(&self, cloud_id: &str) -> Option { Some(container_name(cloud_id, "server")) } fn proxy_port(&self) -> u16 { AUTHENTIK_PORT } async fn is_healthy( &self, docker: &Docker, cloud_id: &str, _options: &CloudOptions, ) -> Result { // Authentik takes over a minute to boot, so only wait for the container. self.is_running(docker, cloud_id).await } async fn start_message( &self, docker: &Docker, cloud_id: &str, proxy: &ProxyConfig, ) -> Result> { let container = self.container_name(cloud_id).unwrap(); let ip = container_ip(docker, &container, None).await?; let addr = proxy.addr_with_port(&container, ip, self.proxy_port()); Ok(Some(format!( r#" Authentik running at: {addr} - It takes a few minute to finishes starting. Admin login: 'akadmin' with password 'password' Authentik users: 'alice', 'bob' and 'charlie' with password 'password' Authentik groups: 'authentik-group1', 'authentik-group2' and 'authentik-group3' "#, ))) } }