From 46ea577dbdd204d98fca329c44f00fea87b0b51b Mon Sep 17 00:00:00 2001 From: Louis Chmn Date: Wed, 16 Sep 2026 22:40:18 +0200 Subject: [PATCH] feat(services): Add a service for the lookup server Signed-off-by: Louis Chmn --- README.md | 64 ++++++++++++++++ src/service.rs | 7 ++ src/service/lookup_server.rs | 142 +++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 src/service/lookup_server.rs diff --git a/README.md b/README.md index d5a3f9b..0494802 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ Additionally, you can use the following options when starting an instance: - `saml`: set up authentik as a SAML IDP. - `oidc`: set up authentik as an OIDC IDP. - `scim`: set up authentik as a SCIM server. +- `lookup`: set up a + [lookup server](https://github.com/nextcloud/lookup-server). - `office`: set up a Nextcloud Office server. - `onlyoffice` setup an onlyoffice document server. - `push` set up [client push](https://github.com/nextcloud/notify_push). @@ -293,6 +295,68 @@ name to allow for testing federation between instances. Alternatively, you can set up the haze proxy and the proxied domains to get https support between instances. +## Global scale + +You can start a lookup-server service when starting an instance: + +```bash +haze start --name gs-master lookup +``` + +The lookup server is then accessible by other instances at +`http://haze-gs-master-lookup`. + +The Nextcloud instance started with this command is already properly configured +as master node. + +For the slaves instance, here is a sane preset: + +```toml +[[preset]] +name = "gs-slave" +apps = ["globalsiteselector"] +config = { + "gs.enabled" = true, + "gs.federation" = "global", + "gss.jwt.key" = "random-key", # Matches the key set in the docker container of the lookup server. + "gss.mode" = "slave", +} +``` + +You'll then have to manually point your slave instances to the lookup and master +instances: + +```bash +haze start --name gs-slave1 gs-slave +# Assuming that the master instance was started with `--name gs-master`. +haze gs-slave1 occ config:system:set gss.master.url --value="http://haze-gs-master.haze.example.com" +haze gs-slave1 occ config:system:set lookup_server --value="http://haze-gs-master-lookup" +``` + +If you want to use the `ManualUserMapping` module, you'll have to set the +following config on the master instance: + +```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 +``` + +You can test the connection between the instances and the lookup server by +running the following command on the slave instance: + +```bash +haze gs-master occ globalsiteselector:discovery +haze gs-slave1 occ globalsiteselector:discovery +``` + ## Proxy By default, instances can be accessed by their IP. In order to get more diff --git a/src/service.rs b/src/service.rs index 233e88f..bce3e1e 100644 --- a/src/service.rs +++ b/src/service.rs @@ -4,6 +4,7 @@ mod dav; mod imaginary; mod kaspersky; mod ldap; +mod lookup_server; mod mail; mod objectstore; mod oc; @@ -25,6 +26,7 @@ use crate::service::dav::Dav; use crate::service::imaginary::Imaginary; use crate::service::kaspersky::{Kaspersky, KasperskyIcap}; pub use crate::service::ldap::{Ldap, LdapAdmin}; +pub use crate::service::lookup_server::LookupServer; use crate::service::mail::Mail; pub use crate::service::objectstore::ObjectStore; use crate::service::oc::Oc; @@ -332,6 +334,9 @@ pub enum ServiceType { Oidc, /// Configure Authentik as a SCIM server for Nextcloud Scim, + /// Global scale lookup server + #[strum(serialize = "lookup")] + LookupServer, } #[enum_dispatch] @@ -369,6 +374,7 @@ pub enum Service { AuthentikSaml(AuthentikSaml), AuthentikOidc(AuthentikOidc), AuthentikScim(AuthentikScim), + LookupServer(LookupServer), Preset(PresetService), } @@ -428,6 +434,7 @@ impl Service { Service::Authentik(Authentik), Service::AuthentikScim(AuthentikScim), ]), + ServiceType::LookupServer => Some(vec![Service::LookupServer(LookupServer)]), } } else { presets diff --git a/src/service/lookup_server.rs b/src/service/lookup_server.rs new file mode 100644 index 0000000..5562af8 --- /dev/null +++ b/src/service/lookup_server.rs @@ -0,0 +1,142 @@ +use crate::Result; +use crate::cloud::CloudOptions; +use crate::config::HazeConfig; +use crate::exec::exec; +use crate::image::pull_image; +use crate::network::ensure_network_exists; +use crate::service::ServiceTrait; +use bollard::Docker; +use bollard::config::{NetworkConnectRequest, NetworkingConfig}; +use bollard::models::{ContainerCreateBody, EndpointSettings, HostConfig}; +use bollard::query_parameters::CreateContainerOptions; +use maplit::hashmap; +use miette::IntoDiagnostic; +use serde_json::Value; +use std::collections::HashMap; +use std::io::Stdout; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct LookupServer; + +#[async_trait::async_trait] +impl ServiceTrait for LookupServer { + fn name(&self) -> &str { + "lookup" + } + + async fn spawn( + &self, + docker: &Docker, + cloud_id: &str, + network: &str, + _config: &HazeConfig, + _options: &CloudOptions, + ) -> Result> { + let image = "ghcr.io/juliusknorr/nextcloud-dev-lookupserver:latest"; + pull_image(docker, image).await?; + + // The lookup server needs to be reachable from other instances, so it's + // attached to the shared network in addition to the instance network + ensure_network_exists(docker, "haze").await?; + + let options = Some(CreateContainerOptions { + name: self.container_name(cloud_id), + ..CreateContainerOptions::default() + }); + let container_config = ContainerCreateBody { + image: Some(image.into()), + host_config: Some(HostConfig { + network_mode: Some(network.to_string()), + ..Default::default() + }), + labels: Some(hashmap! { + "haze-type".into() => self.name().into(), + "haze-cloud-id".into() => cloud_id.into(), + }), + networking_config: Some(NetworkingConfig { + endpoints_config: Some(hashmap! { + network.into() => EndpointSettings { + aliases: Some(vec![self.name().to_string()]), + ..Default::default() + } + }), + }), + ..Default::default() + }; + let id = docker + .create_container(options, container_config) + .await + .into_diagnostic()? + .id; + docker.start_container(&id, None).await.into_diagnostic()?; + + if let Err(e) = docker + .connect_network( + "haze", + NetworkConnectRequest { + container: id.clone(), + endpoint_config: Some(EndpointSettings { + aliases: self.container_name(cloud_id).map(|name| vec![name]), + ..Default::default() + }), + }, + ) + .await + .into_diagnostic() + { + docker.remove_container(&id, None).await.ok(); + return Err(e); + } + + Ok(vec![id.into()]) + } + + fn container_name(&self, cloud_id: &str) -> Option { + Some(format!("{}-lookup", cloud_id)) + } + + fn apps(&self) -> &'static [&'static str] { + &["globalsiteselector"] + } + + async fn is_healthy( + &self, + docker: &Docker, + cloud_id: &str, + _options: &CloudOptions, + ) -> Result { + if !self.is_running(docker, cloud_id).await? { + return Ok(false); + } + let exit = exec( + docker, + self.container_name(cloud_id).unwrap(), + "root", + vec![ + "bash", + "-c", + r#"php -r 'exit(str_contains(@file_get_contents("http://127.0.0.1/index.php/status") ?: "", "version") ? 0 : 1);'"#, + ], + Vec::::default(), + Option::::None, + ) + .await?; + Ok(exit.to_result().is_ok()) + } + + fn config( + &self, + _docker: &Docker, + _cloud_id: &str, + _config: &HazeConfig, + ) -> Result> { + Ok(hashmap! { + "lookup_server".into() => Value::String("http://lookup".into()), + "gs.enabled".into() => Value::Bool(true), + "gss.mode".into() => Value::String("master".into()), + "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("random-key".into()), + }) + } +}