diff --git a/README.md b/README.md index e1be066..acf3ce7 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Additionally, you can use the following options when starting an instance: - `s3mb`: enable multi-bucket S3 setup. - `s3m`: enable multi-instance S3 setup. - `ldap`: set up an LDAP server. +- `saml`: set up authentik as a SAML IDP. - `office`: set up a Nextcloud Office server. - `onlyoffice` setup an onlyoffice document server. - `push` set up [client push](https://github.com/nextcloud/notify_push). diff --git a/blueprints/authentik-saml.yaml b/blueprints/authentik-saml.yaml new file mode 100644 index 0000000..9c10dfe --- /dev/null +++ b/blueprints/authentik-saml.yaml @@ -0,0 +1,96 @@ +# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json +version: 1 +metadata: + name: haze-nextcloud-saml +entries: + - model: authentik_crypto.certificatekeypair + id: haze-saml-cert + identifiers: + name: haze-saml + attrs: + certificate_data: !File /haze/authentik/public.crt + key_data: !File /haze/authentik/private.key + + # Prefix SAML users with 'saml-' so that they don't collide with local users. + - model: authentik_providers_saml.samlpropertymapping + id: haze-nextcloud-uid + identifiers: + name: haze-nextcloud-uid + attrs: + saml_name: http://haze.test/nextcloud/uid + expression: 'return "saml-" + request.user.username' + + - model: authentik_providers_saml.samlprovider + id: nextcloud-provider + identifiers: + name: nextcloud + attrs: + acs_url: + !Format [ + "%s/index.php/apps/user_saml/saml/acs", + !File /haze/authentik/nextcloud-url, + ] + audience: + !Format [ + "%s/index.php/apps/user_saml/saml/metadata", + !File /haze/authentik/nextcloud-url, + ] + sls_url: + !Format [ + "%s/index.php/apps/user_saml/saml/sls", + !File /haze/authentik/nextcloud-url, + ] + sls_binding: redirect + sp_binding: post + issuer_override: + !Format [ + "%s/application/saml/nextcloud-saml/metadata/", + !File /haze/authentik/authentik-url, + ] + default_name_id_policy: urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + assertion_valid_not_before: minutes=-5 + assertion_valid_not_on_or_after: minutes=5 + session_valid_not_on_or_after: minutes=86400 + digest_algorithm: http://www.w3.org/2001/04/xmlenc#sha256 + signature_algorithm: http://www.w3.org/2001/04/xmldsig-more#rsa-sha256 + sign_assertion: true + signing_kp: !KeyOf haze-saml-cert + authorization_flow: + !Find [ + authentik_flows.flow, + [slug, default-provider-authorization-implicit-consent], + ] + invalidation_flow: + !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + name_id_mapping: !KeyOf haze-nextcloud-uid + property_mappings: + - !KeyOf haze-nextcloud-uid + - !Find [ + authentik_providers_saml.samlpropertymapping, + [managed, goauthentik.io/providers/saml/username], + ] + - !Find [ + authentik_providers_saml.samlpropertymapping, + [managed, goauthentik.io/providers/saml/email], + ] + - !Find [ + authentik_providers_saml.samlpropertymapping, + [managed, goauthentik.io/providers/saml/name], + ] + - !Find [ + authentik_providers_saml.samlpropertymapping, + [managed, goauthentik.io/providers/saml/uid], + ] + - !Find [ + authentik_providers_saml.samlpropertymapping, + [managed, goauthentik.io/providers/saml/groups], + ] + + - model: authentik_core.application + identifiers: + slug: nextcloud-saml + attrs: + name: Nextcloud with SAML + provider: !KeyOf nextcloud-provider + meta_launch_url: !File /haze/authentik/nextcloud-url + meta_description: Nextcloud instance provisioned by haze diff --git a/blueprints/authentik.yaml b/blueprints/authentik.yaml new file mode 100644 index 0000000..111d965 --- /dev/null +++ b/blueprints/authentik.yaml @@ -0,0 +1,51 @@ +# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json +version: 1 +metadata: + name: haze-nextcloud +entries: + - model: authentik_core.group + id: authentik-group1 + identifiers: + name: authentik-group1 + - model: authentik_core.group + id: authentik-group2 + identifiers: + name: authentik-group2 + - model: authentik_core.group + id: authentik-group3 + identifiers: + name: authentik-group3 + + - model: authentik_core.user + id: alice + identifiers: + username: alice + attrs: + name: Authentik Alice + email: alice@haze.test + password: password + type: internal + groups: + - !KeyOf authentik-group1 + - model: authentik_core.user + id: bob + identifiers: + username: bob + attrs: + name: Authentik Bob + email: bob@haze.test + password: password + type: internal + groups: + - !KeyOf authentik-group2 + - model: authentik_core.user + id: charlie + identifiers: + username: charlie + attrs: + name: Authentik Charlie + email: charlie@haze.test + password: password + type: internal + groups: + - !KeyOf authentik-group3 diff --git a/certificates/authentik/private.key b/certificates/authentik/private.key new file mode 100644 index 0000000..f593d9c --- /dev/null +++ b/certificates/authentik/private.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCkyLkZjXOf8974 +xHp2bSFRxebTUhpPLqRKb7qLFeb7Qb192y29SfO/IHjRxQ59wwJcsZa0m88EU7J/ +otAExK8YMZpBSH+3itzh8rPy1A6soBJNiWPT4iKzB736sBEOy8rI0lCkP58YFX6Z +RCaeVEbxO5WHuH8htVedstY64XVG7BFHbqsoo48u2KcdgqN98XlKWb7O1ql2rnOB +TL0zs61L3WFgCW546gP+Lh7gsklWC4wnDmC3bVqm8uV9HpEbQw5sjIqMeGmy+Myu +rYPWeWJJHCCrmGktTCOvPfTKvShuFeK+tvwGzkiosORiq/bm69phzy1EDIMlEFCl +3EYpat93AgMBAAECggEAUe7j6kqk9SFC+ppm8b9tU8V88hHetwRP+Br5u/JV+RRE +7fEvGvFMWvoAWP0MKYfvArviXUcjddlP5ZrEp7pL/VGci11K863+CfKtes3pxfeJ +YjgwBMhpzG7LDXzB3oOB/rxkEGb56fW2DusN8Kei5otj3CnmPJJ4UBb94iT8NRia +4Yd5rP4mcBlGoXOAIzNtCMBmSVbfN1Uj/lM6O3lOQbg0UiY5yoW5H9QK2FAmdQqh +YzOzX3fGuX2ZOQKMU1t0xdjemcRwBtBtvp0hfwm/P8sE8EjgKNDzKvnEMmAIvxkV +8HiRjlZRK1TeVSDa9Pb2rMGWtebGipQ/C91r5ExDAQKBgQDdh8BYTmc8sqmEsZxj +kHFHjbi/qiNqbb34DF3RVLStkhM99COal2rtXGA/y6OmTF/7mh8G2Ywf71cChEMN +yXsViZZ7wcx+LidUQoW3L7LV28cCeD02oF8TCGVD1GIHyy2ohJ/Vy8vGHJHUiArS +hCFDiSRhMH6c4dhAXs1twsWBQQKBgQC+bJd38gqEzz3N0idhW6Go2aNgc5UXC6um +79M3XO79gJPeLLrq/Nr1+EJ3hRfyvJvRTVa/vGNPSlPbIHxRStaJVb36KA6gR1Ui +g+vXLWd8r98xD5CttKdViPBMLsbjneFqd5GMNjPtzVOzXyMtG410pJg6ve0s2N9G +yFW5q4L6twKBgCCu95TPtHGDFnmKTr1twRjCcwBsFJ+OI1nmUS0iJyn4hDg+vcYA +EvmECHtBCxrs57hSK8Ox8vd/M0Iey1nMYQlzbC1EEWyIWKsYyWuWcPcWXs0hej6F ++KDxOyd/vRrTQiA7uO0tDRpkeqt1iss2TUYOhLyGEBgLRgFxOzO3abZBAoGAYdTc +hM0fRlhKwmGDxesTxPH7k+QN5sciKyPvefQO/MKANZb5eRzrSY+AZnNEeHsZ+pAn +T150Dxp6toucEw/F5MzeS5Uk3oeHX7IzClvTXSXmHwiGJhg4GCPAgQNPP0Wvt8ky +R7zZNQVWSUNJiTUsmY6ufw9wuKe7HlxyXm+VXUUCgYAKDF0ogAnxO7OzXKQcf7Kj +xi54Y+gYUXGVCtMPxcZsa7hdH4HtIto1o02HlxdZk6vPWjYSY4dnO42Q4Ck6cIjN +/m0/NMrEq0cmfmCrzOD/jACQLQE8typfUHT9lLQa2jz6JWBfY9lsdmmxhcf1RQex +ICm/Hp4iwTAo2aFE0OvhRQ== +-----END PRIVATE KEY----- diff --git a/certificates/authentik/public.crt b/certificates/authentik/public.crt new file mode 100644 index 0000000..f771f0f --- /dev/null +++ b/certificates/authentik/public.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDHTCCAgWgAwIBAgIUUmoOy7JNxHn51Xvxf9dBS+wCPb4wDQYJKoZIhvcNAQEL +BQAwHjEcMBoGA1UEAwwTaGF6ZS1hdXRoZW50aWstc2FtbDAeFw0yNjA4MjIyMDIy +MTZaFw00NjA4MTcyMDIyMTZaMB4xHDAaBgNVBAMME2hhemUtYXV0aGVudGlrLXNh +bWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCkyLkZjXOf8974xHp2 +bSFRxebTUhpPLqRKb7qLFeb7Qb192y29SfO/IHjRxQ59wwJcsZa0m88EU7J/otAE +xK8YMZpBSH+3itzh8rPy1A6soBJNiWPT4iKzB736sBEOy8rI0lCkP58YFX6ZRCae +VEbxO5WHuH8htVedstY64XVG7BFHbqsoo48u2KcdgqN98XlKWb7O1ql2rnOBTL0z +s61L3WFgCW546gP+Lh7gsklWC4wnDmC3bVqm8uV9HpEbQw5sjIqMeGmy+MyurYPW +eWJJHCCrmGktTCOvPfTKvShuFeK+tvwGzkiosORiq/bm69phzy1EDIMlEFCl3EYp +at93AgMBAAGjUzBRMB0GA1UdDgQWBBRZT+y86UNIVosbPK1Zg1hx9vRJUzAfBgNV +HSMEGDAWgBRZT+y86UNIVosbPK1Zg1hx9vRJUzAPBgNVHRMBAf8EBTADAQH/MA0G +CSqGSIb3DQEBCwUAA4IBAQCSwwVwaPl/WK0oItWXXQmYFeNYeByGiHttKPYIDW0g +KBk74iZKVvIfZlm3r8Z8xhiAwCxGeFLPrOU3PUD5AcPlLurCdJqSWCYau5njXChw +jDgZVXJiVJ61QwCcULPa9TFsvoQY8/r+9UnJFqFtSNeU562WBIOavdSLKLv/UVed +SVAzqTALYlNmMkMXZGmvNC5pNscC2ekPcA8IvNiqNG/p1Vc9OWkUGstbhswRNy7E +gNxZ74RSCY0NFUhTFMuP2fpHEnh8krXkwb9P7kfwFnLxp3Z+EdsW6f7P5O6o85r7 +BJbJAd113khrUFgnR8eRcruKp6WBqbO6jnXypCQEdAUE +-----END CERTIFICATE----- diff --git a/flake.nix b/flake.nix index 288ba23..a3b6ebd 100644 --- a/flake.nix +++ b/flake.nix @@ -37,6 +37,7 @@ extraPaths = [ ./certificates + ./blueprints ]; withOverlays = [ diff --git a/nix/package.nix b/nix/package.nix index c6166f0..d8fcdb9 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -6,7 +6,7 @@ inherit (lib) getExe; inherit (lib.sources) sourceByRegex; inherit (builtins) fromTOML readFile; - src = sourceByRegex ../. ["Cargo.*" "(src|certificates)(/.*)?"]; + src = sourceByRegex ../. ["Cargo.*" "(src|certificates|blueprints)(/.*)?"]; version = (fromTOML (readFile ../Cargo.toml)).package.version; in rustPlatform.buildRustPackage { diff --git a/src/cloud.rs b/src/cloud.rs index d87cf4f..6932d00 100644 --- a/src/cloud.rs +++ b/src/cloud.rs @@ -6,6 +6,7 @@ use crate::mapping::{Mapping, for_config}; use crate::php::PhpVersion; use crate::service::Service; use crate::service::ServiceTrait; +use crate::service::deduplicate_services; use crate::sources::download_nc; use bollard::Docker; use bollard::config::NetworkCreateRequest; @@ -243,7 +244,7 @@ impl CloudOptions { php: php .or_else(|| get_max_php_version(&config.sources_root)) .unwrap_or_default(), - services, + services: deduplicate_services(services), app_packages: app_package, mappings: vec![], version, diff --git a/src/service.rs b/src/service.rs index 75808f8..0ab6411 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,3 +1,4 @@ +mod authentik; mod clam; mod dav; mod imaginary; @@ -18,6 +19,7 @@ mod webhook; use crate::cloud::CloudOptions; use crate::config::{HazeConfig, Preset, ProxyConfig}; +pub use crate::service::authentik::{Authentik, AuthentikSaml}; pub use crate::service::clam::{Clam, ClamIcap, ClamIcapTls, ClamSocket}; use crate::service::dav::Dav; use crate::service::imaginary::Imaginary; @@ -35,8 +37,8 @@ use crate::service::sftp::{Sftp, SftpKey}; use crate::service::sharded::{Sharding, ShardingMigrate, ShardingMigrateUnset, SingleShard}; use crate::service::smb::Smb; use crate::service::webhook::Webhook; -use bollard::models::ContainerState; use bollard::Docker; +use bollard::models::ContainerState; use enum_dispatch::enum_dispatch; use miette::{IntoDiagnostic, Report, Result, WrapErr}; use serde_json::Value; @@ -314,6 +316,10 @@ pub enum ServiceType { Webhook, /// Enable DB partitioning for mariadb Partitioning, + /// Authentik identity provider + Authentik, + /// Configure Authentik as a SAML IDP for Nextcloud + Saml, } #[enum_dispatch] @@ -347,6 +353,8 @@ pub enum Service { RedisTls(RedisTls), FrankenPhp(FrankenPhp), Webhook(Webhook), + Authentik(Authentik), + AuthentikSaml(AuthentikSaml), Preset(PresetService), } @@ -393,6 +401,11 @@ impl Service { ServiceType::RedisTls => Some(vec![Service::RedisTls(RedisTls)]), ServiceType::FrankenPhp => Some(vec![Service::FrankenPhp(FrankenPhp)]), ServiceType::Webhook => Some(vec![Service::Webhook(Webhook)]), + ServiceType::Authentik => Some(vec![Service::Authentik(Authentik)]), + ServiceType::Saml => Some(vec![ + Service::Authentik(Authentik), + Service::AuthentikSaml(AuthentikSaml), + ]), } } else { presets @@ -421,6 +434,18 @@ impl Service { } } +// Remove duplicate services. +// Useful for Authentik as it is needed by saml and oidc. +pub fn deduplicate_services(services: impl IntoIterator) -> Vec { + let mut collected = Vec::new(); + for service in services { + if !collected.contains(&service) { + collected.push(service); + } + } + collected +} + fn get_preset<'a>(presets: &'a [Preset], name: &str) -> Option<&'a Preset> { presets.iter().find(|preset| preset.name == name) } diff --git a/src/service/authentik/mod.rs b/src/service/authentik/mod.rs new file mode 100644 index 0000000..5c24480 --- /dev/null +++ b/src/service/authentik/mod.rs @@ -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 { + 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"); + 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?, + ]) + } + + 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' +"#, + ))) + } +} diff --git a/src/service/authentik/saml.rs b/src/service/authentik/saml.rs new file mode 100644 index 0000000..870b2cf --- /dev/null +++ b/src/service/authentik/saml.rs @@ -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> { + 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>> { + 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> { + if !proxy.https { + Ok(Some( + "WARNING: Nextcloud does not support SAML login in non secure setups".into(), + )) + } else { + Ok(Some("".into())) + } + } +}