From 853ee15a3dbfc7660bf6b0f4f5c1f78196dc2d35 Mon Sep 17 00:00:00 2001 From: Louis Chmn Date: Thu, 17 Sep 2026 14:54:54 +0200 Subject: [PATCH] WIP: make it work Signed-off-by: Louis Chmn --- README.md | 52 ++++++--- blueprints/authentik-oidc.yaml | 6 +- blueprints/authentik-saml.yaml | 11 ++ blueprints/authentik.yaml | 6 + src/service/authentik/oidc.rs | 16 +++ src/service/authentik/saml.rs | 16 +++ src/service/lookup_server.rs | 18 +++ src/service/slave.rs | 194 +++++++++++++++++++++++++++++++-- 8 files changed, 292 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e0eb344..034b981 100644 --- a/README.md +++ b/README.md @@ -297,10 +297,12 @@ instances. ## Global scale -You can start a lookup-server service when starting an instance: +You can start a lookup-server service when starting an instance. Adding an +authentik single sign-on service lets the master route accounts it has never +seen before: ```bash -haze start --name gs-master lookup +haze start --name gs-master lookup oidc ``` The Nextcloud instance started with this command is already properly configured @@ -308,7 +310,7 @@ as master node. If you want to work on the lookup_server, you can set the `lookup_server_source` config in `haze.toml`. This will mount your local checkout of the lookup server into the container. -You can then start slave instance with the following command: +You can then start slave instances with the following command: ```bash haze start --name gs-slave1 slave @@ -317,28 +319,48 @@ haze start --name gs-slave1 slave A slave attaches to the most recently started instance running a lookup server, and is pointed at both that lookup server and the master instance automatically. -If you want to use the `ManualUserMapping` module, you'll have to set the -following config on the master instance: +### How accounts are routed + +The master is configured to use the `ManualUserMapping` discovery module, which +resolves a node name coming from the identity provider to the address of a +slave. Haze keeps that mapping in `gs-user-mapping.json` in the config folder of +the master, and every slave adds itself to it when it starts, under its node +name (`slave1`, `slave2`, ...), its instance name (`gs-slave1`) and its full +cloud id (`haze-gs-slave1`). Entries of instances that are no longer running are +dropped. The file is read on every login, so no restart is needed after starting +a slave. + +The accounts shipped in the authentik blueprint carry the node name they belong +to in the `nextcloud_gss_node` user attribute: alice is routed to `slave1`, bob +to `slave2` and charlie to `slave3`. Logging in on the master with "Log in with +Authentik OIDC" as alice redirects to the first slave, which creates the +`oidc-alice` account on the fly. + +An account without a `nextcloud_gss_node` value is rejected with "Unknown +Account". If you want a catch-all instead, switch the dictionary to regular +expressions and use regex keys: ```bash -haze gs-master occ config:system:set gss.user.discovery.module --value="\\OCA\\GlobalSiteSelector\\UserDiscoveryModules\\ManualUserMapping" -haze gs-master occ config:system:set gss.discovery.manual.mapping.file --value="/shared/config/gs-user-mapping.json" # See the volume section below for how to mount this file into the container. haze gs-master occ config:system:set gss.discovery.manual.mapping.regex --type boolean --value=true ``` -```toml -[[volume]] # Needed if you are using "ManualUserMapping" module. -source = "/home/louis/.config/haze/config/gs-user-mapping.json" -target = "/shared/config/gs-user-mapping.json" -read_only = true -``` +Accounts that are already registered in the lookup server are routed without the +discovery module, so they also work with a password login. Each slave seeds one +such account, named after the instance (`gs-slave1` on `haze-gs-slave1`) with +the usual haze password, to make that path easy to try out. Because that password +is usually too weak for the default policy, the length and common-password checks +of `password_policy` are turned off on slaves. + +The mapping file can be inspected on the host, in the config folder of the master +within the haze work directory, e.g. +`/tmp/haze/haze-gs-master/config/gs-user-mapping.json`. You can test the connection between the instances and the lookup server by -running the following command on the slave instance: +running the following commands: ```bash haze gs-master occ globalsiteselector:discovery -haze gs-slave1 occ globalsiteselector:discovery +haze gs-slave1 occ globalsiteselector:discovery --current ``` ## Proxy diff --git a/blueprints/authentik-oidc.yaml b/blueprints/authentik-oidc.yaml index b50e9d8..a10ac4c 100644 --- a/blueprints/authentik-oidc.yaml +++ b/blueprints/authentik-oidc.yaml @@ -18,7 +18,11 @@ entries: name: haze-nextcloud-oidc-uid attrs: scope_name: nextcloud - expression: 'return {"nextcloud_uid": "oidc-" + request.user.username}' + expression: | + return { + "nextcloud_uid": "oidc-" + request.user.username, + "nextcloud_gss_node": request.user.attributes.get("nextcloud_gss_node", ""), + } - model: authentik_providers_oauth2.oauth2provider id: nextcloud-oidc-provider diff --git a/blueprints/authentik-saml.yaml b/blueprints/authentik-saml.yaml index 9c10dfe..41d69c7 100644 --- a/blueprints/authentik-saml.yaml +++ b/blueprints/authentik-saml.yaml @@ -20,6 +20,16 @@ entries: saml_name: http://haze.test/nextcloud/uid expression: 'return "saml-" + request.user.username' + # Symbolic name of the Global Scale node the user belongs to, resolved to an + # address by the mapping file maintained by haze. + - model: authentik_providers_saml.samlpropertymapping + id: haze-nextcloud-gss-node + identifiers: + name: haze-nextcloud-gss-node + attrs: + saml_name: http://haze.test/nextcloud/gss-node + expression: 'return request.user.attributes.get("nextcloud_gss_node", "")' + - model: authentik_providers_saml.samlprovider id: nextcloud-provider identifiers: @@ -65,6 +75,7 @@ entries: name_id_mapping: !KeyOf haze-nextcloud-uid property_mappings: - !KeyOf haze-nextcloud-uid + - !KeyOf haze-nextcloud-gss-node - !Find [ authentik_providers_saml.samlpropertymapping, [managed, goauthentik.io/providers/saml/username], diff --git a/blueprints/authentik.yaml b/blueprints/authentik.yaml index 111d965..cec4980 100644 --- a/blueprints/authentik.yaml +++ b/blueprints/authentik.yaml @@ -25,6 +25,8 @@ entries: email: alice@haze.test password: password type: internal + attributes: + nextcloud_gss_node: slave1 groups: - !KeyOf authentik-group1 - model: authentik_core.user @@ -36,6 +38,8 @@ entries: email: bob@haze.test password: password type: internal + attributes: + nextcloud_gss_node: slave2 groups: - !KeyOf authentik-group2 - model: authentik_core.user @@ -47,5 +51,7 @@ entries: email: charlie@haze.test password: password type: internal + attributes: + nextcloud_gss_node: slave3 groups: - !KeyOf authentik-group3 diff --git a/src/service/authentik/oidc.rs b/src/service/authentik/oidc.rs index 197bb2f..e897199 100644 --- a/src/service/authentik/oidc.rs +++ b/src/service/authentik/oidc.rs @@ -4,6 +4,9 @@ use crate::cloud::CloudOptions; use crate::config::HazeConfig; use crate::service::{ServiceTrait, split_cmnd}; use bollard::Docker; +use maplit::hashmap; +use serde_json::Value; +use std::collections::HashMap; const BLUEPRINT: &str = include_str!("../../../blueprints/authentik-oidc.yaml"); @@ -38,6 +41,19 @@ impl ServiceTrait for AuthentikOidc { &["user_oidc"] } + /// The claim carrying the Global Scale node name, only used when the instance + /// also runs a lookup server. + fn config( + &self, + _docker: &Docker, + _cloud_id: &str, + _config: &HazeConfig, + ) -> Result> { + Ok(hashmap! { + "gss.discovery.manual.mapping.parameter".into() => Value::String("nextcloud_gss_node".into()), + }) + } + async fn post_setup( &self, docker: &Docker, diff --git a/src/service/authentik/saml.rs b/src/service/authentik/saml.rs index b64dc7b..c306d78 100644 --- a/src/service/authentik/saml.rs +++ b/src/service/authentik/saml.rs @@ -5,6 +5,9 @@ use crate::config::{HazeConfig, ProxyConfig}; use crate::service::authentik::SIGNING_CERT; use crate::service::{ServiceTrait, split_cmnd}; use bollard::Docker; +use maplit::hashmap; +use serde_json::Value; +use std::collections::HashMap; const BLUEPRINT: &str = include_str!("../../../blueprints/authentik-saml.yaml"); @@ -39,6 +42,19 @@ impl ServiceTrait for AuthentikSaml { &["user_saml"] } + /// The SAML attribute carrying the Global Scale node name, only used when the + /// instance also runs a lookup server. + fn config( + &self, + _docker: &Docker, + _cloud_id: &str, + _config: &HazeConfig, + ) -> Result> { + Ok(hashmap! { + "gss.discovery.manual.mapping.parameter".into() => Value::String("http://haze.test/nextcloud/gss-node".into()), + }) + } + async fn post_setup( &self, docker: &Docker, diff --git a/src/service/lookup_server.rs b/src/service/lookup_server.rs index d5bd950..66fac41 100644 --- a/src/service/lookup_server.rs +++ b/src/service/lookup_server.rs @@ -18,6 +18,9 @@ use std::io::Stdout; pub(super) const GSS_JWT_KEY: &str = "random-key-that-is-loong-enough"; +/// Name of the `ManualUserMapping` dictionary within the instance config folder. +pub(super) const USER_MAPPING_FILE: &str = "gs-user-mapping.json"; + #[derive(Debug, Clone, Eq, PartialEq)] pub struct LookupServer; @@ -44,6 +47,14 @@ impl ServiceTrait for LookupServer { let config_dir = config.work_dir.join(cloud_id).join("lookup"); write_file(&config_dir, "config.php", &lookup_config())?; + + // The mapping file has to exist before the instance is installed, slaves + // fill it in with their own address as they register. + write_file( + &config.work_dir.join(cloud_id).join("config"), + USER_MAPPING_FILE, + "{}", + )?; let mut binds = vec![format!( "{config_dir}/config.php:/var/www/html/config/config.php:ro" )]; @@ -157,6 +168,13 @@ impl ServiceTrait for LookupServer { "gss.master.accounts".into() => Value::Array(vec![Value::String("admin".into())]), "gss.master.csp-allow".into() => Value::Array(vec![Value::String("*".into())]), "gss.jwt.key".into() => Value::String(GSS_JWT_KEY.into()), + "gss.user.discovery.module".into() => Value::String("\\OCA\\GlobalSiteSelector\\UserDiscoveryModules\\ManualUserMapping".into()), + "gss.discovery.manual.mapping.file".into() => Value::String(format!("/var/www/html/config/{USER_MAPPING_FILE}")), + // Without a proxy, cloud ids look like `alice@http://172.17.0.4`, which + // `ICloudIdManager::resolveCloudId` can't parse. + "gss.username_format".into() => Value::String("ignore".into()), + "gss.selfsigned.allow".into() => Value::Bool(true), + "gs.federation".into() => Value::String("internal".into()), }) } } diff --git a/src/service/slave.rs b/src/service/slave.rs index 023f0ea..d9a08ad 100644 --- a/src/service/slave.rs +++ b/src/service/slave.rs @@ -1,14 +1,16 @@ use crate::Result; use crate::cloud::{Cloud, CloudOptions}; use crate::config::{HazeConfig, ProxyConfig}; -use crate::service::lookup_server::GSS_JWT_KEY; -use crate::service::{LookupServer, ServiceTrait}; +use crate::service::authentik::write_file; +use crate::service::lookup_server::{GSS_JWT_KEY, USER_MAPPING_FILE}; +use crate::service::{LookupServer, ServiceTrait, split_cmnd}; use bollard::Docker; use bollard::query_parameters::ListContainersOptions; use maplit::hashmap; -use miette::{IntoDiagnostic, Report}; +use miette::{IntoDiagnostic, Report, WrapErr}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs::read_to_string; /// Cloud id of the most recently started lookup server. async fn master_cloud_id(docker: &Docker) -> Result { @@ -37,14 +39,123 @@ async fn master_cloud_id(docker: &Docker) -> Result { }) } -/// Latest cloud started with a lookup server. -async fn master_instance(docker: &Docker, config: &HazeConfig) -> Result { - let id = master_cloud_id(docker).await?; - Cloud::list(docker, Some(id.clone()), config) +async fn cloud_by_id(docker: &Docker, id: &str, config: &HazeConfig) -> Result { + Cloud::list(docker, Some(id.to_string()), config) .await? .into_iter() .find(|cloud| cloud.id == id) - .ok_or_else(|| Report::msg(format!("Failed to get the address of master instance {id}"))) + .ok_or_else(|| Report::msg(format!("Failed to get the address of instance {id}"))) +} + +/// Latest cloud started with a lookup server. +async fn master_instance(docker: &Docker, config: &HazeConfig) -> Result { + let id = master_cloud_id(docker).await?; + cloud_by_id(docker, &id, config).await +} + +/// A slave as it is registered in the master's user mapping file. +#[derive(Debug, Clone, Eq, PartialEq)] +struct SlaveNode { + /// Cloud id, e.g. `haze-gs-slave1`. + id: String, + /// Cloud id without the `haze-` prefix, e.g. `gs-slave1`. + short_name: String, + address: String, +} + +impl SlaveNode { + fn new(cloud: &Cloud) -> Self { + SlaveNode { + id: cloud.id.clone(), + short_name: cloud.id.strip_prefix("haze-").unwrap_or(&cloud.id).into(), + address: cloud.address.clone(), + } + } +} + +fn node_index(key: &str) -> Option { + key.strip_prefix("slave")?.parse().ok() +} + +/// Lowest `slave` name that isn't taken yet. +fn next_node_index(mapping: &BTreeMap) -> u32 { + (1..) + .find(|index| !mapping.contains_key(&format!("slave{index}"))) + .expect("there is always a free index") +} + +/// Add `slave` to the `ManualUserMapping` dictionary, dropping the entries of +/// instances that are no longer running. +/// +/// The slave is registered under its `slave` node name, its short name and its +/// full cloud id, so that it can be targeted by any of them. A slave that is +/// already registered keeps its node name. +fn update_user_mapping( + existing: &Value, + live_addresses: &HashSet<&str>, + slave: &SlaveNode, +) -> Value { + let mut mapping: BTreeMap = existing + .as_object() + .map(|object| { + object + .iter() + .filter_map(|(key, address)| Some((key.clone(), address.as_str()?.to_string()))) + .collect() + }) + .unwrap_or_default(); + + let previous_index = mapping + .iter() + .filter(|(_key, address)| *address == &slave.address) + .find_map(|(key, _address)| node_index(key)); + + mapping.retain(|_key, address| { + live_addresses.contains(address.as_str()) && address != &slave.address + }); + + let index = previous_index.unwrap_or_else(|| next_node_index(&mapping)); + + for key in [ + format!("slave{index}"), + slave.short_name.clone(), + slave.id.clone(), + ] { + mapping.insert(key, slave.address.clone()); + } + + mapping + .into_iter() + .map(|(key, address)| (key, Value::String(address))) + .collect() +} + +/// Register the slave in the user mapping file of its master. +async fn register_in_user_mapping( + docker: &Docker, + master: &Cloud, + slave: &SlaveNode, + config: &HazeConfig, +) -> Result<()> { + let clouds = Cloud::list(docker, None, config).await?; + let live_addresses: HashSet<&str> = clouds + .iter() + .map(|cloud| cloud.address.as_str()) + .chain([slave.address.as_str()]) + .collect(); + + let config_dir = master.workdir.join("config"); + let existing = read_to_string(config_dir.join(USER_MAPPING_FILE)) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or(Value::Null); + + let mapping = update_user_mapping(&existing, &live_addresses, slave); + let encoded = serde_json::to_string_pretty(&mapping) + .into_diagnostic() + .wrap_err("Failed to encode the global scale user mapping")?; + + write_file(&config_dir, USER_MAPPING_FILE, &encoded) } #[derive(Debug, Clone, Eq, PartialEq)] @@ -83,13 +194,21 @@ impl ServiceTrait for GlobalScaleSlave { "gs.enabled".into() => Value::Bool(true), "gss.mode".into() => Value::String("slave".into()), "gss.jwt.key".into() => Value::String(GSS_JWT_KEY.into()), + // Without a proxy, cloud ids look like `alice@http://172.17.0.4`, which + // `ICloudIdManager::resolveCloudId` can't parse. + "gss.username_format".into() => Value::String("ignore".into()), + "gss.selfsigned.allow".into() => Value::Bool(true), + // Push accounts to the lookup server often enough to be observable. + "gss.updatels.interval".into() => Value::Number(60.into()), + "gs.federation".into() => Value::String("internal".into()), + "gs.trustedHosts".into() => Value::Array(vec![Value::String("*".into())]), }) } async fn post_setup( &self, docker: &Docker, - _cloud_id: &str, + cloud_id: &str, config: &HazeConfig, ) -> Result>> { let master = master_instance(docker, config).await?; @@ -97,6 +216,9 @@ impl ServiceTrait for GlobalScaleSlave { .container_name(&master.id) .expect("lookup server has a container name"); + let slave = SlaveNode::new(&cloud_by_id(docker, cloud_id, config).await?); + register_in_user_mapping(docker, &master, &slave, config).await?; + Ok(vec![ vec![ "occ".into(), @@ -114,6 +236,26 @@ impl ServiceTrait for GlobalScaleSlave { "--value".into(), master.address.clone(), ], + // The haze password is usually too weak for the default policy, and + // the demo account below is created with it. + // split_cmnd("occ config:app:set --silent password_policy minLength --value 0"), + // split_cmnd( + // "occ config:app:set --silent password_policy enforceNonCommonPassword --value 0", + // ), + // split_cmnd( + // "occ config:app:set --silent password_policy enforceHaveIBeenPwned --value 0", + // ), + // An account that only exists on this slave, to demo the password + // login path where the master routes by lookup server entry. + vec![ + "bash".into(), + "-c".into(), + format!( + "NC_PASS={password} OC_PASS={password} occ user:add --password-from-env --display-name {name} {name}", + password = shell_words::quote(&config.auto_setup.password), + name = shell_words::quote(&slave.short_name), + ), + ], vec!["occ".into(), "globalsiteselector:users:update".into()], ]) } @@ -125,6 +267,36 @@ impl ServiceTrait for GlobalScaleSlave { _proxy: &ProxyConfig, ) -> Result> { let master = master_cloud_id(docker).await?; - Ok(Some(format!("Nextcloud was setup as slave of {master}."))) + let mut message = format!("Nextcloud was setup as slave of {master}."); + + if !master_has_sso(docker, &master).await? { + message.push_str(&format!( + "\nWARNING: {master} has no single sign-on service, so it can only route accounts \ + that are already known to the lookup server. Start the master with \ + `haze start --name gs-master lookup oidc` to route accounts by their \ + authentik node attribute." + )); + } + + Ok(Some(message)) } } + +/// Whether the master instance runs a service that can provide the node name of +/// an unknown account. +async fn master_has_sso(docker: &Docker, master: &str) -> Result { + let services = docker + .inspect_container(master, None) + .await + .into_diagnostic()? + .config + .and_then(|config| config.labels) + .and_then(|labels| labels.get("haze-services").cloned()) + .unwrap_or_default(); + + Ok(services + .split(',') + .any(|service| service == "oidc" || service == "saml")) +} + +}