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:
parent
2778f31c93
commit
853ee15a3d
8 changed files with 291 additions and 26 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue