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
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

View file

@ -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

View file

@ -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],

View file

@ -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

View file

@ -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<HashMap<String, Value>> {
Ok(hashmap! {
"gss.discovery.manual.mapping.parameter".into() => Value::String("nextcloud_gss_node".into()),
})
}
async fn post_setup(
&self,
docker: &Docker,

View file

@ -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<HashMap<String, Value>> {
Ok(hashmap! {
"gss.discovery.manual.mapping.parameter".into() => Value::String("http://haze.test/nextcloud/gss-node".into()),
})
}
async fn post_setup(
&self,
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";
/// 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()),
})
}
}

View file

@ -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<String> {
@ -37,14 +39,123 @@ async fn master_cloud_id(docker: &Docker) -> Result<String> {
})
}
/// 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::list(docker, Some(id.clone()), config)
async fn cloud_by_id(docker: &Docker, id: &str, config: &HazeConfig) -> Result<Cloud> {
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<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)]
@ -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<Vec<Vec<String>>> {
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<Option<String>> {
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"))
}
}