1
0
Fork 0
mirror of https://codeberg.org/icewind/haze.git synced 2026-10-01 08:44:09 +02:00

feat(services): Add Authentik to provide SAML

Signed-off-by: Louis Chmn <louis@chmn.me>
This commit is contained in:
Louis Chmn 2026-09-06 16:12:44 +02:00
commit c8d6cb170a
11 changed files with 622 additions and 3 deletions

View file

@ -0,0 +1,276 @@
mod saml;
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 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<IpAddr> {
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<String> {
let domain_name = domain_name(role);
let name = container_name(cloud_id, role);
let haze_directory = config.work_dir.join(cloud_id).join("authentik");
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<String> {
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<Vec<String>> {
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?,
])
}
fn container_name(&self, cloud_id: &str) -> Option<String> {
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<bool> {
// 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<Option<String>> {
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'
"#,
)))
}
}