1
0
Fork 0
mirror of https://codeberg.org/icewind/haze.git synced 2026-10-01 08:44:09 +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

@ -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"))
}
}