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

WIP: make it work

Signed-off-by: Louis Chmn <louis@chmn.me>
This commit is contained in:
Louis Chmn 2026-09-17 14:54:54 +02:00
commit 853ee15a3d
8 changed files with 291 additions and 26 deletions

View file

@ -297,10 +297,12 @@ instances.
## Global scale ## 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 ```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 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. 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 ```bash
haze start --name gs-slave1 slave 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, 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. 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 ### How accounts are routed
following config on the master instance:
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 ```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 haze gs-master occ config:system:set gss.discovery.manual.mapping.regex --type boolean --value=true
``` ```
```toml Accounts that are already registered in the lookup server are routed without the
[[volume]] # Needed if you are using "ManualUserMapping" module. discovery module, so they also work with a password login. Each slave seeds one
source = "/home/louis/.config/haze/config/gs-user-mapping.json" such account, named after the instance (`gs-slave1` on `haze-gs-slave1`) with
target = "/shared/config/gs-user-mapping.json" the usual haze password, to make that path easy to try out. Because that password
read_only = true 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 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 ```bash
haze gs-master occ globalsiteselector:discovery haze gs-master occ globalsiteselector:discovery
haze gs-slave1 occ globalsiteselector:discovery haze gs-slave1 occ globalsiteselector:discovery --current
``` ```
## Proxy ## Proxy

View file

@ -18,7 +18,11 @@ entries:
name: haze-nextcloud-oidc-uid name: haze-nextcloud-oidc-uid
attrs: attrs:
scope_name: nextcloud 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 - model: authentik_providers_oauth2.oauth2provider
id: nextcloud-oidc-provider id: nextcloud-oidc-provider

View file

@ -20,6 +20,16 @@ entries:
saml_name: http://haze.test/nextcloud/uid saml_name: http://haze.test/nextcloud/uid
expression: 'return "saml-" + request.user.username' 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 - model: authentik_providers_saml.samlprovider
id: nextcloud-provider id: nextcloud-provider
identifiers: identifiers:
@ -65,6 +75,7 @@ entries:
name_id_mapping: !KeyOf haze-nextcloud-uid name_id_mapping: !KeyOf haze-nextcloud-uid
property_mappings: property_mappings:
- !KeyOf haze-nextcloud-uid - !KeyOf haze-nextcloud-uid
- !KeyOf haze-nextcloud-gss-node
- !Find [ - !Find [
authentik_providers_saml.samlpropertymapping, authentik_providers_saml.samlpropertymapping,
[managed, goauthentik.io/providers/saml/username], [managed, goauthentik.io/providers/saml/username],

View file

@ -25,6 +25,8 @@ entries:
email: alice@haze.test email: alice@haze.test
password: password password: password
type: internal type: internal
attributes:
nextcloud_gss_node: slave1
groups: groups:
- !KeyOf authentik-group1 - !KeyOf authentik-group1
- model: authentik_core.user - model: authentik_core.user
@ -36,6 +38,8 @@ entries:
email: bob@haze.test email: bob@haze.test
password: password password: password
type: internal type: internal
attributes:
nextcloud_gss_node: slave2
groups: groups:
- !KeyOf authentik-group2 - !KeyOf authentik-group2
- model: authentik_core.user - model: authentik_core.user
@ -47,5 +51,7 @@ entries:
email: charlie@haze.test email: charlie@haze.test
password: password password: password
type: internal type: internal
attributes:
nextcloud_gss_node: slave3
groups: groups:
- !KeyOf authentik-group3 - !KeyOf authentik-group3

View file

@ -4,6 +4,9 @@ use crate::cloud::CloudOptions;
use crate::config::HazeConfig; use crate::config::HazeConfig;
use crate::service::{ServiceTrait, split_cmnd}; use crate::service::{ServiceTrait, split_cmnd};
use bollard::Docker; use bollard::Docker;
use maplit::hashmap;
use serde_json::Value;
use std::collections::HashMap;
const BLUEPRINT: &str = include_str!("../../../blueprints/authentik-oidc.yaml"); const BLUEPRINT: &str = include_str!("../../../blueprints/authentik-oidc.yaml");
@ -38,6 +41,19 @@ impl ServiceTrait for AuthentikOidc {
&["user_oidc"] &["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<HashMap<String, Value>> {
Ok(hashmap! {
"gss.discovery.manual.mapping.parameter".into() => Value::String("nextcloud_gss_node".into()),
})
}
async fn post_setup( async fn post_setup(
&self, &self,
docker: &Docker, docker: &Docker,

View file

@ -5,6 +5,9 @@ use crate::config::{HazeConfig, ProxyConfig};
use crate::service::authentik::SIGNING_CERT; use crate::service::authentik::SIGNING_CERT;
use crate::service::{ServiceTrait, split_cmnd}; use crate::service::{ServiceTrait, split_cmnd};
use bollard::Docker; use bollard::Docker;
use maplit::hashmap;
use serde_json::Value;
use std::collections::HashMap;
const BLUEPRINT: &str = include_str!("../../../blueprints/authentik-saml.yaml"); const BLUEPRINT: &str = include_str!("../../../blueprints/authentik-saml.yaml");
@ -39,6 +42,19 @@ impl ServiceTrait for AuthentikSaml {
&["user_saml"] &["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<HashMap<String, Value>> {
Ok(hashmap! {
"gss.discovery.manual.mapping.parameter".into() => Value::String("http://haze.test/nextcloud/gss-node".into()),
})
}
async fn post_setup( async fn post_setup(
&self, &self,
docker: &Docker, docker: &Docker,

View file

@ -18,6 +18,9 @@ use std::io::Stdout;
pub(super) const GSS_JWT_KEY: &str = "random-key-that-is-loong-enough"; 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)] #[derive(Debug, Clone, Eq, PartialEq)]
pub struct LookupServer; pub struct LookupServer;
@ -44,6 +47,14 @@ impl ServiceTrait for LookupServer {
let config_dir = config.work_dir.join(cloud_id).join("lookup"); let config_dir = config.work_dir.join(cloud_id).join("lookup");
write_file(&config_dir, "config.php", &lookup_config())?; 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!( let mut binds = vec![format!(
"{config_dir}/config.php:/var/www/html/config/config.php:ro" "{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.accounts".into() => Value::Array(vec![Value::String("admin".into())]),
"gss.master.csp-allow".into() => Value::Array(vec![Value::String("*".into())]), "gss.master.csp-allow".into() => Value::Array(vec![Value::String("*".into())]),
"gss.jwt.key".into() => Value::String(GSS_JWT_KEY.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()),
}) })
} }
} }

View file

@ -1,14 +1,16 @@
use crate::Result; use crate::Result;
use crate::cloud::{Cloud, CloudOptions}; use crate::cloud::{Cloud, CloudOptions};
use crate::config::{HazeConfig, ProxyConfig}; use crate::config::{HazeConfig, ProxyConfig};
use crate::service::lookup_server::GSS_JWT_KEY; use crate::service::authentik::write_file;
use crate::service::{LookupServer, ServiceTrait}; use crate::service::lookup_server::{GSS_JWT_KEY, USER_MAPPING_FILE};
use crate::service::{LookupServer, ServiceTrait, split_cmnd};
use bollard::Docker; use bollard::Docker;
use bollard::query_parameters::ListContainersOptions; use bollard::query_parameters::ListContainersOptions;
use maplit::hashmap; use maplit::hashmap;
use miette::{IntoDiagnostic, Report}; use miette::{IntoDiagnostic, Report, WrapErr};
use serde_json::Value; 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. /// Cloud id of the most recently started lookup server.
async fn master_cloud_id(docker: &Docker) -> Result<String> { async fn master_cloud_id(docker: &Docker) -> Result<String> {
@ -37,14 +39,123 @@ async fn master_cloud_id(docker: &Docker) -> Result<String> {
}) })
} }
/// Latest cloud started with a lookup server. async fn cloud_by_id(docker: &Docker, id: &str, config: &HazeConfig) -> Result<Cloud> {
async fn master_instance(docker: &Docker, config: &HazeConfig) -> Result<Cloud> { Cloud::list(docker, Some(id.to_string()), config)
let id = master_cloud_id(docker).await?;
Cloud::list(docker, Some(id.clone()), config)
.await? .await?
.into_iter() .into_iter()
.find(|cloud| cloud.id == id) .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<Cloud> {
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<u32> {
key.strip_prefix("slave")?.parse().ok()
}
/// Lowest `slave<n>` name that isn't taken yet.
fn next_node_index(mapping: &BTreeMap<String, String>) -> 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<n>` 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<String, String> = 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)] #[derive(Debug, Clone, Eq, PartialEq)]
@ -83,13 +194,21 @@ impl ServiceTrait for GlobalScaleSlave {
"gs.enabled".into() => Value::Bool(true), "gs.enabled".into() => Value::Bool(true),
"gss.mode".into() => Value::String("slave".into()), "gss.mode".into() => Value::String("slave".into()),
"gss.jwt.key".into() => Value::String(GSS_JWT_KEY.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( async fn post_setup(
&self, &self,
docker: &Docker, docker: &Docker,
_cloud_id: &str, cloud_id: &str,
config: &HazeConfig, config: &HazeConfig,
) -> Result<Vec<Vec<String>>> { ) -> Result<Vec<Vec<String>>> {
let master = master_instance(docker, config).await?; let master = master_instance(docker, config).await?;
@ -97,6 +216,9 @@ impl ServiceTrait for GlobalScaleSlave {
.container_name(&master.id) .container_name(&master.id)
.expect("lookup server has a container name"); .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![ Ok(vec![
vec![ vec![
"occ".into(), "occ".into(),
@ -114,6 +236,26 @@ impl ServiceTrait for GlobalScaleSlave {
"--value".into(), "--value".into(),
master.address.clone(), 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()], vec!["occ".into(), "globalsiteselector:users:update".into()],
]) ])
} }
@ -125,6 +267,36 @@ impl ServiceTrait for GlobalScaleSlave {
_proxy: &ProxyConfig, _proxy: &ProxyConfig,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
let master = master_cloud_id(docker).await?; 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<bool> {
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"))
}
}