mirror of
https://codeberg.org/icewind/haze.git
synced 2026-10-01 08:44:09 +02:00
Merge pull request 'feat(services): Add Authentik to provide SAML' (#46) from artonge/haze:artonge/feat/authentik into main
Reviewed-on: https://codeberg.org/icewind/haze/pulls/46
This commit is contained in:
commit
f4cbaf218d
11 changed files with 622 additions and 3 deletions
276
src/service/authentik/mod.rs
Normal file
276
src/service/authentik/mod.rs
Normal 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'
|
||||
"#,
|
||||
)))
|
||||
}
|
||||
}
|
||||
121
src/service/authentik/saml.rs
Normal file
121
src/service/authentik/saml.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
use super::{AUTHENTIK_PORT, container_ip, container_name, write_file};
|
||||
use crate::Result;
|
||||
use crate::cloud::CloudOptions;
|
||||
use crate::config::{HazeConfig, ProxyConfig};
|
||||
use crate::service::{ServiceTrait, split_cmnd};
|
||||
use bollard::Docker;
|
||||
|
||||
const SIGNING_CERT: &str = include_str!("../../../certificates/authentik/public.crt");
|
||||
const SIGNING_KEY: &str = include_str!("../../../certificates/authentik/private.key");
|
||||
const BLUEPRINT: &str = include_str!("../../../blueprints/authentik-saml.yaml");
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct AuthentikSaml;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ServiceTrait for AuthentikSaml {
|
||||
fn name(&self) -> &str {
|
||||
"saml"
|
||||
}
|
||||
|
||||
async fn spawn(
|
||||
&self,
|
||||
_docker: &Docker,
|
||||
cloud_id: &str,
|
||||
_network: &str,
|
||||
config: &HazeConfig,
|
||||
_options: &CloudOptions,
|
||||
) -> Result<Vec<String>> {
|
||||
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 = config
|
||||
.work_dir
|
||||
.join(cloud_id)
|
||||
.join("authentik")
|
||||
.join("blueprints");
|
||||
write_file(&blueprints_directory, "saml.yaml", BLUEPRINT)?;
|
||||
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn apps(&self) -> &'static [&'static str] {
|
||||
&["user_saml"]
|
||||
}
|
||||
|
||||
async fn post_setup(
|
||||
&self,
|
||||
docker: &Docker,
|
||||
cloud_id: &str,
|
||||
config: &HazeConfig,
|
||||
) -> Result<Vec<Vec<String>>> {
|
||||
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![
|
||||
split_cmnd("occ config:app:set --silent user_saml type --value saml"),
|
||||
split_cmnd(
|
||||
"occ config:app:set --silent user_saml general-allow_multiple_user_back_ends --value 1",
|
||||
),
|
||||
split_cmnd(
|
||||
"occ config:app:set --silent user_saml general-require_provisioned_account --value 0",
|
||||
),
|
||||
vec![
|
||||
"occ".into(),
|
||||
"config:app:set".into(),
|
||||
"--silent".into(),
|
||||
"user_saml".into(),
|
||||
"directLoginName".into(),
|
||||
"--value".into(),
|
||||
"Local login".into(),
|
||||
],
|
||||
split_cmnd("occ saml:config:create"),
|
||||
vec![
|
||||
"occ".into(),
|
||||
"saml:config:set".into(),
|
||||
"--silent".into(),
|
||||
"1".into(),
|
||||
"--general-idp0_display_name=Authentik SAML".into(),
|
||||
format!("--general-uid_mapping=http://haze.test/nextcloud/uid"),
|
||||
format!("--idp-entityId={authentik_url}/application/saml/nextcloud-saml/metadata/"),
|
||||
format!("--idp-singleSignOnService.url={authentik_url}/application/saml/nextcloud-saml/"),
|
||||
format!("--idp-singleLogoutService.url={authentik_url}/application/saml/nextcloud-saml/"),
|
||||
format!("--idp-x509cert={}", SIGNING_CERT.trim()),
|
||||
"--saml-attribute-mapping-displayName_mapping=http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name".into(),
|
||||
"--saml-attribute-mapping-email_mapping=http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress".into(),
|
||||
"--saml-attribute-mapping-group_mapping=http://schemas.xmlsoap.org/claims/Group".into(),
|
||||
"--security-wantAssertionsSigned=1".into(),
|
||||
],
|
||||
split_cmnd("occ saml:config:validate --silent"),
|
||||
])
|
||||
}
|
||||
|
||||
async fn start_message(
|
||||
&self,
|
||||
_docker: &Docker,
|
||||
_cloud_id: &str,
|
||||
proxy: &ProxyConfig,
|
||||
) -> Result<Option<String>> {
|
||||
if !proxy.https {
|
||||
Ok(Some(
|
||||
"WARNING: Nextcloud does not support SAML login in non secure setups".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(Some("".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue