From 13091f321b9251d561d62de30b3d316110928895 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Tue, 11 Aug 2026 14:43:37 +0200 Subject: [PATCH 01/22] Integrate with easydeploy engine --- README.md | 4 +- deploy.yaml.example | 6 ++- docs/integrating-engine.md | 68 ++++++++++++++++++++++++++++ scripts/apply.py | 93 +++++++++++++++++++++++++++++++++----- scripts/config_edit.py | 7 ++- tests/test_apply.py | 37 ++++++++++++++- wizard.sh | 9 +++- 7 files changed, 207 insertions(+), 17 deletions(-) create mode 100644 docs/integrating-engine.md diff --git a/README.md b/README.md index c85011c..2f7d3ed 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ bash apply.sh See [`deploy.yaml.example`](deploy.yaml.example). Key sections: - **opencloud** — domain, image tag, persistent `data_dir` / `config_dir` / `apps_dir` -- **proxy** — `caddy` (only option in v1) +- **proxy** — `caddy` with `mode: standalone` (default) or `integrate` (shared Caddy via [easydeploy-engine](../easydeploy-engine)) - **auth** — `builtin` (simple admin login) or `oidc` (external IdP) - **weboffice** — `euro_office` or `collabora` (mutually exclusive with each other) - **modules** — optional search, antivirus, radicale, monitoring @@ -70,6 +70,8 @@ Uses OpenCloud's built-in LDAP. Admin password is generated on first `apply.sh` Set `auth.mode: oidc` and configure `auth.oidc` in `deploy.yaml`. The stack adds `idm/external-idp.yml` plus a local overlay for role mapping via `proxy.yaml`. +For **Authelia** on the same VPS (with easydeploy-engine), set `auth.oidc.provider: authelia` and follow [`docs/integrating-engine.md`](docs/integrating-engine.md). + #### Authentik setup 1. **Create groups** for OpenCloud roles: diff --git a/deploy.yaml.example b/deploy.yaml.example index 8f90114..a4d0804 100644 --- a/deploy.yaml.example +++ b/deploy.yaml.example @@ -13,12 +13,16 @@ opencloud: proxy: type: caddy + mode: standalone + integrate: + network: easydeploy-net # auth.mode: builtin — built-in LDAP admin (simple VPS default) -# auth.mode: oidc — external IdP (Authentik, Keycloak, etc.) +# auth.mode: oidc — external IdP (Authentik, Keycloak, Authelia, …) auth: mode: builtin oidc: + # provider: authelia — adds idm/external-authelia.yml compose overlay when set issuer_url: https://authentik.example.com/application/o/opencloud/ account_url: https://authentik.example.com/if/user/ domain: authentik.example.com diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md new file mode 100644 index 0000000..f5e9dde --- /dev/null +++ b/docs/integrating-engine.md @@ -0,0 +1,68 @@ +# Integrating with easydeploy-engine + +Use this when Authelia (or another kit) already runs on the same VPS behind **easydeploy-engine** on `easydeploy-net`. + +## deploy.yaml + +```yaml +proxy: + type: caddy + mode: integrate + integrate: + network: easydeploy-net + +opencloud: + domain: cloud.example.com + # ... + +auth: + mode: oidc + oidc: + provider: authelia # adds idm/external-authelia.yml overlay + issuer_url: https://auth.example.com + account_url: https://auth.example.com/ + domain: auth.example.com + client_id: opencloud + client_scopes: openid profile email offline_access + role_claim: groups + role_mapping: + admin: opencloud-admin + user: opencloud-user + guest: opencloud-guest +``` + +## Authelia OIDC client + +In Authelia `deploy.yaml`, add an OIDC client (after OpenCloud domain is known): + +```yaml +oidc: + clients: + - id: opencloud + description: OpenCloud + secret: "" + redirect_uris: + - https://cloud.example.com/oidc-callback.html + scopes: + - openid + - profile + - email + - offline_access + grant_types: + - authorization_code + response_types: + - code + authorization_policy: two_factor +``` + +Re-run `bash apply.sh` in authelia-easy-deploy, then opencloud-easy-deploy, then easydeploy-engine. + +## Apply order + +1. Engine + Authelia in integrate mode (see authelia-easy-deploy `docs/integrating-engine.md`). +2. Clone/configure opencloud-easy-deploy; set `proxy.mode: integrate` and OIDC as above. +3. `bash apply.sh` in opencloud-easy-deploy. +4. Register OpenCloud in `engine.yaml` (fragment path `.opencloud-easy-deploy/integration/caddy.caddy`). +5. `bash apply.sh` in easydeploy-engine. + +Standalone OpenCloud Caddy (`opencloud_caddy`) is not started in integrate mode. diff --git a/scripts/apply.py b/scripts/apply.py index 434d88f..009704d 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -28,6 +28,9 @@ SECRETS_PATH = STATE_DIR / "secrets.yaml" DEPLOY_PATH = PROJECT_ROOT / "deploy.yaml" NETWORK_OVERLAY_PATH = STATE_DIR / "compose" / "network-fixups.yml" +INTEGRATION_DIR = STATE_DIR / "integration" +INTEGRATION_CADDY_FRAGMENT = INTEGRATION_DIR / "caddy.caddy" +DEFAULT_INTEGRATE_NETWORK = "easydeploy-net" CADDY_DIR = PROJECT_ROOT / "caddy" CADDY_TEMPLATE = CADDY_DIR / "Caddyfile.template" CADDYFILE = CADDY_DIR / "Caddyfile" @@ -52,6 +55,13 @@ def to_bool(value: Any) -> bool: return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} +def proxy_mode(config: dict) -> str: + mode = str((config.get("proxy") or {}).get("mode") or "standalone").strip().lower() + if mode not in {"standalone", "integrate"}: + raise ValueError("proxy.mode must be 'standalone' or 'integrate'") + return mode + + def load_yaml(path: Path) -> dict: with path.open() as handle: data = yaml.safe_load(handle) or {} @@ -122,10 +132,13 @@ def validate_config(config: dict) -> None: raise ValueError("weboffice.domain is required when weboffice is enabled") validate_backup_config(config) + proxy_mode(config) def derive_compose_files(config: dict) -> list[str]: - files = ["docker-compose.yml", "external-proxy/opencloud.yml", "../overlays/proxy/caddy.yml"] + files = ["docker-compose.yml", "external-proxy/opencloud.yml"] + if proxy_mode(config) == "standalone": + files.append("../overlays/proxy/caddy.yml") weboffice = config.get("weboffice") or {} if to_bool(weboffice.get("enabled")): @@ -146,6 +159,9 @@ def derive_compose_files(config: dict) -> list[str]: auth_mode = str((config.get("auth") or {}).get("mode") or "builtin").lower() if auth_mode == "oidc": files.extend(["idm/external-idp.yml", "../overlays/idm/oidc-external.yml"]) + provider = str((config.get("auth") or {}).get("oidc", {}).get("provider") or "").lower() + if provider == "authelia": + files.append("idm/external-authelia.yml") modules = config.get("modules") or {} if to_bool(modules.get("search")): @@ -171,6 +187,8 @@ def render_network_overlay(config: dict) -> None: "container_name": "opencloud", "extra_hosts": [f"{opencloud_domain}:host-gateway"], } + if proxy_mode(config) == "integrate": + opencloud_service["networks"] = ["opencloud-net", DEFAULT_INTEGRATE_NETWORK] services: dict[str, Any] = {"opencloud": opencloud_service} @@ -182,19 +200,31 @@ def render_network_overlay(config: dict) -> None: opencloud_service["extra_hosts"].append(f"{office_domain}:host-gateway") if office_type == "euro_office": - services["euro-office"] = { + euro_svc: dict[str, Any] = { "container_name": "euro-office", "extra_hosts": [f"{opencloud_domain}:host-gateway"], } + if proxy_mode(config) == "integrate": + euro_svc["networks"] = ["opencloud-net", DEFAULT_INTEGRATE_NETWORK] + services["euro-office"] = euro_svc elif office_type == "collabora": - services["collabora"] = { + collab_svc: dict[str, Any] = { "container_name": "collabora", "extra_hosts": [f"{opencloud_domain}:host-gateway"], } + if proxy_mode(config) == "integrate": + collab_svc["networks"] = ["opencloud-net", DEFAULT_INTEGRATE_NETWORK] + services["collabora"] = collab_svc + + overlay: dict[str, Any] = {"services": services} + if proxy_mode(config) == "integrate": + overlay["networks"] = { + DEFAULT_INTEGRATE_NETWORK: {"external": True, "name": DEFAULT_INTEGRATE_NETWORK} + } NETWORK_OVERLAY_PATH.parent.mkdir(parents=True, exist_ok=True) with NETWORK_OVERLAY_PATH.open("w") as handle: - yaml.safe_dump({"services": services}, handle, default_flow_style=False) + yaml.safe_dump(overlay, handle, default_flow_style=False) def generate_secret(length: int = 32) -> str: @@ -256,8 +286,9 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: "OC_APPS_DIR": str(opencloud["apps_dir"]), "DEFAULT_LANGUAGE": str(opencloud.get("language") or "en"), "START_ADDITIONAL_SERVICES": build_additional_services(config), - "OCD_CADDYFILE": str(CADDYFILE.resolve()), } + if proxy_mode(config) == "standalone": + env["OCD_CADDYFILE"] = str(CADDYFILE.resolve()) ldap_base = Path(str(opencloud["config_dir"])).parent env["LDAP_CERTS_DIR"] = str(ldap_base / "ldap_certs") @@ -397,7 +428,7 @@ def render_proxy_yaml(config: dict) -> None: proxy_path.write_text(upstream_body) -def render_caddyfile(config: dict) -> None: +def build_caddy_site_blocks(config: dict) -> tuple[str, str]: opencloud_domain = str(config["opencloud"]["domain"]) weboffice = config.get("weboffice") or {} @@ -424,7 +455,6 @@ def render_caddyfile(config: dict) -> None: else "collabora:9980" ) if office_domain: - # Euro Office/Collabora must be embeddable in OpenCloud iframes (cross-origin). euro_block = f""" {office_domain} {{ reverse_proxy {upstream} {{ @@ -439,18 +469,36 @@ def render_caddyfile(config: dict) -> None: }} encode gzip log -}}""" +}}""".strip() + + return oc_block.strip(), euro_block + + +def build_caddy_fragment(config: dict) -> str: + oc_block, euro_block = build_caddy_site_blocks(config) + parts = ["# opencloud-easy-deploy", oc_block] + if euro_block: + parts.extend(["", "# opencloud-easy-deploy — web office", euro_block]) + return "\n".join(parts) + "\n" + +def render_caddyfile(config: dict) -> None: + oc_block, euro_block = build_caddy_site_blocks(config) rendered = render_template( CADDY_TEMPLATE.read_text(), { - "OC_DOMAIN_BLOCK": oc_block.strip(), - "EURO_OFFICE_DOMAIN_BLOCK": euro_block.strip(), + "OC_DOMAIN_BLOCK": oc_block, + "EURO_OFFICE_DOMAIN_BLOCK": euro_block, }, ) CADDYFILE.write_text(rendered + "\n") +def render_integration_fragment(config: dict) -> None: + INTEGRATION_DIR.mkdir(parents=True, exist_ok=True) + INTEGRATION_CADDY_FRAGMENT.write_text(build_caddy_fragment(config)) + + def fix_data_permissions(config: dict) -> None: opencloud = config["opencloud"] paths = [ @@ -548,6 +596,13 @@ def stop_legacy_caddy() -> None: ) +def stop_opencloud_caddy() -> None: + if subprocess.run(["docker", "inspect", "opencloud_caddy"], capture_output=True).returncode == 0: + print("Stopping standalone opencloud_caddy (integrate mode uses easydeploy-engine)…") + subprocess.run(["docker", "stop", "opencloud_caddy"], check=False) + subprocess.run(["docker", "rm", "opencloud_caddy"], check=False) + + def reconcile_runtime(env_path: Path, config: dict) -> None: env = {} if env_path.is_file(): @@ -558,12 +613,18 @@ def reconcile_runtime(env_path: Path, config: dict) -> None: env[key.strip()] = value.strip() ensure_docker_network("opencloud-net") + if proxy_mode(config) == "integrate": + ensure_docker_network(DEFAULT_INTEGRATE_NETWORK) + stop_opencloud_caddy() stop_legacy_caddy() print("Pulling OpenCloud stack images…") run_compose(COMPOSE_DIR, "pull", env=env) - print("Starting OpenCloud stack (includes Caddy)…") + if proxy_mode(config) == "integrate": + print("Starting OpenCloud stack (no local Caddy — use easydeploy-engine)…") + else: + print("Starting OpenCloud stack (includes Caddy)…") run_compose(COMPOSE_DIR, "up", "-d", "--wait", "--force-recreate", env=env) weboffice = config.get("weboffice") or {} @@ -586,6 +647,11 @@ def print_summary(config: dict) -> None: if to_bool(weboffice.get("enabled")): print(f" - {weboffice.get('domain')}") + if proxy_mode(config) == "integrate": + print() + print(f"Proxy mode: integrate (fragment: {INTEGRATION_CADDY_FRAGMENT})") + print("Run easydeploy-engine apply.sh after enabling OpenCloud in engine.yaml.") + auth_mode = str((config.get("auth") or {}).get("mode") or "builtin").lower() if auth_mode == "builtin": print() @@ -623,7 +689,10 @@ def apply( bootstrap_config(config) render_network_overlay(config) render_proxy_yaml(config) - render_caddyfile(config) + if proxy_mode(config) == "integrate": + render_integration_fragment(config) + else: + render_caddyfile(config) bootstrap_backup(config, secret_values) env_vars = build_env_vars(config, secret_values) diff --git a/scripts/config_edit.py b/scripts/config_edit.py index de03627..c7fc7b7 100644 --- a/scripts/config_edit.py +++ b/scripts/config_edit.py @@ -51,6 +51,7 @@ def update_from_wizard( modules_antivirus: bool, modules_radicale: bool, modules_monitoring: bool, + proxy_mode: str = "standalone", path: Path = DEFAULT_DEPLOY_PATH, ) -> None: config = load_or_init(path) @@ -65,7 +66,11 @@ def update_from_wizard( opencloud["config_dir"] = f"{data_root.rstrip('/')}/config" opencloud["apps_dir"] = f"{data_root.rstrip('/')}/apps" - config["proxy"] = {"type": "caddy"} + config["proxy"] = { + "type": "caddy", + "mode": proxy_mode, + "integrate": {"network": "easydeploy-net"}, + } config["auth"] = { "mode": auth_mode, "oidc": { diff --git a/tests/test_apply.py b/tests/test_apply.py index 29334e1..dd25cf7 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -29,7 +29,7 @@ def _base_config(**overrides) -> dict: "apps_dir": "/var/lib/opencloud/apps", "language": "en", }, - "proxy": {"type": "caddy"}, + "proxy": {"type": "caddy", "mode": "standalone", "integrate": {"network": "easydeploy-net"}}, "auth": {"mode": "builtin"}, "weboffice": { "enabled": True, @@ -51,6 +51,41 @@ def _base_config(**overrides) -> dict: return config +def test_derive_compose_files_integrate_excludes_caddy(): + config = _base_config(proxy={"type": "caddy", "mode": "integrate"}) + files = derive_compose_files(config) + assert "../overlays/proxy/caddy.yml" not in files + assert "docker-compose.yml" in files + + +def test_derive_compose_files_oidc_authelia_provider(): + config = _base_config( + auth={ + "mode": "oidc", + "oidc": { + "provider": "authelia", + "issuer_url": "https://auth.example/o", + "account_url": "https://auth.example/", + "domain": "auth.example", + "client_id": "opencloud", + }, + }, + ) + files = derive_compose_files(config) + assert "idm/external-authelia.yml" in files + + +def test_render_integration_fragment(tmp_path, monkeypatch): + from scripts.apply import INTEGRATION_CADDY_FRAGMENT, render_integration_fragment + + monkeypatch.setattr("scripts.apply.INTEGRATION_DIR", tmp_path) + monkeypatch.setattr("scripts.apply.INTEGRATION_CADDY_FRAGMENT", tmp_path / "caddy.caddy") + render_integration_fragment(_base_config()) + text = (tmp_path / "caddy.caddy").read_text() + assert "cloud.test.example" in text + assert "eurooffice.test.example" in text + + def test_derive_compose_files_builtin_euro_office(): files = derive_compose_files(_base_config()) assert files[0] == "docker-compose.yml" diff --git a/wizard.sh b/wizard.sh index a6560cf..531796d 100755 --- a/wizard.sh +++ b/wizard.sh @@ -21,7 +21,7 @@ gather_config() { local role_admin role_user role_guest local weboffice_enabled weboffice_domain local modules_search modules_antivirus modules_radicale modules_monitoring - local base_domain proceed + local base_domain proceed proxy_mode print_banner echo -e " Press Enter to accept a ${CYAN}[default]${RESET}.\n" @@ -79,6 +79,11 @@ gather_config() { ask_yn modules_radicale "Enable Radicale (Cal/CardDAV)?" "n" ask_yn modules_monitoring "Enable monitoring endpoints?" "n" + echo + echo -e "${BOLD} Reverse proxy${RESET}" + ask proxy_mode "Proxy mode: standalone or integrate" "standalone" + proxy_mode="${proxy_mode,,}" + echo echo -e "${BOLD} Summary${RESET}" echo " OpenCloud: https://${domain}" @@ -87,6 +92,7 @@ gather_config() { fi echo " Auth: ${auth_mode}" echo " Data root: ${data_root}" + echo " Proxy mode: ${proxy_mode}" echo echo " Ensure DNS A/AAAA records point to this server before continuing." echo @@ -120,6 +126,7 @@ update_from_wizard( modules_antivirus=${modules_antivirus@Q} == "y", modules_radicale=${modules_radicale@Q} == "y", modules_monitoring=${modules_monitoring@Q} == "y", + proxy_mode=${proxy_mode@Q}, path=Path(${DEPLOY_YAML@Q}), ) PY From 0f6e50014938811f1089b4cdf71317433e75f3fb Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Wed, 12 Aug 2026 11:05:15 +0200 Subject: [PATCH 02/22] Fix user provisioning --- overlays/idm/oidc-external.yml | 1 + scripts/apply.py | 70 +++++++++++++++++++++++++++++++--- tests/test_apply.py | 12 +++++- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/overlays/idm/oidc-external.yml b/overlays/idm/oidc-external.yml index c20cb63..b6d3cdc 100644 --- a/overlays/idm/oidc-external.yml +++ b/overlays/idm/oidc-external.yml @@ -9,4 +9,5 @@ services: networks: opencloud-net: + external: true name: opencloud-net diff --git a/scripts/apply.py b/scripts/apply.py index 009704d..f0daf93 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -31,6 +31,8 @@ INTEGRATION_DIR = STATE_DIR / "integration" INTEGRATION_CADDY_FRAGMENT = INTEGRATION_DIR / "caddy.caddy" DEFAULT_INTEGRATE_NETWORK = "easydeploy-net" +BITNAMI_OPENLDAP_UID = 1001 +BITNAMI_OPENLDAP_GID = 1001 CADDY_DIR = PROJECT_ROOT / "caddy" CADDY_TEMPLATE = CADDY_DIR / "Caddyfile.template" CADDYFILE = CADDY_DIR / "Caddyfile" @@ -217,9 +219,13 @@ def render_network_overlay(config: dict) -> None: services["collabora"] = collab_svc overlay: dict[str, Any] = {"services": services} + overlay["networks"] = { + "opencloud-net": {"external": True, "name": "opencloud-net"}, + } if proxy_mode(config) == "integrate": - overlay["networks"] = { - DEFAULT_INTEGRATE_NETWORK: {"external": True, "name": DEFAULT_INTEGRATE_NETWORK} + overlay["networks"][DEFAULT_INTEGRATE_NETWORK] = { + "external": True, + "name": DEFAULT_INTEGRATE_NETWORK, } NETWORK_OVERLAY_PATH.parent.mkdir(parents=True, exist_ok=True) @@ -366,6 +372,47 @@ def ensure_compose_submodule() -> None: raise FileNotFoundError("Failed to initialize opencloud-compose submodule") +def bootstrap_ldap_tls(ldap_certs_dir: Path) -> None: + """Pre-create LDAP TLS material on the host (Bitnami entrypoint cannot write bind mounts).""" + key_path = ldap_certs_dir / "openldap.key" + cert_path = ldap_certs_dir / "openldap.crt" + if key_path.is_file() and cert_path.is_file(): + return + + ldap_certs_dir.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:4096", + "-keyout", + str(key_path), + "-out", + str(cert_path), + "-sha256", + "-days", + "365", + "-batch", + "-nodes", + "-subj", + "/CN=opencloud-ldap", + ], + check=True, + capture_output=True, + ) + key_path.chmod(0o640) + cert_path.chmod(0o644) + if os.geteuid() == 0: + try: + shutil.chown(key_path, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) + shutil.chown(cert_path, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) + shutil.chown(ldap_certs_dir, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) + except OSError: + key_path.chmod(0o644) + + def bootstrap_config(config: dict) -> None: opencloud = config["opencloud"] config_dir = Path(str(opencloud["config_dir"])) @@ -383,6 +430,10 @@ def bootstrap_config(config: dict) -> None: ): directory.mkdir(parents=True, exist_ok=True) + auth_mode = str((config.get("auth") or {}).get("mode") or "builtin").lower() + if auth_mode == "oidc": + bootstrap_ldap_tls(ldap_base / "ldap_certs") + upstream_config = COMPOSE_DIR / "config" / "opencloud" if not upstream_config.is_dir(): raise FileNotFoundError(f"Missing upstream config at {upstream_config}") @@ -501,18 +552,27 @@ def render_integration_fragment(config: dict) -> None: def fix_data_permissions(config: dict) -> None: opencloud = config["opencloud"] + config_dir = Path(str(opencloud["config_dir"])) + ldap_base = config_dir.parent + auth_mode = str((config.get("auth") or {}).get("mode") or "builtin").lower() paths = [ - Path(str(opencloud["config_dir"])), + config_dir, Path(str(opencloud["data_dir"])), Path(str(opencloud["apps_dir"])), - Path(str(opencloud["config_dir"])).parent / "ldap_certs", - Path(str(opencloud["config_dir"])).parent / "ldap_data", ] if os.geteuid() != 0: return for path in paths: if path.exists(): shutil.chown(path, user=1000, group=1000) + if auth_mode == "oidc": + for name in ("ldap_certs", "ldap_data"): + path = ldap_base / name + if path.exists(): + try: + shutil.chown(path, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) + except OSError: + pass def docker_compose_cmd() -> list[str]: diff --git a/tests/test_apply.py b/tests/test_apply.py index dd25cf7..c1c8c3c 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -9,6 +9,7 @@ import yaml from scripts.apply import ( + bootstrap_ldap_tls, build_env_vars, derive_compose_files, render_caddyfile, @@ -218,6 +219,7 @@ def test_render_network_overlay_sets_container_names(tmp_path, monkeypatch): assert data["services"]["opencloud"]["container_name"] == "opencloud" assert data["services"]["euro-office"]["container_name"] == "euro-office" + assert data["networks"]["opencloud-net"]["external"] is True assert "eurooffice.test.example:host-gateway" in data["services"]["opencloud"]["extra_hosts"] assert "cloud.test.example:host-gateway" in data["services"]["euro-office"]["extra_hosts"] @@ -237,5 +239,11 @@ def test_render_caddyfile_allows_opencloud_iframe(tmp_path, monkeypatch): render_caddyfile(_base_config()) rendered = caddyfile.read_text() assert "frame-ancestors 'self' https://cloud.test.example" in rendered - assert "header_down -X-Frame-Options" in rendered - assert rendered.count("X-Frame-Options SAMEORIGIN") == 1 + + +def test_bootstrap_ldap_tls_creates_cert_files(tmp_path): + certs_dir = tmp_path / "ldap_certs" + bootstrap_ldap_tls(certs_dir) + assert (certs_dir / "openldap.key").is_file() + assert (certs_dir / "openldap.crt").is_file() + bootstrap_ldap_tls(certs_dir) From bfc4e4146756b9e3d0058990ba1e76f95074a60e Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Wed, 12 Aug 2026 11:45:49 +0200 Subject: [PATCH 03/22] Add integration fix --- docs/integrating-engine.md | 30 ++++++++++++++++++++++-------- overlays/idm/authelia-provider.yml | 20 ++++++++++++++++++++ scripts/apply.py | 6 +++++- tests/test_apply.py | 1 + 4 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 overlays/idm/authelia-provider.yml diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md index f5e9dde..46314b2 100644 --- a/docs/integrating-engine.md +++ b/docs/integrating-engine.md @@ -18,12 +18,12 @@ opencloud: auth: mode: oidc oidc: - provider: authelia # adds idm/external-authelia.yml overlay + provider: authelia # adds Authelia-specific compose overlay issuer_url: https://auth.example.com account_url: https://auth.example.com/ domain: auth.example.com client_id: opencloud - client_scopes: openid profile email offline_access + client_scopes: openid profile email groups offline_access role_claim: groups role_mapping: admin: opencloud-admin @@ -33,30 +33,44 @@ auth: ## Authelia OIDC client -In Authelia `deploy.yaml`, add an OIDC client (after OpenCloud domain is known): +OpenCloud's **browser** login uses a **public** OIDC client with PKCE (no client secret). The client ID must match `auth.oidc.client_id` in OpenCloud (`opencloud` above). + +In Authelia `deploy.yaml`: ```yaml oidc: + enabled: true clients: - - id: opencloud - description: OpenCloud - secret: "" + - client_id: opencloud + client_name: OpenCloud + public: true + authorization_policy: two_factor + require_pkce: true + pkce_challenge_method: S256 + token_endpoint_auth_method: none redirect_uris: + - https://cloud.example.com/ - https://cloud.example.com/oidc-callback.html + - https://cloud.example.com/oidc-silent-redirect.html scopes: - openid + - offline_access + - groups - profile - email - - offline_access grant_types: - authorization_code + - refresh_token response_types: - code - authorization_policy: two_factor ``` +Give your user the `opencloud-admin` group in Authelia `deploy.yaml` (`users:` section), not by editing `users_database.yml` directly. + Re-run `bash apply.sh` in authelia-easy-deploy, then opencloud-easy-deploy, then easydeploy-engine. +Official reference: [Authelia — openCloud client](https://www.authelia.com/integration/openid-connect/clients/opencloud/) + ## Apply order 1. Engine + Authelia in integrate mode (see authelia-easy-deploy `docs/integrating-engine.md`). diff --git a/overlays/idm/authelia-provider.yml b/overlays/idm/authelia-provider.yml new file mode 100644 index 0000000..beb5c8e --- /dev/null +++ b/overlays/idm/authelia-provider.yml @@ -0,0 +1,20 @@ +--- +# Overrides upstream external-authelia.yml for Authelia OIDC (public client + webfinger). +services: + opencloud: + environment: + PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD: "jwt" + PROXY_AUTOPROVISION_CLAIM_USERNAME: "preferred_username" + PROXY_AUTOPROVISION_CLAIM_EMAIL: "email" + PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME: "name" + PROXY_AUTOPROVISION_CLAIM_GROUPS: "groups" + PROXY_ROLE_ASSIGNMENT_DRIVER: "oidc" + GRAPH_ASSIGN_DEFAULT_USER_ROLE: "false" + WEBFINGER_WEB_OIDC_CLIENT_ID: ${OC_OIDC_CLIENT_ID} + WEBFINGER_WEB_OIDC_CLIENT_SCOPES: "openid profile email groups offline_access" + WEBFINGER_ANDROID_OIDC_CLIENT_ID: ${WEBFINGER_ANDROID_OIDC_CLIENT_ID:-opencloud-android} + WEBFINGER_ANDROID_OIDC_CLIENT_SCOPES: "openid profile email groups offline_access" + WEBFINGER_IOS_OIDC_CLIENT_ID: ${WEBFINGER_IOS_OIDC_CLIENT_ID:-opencloud-ios} + WEBFINGER_IOS_OIDC_CLIENT_SCOPES: "openid profile email groups offline_access" + WEBFINGER_DESKTOP_OIDC_CLIENT_ID: ${WEBFINGER_DESKTOP_OIDC_CLIENT_ID:-opencloud-desktop} + WEBFINGER_DESKTOP_OIDC_CLIENT_SCOPES: "openid profile email groups offline_access" diff --git a/scripts/apply.py b/scripts/apply.py index f0daf93..1b98df1 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -164,6 +164,7 @@ def derive_compose_files(config: dict) -> list[str]: provider = str((config.get("auth") or {}).get("oidc", {}).get("provider") or "").lower() if provider == "authelia": files.append("idm/external-authelia.yml") + files.append("../overlays/idm/authelia-provider.yml") modules = config.get("modules") or {} if to_bool(modules.get("search")): @@ -494,7 +495,10 @@ def build_caddy_site_blocks(config: dict) -> tuple[str, str]: log""" oc_block = f"""{opencloud_domain} {{ - reverse_proxy opencloud:9200{oc_security_headers} + reverse_proxy opencloud:9200 {{ + header_up X-Forwarded-Proto {{scheme}} + header_up X-Forwarded-Host {{host}} + }}{oc_security_headers} }}""" euro_block = "" diff --git a/tests/test_apply.py b/tests/test_apply.py index c1c8c3c..8835dcc 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -74,6 +74,7 @@ def test_derive_compose_files_oidc_authelia_provider(): ) files = derive_compose_files(config) assert "idm/external-authelia.yml" in files + assert "../overlays/idm/authelia-provider.yml" in files def test_render_integration_fragment(tmp_path, monkeypatch): From 9b943b4a147e0d03740da3eecb088bced7e058b4 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Wed, 12 Aug 2026 13:56:13 +0200 Subject: [PATCH 04/22] Fix setting --- docs/integrating-engine.md | 3 ++- overlays/idm/authelia-provider.yml | 5 +++-- scripts/apply.py | 9 ++++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md index 46314b2..7b6336e 100644 --- a/docs/integrating-engine.md +++ b/docs/integrating-engine.md @@ -23,7 +23,7 @@ auth: account_url: https://auth.example.com/ domain: auth.example.com client_id: opencloud - client_scopes: openid profile email groups offline_access + client_scopes: openid profile email groups role_claim: groups role_mapping: admin: opencloud-admin @@ -50,6 +50,7 @@ oidc: token_endpoint_auth_method: none redirect_uris: - https://cloud.example.com/ + - https://cloud.example.com/web-oidc-callback - https://cloud.example.com/oidc-callback.html - https://cloud.example.com/oidc-silent-redirect.html scopes: diff --git a/overlays/idm/authelia-provider.yml b/overlays/idm/authelia-provider.yml index beb5c8e..e1caf0c 100644 --- a/overlays/idm/authelia-provider.yml +++ b/overlays/idm/authelia-provider.yml @@ -3,7 +3,7 @@ services: opencloud: environment: - PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD: "jwt" + PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD: "none" PROXY_AUTOPROVISION_CLAIM_USERNAME: "preferred_username" PROXY_AUTOPROVISION_CLAIM_EMAIL: "email" PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME: "name" @@ -11,7 +11,8 @@ services: PROXY_ROLE_ASSIGNMENT_DRIVER: "oidc" GRAPH_ASSIGN_DEFAULT_USER_ROLE: "false" WEBFINGER_WEB_OIDC_CLIENT_ID: ${OC_OIDC_CLIENT_ID} - WEBFINGER_WEB_OIDC_CLIENT_SCOPES: "openid profile email groups offline_access" + WEBFINGER_WEB_OIDC_CLIENT_SCOPES: "openid profile email groups" + WEB_OIDC_SCOPE: "openid profile email groups" WEBFINGER_ANDROID_OIDC_CLIENT_ID: ${WEBFINGER_ANDROID_OIDC_CLIENT_ID:-opencloud-android} WEBFINGER_ANDROID_OIDC_CLIENT_SCOPES: "openid profile email groups offline_access" WEBFINGER_IOS_OIDC_CLIENT_ID: ${WEBFINGER_IOS_OIDC_CLIENT_ID:-opencloud-ios} diff --git a/scripts/apply.py b/scripts/apply.py index 1b98df1..3558168 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -318,6 +318,12 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: if auth_mode == "oidc": role_mapping = oidc.get("role_mapping") or {} + provider = str(oidc.get("provider") or "").lower() + default_scopes = ( + "openid profile email groups" + if provider == "authelia" + else "openid profile email offline_access" + ) env.update( { "LDAP_BIND_PASSWORD": secrets["LDAP_BIND_PASSWORD"], @@ -331,7 +337,7 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: ), "OC_OIDC_CLIENT_ID": str(oidc["client_id"]), "OC_OIDC_CLIENT_SCOPES": str( - oidc.get("client_scopes") or "openid profile email offline_access" + oidc.get("client_scopes") or default_scopes ), "OC_SHARING_PUBLIC_SHARE_MUST_HAVE_PASSWORD": "false", "OC_SHARING_PUBLIC_WRITEABLE_SHARE_MUST_HAVE_PASSWORD": "false", @@ -726,6 +732,7 @@ def print_summary(config: dict) -> None: print() print("OIDC auth: configure your IdP with these redirect URIs (strict):") print(f" - https://{domain}/") + print(f" - https://{domain}/web-oidc-callback") print(f" - https://{domain}/oidc-callback.html") print(f" - https://{domain}/oidc-silent-redirect.html") print() From 040e9b9cc43ccfff664830479a401017fc038ff9 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Wed, 12 Aug 2026 14:18:25 +0200 Subject: [PATCH 05/22] Fix opencloud env overrides that still use sub over preferred_username --- config-templates/opencloud/csp.yaml.template | 46 ++++++++++++++++++++ overlays/idm/authelia-provider.yml | 4 ++ overlays/idm/oidc-external.yml | 1 + scripts/apply.py | 13 ++++++ 4 files changed, 64 insertions(+) create mode 100644 config-templates/opencloud/csp.yaml.template diff --git a/config-templates/opencloud/csp.yaml.template b/config-templates/opencloud/csp.yaml.template new file mode 100644 index 0000000..f56d717 --- /dev/null +++ b/config-templates/opencloud/csp.yaml.template @@ -0,0 +1,46 @@ +# Generated by opencloud-easy-deploy — allow OpenCloud web UI to reach the external IdP. +directives: + child-src: + - '''self''' + connect-src: + - '''self''' + - 'blob:' + - 'https://{{IDP_DOMAIN}}' + - 'https://raw.githubusercontent.com/opencloud-eu/awesome-apps/' + - 'https://update.opencloud.eu/' + - 'https://tile.openstreetmap.org/' + default-src: + - '''none''' + font-src: + - '''self''' + frame-ancestors: + - '''self''' + frame-src: + - '''self''' + - 'blob:' + - 'https://embed.diagrams.net/' + - 'https://{{IDP_DOMAIN}}' + - 'https://docs.opencloud.eu' + img-src: + - '''self''' + - 'data:' + - 'blob:' + - 'https://raw.githubusercontent.com/opencloud-eu/awesome-apps/' + - 'https://tile.openstreetmap.org/' + manifest-src: + - '''self''' + media-src: + - '''self''' + object-src: + - '''self''' + - 'blob:' + script-src: + - '''self''' + - '''unsafe-inline''' + - 'https://{{IDP_DOMAIN}}' + style-src: + - '''self''' + - '''unsafe-inline''' + worker-src: + - '''self''' + - 'blob:' diff --git a/overlays/idm/authelia-provider.yml b/overlays/idm/authelia-provider.yml index e1caf0c..80a1118 100644 --- a/overlays/idm/authelia-provider.yml +++ b/overlays/idm/authelia-provider.yml @@ -4,6 +4,10 @@ services: opencloud: environment: PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD: "none" + PROXY_OIDC_REWRITE_WELLKNOWN: "true" + PROXY_AUTOPROVISION_ACCOUNTS: "true" + PROXY_USER_OIDC_CLAIM: "preferred_username" + PROXY_USER_CS3_CLAIM: "username" PROXY_AUTOPROVISION_CLAIM_USERNAME: "preferred_username" PROXY_AUTOPROVISION_CLAIM_EMAIL: "email" PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME: "name" diff --git a/overlays/idm/oidc-external.yml b/overlays/idm/oidc-external.yml index b6d3cdc..756e0b9 100644 --- a/overlays/idm/oidc-external.yml +++ b/overlays/idm/oidc-external.yml @@ -6,6 +6,7 @@ services: PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD: "none" volumes: - ${OC_CONFIG_DIR}/proxy.yaml:/etc/opencloud/proxy.yaml + - ${OC_CONFIG_DIR}/csp.yaml:/etc/opencloud/csp.yaml networks: opencloud-net: diff --git a/scripts/apply.py b/scripts/apply.py index 3558168..a73af8b 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -39,6 +39,7 @@ PROXY_ROLE_TEMPLATE = ( PROJECT_ROOT / "config-templates" / "opencloud" / "proxy.yaml.template" ) +CSP_TEMPLATE = PROJECT_ROOT / "config-templates" / "opencloud" / "csp.yaml.template" SECRET_KEYS = ( "INITIAL_ADMIN_PASSWORD", @@ -482,10 +483,22 @@ def render_proxy_yaml(config: dict) -> None: }, ) proxy_path.write_text(f"{role_block.rstrip()}\n{upstream_body.lstrip()}") + render_csp_yaml(config) elif not proxy_path.exists(): proxy_path.write_text(upstream_body) +def render_csp_yaml(config: dict) -> None: + config_dir = Path(str(config["opencloud"]["config_dir"])) + csp_path = config_dir / "csp.yaml" + oidc = config["auth"]["oidc"] + rendered = render_template( + CSP_TEMPLATE.read_text(), + {"IDP_DOMAIN": str(oidc["domain"])}, + ) + csp_path.write_text(rendered) + + def build_caddy_site_blocks(config: dict) -> tuple[str, str]: opencloud_domain = str(config["opencloud"]["domain"]) weboffice = config.get("weboffice") or {} From f327e3eee74c6d1149cbb0a8506c0be7ec8c6a8c Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Fri, 14 Aug 2026 17:00:31 +0200 Subject: [PATCH 06/22] CORS fix --- docs/integrating-engine.md | 15 +++++++++++++++ scripts/apply.py | 8 +++++++- tests/test_apply.py | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md index 7b6336e..ff3b2eb 100644 --- a/docs/integrating-engine.md +++ b/docs/integrating-engine.md @@ -68,6 +68,21 @@ oidc: Give your user the `opencloud-admin` group in Authelia `deploy.yaml` (`users:` section), not by editing `users_database.yml` directly. +Authelia must also allow browser CORS for the token/userinfo endpoints (OpenCloud Web POSTs the auth code from `https://cloud…` to `https://auth…`). `apply.sh` in authelia-easy-deploy now writes this automatically: + +```yaml +identity_providers: + oidc: + cors: + endpoints: + - authorization + - token + - revocation + - userinfo + - introspection + allowed_origins_from_client_redirect_uris: true +``` + Re-run `bash apply.sh` in authelia-easy-deploy, then opencloud-easy-deploy, then easydeploy-engine. Official reference: [Authelia — openCloud client](https://www.authelia.com/integration/openid-connect/clients/opencloud/) diff --git a/scripts/apply.py b/scripts/apply.py index a73af8b..0b76839 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -187,9 +187,15 @@ def render_network_overlay(config: dict) -> None: opencloud_domain = str(config["opencloud"]["domain"]) weboffice = config.get("weboffice") or {} + extra_hosts = [f"{opencloud_domain}:host-gateway"] + oidc = (config.get("auth") or {}).get("oidc") or {} + idp_domain = str(oidc.get("domain") or "").strip() + if idp_domain and idp_domain != opencloud_domain: + extra_hosts.append(f"{idp_domain}:host-gateway") + opencloud_service: dict[str, Any] = { "container_name": "opencloud", - "extra_hosts": [f"{opencloud_domain}:host-gateway"], + "extra_hosts": extra_hosts, } if proxy_mode(config) == "integrate": opencloud_service["networks"] = ["opencloud-net", DEFAULT_INTEGRATE_NETWORK] diff --git a/tests/test_apply.py b/tests/test_apply.py index 8835dcc..958a93e 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -225,6 +225,23 @@ def test_render_network_overlay_sets_container_names(tmp_path, monkeypatch): assert "cloud.test.example:host-gateway" in data["services"]["euro-office"]["extra_hosts"] +def test_render_network_overlay_adds_idp_host_gateway(tmp_path, monkeypatch): + from scripts import apply as apply_module + + overlay_path = tmp_path / "network-fixups.yml" + monkeypatch.setattr(apply_module, "NETWORK_OVERLAY_PATH", overlay_path) + render_network_overlay( + _base_config( + auth={ + "mode": "oidc", + "oidc": {"domain": "auth.test.example"}, + } + ) + ) + data = yaml.safe_load(overlay_path.read_text()) + assert "auth.test.example:host-gateway" in data["services"]["opencloud"]["extra_hosts"] + + def test_render_caddyfile_allows_opencloud_iframe(tmp_path, monkeypatch): from scripts import apply as apply_module From 03cd00775c72ea2114d9631e27b78c161c26e164 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Mon, 17 Aug 2026 11:29:50 +0200 Subject: [PATCH 07/22] OIDC integration --- README.md | 4 +- docs/integrating-engine.md | 2 + scripts/apply.py | 47 ++++++++++++++- scripts/config_edit.py | 8 ++- tests/test_apply.py | 23 ++++++++ wizard.sh | 113 ++++++++++++++++++++++++++++++------- 6 files changed, 174 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 2f7d3ed..2cc6e5a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This replaces the official test installer (`curl -L https://opencloud.eu/install **Requirements:** Linux VPS, Docker Compose v2, DNS pointing at the server, ports 80/443 open. ```bash -git clone --recurse-submodules https://github.com/your-org/opencloud-easy-deploy.git +git clone --recurse-submodules https://github.com/opencomp-eu/opencloud-easy-deploy.git cd opencloud-easy-deploy bash ensure-dependencies.sh # Docker, uv, submodules, Python deps bash wizard.sh # interactive: writes deploy.yaml and deploys @@ -70,7 +70,7 @@ Uses OpenCloud's built-in LDAP. Admin password is generated on first `apply.sh` Set `auth.mode: oidc` and configure `auth.oidc` in `deploy.yaml`. The stack adds `idm/external-idp.yml` plus a local overlay for role mapping via `proxy.yaml`. -For **Authelia** on the same VPS (with easydeploy-engine), set `auth.oidc.provider: authelia` and follow [`docs/integrating-engine.md`](docs/integrating-engine.md). +For **Authelia** on the same VPS, clone this repo on its own and run `bash wizard.sh`, or let [easydeploy-engine](../easydeploy-engine) clone both kits and run their wizards. See [`docs/integrating-engine.md`](docs/integrating-engine.md). #### Authentik setup diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md index ff3b2eb..c7f9d48 100644 --- a/docs/integrating-engine.md +++ b/docs/integrating-engine.md @@ -85,6 +85,8 @@ identity_providers: Re-run `bash apply.sh` in authelia-easy-deploy, then opencloud-easy-deploy, then easydeploy-engine. +On a **same-VPS** engine install you can skip the Authelia client block: `bash wizard.sh` in easydeploy-engine clones this repo if needed and runs `wizard.sh`. See [easydeploy-engine README](../easydeploy-engine/README.md). + Official reference: [Authelia — openCloud client](https://www.authelia.com/integration/openid-connect/clients/opencloud/) ## Apply order diff --git a/scripts/apply.py b/scripts/apply.py index 0b76839..448c441 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -89,12 +89,57 @@ def render_template(template: str, values: dict[str, str]) -> str: return rendered +def _blank(value: Any) -> bool: + if value is None: + return True + if isinstance(value, str): + return not value.strip() + if isinstance(value, dict): + return not value + return False + + +def managed_is_false(section: dict | None) -> bool: + value = (section or {}).get("managed") + if value is False: + return True + return str(value or "").strip().lower() in {"false", "no", "0"} + + +def apply_engine_oidc_sidecar(config: dict, sidecar_path: Path | None = None) -> None: + """Merge engine-generated Authelia OIDC settings. Operator deploy.yaml wins when set.""" + path = sidecar_path or (INTEGRATION_DIR / "oidc-provider.yaml") + if not path.is_file(): + return + sidecar = load_yaml(path) + if not isinstance(sidecar, dict): + return + auth = config.setdefault("auth", {}) + oidc = auth.setdefault("oidc", {}) + if not isinstance(oidc, dict): + return + if managed_is_false(oidc): + return + existing_provider = str(oidc.get("provider") or "").strip().lower() + if existing_provider and existing_provider != "authelia": + return + auth["mode"] = "oidc" + for key, value in sidecar.items(): + if key == "managed": + continue + if _blank(oidc.get(key)): + oidc[key] = value + oidc.setdefault("provider", "authelia") + + def load_config(path: Path = DEPLOY_PATH) -> dict: if not path.exists(): raise FileNotFoundError( f"Missing {path.name}. Copy deploy.yaml.example to deploy.yaml or run wizard.sh." ) - return load_yaml(path) + config = load_yaml(path) + apply_engine_oidc_sidecar(config) + return config def validate_config(config: dict) -> None: diff --git a/scripts/config_edit.py b/scripts/config_edit.py index c7fc7b7..6b8f846 100644 --- a/scripts/config_edit.py +++ b/scripts/config_edit.py @@ -42,6 +42,7 @@ def update_from_wizard( oidc_account_url: str | None, oidc_domain: str | None, oidc_client_id: str | None, + oidc_provider: str | None, role_admin: str, role_user: str, role_guest: str, @@ -74,11 +75,16 @@ def update_from_wizard( config["auth"] = { "mode": auth_mode, "oidc": { + "provider": oidc_provider or "", "issuer_url": oidc_issuer or "", "account_url": oidc_account_url or "", "domain": oidc_domain or "", "client_id": oidc_client_id or "opencloud", - "client_scopes": "openid profile email offline_access", + "client_scopes": ( + "openid profile email groups" + if (oidc_provider or "").lower() == "authelia" + else "openid profile email offline_access" + ), "role_claim": "groups", "role_mapping": { "admin": role_admin, diff --git a/tests/test_apply.py b/tests/test_apply.py index 958a93e..d20115b 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -9,6 +9,7 @@ import yaml from scripts.apply import ( + apply_engine_oidc_sidecar, bootstrap_ldap_tls, build_env_vars, derive_compose_files, @@ -265,3 +266,25 @@ def test_bootstrap_ldap_tls_creates_cert_files(tmp_path): assert (certs_dir / "openldap.key").is_file() assert (certs_dir / "openldap.crt").is_file() bootstrap_ldap_tls(certs_dir) + + +def test_apply_engine_oidc_sidecar_fills_blank_fields(tmp_path): + sidecar = tmp_path / "oidc-provider.yaml" + sidecar.write_text( + "provider: authelia\nissuer_url: https://auth.test.example\n" + "account_url: https://auth.test.example/\ndomain: auth.test.example\n" + "client_id: opencloud\n" + ) + config = {"auth": {"mode": "builtin", "oidc": {}}} + apply_engine_oidc_sidecar(config, sidecar) + assert config["auth"]["mode"] == "oidc" + assert config["auth"]["oidc"]["issuer_url"] == "https://auth.test.example" + assert config["auth"]["oidc"]["provider"] == "authelia" + + +def test_apply_engine_oidc_sidecar_respects_external_provider(tmp_path): + sidecar = tmp_path / "oidc-provider.yaml" + sidecar.write_text("provider: authelia\nissuer_url: https://auth.test.example\n") + config = {"auth": {"mode": "oidc", "oidc": {"provider": "keycloak", "issuer_url": "https://idp.example"}}} + apply_engine_oidc_sidecar(config, sidecar) + assert config["auth"]["oidc"]["issuer_url"] == "https://idp.example" diff --git a/wizard.sh b/wizard.sh index 531796d..d65395b 100755 --- a/wizard.sh +++ b/wizard.sh @@ -7,6 +7,41 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/scripts/lib.sh" DEPLOY_YAML="${SCRIPT_DIR}/deploy.yaml" +NO_APPLY=0 +PROXY_MODE="" + +usage() { + echo "Usage: bash wizard.sh [--from-engine] [--no-apply] [--proxy-mode standalone|integrate]" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + usage + exit 0 + ;; + --from-engine) + NO_APPLY=1 + PROXY_MODE="integrate" + shift + ;; + --no-apply) + NO_APPLY=1 + shift + ;; + --proxy-mode) + PROXY_MODE="${2:-}" + shift 2 + ;; + --proxy-mode=*) + PROXY_MODE="${1#*=}" + shift + ;; + *) + die "Unknown option: $1" + ;; + esac +done print_banner() { echo @@ -21,7 +56,8 @@ gather_config() { local role_admin role_user role_guest local weboffice_enabled weboffice_domain local modules_search modules_antivirus modules_radicale modules_monitoring - local base_domain proceed proxy_mode + local base_domain proceed proxy_mode use_local_authelia oidc_provider + local authelia_deploy authelia_domain print_banner echo -e " Press Enter to accept a ${CYAN}[default]${RESET}.\n" @@ -33,10 +69,35 @@ gather_config() { echo echo -e "${BOLD} Authentication${RESET}" - ask auth_mode "Auth mode: builtin or oidc" "builtin" - auth_mode="${auth_mode,,}" - if [[ "$auth_mode" != "builtin" && "$auth_mode" != "oidc" ]]; then - die "auth mode must be 'builtin' or 'oidc'" + authelia_deploy="$(cd "${SCRIPT_DIR}/.." && pwd)/authelia-easy-deploy/deploy.yaml" + use_local_authelia="n" + oidc_provider="" + if [[ -f "$authelia_deploy" ]]; then + ask_yn use_local_authelia "Use Authelia on this VPS as the OpenCloud IdP?" "y" + fi + if [[ "$use_local_authelia" == "y" ]]; then + auth_mode="oidc" + oidc_provider="authelia" + authelia_domain="$(uv run python - < Date: Mon, 17 Aug 2026 11:54:08 +0200 Subject: [PATCH 08/22] Update --- deploy.yaml.example | 8 ++--- scripts/config_edit.py | 51 +++++++++++++++++++++++++++++++ tests/test_config_edit.py | 63 +++++++++++++++++++++++++++++++++++++++ wizard.sh | 46 ++++++++++++++++------------ 4 files changed, 145 insertions(+), 23 deletions(-) create mode 100644 tests/test_config_edit.py diff --git a/deploy.yaml.example b/deploy.yaml.example index a4d0804..50fa02f 100644 --- a/deploy.yaml.example +++ b/deploy.yaml.example @@ -18,14 +18,14 @@ proxy: network: easydeploy-net # auth.mode: builtin — built-in LDAP admin (simple VPS default) -# auth.mode: oidc — external IdP (Authentik, Keycloak, Authelia, …) +# auth.mode: oidc — Authelia (same VPS via easydeploy-engine) or another IdP auth: mode: builtin oidc: # provider: authelia — adds idm/external-authelia.yml compose overlay when set - issuer_url: https://authentik.example.com/application/o/opencloud/ - account_url: https://authentik.example.com/if/user/ - domain: authentik.example.com + issuer_url: https://auth.example.com + account_url: https://auth.example.com/ + domain: auth.example.com client_id: opencloud client_scopes: "openid profile email offline_access" role_claim: groups diff --git a/scripts/config_edit.py b/scripts/config_edit.py index 6b8f846..27d7564 100644 --- a/scripts/config_edit.py +++ b/scripts/config_edit.py @@ -4,6 +4,8 @@ from __future__ import annotations import argparse +import os +import shlex from pathlib import Path import yaml @@ -32,6 +34,50 @@ def save(path: Path, data: dict) -> None: yaml.safe_dump(data, handle, default_flow_style=False, sort_keys=False) +def read_authelia_domain(deploy_path: Path) -> str: + if not deploy_path.is_file(): + return "" + with deploy_path.open() as handle: + data = yaml.safe_load(handle) or {} + if not isinstance(data, dict): + return "" + return str((data.get("authelia") or {}).get("domain") or "").strip() + + +def discover_local_authelia(opencloud_root: Path = PROJECT_ROOT) -> dict[str, str]: + """Find a sibling (or engine-exported) Authelia deploy.yaml and portal domain.""" + candidates: list[Path] = [] + env_deploy = str(os.environ.get("EASYDEPLOY_AUTHELIA_DEPLOY") or "").strip() + if env_deploy: + candidates.append(Path(env_deploy).expanduser()) + candidates.append((opencloud_root.parent / "authelia-easy-deploy" / "deploy.yaml").resolve()) + + seen: set[Path] = set() + for path in candidates: + resolved = path.resolve() + if resolved in seen: + continue + seen.add(resolved) + domain = read_authelia_domain(resolved) + if domain: + return {"domain": domain, "deploy": str(resolved)} + + env_domain = str(os.environ.get("EASYDEPLOY_AUTHELIA_DOMAIN") or "").strip() + if env_domain: + return {"domain": env_domain, "deploy": env_deploy} + return {} + + +def emit_local_authelia(opencloud_root: Path = PROJECT_ROOT) -> str: + found = discover_local_authelia(opencloud_root) + domain = found.get("domain", "") + deploy = found.get("deploy", "") + return ( + f"LOCAL_AUTHELIA_DOMAIN={shlex.quote(domain)}\n" + f"LOCAL_AUTHELIA_DEPLOY={shlex.quote(deploy)}\n" + ) + + def update_from_wizard( *, domain: str, @@ -128,9 +174,14 @@ def update_from_wizard( def main() -> None: parser = argparse.ArgumentParser(description="Edit deploy.yaml") parser.add_argument("--show", action="store_true", help="Print deploy.yaml as JSON") + parser.add_argument("--print-local-authelia", action="store_true") parser.add_argument("--path", type=Path, default=DEFAULT_DEPLOY_PATH) args = parser.parse_args() + if args.print_local_authelia: + print(emit_local_authelia(PROJECT_ROOT), end="") + return + if args.show: import json diff --git a/tests/test_config_edit.py b/tests/test_config_edit.py new file mode 100644 index 0000000..774bf77 --- /dev/null +++ b/tests/test_config_edit.py @@ -0,0 +1,63 @@ +"""Tests for OpenCloud wizard config helpers.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from scripts.config_edit import discover_local_authelia, emit_local_authelia, read_authelia_domain + + +def test_read_authelia_domain(tmp_path: Path): + deploy = tmp_path / "deploy.yaml" + deploy.write_text(yaml.safe_dump({"authelia": {"domain": "auth.opencomp.eu"}})) + assert read_authelia_domain(deploy) == "auth.opencomp.eu" + assert read_authelia_domain(tmp_path / "missing.yaml") == "" + + +def test_discover_local_authelia_sibling(tmp_path: Path, monkeypatch): + monkeypatch.delenv("EASYDEPLOY_AUTHELIA_DEPLOY", raising=False) + monkeypatch.delenv("EASYDEPLOY_AUTHELIA_DOMAIN", raising=False) + opencloud = tmp_path / "opencloud-easy-deploy" + authelia = tmp_path / "authelia-easy-deploy" + opencloud.mkdir() + authelia.mkdir() + (authelia / "deploy.yaml").write_text( + yaml.safe_dump({"authelia": {"domain": "auth.opencomp.eu", "sso_domain": "opencomp.eu"}}) + ) + + found = discover_local_authelia(opencloud) + assert found["domain"] == "auth.opencomp.eu" + assert found["deploy"].endswith("authelia-easy-deploy/deploy.yaml") + + +def test_discover_local_authelia_env_path(tmp_path: Path, monkeypatch): + opencloud = tmp_path / "opencloud-easy-deploy" + opencloud.mkdir() + deploy = tmp_path / "elsewhere" / "deploy.yaml" + deploy.parent.mkdir() + deploy.write_text(yaml.safe_dump({"authelia": {"domain": "auth.other.example"}})) + monkeypatch.setenv("EASYDEPLOY_AUTHELIA_DEPLOY", str(deploy)) + + found = discover_local_authelia(opencloud) + assert found["domain"] == "auth.other.example" + + +def test_discover_local_authelia_env_domain_only(tmp_path: Path, monkeypatch): + opencloud = tmp_path / "opencloud-easy-deploy" + opencloud.mkdir() + monkeypatch.setenv("EASYDEPLOY_AUTHELIA_DOMAIN", "auth.env.example") + monkeypatch.delenv("EASYDEPLOY_AUTHELIA_DEPLOY", raising=False) + + found = discover_local_authelia(opencloud) + assert found["domain"] == "auth.env.example" + + +def test_emit_local_authelia_empty(tmp_path: Path, monkeypatch): + monkeypatch.delenv("EASYDEPLOY_AUTHELIA_DEPLOY", raising=False) + monkeypatch.delenv("EASYDEPLOY_AUTHELIA_DOMAIN", raising=False) + opencloud = tmp_path / "opencloud-easy-deploy" + opencloud.mkdir() + text = emit_local_authelia(opencloud) + assert "LOCAL_AUTHELIA_DOMAIN=''" in text or "LOCAL_AUTHELIA_DOMAIN=" in text diff --git a/wizard.sh b/wizard.sh index d65395b..62a7b8a 100755 --- a/wizard.sh +++ b/wizard.sh @@ -9,6 +9,7 @@ source "${SCRIPT_DIR}/scripts/lib.sh" DEPLOY_YAML="${SCRIPT_DIR}/deploy.yaml" NO_APPLY=0 PROXY_MODE="" +FROM_ENGINE=0 usage() { echo "Usage: bash wizard.sh [--from-engine] [--no-apply] [--proxy-mode standalone|integrate]" @@ -23,6 +24,7 @@ while [[ $# -gt 0 ]]; do --from-engine) NO_APPLY=1 PROXY_MODE="integrate" + FROM_ENGINE=1 shift ;; --no-apply) @@ -57,10 +59,12 @@ gather_config() { local weboffice_enabled weboffice_domain local modules_search modules_antivirus modules_radicale modules_monitoring local base_domain proceed proxy_mode use_local_authelia oidc_provider - local authelia_deploy authelia_domain + local authelia_domain default_idp + local LOCAL_AUTHELIA_DOMAIN="" LOCAL_AUTHELIA_DEPLOY="" print_banner echo -e " Press Enter to accept a ${CYAN}[default]${RESET}.\n" + cd "${SCRIPT_DIR}" ask domain "OpenCloud domain (e.g. cloud.example.com)" "cloud.example.com" base_domain="$(base_domain_from_host "$domain")" @@ -69,29 +73,28 @@ gather_config() { echo echo -e "${BOLD} Authentication${RESET}" - authelia_deploy="$(cd "${SCRIPT_DIR}/.." && pwd)/authelia-easy-deploy/deploy.yaml" + eval "$(uv run python -m scripts.config_edit --print-local-authelia)" use_local_authelia="n" oidc_provider="" - if [[ -f "$authelia_deploy" ]]; then - ask_yn use_local_authelia "Use Authelia on this VPS as the OpenCloud IdP?" "y" + authelia_domain="${LOCAL_AUTHELIA_DOMAIN:-}" + if [[ -n "$authelia_domain" ]]; then + if [[ "${FROM_ENGINE}" == "1" ]]; then + use_local_authelia="y" + info "Using Authelia on this VPS at https://${authelia_domain}." + else + ask_yn use_local_authelia "Use Authelia at https://${authelia_domain} as the OpenCloud IdP?" "y" + fi fi if [[ "$use_local_authelia" == "y" ]]; then - auth_mode="oidc" - oidc_provider="authelia" - authelia_domain="$(uv run python - < Date: Sat, 29 Aug 2026 10:32:32 +0200 Subject: [PATCH 09/22] Fix permission --- apply.sh | 3 +++ scripts/apply.py | 35 ++++++++++++++++++++--------------- scripts/deps_config.sh | 6 ++++-- tests/test_apply.py | 5 +++++ wizard.sh | 7 ++++++- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/apply.sh b/apply.sh index dce41e7..5e06303 100755 --- a/apply.sh +++ b/apply.sh @@ -7,6 +7,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/scripts/lib.sh" cd "${SCRIPT_DIR}" +clear_parent_python_env +ensure_docker_group_session "$@" + ensure_dependencies="false" python_args=() diff --git a/scripts/apply.py b/scripts/apply.py index 448c441..7dbf244 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -23,6 +23,9 @@ ) PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "easydeploy-lib" / "python")) +import hostfs # noqa: E402 + COMPOSE_DIR = PROJECT_ROOT / "opencloud-compose" STATE_DIR = PROJECT_ROOT / ".opencloud-easy-deploy" SECRETS_PATH = STATE_DIR / "secrets.yaml" @@ -343,6 +346,7 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: "OC_CONFIG_DIR": str(opencloud["config_dir"]), "OC_DATA_DIR": str(opencloud["data_dir"]), "OC_APPS_DIR": str(opencloud["apps_dir"]), + "OC_CONTAINER_UID_GID": "{0}:{1}".format(*hostfs.service_uid_gid(root_default=(1000, 1000))), "DEFAULT_LANGUAGE": str(opencloud.get("language") or "en"), "START_ADDITIONAL_SERVICES": build_additional_services(config), } @@ -438,7 +442,7 @@ def bootstrap_ldap_tls(ldap_certs_dir: Path) -> None: if key_path.is_file() and cert_path.is_file(): return - ldap_certs_dir.mkdir(parents=True, exist_ok=True) + ldap_certs_dir = hostfs.ensure_writable_directory(ldap_certs_dir) subprocess.run( [ "openssl", @@ -463,13 +467,12 @@ def bootstrap_ldap_tls(ldap_certs_dir: Path) -> None: ) key_path.chmod(0o640) cert_path.chmod(0o644) - if os.geteuid() == 0: - try: - shutil.chown(key_path, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) - shutil.chown(cert_path, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) - shutil.chown(ldap_certs_dir, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) - except OSError: - key_path.chmod(0o644) + try: + hostfs.chown_path(key_path, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) + hostfs.chown_path(cert_path, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) + hostfs.chown_path(ldap_certs_dir, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) + except PermissionError: + key_path.chmod(0o644) def bootstrap_config(config: dict) -> None: @@ -487,7 +490,7 @@ def bootstrap_config(config: dict) -> None: ldap_base / "ldap_certs", ldap_base / "ldap_data", ): - directory.mkdir(parents=True, exist_ok=True) + hostfs.ensure_writable_directory(directory) auth_mode = str((config.get("auth") or {}).get("mode") or "builtin").lower() if auth_mode == "oidc": @@ -629,23 +632,25 @@ def fix_data_permissions(config: dict) -> None: config_dir = Path(str(opencloud["config_dir"])) ldap_base = config_dir.parent auth_mode = str((config.get("auth") or {}).get("mode") or "builtin").lower() + uid, gid = hostfs.service_uid_gid(root_default=(1000, 1000)) paths = [ config_dir, Path(str(opencloud["data_dir"])), Path(str(opencloud["apps_dir"])), ] - if os.geteuid() != 0: - return for path in paths: if path.exists(): - shutil.chown(path, user=1000, group=1000) + try: + hostfs.chown_path(path, uid, gid) + except PermissionError: + pass if auth_mode == "oidc": for name in ("ldap_certs", "ldap_data"): path = ldap_base / name if path.exists(): try: - shutil.chown(path, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) - except OSError: + hostfs.chown_path(path, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) + except PermissionError: pass @@ -863,7 +868,7 @@ def main() -> None: no_reconcile_runtime=args.no_reconcile_runtime, rotate_secrets=args.rotate_secrets, ) - except (FileNotFoundError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc: + except (FileNotFoundError, ValueError, RuntimeError, subprocess.CalledProcessError, PermissionError) as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) diff --git a/scripts/deps_config.sh b/scripts/deps_config.sh index 5717448..ba26986 100644 --- a/scripts/deps_config.sh +++ b/scripts/deps_config.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash -# scripts/deps_config.sh — OpenCloud Easy Deploy dependency list (easydeploy-lib hook) +# scripts/deps_config.sh — OpenCloud Easy Deploy extra dependency keys +# (easydeploy-lib already installs docker, compose, openssl, curl, python3, +# borg, borgmatic, and age.) easydeploy_required_deps() { - printf '%s\n' docker docker-compose git + printf '%s\n' git } diff --git a/tests/test_apply.py b/tests/test_apply.py index d20115b..ee175f8 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import textwrap from pathlib import Path @@ -153,6 +154,10 @@ def test_build_env_vars_production_defaults(): assert env["EURO_OFFICE_DOMAIN"] == "eurooffice.test.example" assert env["EURO_OFFICE_JWT_SECRET"] == "jwt-secret" assert env["EURO_OFFICE_DATA_DIR"] == "/var/lib/opencloud/euro-office" + expected_uid_gid = ( + "1000:1000" if os.geteuid() == 0 else f"{os.getuid()}:{os.getgid()}" + ) + assert env["OC_CONTAINER_UID_GID"] == expected_uid_gid assert env["OCD_CADDYFILE"].endswith("/caddy/Caddyfile") assert "idm/external-idp.yml" not in env["COMPOSE_FILE"] diff --git a/wizard.sh b/wizard.sh index 62a7b8a..e5cb99a 100755 --- a/wizard.sh +++ b/wizard.sh @@ -6,6 +6,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=scripts/lib.sh source "${SCRIPT_DIR}/scripts/lib.sh" +EASYDEPLOY_INVOKE_ARGS=("$@") +clear_parent_python_env + DEPLOY_YAML="${SCRIPT_DIR}/deploy.yaml" NO_APPLY=0 PROXY_MODE="" @@ -64,12 +67,13 @@ gather_config() { print_banner echo -e " Press Enter to accept a ${CYAN}[default]${RESET}.\n" + print_data_dir_hint cd "${SCRIPT_DIR}" ask domain "OpenCloud domain (e.g. cloud.example.com)" "cloud.example.com" base_domain="$(base_domain_from_host "$domain")" - ask data_root "Data root directory" "/var/lib/opencloud" + ask data_root "Data root directory" "$(default_data_dir opencloud)" echo echo -e "${BOLD} Authentication${RESET}" @@ -219,6 +223,7 @@ PY main() { bash "${SCRIPT_DIR}/ensure-dependencies.sh" + ensure_docker_group_session "${EASYDEPLOY_INVOKE_ARGS[@]}" cd "${SCRIPT_DIR}" gather_config if [[ "${NO_APPLY}" == "1" ]]; then From 8f281671fd7dc045c95081da83d025bc3002bbb7 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Sat, 29 Aug 2026 10:33:21 +0200 Subject: [PATCH 10/22] Update submodule --- easydeploy-lib | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easydeploy-lib b/easydeploy-lib index 9285f8e..e496897 160000 --- a/easydeploy-lib +++ b/easydeploy-lib @@ -1 +1 @@ -Subproject commit 9285f8efa0e3d6ce022604708d78c95c3b5bd933 +Subproject commit e496897ab567d0ff9c265c5e0662fbd519f93f8f From 49430d43a2a6d741da32400e021d39dab3ac2560 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Sun, 30 Aug 2026 11:56:15 +0200 Subject: [PATCH 11/22] Switch to kanidm instead of authelia for improved identity management --- README.md | 2 +- deploy.yaml.example | 12 +++--- docs/integrating-engine.md | 72 ++++++-------------------------- overlays/idm/kanidm-provider.yml | 25 +++++++++++ scripts/apply.py | 13 +++--- scripts/config_edit.py | 34 +++++++-------- tests/test_apply.py | 25 +++++------ tests/test_config_edit.py | 60 +++++++++++++------------- wizard.sh | 50 +++++++++++----------- 9 files changed, 135 insertions(+), 158 deletions(-) create mode 100644 overlays/idm/kanidm-provider.yml diff --git a/README.md b/README.md index 2cc6e5a..9b306e3 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Uses OpenCloud's built-in LDAP. Admin password is generated on first `apply.sh` Set `auth.mode: oidc` and configure `auth.oidc` in `deploy.yaml`. The stack adds `idm/external-idp.yml` plus a local overlay for role mapping via `proxy.yaml`. -For **Authelia** on the same VPS, clone this repo on its own and run `bash wizard.sh`, or let [easydeploy-engine](../easydeploy-engine) clone both kits and run their wizards. See [`docs/integrating-engine.md`](docs/integrating-engine.md). +For **Kanidm** on the same VPS, clone this repo on its own and run `bash wizard.sh`, or let [easydeploy-engine](../easydeploy-engine) clone both kits and run their wizards. See [`docs/integrating-engine.md`](docs/integrating-engine.md). #### Authentik setup diff --git a/deploy.yaml.example b/deploy.yaml.example index 50fa02f..6f4bace 100644 --- a/deploy.yaml.example +++ b/deploy.yaml.example @@ -18,16 +18,16 @@ proxy: network: easydeploy-net # auth.mode: builtin — built-in LDAP admin (simple VPS default) -# auth.mode: oidc — Authelia (same VPS via easydeploy-engine) or another IdP +# auth.mode: oidc — Kanidm (same VPS via easydeploy-engine) or another IdP auth: mode: builtin oidc: - # provider: authelia — adds idm/external-authelia.yml compose overlay when set - issuer_url: https://auth.example.com - account_url: https://auth.example.com/ - domain: auth.example.com + # provider: kanidm — adds overlays/idm/kanidm-provider.yml when set + issuer_url: https://idm.example.com/oauth2/openid/opencloud + account_url: https://idm.example.com/ + domain: idm.example.com client_id: opencloud - client_scopes: "openid profile email offline_access" + client_scopes: "openid profile email groups groups_name" role_claim: groups role_mapping: admin: opencloud-admin diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md index c7f9d48..b125b29 100644 --- a/docs/integrating-engine.md +++ b/docs/integrating-engine.md @@ -1,6 +1,6 @@ # Integrating with easydeploy-engine -Use this when Authelia (or another kit) already runs on the same VPS behind **easydeploy-engine** on `easydeploy-net`. +Use this when Kanidm already runs on the same VPS behind **easydeploy-engine** on `easydeploy-net`. ## deploy.yaml @@ -18,12 +18,12 @@ opencloud: auth: mode: oidc oidc: - provider: authelia # adds Authelia-specific compose overlay - issuer_url: https://auth.example.com - account_url: https://auth.example.com/ - domain: auth.example.com + provider: kanidm # adds Kanidm-specific compose overlay + issuer_url: https://idm.example.com/oauth2/openid/opencloud + account_url: https://idm.example.com/ + domain: idm.example.com client_id: opencloud - client_scopes: openid profile email groups + client_scopes: openid profile email groups groups_name role_claim: groups role_mapping: admin: opencloud-admin @@ -31,67 +31,19 @@ auth: guest: opencloud-guest ``` -## Authelia OIDC client +Kanidm uses a **per-client** issuer (`/oauth2/openid/`), not the portal origin. -OpenCloud's **browser** login uses a **public** OIDC client with PKCE (no client secret). The client ID must match `auth.oidc.client_id` in OpenCloud (`opencloud` above). - -In Authelia `deploy.yaml`: - -```yaml -oidc: - enabled: true - clients: - - client_id: opencloud - client_name: OpenCloud - public: true - authorization_policy: two_factor - require_pkce: true - pkce_challenge_method: S256 - token_endpoint_auth_method: none - redirect_uris: - - https://cloud.example.com/ - - https://cloud.example.com/web-oidc-callback - - https://cloud.example.com/oidc-callback.html - - https://cloud.example.com/oidc-silent-redirect.html - scopes: - - openid - - offline_access - - groups - - profile - - email - grant_types: - - authorization_code - - refresh_token - response_types: - - code -``` - -Give your user the `opencloud-admin` group in Authelia `deploy.yaml` (`users:` section), not by editing `users_database.yml` directly. +## Kanidm OIDC client -Authelia must also allow browser CORS for the token/userinfo endpoints (OpenCloud Web POSTs the auth code from `https://cloud…` to `https://auth…`). `apply.sh` in authelia-easy-deploy now writes this automatically: - -```yaml -identity_providers: - oidc: - cors: - endpoints: - - authorization - - token - - revocation - - userinfo - - introspection - allowed_origins_from_client_redirect_uris: true -``` - -Re-run `bash apply.sh` in authelia-easy-deploy, then opencloud-easy-deploy, then easydeploy-engine. +OpenCloud's **browser** login uses a **public** OIDC client with PKCE (no client secret). The client ID must match `auth.oidc.client_id` in OpenCloud (`opencloud` above). -On a **same-VPS** engine install you can skip the Authelia client block: `bash wizard.sh` in easydeploy-engine clones this repo if needed and runs `wizard.sh`. See [easydeploy-engine README](../easydeploy-engine/README.md). +On a same-VPS engine install you can skip registering the client by hand: `bash wizard.sh` in easydeploy-engine clones this repo if needed and writes the Kanidm OIDC sidecar. Kanidm apply then creates the public client and default groups (`opencloud-admin`, `opencloud-user`, `opencloud-guest`). -Official reference: [Authelia — openCloud client](https://www.authelia.com/integration/openid-connect/clients/opencloud/) +Give your user the `opencloud-admin` group in Kanidm, not by creating a local OpenCloud account. ## Apply order -1. Engine + Authelia in integrate mode (see authelia-easy-deploy `docs/integrating-engine.md`). +1. Engine + Kanidm in integrate mode (see kanidm-easy-deploy `docs/integrating-engine.md`). 2. Clone/configure opencloud-easy-deploy; set `proxy.mode: integrate` and OIDC as above. 3. `bash apply.sh` in opencloud-easy-deploy. 4. Register OpenCloud in `engine.yaml` (fragment path `.opencloud-easy-deploy/integration/caddy.caddy`). diff --git a/overlays/idm/kanidm-provider.yml b/overlays/idm/kanidm-provider.yml new file mode 100644 index 0000000..db1c682 --- /dev/null +++ b/overlays/idm/kanidm-provider.yml @@ -0,0 +1,25 @@ +--- +# Kanidm OIDC (public PKCE client + preferred_username + groups). +services: + opencloud: + environment: + PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD: "none" + PROXY_OIDC_REWRITE_WELLKNOWN: "true" + PROXY_AUTOPROVISION_ACCOUNTS: "true" + PROXY_USER_OIDC_CLAIM: "preferred_username" + PROXY_USER_CS3_CLAIM: "username" + PROXY_AUTOPROVISION_CLAIM_USERNAME: "preferred_username" + PROXY_AUTOPROVISION_CLAIM_EMAIL: "email" + PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME: "name" + PROXY_AUTOPROVISION_CLAIM_GROUPS: "groups" + PROXY_ROLE_ASSIGNMENT_DRIVER: "oidc" + GRAPH_ASSIGN_DEFAULT_USER_ROLE: "false" + WEBFINGER_WEB_OIDC_CLIENT_ID: ${OC_OIDC_CLIENT_ID} + WEBFINGER_WEB_OIDC_CLIENT_SCOPES: "openid profile email groups groups_name" + WEB_OIDC_SCOPE: "openid profile email groups groups_name" + WEBFINGER_ANDROID_OIDC_CLIENT_ID: ${WEBFINGER_ANDROID_OIDC_CLIENT_ID:-opencloud-android} + WEBFINGER_ANDROID_OIDC_CLIENT_SCOPES: "openid profile email groups groups_name offline_access" + WEBFINGER_IOS_OIDC_CLIENT_ID: ${WEBFINGER_IOS_OIDC_CLIENT_ID:-opencloud-ios} + WEBFINGER_IOS_OIDC_CLIENT_SCOPES: "openid profile email groups groups_name offline_access" + WEBFINGER_DESKTOP_OIDC_CLIENT_ID: ${WEBFINGER_DESKTOP_OIDC_CLIENT_ID:-opencloud-desktop} + WEBFINGER_DESKTOP_OIDC_CLIENT_SCOPES: "openid profile email groups groups_name offline_access" diff --git a/scripts/apply.py b/scripts/apply.py index 7dbf244..1c404e0 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -124,7 +124,7 @@ def apply_engine_oidc_sidecar(config: dict, sidecar_path: Path | None = None) -> if managed_is_false(oidc): return existing_provider = str(oidc.get("provider") or "").strip().lower() - if existing_provider and existing_provider != "authelia": + if existing_provider and existing_provider not in {"kanidm", "authelia"}: return auth["mode"] = "oidc" for key, value in sidecar.items(): @@ -132,7 +132,7 @@ def apply_engine_oidc_sidecar(config: dict, sidecar_path: Path | None = None) -> continue if _blank(oidc.get(key)): oidc[key] = value - oidc.setdefault("provider", "authelia") + oidc.setdefault("provider", "kanidm") def load_config(path: Path = DEPLOY_PATH) -> dict: @@ -211,9 +211,8 @@ def derive_compose_files(config: dict) -> list[str]: if auth_mode == "oidc": files.extend(["idm/external-idp.yml", "../overlays/idm/oidc-external.yml"]) provider = str((config.get("auth") or {}).get("oidc", {}).get("provider") or "").lower() - if provider == "authelia": - files.append("idm/external-authelia.yml") - files.append("../overlays/idm/authelia-provider.yml") + if provider in {"kanidm", "authelia"}: + files.append("../overlays/idm/kanidm-provider.yml") modules = config.get("modules") or {} if to_bool(modules.get("search")): @@ -376,8 +375,8 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: role_mapping = oidc.get("role_mapping") or {} provider = str(oidc.get("provider") or "").lower() default_scopes = ( - "openid profile email groups" - if provider == "authelia" + "openid profile email groups groups_name" + if provider in {"kanidm", "authelia"} else "openid profile email offline_access" ) env.update( diff --git a/scripts/config_edit.py b/scripts/config_edit.py index 27d7564..4e343a2 100644 --- a/scripts/config_edit.py +++ b/scripts/config_edit.py @@ -34,23 +34,23 @@ def save(path: Path, data: dict) -> None: yaml.safe_dump(data, handle, default_flow_style=False, sort_keys=False) -def read_authelia_domain(deploy_path: Path) -> str: +def read_kanidm_domain(deploy_path: Path) -> str: if not deploy_path.is_file(): return "" with deploy_path.open() as handle: data = yaml.safe_load(handle) or {} if not isinstance(data, dict): return "" - return str((data.get("authelia") or {}).get("domain") or "").strip() + return str((data.get("kanidm") or {}).get("domain") or "").strip() -def discover_local_authelia(opencloud_root: Path = PROJECT_ROOT) -> dict[str, str]: - """Find a sibling (or engine-exported) Authelia deploy.yaml and portal domain.""" +def discover_local_kanidm(opencloud_root: Path = PROJECT_ROOT) -> dict[str, str]: + """Find a sibling (or engine-exported) Kanidm deploy.yaml and portal domain.""" candidates: list[Path] = [] - env_deploy = str(os.environ.get("EASYDEPLOY_AUTHELIA_DEPLOY") or "").strip() + env_deploy = str(os.environ.get("EASYDEPLOY_KANIDM_DEPLOY") or "").strip() if env_deploy: candidates.append(Path(env_deploy).expanduser()) - candidates.append((opencloud_root.parent / "authelia-easy-deploy" / "deploy.yaml").resolve()) + candidates.append((opencloud_root.parent / "kanidm-easy-deploy" / "deploy.yaml").resolve()) seen: set[Path] = set() for path in candidates: @@ -58,23 +58,23 @@ def discover_local_authelia(opencloud_root: Path = PROJECT_ROOT) -> dict[str, st if resolved in seen: continue seen.add(resolved) - domain = read_authelia_domain(resolved) + domain = read_kanidm_domain(resolved) if domain: return {"domain": domain, "deploy": str(resolved)} - env_domain = str(os.environ.get("EASYDEPLOY_AUTHELIA_DOMAIN") or "").strip() + env_domain = str(os.environ.get("EASYDEPLOY_KANIDM_DOMAIN") or "").strip() if env_domain: return {"domain": env_domain, "deploy": env_deploy} return {} -def emit_local_authelia(opencloud_root: Path = PROJECT_ROOT) -> str: - found = discover_local_authelia(opencloud_root) +def emit_local_kanidm(opencloud_root: Path = PROJECT_ROOT) -> str: + found = discover_local_kanidm(opencloud_root) domain = found.get("domain", "") deploy = found.get("deploy", "") return ( - f"LOCAL_AUTHELIA_DOMAIN={shlex.quote(domain)}\n" - f"LOCAL_AUTHELIA_DEPLOY={shlex.quote(deploy)}\n" + f"LOCAL_KANIDM_DOMAIN={shlex.quote(domain)}\n" + f"LOCAL_KANIDM_DEPLOY={shlex.quote(deploy)}\n" ) @@ -127,8 +127,8 @@ def update_from_wizard( "domain": oidc_domain or "", "client_id": oidc_client_id or "opencloud", "client_scopes": ( - "openid profile email groups" - if (oidc_provider or "").lower() == "authelia" + "openid profile email groups groups_name" + if (oidc_provider or "").lower() in {"kanidm", "authelia"} else "openid profile email offline_access" ), "role_claim": "groups", @@ -174,12 +174,12 @@ def update_from_wizard( def main() -> None: parser = argparse.ArgumentParser(description="Edit deploy.yaml") parser.add_argument("--show", action="store_true", help="Print deploy.yaml as JSON") - parser.add_argument("--print-local-authelia", action="store_true") + parser.add_argument("--print-local-kanidm", action="store_true") parser.add_argument("--path", type=Path, default=DEFAULT_DEPLOY_PATH) args = parser.parse_args() - if args.print_local_authelia: - print(emit_local_authelia(PROJECT_ROOT), end="") + if args.print_local_kanidm: + print(emit_local_kanidm(PROJECT_ROOT), end="") return if args.show: diff --git a/tests/test_apply.py b/tests/test_apply.py index ee175f8..fb650da 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -61,22 +61,23 @@ def test_derive_compose_files_integrate_excludes_caddy(): assert "docker-compose.yml" in files -def test_derive_compose_files_oidc_authelia_provider(): +def test_derive_compose_files_oidc_kanidm_provider(): config = _base_config( auth={ "mode": "oidc", "oidc": { - "provider": "authelia", - "issuer_url": "https://auth.example/o", - "account_url": "https://auth.example/", - "domain": "auth.example", + "provider": "kanidm", + "issuer_url": "https://idm.example/oauth2/openid/opencloud", + "account_url": "https://idm.example/", + "domain": "idm.example", "client_id": "opencloud", }, }, ) files = derive_compose_files(config) - assert "idm/external-authelia.yml" in files - assert "../overlays/idm/authelia-provider.yml" in files + assert "idm/external-idp.yml" in files + assert "../overlays/idm/kanidm-provider.yml" in files + assert "idm/external-authelia.yml" not in files def test_render_integration_fragment(tmp_path, monkeypatch): @@ -276,20 +277,20 @@ def test_bootstrap_ldap_tls_creates_cert_files(tmp_path): def test_apply_engine_oidc_sidecar_fills_blank_fields(tmp_path): sidecar = tmp_path / "oidc-provider.yaml" sidecar.write_text( - "provider: authelia\nissuer_url: https://auth.test.example\n" - "account_url: https://auth.test.example/\ndomain: auth.test.example\n" + "provider: kanidm\nissuer_url: https://idm.test.example/oauth2/openid/opencloud\n" + "account_url: https://idm.test.example/\ndomain: idm.test.example\n" "client_id: opencloud\n" ) config = {"auth": {"mode": "builtin", "oidc": {}}} apply_engine_oidc_sidecar(config, sidecar) assert config["auth"]["mode"] == "oidc" - assert config["auth"]["oidc"]["issuer_url"] == "https://auth.test.example" - assert config["auth"]["oidc"]["provider"] == "authelia" + assert config["auth"]["oidc"]["issuer_url"] == "https://idm.test.example/oauth2/openid/opencloud" + assert config["auth"]["oidc"]["provider"] == "kanidm" def test_apply_engine_oidc_sidecar_respects_external_provider(tmp_path): sidecar = tmp_path / "oidc-provider.yaml" - sidecar.write_text("provider: authelia\nissuer_url: https://auth.test.example\n") + sidecar.write_text("provider: kanidm\nissuer_url: https://idm.test.example/oauth2/openid/opencloud\n") config = {"auth": {"mode": "oidc", "oidc": {"provider": "keycloak", "issuer_url": "https://idp.example"}}} apply_engine_oidc_sidecar(config, sidecar) assert config["auth"]["oidc"]["issuer_url"] == "https://idp.example" diff --git a/tests/test_config_edit.py b/tests/test_config_edit.py index 774bf77..80fdebe 100644 --- a/tests/test_config_edit.py +++ b/tests/test_config_edit.py @@ -6,58 +6,58 @@ import yaml -from scripts.config_edit import discover_local_authelia, emit_local_authelia, read_authelia_domain +from scripts.config_edit import discover_local_kanidm, emit_local_kanidm, read_kanidm_domain -def test_read_authelia_domain(tmp_path: Path): +def test_read_kanidm_domain(tmp_path: Path): deploy = tmp_path / "deploy.yaml" - deploy.write_text(yaml.safe_dump({"authelia": {"domain": "auth.opencomp.eu"}})) - assert read_authelia_domain(deploy) == "auth.opencomp.eu" - assert read_authelia_domain(tmp_path / "missing.yaml") == "" + deploy.write_text(yaml.safe_dump({"kanidm": {"domain": "idm.opencomp.eu"}})) + assert read_kanidm_domain(deploy) == "idm.opencomp.eu" + assert read_kanidm_domain(tmp_path / "missing.yaml") == "" -def test_discover_local_authelia_sibling(tmp_path: Path, monkeypatch): - monkeypatch.delenv("EASYDEPLOY_AUTHELIA_DEPLOY", raising=False) - monkeypatch.delenv("EASYDEPLOY_AUTHELIA_DOMAIN", raising=False) +def test_discover_local_kanidm_sibling(tmp_path: Path, monkeypatch): + monkeypatch.delenv("EASYDEPLOY_KANIDM_DEPLOY", raising=False) + monkeypatch.delenv("EASYDEPLOY_KANIDM_DOMAIN", raising=False) opencloud = tmp_path / "opencloud-easy-deploy" - authelia = tmp_path / "authelia-easy-deploy" + kanidm = tmp_path / "kanidm-easy-deploy" opencloud.mkdir() - authelia.mkdir() - (authelia / "deploy.yaml").write_text( - yaml.safe_dump({"authelia": {"domain": "auth.opencomp.eu", "sso_domain": "opencomp.eu"}}) + kanidm.mkdir() + (kanidm / "deploy.yaml").write_text( + yaml.safe_dump({"kanidm": {"domain": "idm.opencomp.eu"}}) ) - found = discover_local_authelia(opencloud) - assert found["domain"] == "auth.opencomp.eu" - assert found["deploy"].endswith("authelia-easy-deploy/deploy.yaml") + found = discover_local_kanidm(opencloud) + assert found["domain"] == "idm.opencomp.eu" + assert found["deploy"].endswith("kanidm-easy-deploy/deploy.yaml") -def test_discover_local_authelia_env_path(tmp_path: Path, monkeypatch): +def test_discover_local_kanidm_env_path(tmp_path: Path, monkeypatch): opencloud = tmp_path / "opencloud-easy-deploy" opencloud.mkdir() deploy = tmp_path / "elsewhere" / "deploy.yaml" deploy.parent.mkdir() - deploy.write_text(yaml.safe_dump({"authelia": {"domain": "auth.other.example"}})) - monkeypatch.setenv("EASYDEPLOY_AUTHELIA_DEPLOY", str(deploy)) + deploy.write_text(yaml.safe_dump({"kanidm": {"domain": "idm.other.example"}})) + monkeypatch.setenv("EASYDEPLOY_KANIDM_DEPLOY", str(deploy)) - found = discover_local_authelia(opencloud) - assert found["domain"] == "auth.other.example" + found = discover_local_kanidm(opencloud) + assert found["domain"] == "idm.other.example" -def test_discover_local_authelia_env_domain_only(tmp_path: Path, monkeypatch): +def test_discover_local_kanidm_env_domain_only(tmp_path: Path, monkeypatch): opencloud = tmp_path / "opencloud-easy-deploy" opencloud.mkdir() - monkeypatch.setenv("EASYDEPLOY_AUTHELIA_DOMAIN", "auth.env.example") - monkeypatch.delenv("EASYDEPLOY_AUTHELIA_DEPLOY", raising=False) + monkeypatch.setenv("EASYDEPLOY_KANIDM_DOMAIN", "idm.env.example") + monkeypatch.delenv("EASYDEPLOY_KANIDM_DEPLOY", raising=False) - found = discover_local_authelia(opencloud) - assert found["domain"] == "auth.env.example" + found = discover_local_kanidm(opencloud) + assert found["domain"] == "idm.env.example" -def test_emit_local_authelia_empty(tmp_path: Path, monkeypatch): - monkeypatch.delenv("EASYDEPLOY_AUTHELIA_DEPLOY", raising=False) - monkeypatch.delenv("EASYDEPLOY_AUTHELIA_DOMAIN", raising=False) +def test_emit_local_kanidm_empty(tmp_path: Path, monkeypatch): + monkeypatch.delenv("EASYDEPLOY_KANIDM_DEPLOY", raising=False) + monkeypatch.delenv("EASYDEPLOY_KANIDM_DOMAIN", raising=False) opencloud = tmp_path / "opencloud-easy-deploy" opencloud.mkdir() - text = emit_local_authelia(opencloud) - assert "LOCAL_AUTHELIA_DOMAIN=''" in text or "LOCAL_AUTHELIA_DOMAIN=" in text + text = emit_local_kanidm(opencloud) + assert "LOCAL_KANIDM_DOMAIN=''" in text or "LOCAL_KANIDM_DOMAIN=" in text diff --git a/wizard.sh b/wizard.sh index e5cb99a..2c5f0a0 100755 --- a/wizard.sh +++ b/wizard.sh @@ -61,9 +61,9 @@ gather_config() { local role_admin role_user role_guest local weboffice_enabled weboffice_domain local modules_search modules_antivirus modules_radicale modules_monitoring - local base_domain proceed proxy_mode use_local_authelia oidc_provider - local authelia_domain default_idp - local LOCAL_AUTHELIA_DOMAIN="" LOCAL_AUTHELIA_DEPLOY="" + local base_domain proceed proxy_mode use_local_kanidm oidc_provider + local kanidm_domain default_idp + local LOCAL_KANIDM_DOMAIN="" LOCAL_KANIDM_DEPLOY="" print_banner echo -e " Press Enter to accept a ${CYAN}[default]${RESET}.\n" @@ -77,27 +77,27 @@ gather_config() { echo echo -e "${BOLD} Authentication${RESET}" - eval "$(uv run python -m scripts.config_edit --print-local-authelia)" - use_local_authelia="n" + eval "$(uv run python -m scripts.config_edit --print-local-kanidm)" + use_local_kanidm="n" oidc_provider="" - authelia_domain="${LOCAL_AUTHELIA_DOMAIN:-}" - if [[ -n "$authelia_domain" ]]; then + kanidm_domain="${LOCAL_KANIDM_DOMAIN:-}" + if [[ -n "$kanidm_domain" ]]; then if [[ "${FROM_ENGINE}" == "1" ]]; then - use_local_authelia="y" - info "Using Authelia on this VPS at https://${authelia_domain}." + use_local_kanidm="y" + info "Using Kanidm on this VPS at https://${kanidm_domain}." else - ask_yn use_local_authelia "Use Authelia at https://${authelia_domain} as the OpenCloud IdP?" "y" + ask_yn use_local_kanidm "Use Kanidm at https://${kanidm_domain} as the OpenCloud IdP?" "y" fi fi - if [[ "$use_local_authelia" == "y" ]]; then - if [[ -z "$authelia_domain" ]]; then - die "Authelia was selected but no portal domain was found." + if [[ "$use_local_kanidm" == "y" ]]; then + if [[ -z "$kanidm_domain" ]]; then + die "Kanidm was selected but no identity domain was found." fi auth_mode="oidc" - oidc_provider="authelia" - oidc_issuer="https://${authelia_domain}" - oidc_account="https://${authelia_domain}/" - oidc_domain="$authelia_domain" + oidc_provider="kanidm" + oidc_issuer="https://${kanidm_domain}/oauth2/openid/opencloud" + oidc_account="https://${kanidm_domain}/" + oidc_domain="$kanidm_domain" info "OIDC issuer: ${oidc_issuer} (engine will register the OIDC client)." else ask auth_mode "Auth mode: builtin or oidc" "builtin" @@ -116,22 +116,22 @@ gather_config() { role_admin="opencloud-admin" role_user="opencloud-user" role_guest="opencloud-guest" - if [[ "$use_local_authelia" != "y" ]]; then + if [[ "$use_local_kanidm" != "y" ]]; then oidc_issuer="" oidc_account="" oidc_domain="" fi - if [[ "$auth_mode" == "oidc" && "$use_local_authelia" != "y" ]]; then - default_idp="${authelia_domain:-auth.${base_domain}}" + if [[ "$auth_mode" == "oidc" && "$use_local_kanidm" != "y" ]]; then + default_idp="${kanidm_domain:-idm.${base_domain}}" echo - echo -e "${BOLD} OIDC issuer (Authelia, Authentik, Keycloak, …)${RESET}" - echo " Authelia issuer is the portal origin, e.g. https://auth.${base_domain}" - ask oidc_issuer "OIDC issuer URL" "https://${default_idp}" + echo -e "${BOLD} OIDC issuer (Kanidm, Authentik, Keycloak, …)${RESET}" + echo " Kanidm issuer is per-client, e.g. https://idm.${base_domain}/oauth2/openid/opencloud" + ask oidc_issuer "OIDC issuer URL" "https://${default_idp}/oauth2/openid/opencloud" ask oidc_account "Account settings URL" "https://${default_idp}/" ask oidc_domain "IdP domain (for CSP)" "${default_idp}" ask oidc_client_id "OIDC client ID" "opencloud" - ask oidc_provider "Provider: authelia, authentik, keycloak, or other" "authelia" + ask oidc_provider "Provider: kanidm, authentik, keycloak, or other" "kanidm" oidc_provider="${oidc_provider,,}" ask role_admin "Admin group name" "opencloud-admin" ask role_user "User group name" "opencloud-user" @@ -159,7 +159,7 @@ gather_config() { proxy_mode="${PROXY_MODE,,}" info "Proxy mode: ${proxy_mode} (set by easydeploy-engine)" else - ask proxy_mode "Proxy mode: standalone or integrate" "$([[ "$use_local_authelia" == "y" ]] && echo integrate || echo standalone)" + ask proxy_mode "Proxy mode: standalone or integrate" "$([[ "$use_local_kanidm" == "y" ]] && echo integrate || echo standalone)" proxy_mode="${proxy_mode,,}" fi if [[ "$proxy_mode" != "standalone" && "$proxy_mode" != "integrate" ]]; then From 35742cd16d05a9dd396c81d30456fc2f4b3c19da Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Sun, 30 Aug 2026 16:26:55 +0200 Subject: [PATCH 12/22] Update --- docs/integrating-engine.md | 12 ++++++++---- scripts/apply.py | 17 ++++++++++++----- tests/test_apply.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md index b125b29..f98afc0 100644 --- a/docs/integrating-engine.md +++ b/docs/integrating-engine.md @@ -43,10 +43,14 @@ Give your user the `opencloud-admin` group in Kanidm, not by creating a local Op ## Apply order -1. Engine + Kanidm in integrate mode (see kanidm-easy-deploy `docs/integrating-engine.md`). -2. Clone/configure opencloud-easy-deploy; set `proxy.mode: integrate` and OIDC as above. -3. `bash apply.sh` in opencloud-easy-deploy. -4. Register OpenCloud in `engine.yaml` (fragment path `.opencloud-easy-deploy/integration/caddy.caddy`). +1. Configure Kanidm and OpenCloud, then enable both in `engine.yaml`. +2. Run `bash apply.sh` in easydeploy-engine. +3. The engine writes both OIDC sidecars, applies Kanidm to register the + `opencloud` client, applies OpenCloud to consume the provider configuration, + and reloads shared Caddy. + +Do not use `--skip-kits` for the initial identity wiring: that writes sidecars +but does not register the client or restart OpenCloud. 5. `bash apply.sh` in easydeploy-engine. Standalone OpenCloud Caddy (`opencloud_caddy`) is not started in integrate mode. diff --git a/scripts/apply.py b/scripts/apply.py index 1c404e0..8be3bbe 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -110,7 +110,12 @@ def managed_is_false(section: dict | None) -> bool: def apply_engine_oidc_sidecar(config: dict, sidecar_path: Path | None = None) -> None: - """Merge engine-generated Authelia OIDC settings. Operator deploy.yaml wins when set.""" + """Apply engine-managed Kanidm OIDC settings. + + When management is enabled, the engine sidecar is authoritative. This + prevents example or stale issuer values in deploy.yaml from overriding the + currently wired Kanidm instance. Set auth.oidc.managed: false to opt out. + """ path = sidecar_path or (INTEGRATION_DIR / "oidc-provider.yaml") if not path.is_file(): return @@ -130,9 +135,8 @@ def apply_engine_oidc_sidecar(config: dict, sidecar_path: Path | None = None) -> for key, value in sidecar.items(): if key == "managed": continue - if _blank(oidc.get(key)): - oidc[key] = value - oidc.setdefault("provider", "kanidm") + oidc[key] = value + oidc["provider"] = "kanidm" def load_config(path: Path = DEPLOY_PATH) -> dict: @@ -798,7 +802,10 @@ def print_summary(config: dict) -> None: else: oidc = config["auth"]["oidc"] print() - print("OIDC auth: configure your IdP with these redirect URIs (strict):") + print(f"OIDC provider: {oidc.get('provider') or 'external'}") + print(f"OIDC issuer: {oidc.get('issuer_url')}") + print(f"OIDC client: {oidc.get('client_id')}") + print("OIDC redirect URIs (strict):") print(f" - https://{domain}/") print(f" - https://{domain}/web-oidc-callback") print(f" - https://{domain}/oidc-callback.html") diff --git a/tests/test_apply.py b/tests/test_apply.py index fb650da..b824c26 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -288,6 +288,35 @@ def test_apply_engine_oidc_sidecar_fills_blank_fields(tmp_path): assert config["auth"]["oidc"]["provider"] == "kanidm" +def test_apply_engine_oidc_sidecar_replaces_stale_managed_values(tmp_path): + sidecar = tmp_path / "oidc-provider.yaml" + sidecar.write_text( + "provider: kanidm\n" + "issuer_url: https://auth.test.example/oauth2/openid/opencloud\n" + "account_url: https://auth.test.example/\n" + "domain: auth.test.example\n" + "client_id: opencloud\n" + ) + config = { + "auth": { + "mode": "builtin", + "oidc": { + "provider": "kanidm", + "issuer_url": "https://idm.example.com/oauth2/openid/opencloud", + "domain": "idm.example.com", + "client_id": "old-client", + }, + } + } + apply_engine_oidc_sidecar(config, sidecar) + assert config["auth"]["mode"] == "oidc" + assert config["auth"]["oidc"]["issuer_url"] == ( + "https://auth.test.example/oauth2/openid/opencloud" + ) + assert config["auth"]["oidc"]["domain"] == "auth.test.example" + assert config["auth"]["oidc"]["client_id"] == "opencloud" + + def test_apply_engine_oidc_sidecar_respects_external_provider(tmp_path): sidecar = tmp_path / "oidc-provider.yaml" sidecar.write_text("provider: kanidm\nissuer_url: https://idm.test.example/oauth2/openid/opencloud\n") From f4fb35400e785050a53668c3ad1a687b2be55060 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Sun, 30 Aug 2026 17:26:39 +0200 Subject: [PATCH 13/22] Update apply --- README.md | 2 +- deploy.yaml.example | 1 + docs/integrating-engine.md | 8 ++++---- scripts/apply.py | 8 ++++++-- scripts/config_edit.py | 4 +++- tests/test_apply.py | 30 ++++++++++++++++++++++++++++++ 6 files changed, 45 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9b306e3..c006524 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ Internet → Caddy (:443, Let's Encrypt) opencloud-compose stack (docker network: opencloud-net) ├── opencloud ├── euro-office (optional) - ├── ldap-server (OIDC mode only) + ├── ldap-server (OIDC mode only — OpenCloud's local user/graph store, not the IdP) └── optional modules (tika, clamav, …) ``` diff --git a/deploy.yaml.example b/deploy.yaml.example index 6f4bace..7ab21b9 100644 --- a/deploy.yaml.example +++ b/deploy.yaml.example @@ -28,6 +28,7 @@ auth: domain: idm.example.com client_id: opencloud client_scopes: "openid profile email groups groups_name" + # Engine-managed Kanidm uses role_claim: opencloudRoles (admin/user/guest). role_claim: groups role_mapping: admin: opencloud-admin diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md index f98afc0..4ada435 100644 --- a/docs/integrating-engine.md +++ b/docs/integrating-engine.md @@ -24,11 +24,11 @@ auth: domain: idm.example.com client_id: opencloud client_scopes: openid profile email groups groups_name - role_claim: groups + role_claim: opencloudRoles role_mapping: - admin: opencloud-admin - user: opencloud-user - guest: opencloud-guest + admin: admin + user: user + guest: guest ``` Kanidm uses a **per-client** issuer (`/oauth2/openid/`), not the portal origin. diff --git a/scripts/apply.py b/scripts/apply.py index 8be3bbe..fd000ca 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -215,8 +215,10 @@ def derive_compose_files(config: dict) -> list[str]: if auth_mode == "oidc": files.extend(["idm/external-idp.yml", "../overlays/idm/oidc-external.yml"]) provider = str((config.get("auth") or {}).get("oidc", {}).get("provider") or "").lower() - if provider in {"kanidm", "authelia"}: + if provider == "kanidm": files.append("../overlays/idm/kanidm-provider.yml") + elif provider == "authelia": + files.append("../overlays/idm/authelia-provider.yml") modules = config.get("modules") or {} if to_bool(modules.get("search")): @@ -380,7 +382,9 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: provider = str(oidc.get("provider") or "").lower() default_scopes = ( "openid profile email groups groups_name" - if provider in {"kanidm", "authelia"} + if provider == "kanidm" + else "openid profile email groups" + if provider == "authelia" else "openid profile email offline_access" ) env.update( diff --git a/scripts/config_edit.py b/scripts/config_edit.py index 4e343a2..c898566 100644 --- a/scripts/config_edit.py +++ b/scripts/config_edit.py @@ -128,7 +128,9 @@ def update_from_wizard( "client_id": oidc_client_id or "opencloud", "client_scopes": ( "openid profile email groups groups_name" - if (oidc_provider or "").lower() in {"kanidm", "authelia"} + if (oidc_provider or "").lower() == "kanidm" + else "openid profile email groups" + if (oidc_provider or "").lower() == "authelia" else "openid profile email offline_access" ), "role_claim": "groups", diff --git a/tests/test_apply.py b/tests/test_apply.py index b824c26..5c1c905 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -77,6 +77,7 @@ def test_derive_compose_files_oidc_kanidm_provider(): files = derive_compose_files(config) assert "idm/external-idp.yml" in files assert "../overlays/idm/kanidm-provider.yml" in files + assert "../overlays/idm/authelia-provider.yml" not in files assert "idm/external-authelia.yml" not in files @@ -188,6 +189,35 @@ def test_build_env_vars_oidc(): assert "idm/external-idp.yml" in env["COMPOSE_FILE"] +def test_build_env_vars_kanidm_uses_groups_name_scopes(): + config = _base_config( + auth={ + "mode": "oidc", + "oidc": { + "provider": "kanidm", + "issuer_url": "https://idm.example/oauth2/openid/opencloud", + "account_url": "https://idm.example/", + "domain": "idm.example", + "client_id": "opencloud", + "role_claim": "opencloudRoles", + "role_mapping": {"admin": "admin", "user": "user", "guest": "guest"}, + }, + } + ) + env = build_env_vars( + config, + { + "INITIAL_ADMIN_PASSWORD": "x", + "EURO_OFFICE_JWT_SECRET": "y", + "LDAP_BIND_PASSWORD": "z", + }, + ) + assert env["OC_OIDC_CLIENT_SCOPES"] == "openid profile email groups groups_name" + assert env["PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM"] == "opencloudRoles" + assert "../overlays/idm/kanidm-provider.yml" in env["COMPOSE_FILE"] + assert "../overlays/idm/authelia-provider.yml" not in env["COMPOSE_FILE"] + + def test_render_proxy_role_template(): template = Path("config-templates/opencloud/proxy.yaml.template").read_text() rendered = render_template( From de34746293959e793b2ce0b2d774c414346556b6 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Sun, 30 Aug 2026 17:52:20 +0200 Subject: [PATCH 14/22] Fix kandim oidc landing on /access-denied --- docs/integrating-engine.md | 2 +- overlays/idm/kanidm-provider.yml | 10 ++- scripts/apply.py | 109 +++++++++++++++++++++++++++---- tests/test_apply.py | 54 +++++++++++++++ 4 files changed, 158 insertions(+), 17 deletions(-) diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md index 4ada435..5ae3716 100644 --- a/docs/integrating-engine.md +++ b/docs/integrating-engine.md @@ -39,7 +39,7 @@ OpenCloud's **browser** login uses a **public** OIDC client with PKCE (no client On a same-VPS engine install you can skip registering the client by hand: `bash wizard.sh` in easydeploy-engine clones this repo if needed and writes the Kanidm OIDC sidecar. Kanidm apply then creates the public client and default groups (`opencloud-admin`, `opencloud-user`, `opencloud-guest`). -Give your user the `opencloud-admin` group in Kanidm, not by creating a local OpenCloud account. +Give your user the `opencloud-admin` group in Kanidm, not by creating a local OpenCloud account. OpenCloud's bundled OpenLDAP is only the local graph store; wipe it with `bash apply.sh --wipe-local-accounts` if a failed first login left a conflicting user (`/access-denied` after a successful Kanidm grant). ## Apply order diff --git a/overlays/idm/kanidm-provider.yml b/overlays/idm/kanidm-provider.yml index db1c682..ec58690 100644 --- a/overlays/idm/kanidm-provider.yml +++ b/overlays/idm/kanidm-provider.yml @@ -12,8 +12,14 @@ services: PROXY_AUTOPROVISION_CLAIM_EMAIL: "email" PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME: "name" PROXY_AUTOPROVISION_CLAIM_GROUPS: "groups" - PROXY_ROLE_ASSIGNMENT_DRIVER: "oidc" - GRAPH_ASSIGN_DEFAULT_USER_ROLE: "false" + # Kanidm custom claims (opencloudRoles) often never reach the access token + # or UserInfo. The oidc driver then lands on /access-denied. Assign the + # built-in user role at login; OC_ADMIN_USER_ID still promotes the operator. + PROXY_ROLE_ASSIGNMENT_DRIVER: "default" + GRAPH_ASSIGN_DEFAULT_USER_ROLE: "true" + SETTINGS_SETUP_DEFAULT_ASSIGNMENTS: "true" + OC_ADMIN_USER_ID: ${OC_ADMIN_USER_ID:-} + OC_LDAP_DISABLE_USER_MECHANISM: "none" WEBFINGER_WEB_OIDC_CLIENT_ID: ${OC_OIDC_CLIENT_ID} WEBFINGER_WEB_OIDC_CLIENT_SCOPES: "openid profile email groups groups_name" WEB_OIDC_SCOPE: "openid profile email groups groups_name" diff --git a/scripts/apply.py b/scripts/apply.py index fd000ca..3903e65 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -109,6 +109,38 @@ def managed_is_false(section: dict | None) -> bool: return str(value or "").strip().lower() in {"false", "no", "0"} +def oidc_provider(config: dict) -> str: + oidc = (config.get("auth") or {}).get("oidc") or {} + return str(oidc.get("provider") or "").strip().lower() + + +def ldap_data_dir(config: dict) -> Path: + return Path(str(config["opencloud"]["config_dir"])).parent / "ldap_data" + + +def opencloud_admin_user_id(config: dict) -> str: + """Kanidm prefer-short-username → OpenCloud username is the person name (thomas).""" + oidc = (config.get("auth") or {}).get("oidc") or {} + explicit = str(oidc.get("admin_user") or "").strip() + if explicit: + return explicit + sibling = PROJECT_ROOT.parent / "kanidm-easy-deploy" / "deploy.yaml" + if not sibling.is_file(): + return "" + try: + kanidm_cfg = load_yaml(sibling) + except (OSError, ValueError): + return "" + for user in kanidm_cfg.get("users") or []: + if not isinstance(user, dict): + continue + username = str(user.get("username") or "").strip() + groups = [str(item).strip() for item in (user.get("groups") or [])] + if username and "opencloud-admin" in groups: + return username + return "" + + def apply_engine_oidc_sidecar(config: dict, sidecar_path: Path | None = None) -> None: """Apply engine-managed Kanidm OIDC settings. @@ -379,7 +411,7 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: if auth_mode == "oidc": role_mapping = oidc.get("role_mapping") or {} - provider = str(oidc.get("provider") or "").lower() + provider = oidc_provider(config) default_scopes = ( "openid profile email groups groups_name" if provider == "kanidm" @@ -387,11 +419,12 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: if provider == "authelia" else "openid profile email offline_access" ) + kanidm = provider == "kanidm" env.update( { "LDAP_BIND_PASSWORD": secrets["LDAP_BIND_PASSWORD"], - "PROXY_ROLE_ASSIGNMENT_DRIVER": "oidc", - "GRAPH_ASSIGN_DEFAULT_USER_ROLE": "false", + "PROXY_ROLE_ASSIGNMENT_DRIVER": "default" if kanidm else "oidc", + "GRAPH_ASSIGN_DEFAULT_USER_ROLE": "true" if kanidm else "false", "IDP_DOMAIN": str(oidc["domain"]), "IDP_ISSUER_URL": str(oidc["issuer_url"]), "IDP_ACCOUNT_URL": str(oidc["account_url"]), @@ -406,6 +439,10 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: "OC_SHARING_PUBLIC_WRITEABLE_SHARE_MUST_HAVE_PASSWORD": "false", } ) + if kanidm: + env["OC_ADMIN_USER_ID"] = opencloud_admin_user_id(config) + env["SETTINGS_SETUP_DEFAULT_ASSIGNMENTS"] = "true" + env["OC_LDAP_DISABLE_USER_MECHANISM"] = "none" env["_ROLE_MAPPING"] = yaml.safe_dump(role_mapping, default_flow_style=True) return env @@ -523,6 +560,50 @@ def bootstrap_config(config: dict) -> None: shutil.copy2(euro_registry, euro_target) +def build_proxy_role_block(config: dict) -> str: + oidc = (config.get("auth") or {}).get("oidc") or {} + if oidc_provider(config) == "kanidm": + return "role_assignment:\n driver: default\n" + role_mapping = oidc.get("role_mapping") or {} + return render_template( + PROXY_ROLE_TEMPLATE.read_text(), + { + "ROLE_CLAIM": str(oidc.get("role_claim") or "groups"), + "ROLE_ADMIN": str(role_mapping.get("admin") or "opencloud-admin"), + "ROLE_USER": str(role_mapping.get("user") or "opencloud-user"), + "ROLE_GUEST": str(role_mapping.get("guest") or "opencloud-guest"), + }, + ) + + +def wipe_local_oidc_accounts(config: dict) -> None: + """Drop OpenCloud's bundled OpenLDAP DB (autoprovisioned users). Does not touch Kanidm.""" + data = ldap_data_dir(config) + env_path = COMPOSE_DIR / ".env" + env: dict[str, str] = {} + if env_path.is_file(): + for line in env_path.read_text().splitlines(): + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + env[key.strip()] = value.strip() + print("Stopping OpenCloud + bundled LDAP to wipe local accounts…") + try: + run_compose(COMPOSE_DIR, "stop", "opencloud", "ldap-server", env=env) + except subprocess.CalledProcessError: + for name in ("opencloud", "opencloud-ldap-server-1", "ldap-server"): + subprocess.run(["docker", "stop", name], check=False, capture_output=True) + if data.is_dir(): + for child in data.iterdir(): + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() + print(f" Wiped bundled LDAP data at {data}") + else: + print(f" No bundled LDAP data at {data}") + + def render_proxy_yaml(config: dict) -> None: config_dir = Path(str(config["opencloud"]["config_dir"])) proxy_path = config_dir / "proxy.yaml" @@ -532,17 +613,7 @@ def render_proxy_yaml(config: dict) -> None: upstream_body = upstream_proxy.read_text() if upstream_proxy.is_file() else "" if auth_mode == "oidc": - oidc = config["auth"]["oidc"] - role_mapping = oidc.get("role_mapping") or {} - role_block = render_template( - PROXY_ROLE_TEMPLATE.read_text(), - { - "ROLE_CLAIM": str(oidc.get("role_claim") or "groups"), - "ROLE_ADMIN": str(role_mapping.get("admin") or "opencloud-admin"), - "ROLE_USER": str(role_mapping.get("user") or "opencloud-user"), - "ROLE_GUEST": str(role_mapping.get("guest") or "opencloud-guest"), - }, - ) + role_block = build_proxy_role_block(config) proxy_path.write_text(f"{role_block.rstrip()}\n{upstream_body.lstrip()}") render_csp_yaml(config) elif not proxy_path.exists(): @@ -829,12 +900,16 @@ def apply( *, no_reconcile_runtime: bool = False, rotate_secrets: bool = False, + wipe_local_accounts: bool = False, ) -> None: check_docker_available() config = load_config() validate_config(config) ensure_compose_submodule() + if wipe_local_accounts: + wipe_local_oidc_accounts(config) + secret_values = create_or_update_secrets(config, rotate=rotate_secrets) bootstrap_config(config) render_network_overlay(config) @@ -871,12 +946,18 @@ def main() -> None: action="store_true", help="Regenerate all secrets (destructive)", ) + parser.add_argument( + "--wipe-local-accounts", + action="store_true", + help="Wipe OpenCloud bundled OpenLDAP users (not Kanidm) before apply", + ) args = parser.parse_args() try: apply( no_reconcile_runtime=args.no_reconcile_runtime, rotate_secrets=args.rotate_secrets, + wipe_local_accounts=args.wipe_local_accounts, ) except (FileNotFoundError, ValueError, RuntimeError, subprocess.CalledProcessError, PermissionError) as exc: print(f"Error: {exc}", file=sys.stderr) diff --git a/tests/test_apply.py b/tests/test_apply.py index 5c1c905..5af55b7 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -13,7 +13,10 @@ apply_engine_oidc_sidecar, bootstrap_ldap_tls, build_env_vars, + build_proxy_role_block, derive_compose_files, + ldap_data_dir, + opencloud_admin_user_id, render_caddyfile, render_network_overlay, render_template, @@ -185,6 +188,7 @@ def test_build_env_vars_oidc(): } env = build_env_vars(config, secrets) assert env["PROXY_ROLE_ASSIGNMENT_DRIVER"] == "oidc" + assert env["GRAPH_ASSIGN_DEFAULT_USER_ROLE"] == "false" assert env["IDP_ISSUER_URL"] == "https://idp.example/o/opencloud/" assert "idm/external-idp.yml" in env["COMPOSE_FILE"] @@ -214,10 +218,60 @@ def test_build_env_vars_kanidm_uses_groups_name_scopes(): ) assert env["OC_OIDC_CLIENT_SCOPES"] == "openid profile email groups groups_name" assert env["PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM"] == "opencloudRoles" + assert env["PROXY_ROLE_ASSIGNMENT_DRIVER"] == "default" + assert env["GRAPH_ASSIGN_DEFAULT_USER_ROLE"] == "true" + assert env["OC_LDAP_DISABLE_USER_MECHANISM"] == "none" assert "../overlays/idm/kanidm-provider.yml" in env["COMPOSE_FILE"] assert "../overlays/idm/authelia-provider.yml" not in env["COMPOSE_FILE"] +def test_build_proxy_role_block_kanidm_uses_default_driver(): + config = _base_config( + auth={ + "mode": "oidc", + "oidc": { + "provider": "kanidm", + "role_claim": "opencloudRoles", + "role_mapping": {"admin": "admin"}, + }, + } + ) + block = build_proxy_role_block(config) + assert "driver: default" in block + assert "oidc_role_mapper" not in block + + +def test_opencloud_admin_user_id_from_sibling_kanidm(tmp_path, monkeypatch): + from scripts import apply as apply_module + + sibling = tmp_path / "kanidm-easy-deploy" + sibling.mkdir() + (sibling / "deploy.yaml").write_text( + yaml.safe_dump( + { + "users": [ + {"username": "thomas", "groups": ["opencloud-admin", "mail-users"]}, + ] + } + ) + ) + monkeypatch.setattr(apply_module, "PROJECT_ROOT", tmp_path / "opencloud-easy-deploy") + config = _base_config(auth={"mode": "oidc", "oidc": {"provider": "kanidm"}}) + assert opencloud_admin_user_id(config) == "thomas" + + +def test_opencloud_admin_user_id_explicit_wins(): + config = _base_config( + auth={"mode": "oidc", "oidc": {"provider": "kanidm", "admin_user": "operator"}} + ) + assert opencloud_admin_user_id(config) == "operator" + + +def test_ldap_data_dir_is_sibling_of_config(): + config = _base_config() + assert ldap_data_dir(config) == Path("/var/lib/opencloud/ldap_data") + + def test_render_proxy_role_template(): template = Path("config-templates/opencloud/proxy.yaml.template").read_text() rendered = render_template( From 4d33c5491f010fd698435294be7fbf8fc0d6b0e7 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Sun, 30 Aug 2026 18:52:20 +0200 Subject: [PATCH 15/22] Recreate LDAP when it has no IP --- scripts/apply.py | 115 +++++++++++++++++++++++++++++++++++++++++++- tests/test_apply.py | 67 ++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/scripts/apply.py b/scripts/apply.py index 3903e65..cb91bf6 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import json import os import secrets import shutil @@ -267,7 +268,68 @@ def derive_compose_files(config: dict) -> list[str]: return files -def render_network_overlay(config: dict) -> None: +LDAP_CONTAINER_CANDIDATES = ("ldap-server", "opencloud-compose-ldap-server-1") + + +def _network_address(net: dict) -> str: + ip = str(net.get("IPAddress") or "").strip() + if ip and ip.lower() not in {"invalid ip", "", "0.0.0.0"}: + return ip + ip6 = str(net.get("GlobalIPv6Address") or "").strip() + if ip6: + return ip6 + return "" + + +def discover_ldap_server_ip() -> str: + """Return the bundled LDAP container IP, or empty if it is not running.""" + for name in LDAP_CONTAINER_CANDIDATES: + result = subprocess.run( + ["docker", "inspect", name], + capture_output=True, + text=True, + ) + if result.returncode != 0 or not (result.stdout or "").strip(): + continue + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError: + continue + if not isinstance(payload, list) or not payload: + continue + networks = (payload[0].get("NetworkSettings") or {}).get("Networks") or {} + if not isinstance(networks, dict): + continue + for net_name in ("opencloud-net", DEFAULT_INTEGRATE_NETWORK): + net = networks.get(net_name) + if isinstance(net, dict): + address = _network_address(net) + if address: + return address + for net in networks.values(): + if isinstance(net, dict): + address = _network_address(net) + if address: + return address + return "" + + +def pin_ldap_hosts_in_container(ip: str) -> None: + """Bypass Docker DNS: write ldap-server into the running container /etc/hosts.""" + if not ip: + return + script = ( + f"grep -qE '[[:space:]]ldap-server$' /etc/hosts || " + f"echo '{ip} ldap-server' >> /etc/hosts" + ) + subprocess.run( + ["docker", "exec", "-u", "0", "opencloud", "sh", "-c", script], + check=False, + capture_output=True, + ) + + +def render_network_overlay(config: dict, ldap_ip: str = "") -> None: """Stable Docker DNS names and host-gateway routes for cross-stack proxy/WOPI.""" opencloud_domain = str(config["opencloud"]["domain"]) weboffice = config.get("weboffice") or {} @@ -277,6 +339,8 @@ def render_network_overlay(config: dict) -> None: idp_domain = str(oidc.get("domain") or "").strip() if idp_domain and idp_domain != opencloud_domain: extra_hosts.append(f"{idp_domain}:host-gateway") + if ldap_ip: + extra_hosts.append(f"ldap-server:{ldap_ip}") opencloud_service: dict[str, Any] = { "container_name": "opencloud", @@ -287,6 +351,20 @@ def render_network_overlay(config: dict) -> None: services: dict[str, Any] = {"opencloud": opencloud_service} + # Dual-homed OpenCloud (opencloud-net + easydeploy-net) makes Docker's + # embedded DNS SERVFAIL lookups for names that only exist on one network. + # Pin ldap-server via extra_hosts/links so the name never hits 127.0.0.11. + if str((config.get("auth") or {}).get("mode") or "").lower() == "oidc": + ldap_networks: list[str] = ["opencloud-net"] + if proxy_mode(config) == "integrate": + ldap_networks.append(DEFAULT_INTEGRATE_NETWORK) + services["ldap-server"] = { + "container_name": "ldap-server", + "networks": ldap_networks, + } + opencloud_service["depends_on"] = ["ldap-server"] + opencloud_service["links"] = ["ldap-server"] + if to_bool(weboffice.get("enabled")): office_domain = str(weboffice.get("domain") or "") office_type = str(weboffice.get("type") or "euro_office").strip().lower() @@ -820,6 +898,39 @@ def stop_opencloud_caddy() -> None: subprocess.run(["docker", "rm", "opencloud_caddy"], check=False) +def pin_ldap_server_after_up(config: dict, env: dict[str, str]) -> None: + """After LDAP has an IP, bake ldap-server into OpenCloud extra_hosts and /etc/hosts.""" + ip = discover_ldap_server_ip() + if not ip: + print("Recreating ldap-server — it has no usable address on opencloud-net…") + run_compose(COMPOSE_DIR, "up", "-d", "--force-recreate", "ldap-server", env=env) + time.sleep(2) + ip = discover_ldap_server_ip() + if not ip: + print( + "Warning: bundled ldap-server has no address yet; " + "OpenCloud will fail lookups for ldap-server.", + file=sys.stderr, + ) + return + before = NETWORK_OVERLAY_PATH.read_text() if NETWORK_OVERLAY_PATH.is_file() else "" + render_network_overlay(config, ldap_ip=ip) + after = NETWORK_OVERLAY_PATH.read_text() + if before != after: + print(f"Pinning ldap-server → {ip} (Docker DNS is unusable from a dual-homed container)…") + run_compose( + COMPOSE_DIR, + "up", + "-d", + "--no-deps", + "--force-recreate", + "opencloud", + env=env, + ) + pin_ldap_hosts_in_container(ip) + print(f" OpenCloud resolves ldap-server as {ip}") + + def reconcile_runtime(env_path: Path, config: dict) -> None: env = {} if env_path.is_file(): @@ -843,6 +954,8 @@ def reconcile_runtime(env_path: Path, config: dict) -> None: else: print("Starting OpenCloud stack (includes Caddy)…") run_compose(COMPOSE_DIR, "up", "-d", "--wait", "--force-recreate", env=env) + if str((config.get("auth") or {}).get("mode") or "").lower() == "oidc": + pin_ldap_server_after_up(config, env) weboffice = config.get("weboffice") or {} if to_bool(weboffice.get("enabled")) and str(weboffice.get("type") or "") == "euro_office": diff --git a/tests/test_apply.py b/tests/test_apply.py index 5af55b7..4126223 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import textwrap from pathlib import Path @@ -10,11 +11,13 @@ import yaml from scripts.apply import ( + _network_address, apply_engine_oidc_sidecar, bootstrap_ldap_tls, build_env_vars, build_proxy_role_block, derive_compose_files, + discover_ldap_server_ip, ldap_data_dir, opencloud_admin_user_id, render_caddyfile, @@ -311,6 +314,7 @@ def test_render_network_overlay_sets_container_names(tmp_path, monkeypatch): assert data["services"]["opencloud"]["container_name"] == "opencloud" assert data["services"]["euro-office"]["container_name"] == "euro-office" + assert "ldap-server" not in data["services"] assert data["networks"]["opencloud-net"]["external"] is True assert "eurooffice.test.example:host-gateway" in data["services"]["opencloud"]["extra_hosts"] assert "cloud.test.example:host-gateway" in data["services"]["euro-office"]["extra_hosts"] @@ -333,6 +337,69 @@ def test_render_network_overlay_adds_idp_host_gateway(tmp_path, monkeypatch): assert "auth.test.example:host-gateway" in data["services"]["opencloud"]["extra_hosts"] +def test_render_network_overlay_dual_homes_ldap_server(tmp_path, monkeypatch): + from scripts import apply as apply_module + + overlay_path = tmp_path / "network-fixups.yml" + monkeypatch.setattr(apply_module, "NETWORK_OVERLAY_PATH", overlay_path) + render_network_overlay( + _base_config( + proxy={"type": "caddy", "mode": "integrate"}, + auth={ + "mode": "oidc", + "oidc": {"domain": "auth.test.example"}, + }, + ) + ) + data = yaml.safe_load(overlay_path.read_text()) + ldap = data["services"]["ldap-server"] + assert ldap["container_name"] == "ldap-server" + assert ldap["networks"] == ["opencloud-net", "easydeploy-net"] + assert data["services"]["opencloud"]["depends_on"] == ["ldap-server"] + assert data["services"]["opencloud"]["links"] == ["ldap-server"] + assert data["services"]["opencloud"]["networks"] == ["opencloud-net", "easydeploy-net"] + + +def test_render_network_overlay_pins_ldap_ip(tmp_path, monkeypatch): + from scripts import apply as apply_module + + overlay_path = tmp_path / "network-fixups.yml" + monkeypatch.setattr(apply_module, "NETWORK_OVERLAY_PATH", overlay_path) + render_network_overlay( + _base_config( + auth={"mode": "oidc", "oidc": {"domain": "auth.test.example"}}, + ), + ldap_ip="172.20.0.7", + ) + data = yaml.safe_load(overlay_path.read_text()) + assert "ldap-server:172.20.0.7" in data["services"]["opencloud"]["extra_hosts"] + + +def test_network_address_skips_invalid_placeholder(): + assert _network_address({"IPAddress": "invalid IP"}) == "" + assert _network_address({"IPAddress": "172.20.0.7"}) == "172.20.0.7" + assert _network_address({"IPAddress": "", "GlobalIPv6Address": "fd00::7"}) == "fd00::7" + + +def test_discover_ldap_server_ip_reads_inspect_json(monkeypatch): + inspect = [ + { + "NetworkSettings": { + "Networks": { + "opencloud-net": {"IPAddress": "172.20.0.9"}, + } + } + } + ] + + def fake_run(cmd, **_kwargs): + assert cmd[:2] == ["docker", "inspect"] + return type("R", (), {"returncode": 0, "stdout": json.dumps(inspect), "stderr": ""})() + + monkeypatch.setattr("scripts.apply.subprocess.run", fake_run) + assert discover_ldap_server_ip() == "172.20.0.9" + + def test_render_caddyfile_allows_opencloud_iframe(tmp_path, monkeypatch): from scripts import apply as apply_module From e40582804015da8bafff0765ab03b02af9152098 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Sun, 30 Aug 2026 19:15:07 +0200 Subject: [PATCH 16/22] Update --- scripts/apply.py | 70 +++++++++++++++++++++++++++++++-------------- tests/test_apply.py | 21 +++++++++++++- 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/scripts/apply.py b/scripts/apply.py index cb91bf6..ffe9fd0 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -300,17 +300,11 @@ def discover_ldap_server_ip() -> str: networks = (payload[0].get("NetworkSettings") or {}).get("Networks") or {} if not isinstance(networks, dict): continue - for net_name in ("opencloud-net", DEFAULT_INTEGRATE_NETWORK): - net = networks.get(net_name) - if isinstance(net, dict): - address = _network_address(net) - if address: - return address - for net in networks.values(): - if isinstance(net, dict): - address = _network_address(net) - if address: - return address + net = networks.get("opencloud-net") + if isinstance(net, dict): + address = _network_address(net) + if address: + return address return "" @@ -319,7 +313,7 @@ def pin_ldap_hosts_in_container(ip: str) -> None: if not ip: return script = ( - f"grep -qE '[[:space:]]ldap-server$' /etc/hosts || " + f"sed -i '/[[:space:]]ldap-server$/d' /etc/hosts; " f"echo '{ip} ldap-server' >> /etc/hosts" ) subprocess.run( @@ -355,12 +349,9 @@ def render_network_overlay(config: dict, ldap_ip: str = "") -> None: # embedded DNS SERVFAIL lookups for names that only exist on one network. # Pin ldap-server via extra_hosts/links so the name never hits 127.0.0.11. if str((config.get("auth") or {}).get("mode") or "").lower() == "oidc": - ldap_networks: list[str] = ["opencloud-net"] - if proxy_mode(config) == "integrate": - ldap_networks.append(DEFAULT_INTEGRATE_NETWORK) services["ldap-server"] = { "container_name": "ldap-server", - "networks": ldap_networks, + "networks": ["opencloud-net"], } opencloud_service["depends_on"] = ["ldap-server"] opencloud_service["links"] = ["ldap-server"] @@ -834,6 +825,32 @@ def ensure_docker_network(name: str) -> None: subprocess.run(["docker", "network", "create", name], check=True) +def ensure_container_on_network(container: str, network: str) -> None: + inspect = subprocess.run( + ["docker", "inspect", container], + capture_output=True, + text=True, + ) + if inspect.returncode != 0: + return + try: + payload = json.loads(inspect.stdout) + except json.JSONDecodeError: + return + if not isinstance(payload, list) or not payload: + return + networks = (payload[0].get("NetworkSettings") or {}).get("Networks") or {} + if network in networks: + return + connected = subprocess.run( + ["docker", "network", "connect", network, container], + capture_output=True, + text=True, + ) + if connected.returncode == 0: + print(f" Attached {container} to {network}") + + def run_compose(directory: Path, *args: str, env: dict[str, str] | None = None) -> None: cmd = docker_compose_cmd() + list(args) merged_env = os.environ.copy() @@ -898,14 +915,24 @@ def stop_opencloud_caddy() -> None: subprocess.run(["docker", "rm", "opencloud_caddy"], check=False) +def destroy_ldap_containers() -> None: + """A dead Docker network sandbox cannot be repaired — only replaced.""" + for name in LDAP_CONTAINER_CANDIDATES: + subprocess.run(["docker", "rm", "-f", name], capture_output=True) + + def pin_ldap_server_after_up(config: dict, env: dict[str, str]) -> None: """After LDAP has an IP, bake ldap-server into OpenCloud extra_hosts and /etc/hosts.""" ip = discover_ldap_server_ip() if not ip: - print("Recreating ldap-server — it has no usable address on opencloud-net…") - run_compose(COMPOSE_DIR, "up", "-d", "--force-recreate", "ldap-server", env=env) - time.sleep(2) - ip = discover_ldap_server_ip() + print("ldap-server has no usable address; replacing the container…") + destroy_ldap_containers() + run_compose(COMPOSE_DIR, "up", "-d", "--force-recreate", "--no-deps", "ldap-server", env=env) + for _ in range(10): + time.sleep(1) + ip = discover_ldap_server_ip() + if ip: + break if not ip: print( "Warning: bundled ldap-server has no address yet; " @@ -917,7 +944,7 @@ def pin_ldap_server_after_up(config: dict, env: dict[str, str]) -> None: render_network_overlay(config, ldap_ip=ip) after = NETWORK_OVERLAY_PATH.read_text() if before != after: - print(f"Pinning ldap-server → {ip} (Docker DNS is unusable from a dual-homed container)…") + print(f"Pinning ldap-server → {ip} on opencloud-net (bypass Docker DNS)…") run_compose( COMPOSE_DIR, "up", @@ -927,6 +954,7 @@ def pin_ldap_server_after_up(config: dict, env: dict[str, str]) -> None: "opencloud", env=env, ) + ensure_container_on_network("opencloud", "opencloud-net") pin_ldap_hosts_in_container(ip) print(f" OpenCloud resolves ldap-server as {ip}") diff --git a/tests/test_apply.py b/tests/test_apply.py index 4126223..4c5d44f 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -354,7 +354,7 @@ def test_render_network_overlay_dual_homes_ldap_server(tmp_path, monkeypatch): data = yaml.safe_load(overlay_path.read_text()) ldap = data["services"]["ldap-server"] assert ldap["container_name"] == "ldap-server" - assert ldap["networks"] == ["opencloud-net", "easydeploy-net"] + assert ldap["networks"] == ["opencloud-net"] assert data["services"]["opencloud"]["depends_on"] == ["ldap-server"] assert data["services"]["opencloud"]["links"] == ["ldap-server"] assert data["services"]["opencloud"]["networks"] == ["opencloud-net", "easydeploy-net"] @@ -400,6 +400,25 @@ def fake_run(cmd, **_kwargs): assert discover_ldap_server_ip() == "172.20.0.9" +def test_discover_ldap_server_ip_ignores_other_networks(monkeypatch): + inspect = [ + { + "NetworkSettings": { + "Networks": { + "easydeploy-net": {"IPAddress": "172.21.0.4"}, + "opencloud-net": {"IPAddress": "172.18.0.8"}, + } + } + } + ] + + def fake_run(cmd, **_kwargs): + return type("R", (), {"returncode": 0, "stdout": json.dumps(inspect), "stderr": ""})() + + monkeypatch.setattr("scripts.apply.subprocess.run", fake_run) + assert discover_ldap_server_ip() == "172.18.0.8" + + def test_render_caddyfile_allows_opencloud_iframe(tmp_path, monkeypatch): from scripts import apply as apply_module From b3f9fd2c4bf62e247ce915fb1a5d378852036a03 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Sun, 30 Aug 2026 19:51:28 +0200 Subject: [PATCH 17/22] Update --- overlays/idm/kanidm-provider.yml | 1 + scripts/apply.py | 93 +++++++++++++++++++++----------- tests/test_apply.py | 20 +++---- 3 files changed, 74 insertions(+), 40 deletions(-) diff --git a/overlays/idm/kanidm-provider.yml b/overlays/idm/kanidm-provider.yml index ec58690..a78f836 100644 --- a/overlays/idm/kanidm-provider.yml +++ b/overlays/idm/kanidm-provider.yml @@ -20,6 +20,7 @@ services: SETTINGS_SETUP_DEFAULT_ASSIGNMENTS: "true" OC_ADMIN_USER_ID: ${OC_ADMIN_USER_ID:-} OC_LDAP_DISABLE_USER_MECHANISM: "none" + OC_LDAP_URI: ${OC_LDAP_URI:-ldaps://ldap-server:1636} WEBFINGER_WEB_OIDC_CLIENT_ID: ${OC_OIDC_CLIENT_ID} WEBFINGER_WEB_OIDC_CLIENT_SCOPES: "openid profile email groups groups_name" WEB_OIDC_SCOPE: "openid profile email groups groups_name" diff --git a/scripts/apply.py b/scripts/apply.py index ffe9fd0..e09b955 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -36,7 +36,9 @@ INTEGRATION_CADDY_FRAGMENT = INTEGRATION_DIR / "caddy.caddy" DEFAULT_INTEGRATE_NETWORK = "easydeploy-net" BITNAMI_OPENLDAP_UID = 1001 -BITNAMI_OPENLDAP_GID = 1001 +# Bitnami OpenLDAP runs as uid 1001 and needs gid 0 to write slapd.ldif +# into the bind-mounted /opt/bitnami/openldap/share (our ldap_certs dir). +BITNAMI_OPENLDAP_GID = 0 CADDY_DIR = PROJECT_ROOT / "caddy" CADDY_TEMPLATE = CADDY_DIR / "Caddyfile.template" CADDYFILE = CADDY_DIR / "Caddyfile" @@ -281,8 +283,41 @@ def _network_address(net: dict) -> str: return "" +def _ldap_ip_from_network(network: str = "opencloud-net") -> str: + """Address Docker actually assigned on the bridge — not a stale inspect cache.""" + result = subprocess.run( + ["docker", "network", "inspect", network], + capture_output=True, + text=True, + ) + if result.returncode != 0 or not (result.stdout or "").strip(): + return "" + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError: + return "" + if not isinstance(payload, list) or not payload: + return "" + containers = payload[0].get("Containers") or {} + if not isinstance(containers, dict): + return "" + for info in containers.values(): + if not isinstance(info, dict): + continue + name = str(info.get("Name") or "") + if name not in LDAP_CONTAINER_CANDIDATES and not name.endswith("ldap-server"): + continue + raw = str(info.get("IPv4Address") or "").split("/", 1)[0].strip() + if raw and raw.lower() not in {"invalid ip", "", "0.0.0.0"}: + return raw + return "" + + def discover_ldap_server_ip() -> str: - """Return the bundled LDAP container IP, or empty if it is not running.""" + """Return the bundled LDAP IPv4 on opencloud-net, or empty if it is not attached.""" + from_bridge = _ldap_ip_from_network("opencloud-net") + if from_bridge: + return from_bridge for name in LDAP_CONTAINER_CANDIDATES: result = subprocess.run( ["docker", "inspect", name], @@ -308,21 +343,6 @@ def discover_ldap_server_ip() -> str: return "" -def pin_ldap_hosts_in_container(ip: str) -> None: - """Bypass Docker DNS: write ldap-server into the running container /etc/hosts.""" - if not ip: - return - script = ( - f"sed -i '/[[:space:]]ldap-server$/d' /etc/hosts; " - f"echo '{ip} ldap-server' >> /etc/hosts" - ) - subprocess.run( - ["docker", "exec", "-u", "0", "opencloud", "sh", "-c", script], - check=False, - capture_output=True, - ) - - def render_network_overlay(config: dict, ldap_ip: str = "") -> None: """Stable Docker DNS names and host-gateway routes for cross-stack proxy/WOPI.""" opencloud_domain = str(config["opencloud"]["domain"]) @@ -792,13 +812,25 @@ def fix_data_permissions(config: dict) -> None: except PermissionError: pass if auth_mode == "oidc": - for name in ("ldap_certs", "ldap_data"): - path = ldap_base / name - if path.exists(): - try: - hostfs.chown_path(path, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) - except PermissionError: - pass + ensure_bitnami_ldap_permissions(ldap_base) + + +def ensure_bitnami_ldap_permissions(ldap_base: Path) -> None: + """Bitnami must create slapd.ldif in ldap_certs (mounted as .../share).""" + owner = f"{BITNAMI_OPENLDAP_UID}:{BITNAMI_OPENLDAP_GID}" + for name in ("ldap_certs", "ldap_data"): + path = ldap_base / name + path.mkdir(parents=True, exist_ok=True) + try: + os.chmod(path, 0o775) + except OSError: + pass + try: + hostfs.chown_path(path, BITNAMI_OPENLDAP_UID, BITNAMI_OPENLDAP_GID) + except PermissionError: + pass + subprocess.run(["sudo", "-n", "chown", "-R", owner, str(path)], check=False, capture_output=True) + subprocess.run(["sudo", "-n", "chmod", "0775", str(path)], check=False, capture_output=True) def docker_compose_cmd() -> list[str]: @@ -940,11 +972,13 @@ def pin_ldap_server_after_up(config: dict, env: dict[str, str]) -> None: file=sys.stderr, ) return - before = NETWORK_OVERLAY_PATH.read_text() if NETWORK_OVERLAY_PATH.is_file() else "" + uri = f"ldaps://{ip}:1636" + previous_uri = env.get("OC_LDAP_URI", "") + env["OC_LDAP_URI"] = uri + write_env_file(env, COMPOSE_DIR / ".env") render_network_overlay(config, ldap_ip=ip) - after = NETWORK_OVERLAY_PATH.read_text() - if before != after: - print(f"Pinning ldap-server → {ip} on opencloud-net (bypass Docker DNS)…") + if previous_uri != uri: + print(f"Pointing OpenCloud at {uri} (Docker /etc/hosts cannot be edited in-place)…") run_compose( COMPOSE_DIR, "up", @@ -955,8 +989,7 @@ def pin_ldap_server_after_up(config: dict, env: dict[str, str]) -> None: env=env, ) ensure_container_on_network("opencloud", "opencloud-net") - pin_ldap_hosts_in_container(ip) - print(f" OpenCloud resolves ldap-server as {ip}") + print(f" OpenCloud LDAP URI is {uri}") def reconcile_runtime(env_path: Path, config: dict) -> None: diff --git a/tests/test_apply.py b/tests/test_apply.py index 4c5d44f..fa6d949 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -381,26 +381,24 @@ def test_network_address_skips_invalid_placeholder(): assert _network_address({"IPAddress": "", "GlobalIPv6Address": "fd00::7"}) == "fd00::7" -def test_discover_ldap_server_ip_reads_inspect_json(monkeypatch): - inspect = [ +def test_discover_ldap_server_ip_reads_bridge_membership(monkeypatch): + payload = [ { - "NetworkSettings": { - "Networks": { - "opencloud-net": {"IPAddress": "172.20.0.9"}, - } + "Containers": { + "abc": {"Name": "ldap-server", "IPv4Address": "172.21.0.9/16"}, } } ] def fake_run(cmd, **_kwargs): - assert cmd[:2] == ["docker", "inspect"] - return type("R", (), {"returncode": 0, "stdout": json.dumps(inspect), "stderr": ""})() + assert cmd[:3] == ["docker", "network", "inspect"] + return type("R", (), {"returncode": 0, "stdout": json.dumps(payload), "stderr": ""})() monkeypatch.setattr("scripts.apply.subprocess.run", fake_run) - assert discover_ldap_server_ip() == "172.20.0.9" + assert discover_ldap_server_ip() == "172.21.0.9" -def test_discover_ldap_server_ip_ignores_other_networks(monkeypatch): +def test_discover_ldap_server_ip_falls_back_to_container_inspect(monkeypatch): inspect = [ { "NetworkSettings": { @@ -413,6 +411,8 @@ def test_discover_ldap_server_ip_ignores_other_networks(monkeypatch): ] def fake_run(cmd, **_kwargs): + if cmd[:3] == ["docker", "network", "inspect"]: + return type("R", (), {"returncode": 1, "stdout": "", "stderr": ""})() return type("R", (), {"returncode": 0, "stdout": json.dumps(inspect), "stderr": ""})() monkeypatch.setattr("scripts.apply.subprocess.run", fake_run) From 2723634a1c3ed7af0481a5ce207ce5369490fa50 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Mon, 31 Aug 2026 09:29:43 +0200 Subject: [PATCH 18/22] Tidy after migration to kanidm --- README.md | 2 +- deploy.yaml.example | 13 +++++++------ docs/integrating-engine.md | 8 +------- overlays/idm/authelia-provider.yml | 25 ------------------------- scripts/apply.py | 25 ++++++++++++------------- scripts/config_edit.py | 2 -- tests/test_apply.py | 2 +- 7 files changed, 22 insertions(+), 55 deletions(-) delete mode 100644 overlays/idm/authelia-provider.yml diff --git a/README.md b/README.md index c006524..935e4cc 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Uses OpenCloud's built-in LDAP. Admin password is generated on first `apply.sh` Set `auth.mode: oidc` and configure `auth.oidc` in `deploy.yaml`. The stack adds `idm/external-idp.yml` plus a local overlay for role mapping via `proxy.yaml`. -For **Kanidm** on the same VPS, clone this repo on its own and run `bash wizard.sh`, or let [easydeploy-engine](../easydeploy-engine) clone both kits and run their wizards. See [`docs/integrating-engine.md`](docs/integrating-engine.md). +**Kanidm** (same VPS) uses overlay `overlays/idm/kanidm-provider.yml` instead: default role driver, not OIDC claim mapping. See [`docs/integrating-engine.md`](docs/integrating-engine.md). For a standalone clone, run `bash wizard.sh` here, or let [easydeploy-engine](../easydeploy-engine) wire both kits. #### Authentik setup diff --git a/deploy.yaml.example b/deploy.yaml.example index 7ab21b9..5a7602d 100644 --- a/deploy.yaml.example +++ b/deploy.yaml.example @@ -28,12 +28,13 @@ auth: domain: idm.example.com client_id: opencloud client_scopes: "openid profile email groups groups_name" - # Engine-managed Kanidm uses role_claim: opencloudRoles (admin/user/guest). - role_claim: groups - role_mapping: - admin: opencloud-admin - user: opencloud-user - guest: opencloud-guest + # Kanidm uses the default user role at login (plus OC_ADMIN_USER_ID). + # Authentik / Keycloak still need role_claim + role_mapping: + # role_claim: groups + # role_mapping: + # admin: opencloud-admin + # user: opencloud-user + # guest: opencloud-guest weboffice: enabled: true diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md index 5ae3716..152112d 100644 --- a/docs/integrating-engine.md +++ b/docs/integrating-engine.md @@ -24,14 +24,9 @@ auth: domain: idm.example.com client_id: opencloud client_scopes: openid profile email groups groups_name - role_claim: opencloudRoles - role_mapping: - admin: admin - user: user - guest: guest ``` -Kanidm uses a **per-client** issuer (`/oauth2/openid/`), not the portal origin. +Kanidm uses a **per-client** issuer (`/oauth2/openid/`), not the portal origin. Role assignment uses the default `user` role at login (`PROXY_ROLE_ASSIGNMENT_DRIVER=default`); put the operator in Kanidm group `opencloud-admin` so they become `OC_ADMIN_USER_ID`. Do not rely on `role_claim` / `opencloudRoles` for Kanidm. ## Kanidm OIDC client @@ -51,6 +46,5 @@ Give your user the `opencloud-admin` group in Kanidm, not by creating a local Op Do not use `--skip-kits` for the initial identity wiring: that writes sidecars but does not register the client or restart OpenCloud. -5. `bash apply.sh` in easydeploy-engine. Standalone OpenCloud Caddy (`opencloud_caddy`) is not started in integrate mode. diff --git a/overlays/idm/authelia-provider.yml b/overlays/idm/authelia-provider.yml deleted file mode 100644 index 80a1118..0000000 --- a/overlays/idm/authelia-provider.yml +++ /dev/null @@ -1,25 +0,0 @@ ---- -# Overrides upstream external-authelia.yml for Authelia OIDC (public client + webfinger). -services: - opencloud: - environment: - PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD: "none" - PROXY_OIDC_REWRITE_WELLKNOWN: "true" - PROXY_AUTOPROVISION_ACCOUNTS: "true" - PROXY_USER_OIDC_CLAIM: "preferred_username" - PROXY_USER_CS3_CLAIM: "username" - PROXY_AUTOPROVISION_CLAIM_USERNAME: "preferred_username" - PROXY_AUTOPROVISION_CLAIM_EMAIL: "email" - PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME: "name" - PROXY_AUTOPROVISION_CLAIM_GROUPS: "groups" - PROXY_ROLE_ASSIGNMENT_DRIVER: "oidc" - GRAPH_ASSIGN_DEFAULT_USER_ROLE: "false" - WEBFINGER_WEB_OIDC_CLIENT_ID: ${OC_OIDC_CLIENT_ID} - WEBFINGER_WEB_OIDC_CLIENT_SCOPES: "openid profile email groups" - WEB_OIDC_SCOPE: "openid profile email groups" - WEBFINGER_ANDROID_OIDC_CLIENT_ID: ${WEBFINGER_ANDROID_OIDC_CLIENT_ID:-opencloud-android} - WEBFINGER_ANDROID_OIDC_CLIENT_SCOPES: "openid profile email groups offline_access" - WEBFINGER_IOS_OIDC_CLIENT_ID: ${WEBFINGER_IOS_OIDC_CLIENT_ID:-opencloud-ios} - WEBFINGER_IOS_OIDC_CLIENT_SCOPES: "openid profile email groups offline_access" - WEBFINGER_DESKTOP_OIDC_CLIENT_ID: ${WEBFINGER_DESKTOP_OIDC_CLIENT_ID:-opencloud-desktop} - WEBFINGER_DESKTOP_OIDC_CLIENT_SCOPES: "openid profile email groups offline_access" diff --git a/scripts/apply.py b/scripts/apply.py index e09b955..8541b13 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -164,7 +164,7 @@ def apply_engine_oidc_sidecar(config: dict, sidecar_path: Path | None = None) -> if managed_is_false(oidc): return existing_provider = str(oidc.get("provider") or "").strip().lower() - if existing_provider and existing_provider not in {"kanidm", "authelia"}: + if existing_provider and existing_provider != "kanidm": return auth["mode"] = "oidc" for key, value in sidecar.items(): @@ -252,8 +252,6 @@ def derive_compose_files(config: dict) -> list[str]: provider = str((config.get("auth") or {}).get("oidc", {}).get("provider") or "").lower() if provider == "kanidm": files.append("../overlays/idm/kanidm-provider.yml") - elif provider == "authelia": - files.append("../overlays/idm/authelia-provider.yml") modules = config.get("modules") or {} if to_bool(modules.get("search")): @@ -499,13 +497,10 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: env["COLLABORA_SSL_VERIFICATION"] = "true" if auth_mode == "oidc": - role_mapping = oidc.get("role_mapping") or {} provider = oidc_provider(config) default_scopes = ( "openid profile email groups groups_name" if provider == "kanidm" - else "openid profile email groups" - if provider == "authelia" else "openid profile email offline_access" ) kanidm = provider == "kanidm" @@ -517,9 +512,6 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: "IDP_DOMAIN": str(oidc["domain"]), "IDP_ISSUER_URL": str(oidc["issuer_url"]), "IDP_ACCOUNT_URL": str(oidc["account_url"]), - "PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM": str( - oidc.get("role_claim") or "groups" - ), "OC_OIDC_CLIENT_ID": str(oidc["client_id"]), "OC_OIDC_CLIENT_SCOPES": str( oidc.get("client_scopes") or default_scopes @@ -532,7 +524,10 @@ def build_env_vars(config: dict, secrets: dict[str, str]) -> dict[str, str]: env["OC_ADMIN_USER_ID"] = opencloud_admin_user_id(config) env["SETTINGS_SETUP_DEFAULT_ASSIGNMENTS"] = "true" env["OC_LDAP_DISABLE_USER_MECHANISM"] = "none" - env["_ROLE_MAPPING"] = yaml.safe_dump(role_mapping, default_flow_style=True) + else: + env["PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM"] = str( + oidc.get("role_claim") or "groups" + ) return env @@ -1060,9 +1055,13 @@ def print_summary(config: dict) -> None: print(f" - https://{domain}/oidc-callback.html") print(f" - https://{domain}/oidc-silent-redirect.html") print() - print("Create IdP groups matching role_mapping in deploy.yaml:") - for role, group in (oidc.get("role_mapping") or {}).items(): - print(f" - {group} → {role}") + if oidc_provider(config) == "kanidm": + print("Kanidm assigns the built-in user role at login (proxy driver: default).") + print("The first person in opencloud-admin is OC_ADMIN_USER_ID.") + else: + print("Create IdP groups matching role_mapping in deploy.yaml:") + for role, group in (oidc.get("role_mapping") or {}).items(): + print(f" - {group} → {role}") def check_docker_available() -> None: diff --git a/scripts/config_edit.py b/scripts/config_edit.py index c898566..9306a35 100644 --- a/scripts/config_edit.py +++ b/scripts/config_edit.py @@ -129,8 +129,6 @@ def update_from_wizard( "client_scopes": ( "openid profile email groups groups_name" if (oidc_provider or "").lower() == "kanidm" - else "openid profile email groups" - if (oidc_provider or "").lower() == "authelia" else "openid profile email offline_access" ), "role_claim": "groups", diff --git a/tests/test_apply.py b/tests/test_apply.py index fa6d949..0dac8f9 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -220,7 +220,7 @@ def test_build_env_vars_kanidm_uses_groups_name_scopes(): }, ) assert env["OC_OIDC_CLIENT_SCOPES"] == "openid profile email groups groups_name" - assert env["PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM"] == "opencloudRoles" + assert "PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM" not in env assert env["PROXY_ROLE_ASSIGNMENT_DRIVER"] == "default" assert env["GRAPH_ASSIGN_DEFAULT_USER_ROLE"] == "true" assert env["OC_LDAP_DISABLE_USER_MECHANISM"] == "none" From 49098393cf7f57a2ff3e7adaa36f757d7d19f9d1 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Mon, 31 Aug 2026 10:54:53 +0200 Subject: [PATCH 19/22] Update tag --- deploy.yaml.example | 2 +- scripts/config_edit.py | 2 +- tests/test_apply.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy.yaml.example b/deploy.yaml.example index 5a7602d..c36e6f8 100644 --- a/deploy.yaml.example +++ b/deploy.yaml.example @@ -4,7 +4,7 @@ opencloud: domain: cloud.example.com image: opencloudeu/opencloud-rolling - tag: "7.2.0" + tag: "7.5.0" admin_username: admin data_dir: /var/lib/opencloud/data config_dir: /var/lib/opencloud/config diff --git a/scripts/config_edit.py b/scripts/config_edit.py index 9306a35..bf572ab 100644 --- a/scripts/config_edit.py +++ b/scripts/config_edit.py @@ -106,7 +106,7 @@ def update_from_wizard( opencloud = config.setdefault("opencloud", {}) opencloud["domain"] = domain opencloud.setdefault("image", "opencloudeu/opencloud-rolling") - opencloud.setdefault("tag", "7.2.0") + opencloud.setdefault("tag", "7.5.0") opencloud.setdefault("admin_username", "admin") opencloud.setdefault("language", "en") opencloud["data_dir"] = f"{data_root.rstrip('/')}/data" diff --git a/tests/test_apply.py b/tests/test_apply.py index 0dac8f9..19a7d6e 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -32,7 +32,7 @@ def _base_config(**overrides) -> dict: "opencloud": { "domain": "cloud.test.example", "image": "opencloudeu/opencloud-rolling", - "tag": "7.2.0", + "tag": "7.5.0", "data_dir": "/var/lib/opencloud/data", "config_dir": "/var/lib/opencloud/config", "apps_dir": "/var/lib/opencloud/apps", From 0f641951dbbc48e49c93bf849e6c55471d0e6b12 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Mon, 31 Aug 2026 15:45:45 +0200 Subject: [PATCH 20/22] Correctly detect document server URL --- README.md | 2 ++ config-templates/opencloud/csp.yaml.template | 2 ++ diagnose.sh | 12 +++++++ scripts/apply.py | 14 +++++++- tests/test_apply.py | 36 ++++++++++++++++++++ 5 files changed, 65 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 935e4cc..4e45f6f 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,8 @@ If OpenCloud logs show `WopiDiscovery: wopi app url failed with unexpected code 4. **X-Frame-Options / iframe blocked** — If the browser console shows Euro Office blocked by `X-Frame-Options: sameorigin`, re-run `bash apply.sh` so Caddy sets `Content-Security-Policy: frame-ancestors` for the Euro Office domain instead. +5. **OpenCloud `frame-src` blocks Euro Office** — If the browser console shows `frame-src` blocking `https:///hosting/wopi/...`, OpenCloud's CSP is missing the document-server origin. Re-run `bash apply.sh` so `csp.yaml` includes `weboffice.domain`. + Euro Office first boot can take **3–5 minutes** (fonts, caches). `apply.sh` waits for WOPI discovery before restarting OpenCloud. After updating, recreate the stack (a plain `stop.sh` / `start.sh` is not enough when networking overlays change): diff --git a/config-templates/opencloud/csp.yaml.template b/config-templates/opencloud/csp.yaml.template index f56d717..d151df6 100644 --- a/config-templates/opencloud/csp.yaml.template +++ b/config-templates/opencloud/csp.yaml.template @@ -20,6 +20,7 @@ directives: - 'blob:' - 'https://embed.diagrams.net/' - 'https://{{IDP_DOMAIN}}' + - 'https://{{WEB_OFFICE_DOMAIN}}' - 'https://docs.opencloud.eu' img-src: - '''self''' @@ -27,6 +28,7 @@ directives: - 'blob:' - 'https://raw.githubusercontent.com/opencloud-eu/awesome-apps/' - 'https://tile.openstreetmap.org/' + - 'https://{{WEB_OFFICE_DOMAIN}}' manifest-src: - '''self''' media-src: diff --git a/diagnose.sh b/diagnose.sh index 70023ec..4826a12 100755 --- a/diagnose.sh +++ b/diagnose.sh @@ -146,6 +146,18 @@ if [[ -n "$OC_DOMAIN" ]]; then section "Public HTTPS: OpenCloud" code="$(http_code "https://${OC_DOMAIN}/")" echo " https://${OC_DOMAIN}/ → HTTP ${code}" + if [[ -n "$EURO_DOMAIN" ]]; then + oc_headers="$(curl -k -sSI "https://${OC_DOMAIN}/" 2>/dev/null || true)" + oc_csp="$(echo "$oc_headers" | grep -i '^content-security-policy:' || true)" + if echo "$oc_csp" | grep -qi "frame-src" && echo "$oc_csp" | grep -qi "$EURO_DOMAIN"; then + success "OpenCloud CSP frame-src allows ${EURO_DOMAIN}" + elif echo "$oc_csp" | grep -qi "frame-src"; then + error "OpenCloud CSP frame-src does not allow ${EURO_DOMAIN} — document editor iframe will be blocked. Re-run apply.sh" + echo " ${oc_csp}" + else + warn "OpenCloud CSP frame-src not found on ${OC_DOMAIN} response" + fi + fi fi section "Recent OpenCloud collaboration errors" diff --git a/scripts/apply.py b/scripts/apply.py index 8541b13..8a270ef 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -704,13 +704,25 @@ def render_proxy_yaml(config: dict) -> None: proxy_path.write_text(upstream_body) +def web_office_csp_domain(config: dict) -> str: + """Origin allowed in OpenCloud CSP for the document-editor iframe.""" + weboffice = config.get("weboffice") or {} + domain = str(weboffice.get("domain") or "").strip() + if to_bool(weboffice.get("enabled")) and domain: + return domain + return str(config["opencloud"]["domain"]) + + def render_csp_yaml(config: dict) -> None: config_dir = Path(str(config["opencloud"]["config_dir"])) csp_path = config_dir / "csp.yaml" oidc = config["auth"]["oidc"] rendered = render_template( CSP_TEMPLATE.read_text(), - {"IDP_DOMAIN": str(oidc["domain"])}, + { + "IDP_DOMAIN": str(oidc["domain"]), + "WEB_OFFICE_DOMAIN": web_office_csp_domain(config), + }, ) csp_path.write_text(rendered) diff --git a/tests/test_apply.py b/tests/test_apply.py index 19a7d6e..15897a4 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -21,9 +21,11 @@ ldap_data_dir, opencloud_admin_user_id, render_caddyfile, + render_csp_yaml, render_network_overlay, render_template, validate_config, + web_office_csp_domain, ) @@ -436,6 +438,40 @@ def test_render_caddyfile_allows_opencloud_iframe(tmp_path, monkeypatch): assert "frame-ancestors 'self' https://cloud.test.example" in rendered +def test_web_office_csp_domain_uses_weboffice_when_enabled(): + assert web_office_csp_domain(_base_config()) == "eurooffice.test.example" + + +def test_web_office_csp_domain_falls_back_when_disabled(): + config = _base_config(weboffice={"enabled": False, "type": "euro_office", "domain": "eurooffice.test.example"}) + assert web_office_csp_domain(config) == "cloud.test.example" + + +def test_render_csp_yaml_allows_euro_office_frame_src(tmp_path): + config_dir = tmp_path / "config" + config_dir.mkdir() + config = _base_config( + opencloud={"config_dir": str(config_dir)}, + auth={ + "mode": "oidc", + "oidc": { + "issuer_url": "https://auth.test.example/oauth2/openid/opencloud", + "account_url": "https://auth.test.example/", + "domain": "auth.test.example", + "client_id": "opencloud", + }, + }, + ) + render_csp_yaml(config) + rendered = (config_dir / "csp.yaml").read_text() + assert "https://eurooffice.test.example" in rendered + assert "https://auth.test.example" in rendered + frame_src = rendered.split("frame-src:")[1].split("img-src:")[0] + assert "https://eurooffice.test.example" in frame_src + img_src = rendered.split("img-src:")[1].split("manifest-src:")[0] + assert "https://eurooffice.test.example" in img_src + + def test_bootstrap_ldap_tls_creates_cert_files(tmp_path): certs_dir = tmp_path / "ldap_certs" bootstrap_ldap_tls(certs_dir) From c37579c439fc124ba789542bfaf7074b848993a7 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Mon, 31 Aug 2026 18:36:29 +0200 Subject: [PATCH 21/22] App embedding --- README.md | 2 + config-templates/opencloud/csp.yaml.template | 2 +- deploy.yaml.example | 6 + docs/integrating-engine.md | 4 + scripts/apply.py | 116 +++++++++++++++++-- tests/test_apply.py | 50 ++++++++ 6 files changed, 171 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4e45f6f..e3589dd 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,8 @@ If OpenCloud logs show `WopiDiscovery: wopi app url failed with unexpected code 5. **OpenCloud `frame-src` blocks Euro Office** — If the browser console shows `frame-src` blocking `https:///hosting/wopi/...`, OpenCloud's CSP is missing the document-server origin. Re-run `bash apply.sh` so `csp.yaml` includes `weboffice.domain`. +6. **Bulwark inline iframe blocked (`frame-ancestors 'self'`)** — OpenCloud refuses to load inside webmail until `embed.frame_ancestors` includes the Bulwark origin. On a same-VPS engine install, re-run `bash apply.sh` in easydeploy-engine so it writes the embed sidecar from `bulwark.domain`. Standalone: set `embed.frame_ancestors: ["https://webmail.example.com"]` in `deploy.yaml` and re-apply. + Euro Office first boot can take **3–5 minutes** (fonts, caches). `apply.sh` waits for WOPI discovery before restarting OpenCloud. After updating, recreate the stack (a plain `stop.sh` / `start.sh` is not enough when networking overlays change): diff --git a/config-templates/opencloud/csp.yaml.template b/config-templates/opencloud/csp.yaml.template index d151df6..a4676d3 100644 --- a/config-templates/opencloud/csp.yaml.template +++ b/config-templates/opencloud/csp.yaml.template @@ -14,7 +14,7 @@ directives: font-src: - '''self''' frame-ancestors: - - '''self''' + - '''self'''{{FRAME_ANCESTORS_EXTRA}} frame-src: - '''self''' - 'blob:' diff --git a/deploy.yaml.example b/deploy.yaml.example index c36e6f8..5ad8238 100644 --- a/deploy.yaml.example +++ b/deploy.yaml.example @@ -41,6 +41,12 @@ weboffice: type: euro_office domain: eurooffice.example.com +# Origins allowed to iframe OpenCloud (Bulwark webmail). On a same-VPS engine +# install this is filled from bulwark.domain; set it here for standalone or extras. +# embed: +# frame_ancestors: +# - https://webmail.example.com + modules: search: false antivirus: false diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md index 152112d..b5ed5e0 100644 --- a/docs/integrating-engine.md +++ b/docs/integrating-engine.md @@ -48,3 +48,7 @@ Do not use `--skip-kits` for the initial identity wiring: that writes sidecars but does not register the client or restart OpenCloud. Standalone OpenCloud Caddy (`opencloud_caddy`) is not started in integrate mode. + +## Bulwark inline iframe + +When Stalwart/Bulwark is enabled on the same engine, apply writes `.opencloud-easy-deploy/integration/embed.yaml` with the webmail origin. OpenCloud then allows that origin in CSP `frame-ancestors`. To add more parents, set `embed.frame_ancestors` in `deploy.yaml`. Set `embed.managed: false` to ignore the engine sidecar. diff --git a/scripts/apply.py b/scripts/apply.py index 8a270ef..3516585 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -34,7 +34,15 @@ NETWORK_OVERLAY_PATH = STATE_DIR / "compose" / "network-fixups.yml" INTEGRATION_DIR = STATE_DIR / "integration" INTEGRATION_CADDY_FRAGMENT = INTEGRATION_DIR / "caddy.caddy" +EMBED_SIDECAR = INTEGRATION_DIR / "embed.yaml" DEFAULT_INTEGRATE_NETWORK = "easydeploy-net" +PLACEHOLDER_EMBED_HOSTS = frozenset( + { + "webmail.example.com", + "example.com", + "mail.example.com", + } +) BITNAMI_OPENLDAP_UID = 1001 # Bitnami OpenLDAP runs as uid 1001 and needs gid 0 to write slapd.ldif # into the bind-mounted /opt/bitnami/openldap/share (our ldap_certs dir). @@ -174,6 +182,69 @@ def apply_engine_oidc_sidecar(config: dict, sidecar_path: Path | None = None) -> oidc["provider"] = "kanidm" +def https_origin(value: Any) -> str: + """Normalize a hostname or URL to `https://host` with no trailing slash.""" + text = str(value or "").strip() + if not text: + return "" + if "://" in text: + scheme, rest = text.split("://", 1) + if scheme.lower() not in {"http", "https"}: + return "" + host = rest.split("/")[0].split("?")[0].split("#")[0].strip().lower() + else: + host = text.split("/")[0].split("?")[0].split("#")[0].strip().lower() + if not host or host in PLACEHOLDER_EMBED_HOSTS: + return "" + return f"https://{host}" + + +def unique_https_origins(values: Any) -> list[str]: + origins: list[str] = [] + seen: set[str] = set() + if isinstance(values, str): + values = [values] + if not isinstance(values, list): + return origins + for item in values: + origin = https_origin(item) + if origin and origin not in seen: + seen.add(origin) + origins.append(origin) + return origins + + +def apply_engine_embed_sidecar(config: dict, sidecar_path: Path | None = None) -> None: + """Merge extra frame-ancestors from the engine sidecar (Bulwark webmail).""" + path = sidecar_path or EMBED_SIDECAR + embed = config.get("embed") + if embed is None: + embed = {} + elif not isinstance(embed, dict): + return + if managed_is_false(embed): + return + extra: list[Any] = [] + if path.is_file(): + sidecar = load_yaml(path) + if isinstance(sidecar, dict): + extra = sidecar.get("frame_ancestors") or [] + current = embed.get("frame_ancestors") or [] + merged = unique_https_origins(list(current) + list(extra)) + if merged: + target = config.setdefault("embed", {}) + if isinstance(target, dict): + target["frame_ancestors"] = merged + + +def extra_frame_ancestors(config: dict) -> list[str]: + embed = config.get("embed") or {} + if not isinstance(embed, dict): + return [] + self_origin = https_origin(config.get("opencloud", {}).get("domain")) + return [origin for origin in unique_https_origins(embed.get("frame_ancestors")) if origin != self_origin] + + def load_config(path: Path = DEPLOY_PATH) -> dict: if not path.exists(): raise FileNotFoundError( @@ -181,6 +252,7 @@ def load_config(path: Path = DEPLOY_PATH) -> dict: ) config = load_yaml(path) apply_engine_oidc_sidecar(config) + apply_engine_embed_sidecar(config) return config @@ -221,6 +293,17 @@ def validate_config(config: dict) -> None: if not str(weboffice.get("domain") or "").strip(): raise ValueError("weboffice.domain is required when weboffice is enabled") + embed = config.get("embed") + if embed is not None: + if not isinstance(embed, dict): + raise ValueError("embed must be a mapping") + ancestors = embed.get("frame_ancestors") + if ancestors is not None: + if isinstance(ancestors, str): + ancestors = [ancestors] + if not isinstance(ancestors, list) or any(not str(item or "").strip() for item in ancestors): + raise ValueError("embed.frame_ancestors must be a list of hostnames or https origins") + validate_backup_config(config) proxy_mode(config) @@ -699,9 +782,9 @@ def render_proxy_yaml(config: dict) -> None: if auth_mode == "oidc": role_block = build_proxy_role_block(config) proxy_path.write_text(f"{role_block.rstrip()}\n{upstream_body.lstrip()}") - render_csp_yaml(config) elif not proxy_path.exists(): proxy_path.write_text(upstream_body) + render_csp_yaml(config) def web_office_csp_domain(config: dict) -> str: @@ -713,15 +796,24 @@ def web_office_csp_domain(config: dict) -> str: return str(config["opencloud"]["domain"]) +def frame_ancestors_extra_yaml(origins: list[str]) -> str: + if not origins: + return "" + return "\n" + "\n".join(f" - '{origin}'" for origin in origins) + + def render_csp_yaml(config: dict) -> None: config_dir = Path(str(config["opencloud"]["config_dir"])) + config_dir.mkdir(parents=True, exist_ok=True) csp_path = config_dir / "csp.yaml" - oidc = config["auth"]["oidc"] + oidc = ((config.get("auth") or {}).get("oidc") or {}) + idp_domain = str(oidc.get("domain") or config["opencloud"]["domain"]).strip() rendered = render_template( CSP_TEMPLATE.read_text(), { - "IDP_DOMAIN": str(oidc["domain"]), + "IDP_DOMAIN": idp_domain, "WEB_OFFICE_DOMAIN": web_office_csp_domain(config), + "FRAME_ANCESTORS_EXTRA": frame_ancestors_extra_yaml(extra_frame_ancestors(config)), }, ) csp_path.write_text(rendered) @@ -731,13 +823,21 @@ def build_caddy_site_blocks(config: dict) -> tuple[str, str]: opencloud_domain = str(config["opencloud"]["domain"]) weboffice = config.get("weboffice") or {} - oc_security_headers = """ - header { + extra_ancestors = extra_frame_ancestors(config) + if extra_ancestors: + frame_header = " -X-Frame-Options" + proxy_down = " header_down -X-Frame-Options\n" + else: + frame_header = " X-Frame-Options SAMEORIGIN" + proxy_down = "" + + oc_security_headers = f""" + header {{ X-Content-Type-Options nosniff - X-Frame-Options SAMEORIGIN +{frame_header} Referrer-Policy strict-origin-when-cross-origin -Server - } + }} encode gzip log""" @@ -745,7 +845,7 @@ def build_caddy_site_blocks(config: dict) -> tuple[str, str]: reverse_proxy opencloud:9200 {{ header_up X-Forwarded-Proto {{scheme}} header_up X-Forwarded-Host {{host}} - }}{oc_security_headers} +{proxy_down} }}{oc_security_headers} }}""" euro_block = "" diff --git a/tests/test_apply.py b/tests/test_apply.py index 15897a4..5338286 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -12,12 +12,15 @@ from scripts.apply import ( _network_address, + apply_engine_embed_sidecar, apply_engine_oidc_sidecar, bootstrap_ldap_tls, + build_caddy_site_blocks, build_env_vars, build_proxy_role_block, derive_compose_files, discover_ldap_server_ip, + extra_frame_ancestors, ldap_data_dir, opencloud_admin_user_id, render_caddyfile, @@ -472,6 +475,53 @@ def test_render_csp_yaml_allows_euro_office_frame_src(tmp_path): assert "https://eurooffice.test.example" in img_src +def test_render_csp_yaml_allows_webmail_frame_ancestors(tmp_path): + config_dir = tmp_path / "config" + config_dir.mkdir() + config = _base_config( + opencloud={"config_dir": str(config_dir)}, + embed={"frame_ancestors": ["webmail.test.example"]}, + ) + render_csp_yaml(config) + rendered = (config_dir / "csp.yaml").read_text() + ancestors = rendered.split("frame-ancestors:")[1].split("frame-src:")[0] + assert "https://webmail.test.example" in ancestors + assert "'''self'''" in ancestors + + +def test_caddy_drops_x_frame_options_when_embed_parents_set(): + oc_block, _euro = build_caddy_site_blocks( + _base_config(embed={"frame_ancestors": ["https://webmail.test.example"]}) + ) + assert "X-Frame-Options SAMEORIGIN" not in oc_block + assert "header_down -X-Frame-Options" in oc_block + assert "-X-Frame-Options" in oc_block + + +def test_caddy_keeps_sameorigin_without_embed_parents(): + oc_block, _euro = build_caddy_site_blocks(_base_config()) + assert "X-Frame-Options SAMEORIGIN" in oc_block + + +def test_apply_engine_embed_sidecar_merges_origins(tmp_path): + sidecar = tmp_path / "embed.yaml" + sidecar.write_text("frame_ancestors:\n - https://webmail.test.example\n") + config = {"opencloud": {"domain": "cloud.test.example"}, "embed": {"frame_ancestors": ["portal.test.example"]}} + apply_engine_embed_sidecar(config, sidecar) + assert extra_frame_ancestors(config) == [ + "https://portal.test.example", + "https://webmail.test.example", + ] + + +def test_apply_engine_embed_sidecar_respects_managed_false(tmp_path): + sidecar = tmp_path / "embed.yaml" + sidecar.write_text("frame_ancestors:\n - https://webmail.test.example\n") + config = {"opencloud": {"domain": "cloud.test.example"}, "embed": {"managed": False}} + apply_engine_embed_sidecar(config, sidecar) + assert extra_frame_ancestors(config) == [] + + def test_bootstrap_ldap_tls_creates_cert_files(tmp_path): certs_dir = tmp_path / "ldap_certs" bootstrap_ldap_tls(certs_dir) From 3f42b7055001dcdfd6b4fbeb27f251aed5836874 Mon Sep 17 00:00:00 2001 From: Thomas Nordentoft Date: Mon, 31 Aug 2026 19:36:06 +0200 Subject: [PATCH 22/22] Allow embedding of documents inside webmail --- README.md | 2 +- docs/integrating-engine.md | 2 +- scripts/apply.py | 15 ++++++++++++++- tests/test_apply.py | 23 ++++++++++++++++++++++- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e3589dd..7b0f1ce 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ If OpenCloud logs show `WopiDiscovery: wopi app url failed with unexpected code 3. **JWT mismatch** — Euro Office `JWT_SECRET` must match OpenCloud `COLLABORATION_WOPI_SECRET` (not `COLLABORATION_JWT_SECRET`, which breaks internal REVA tokens). Both are set from `.opencloud-easy-deploy/secrets.yaml` on apply. If JWT was wrong on first boot, remove `/euro-office` and re-apply so Euro Office regenerates its persisted secrets. -4. **X-Frame-Options / iframe blocked** — If the browser console shows Euro Office blocked by `X-Frame-Options: sameorigin`, re-run `bash apply.sh` so Caddy sets `Content-Security-Policy: frame-ancestors` for the Euro Office domain instead. +4. **X-Frame-Options / iframe blocked** — If the browser console shows Euro Office blocked by `X-Frame-Options: sameorigin`, re-run `bash apply.sh` so Caddy sets `Content-Security-Policy: frame-ancestors` for the Euro Office domain instead. Opening a document while OpenCloud itself is iframed (Bulwark) also needs the webmail origin in that list; engine apply writes it from `bulwark.domain`. 5. **OpenCloud `frame-src` blocks Euro Office** — If the browser console shows `frame-src` blocking `https:///hosting/wopi/...`, OpenCloud's CSP is missing the document-server origin. Re-run `bash apply.sh` so `csp.yaml` includes `weboffice.domain`. diff --git a/docs/integrating-engine.md b/docs/integrating-engine.md index b5ed5e0..63cf4b2 100644 --- a/docs/integrating-engine.md +++ b/docs/integrating-engine.md @@ -51,4 +51,4 @@ Standalone OpenCloud Caddy (`opencloud_caddy`) is not started in integrate mode. ## Bulwark inline iframe -When Stalwart/Bulwark is enabled on the same engine, apply writes `.opencloud-easy-deploy/integration/embed.yaml` with the webmail origin. OpenCloud then allows that origin in CSP `frame-ancestors`. To add more parents, set `embed.frame_ancestors` in `deploy.yaml`. Set `embed.managed: false` to ignore the engine sidecar. +When Stalwart/Bulwark is enabled on the same engine, apply writes `.opencloud-easy-deploy/integration/embed.yaml` with the webmail origin. OpenCloud then allows that origin in CSP `frame-ancestors`, and the document editor (Euro Office / Collabora) allows it too so nested iframes work. To add more parents, set `embed.frame_ancestors` in `deploy.yaml`. Set `embed.managed: false` to ignore the engine sidecar. diff --git a/scripts/apply.py b/scripts/apply.py index 3516585..ca98136 100644 --- a/scripts/apply.py +++ b/scripts/apply.py @@ -245,6 +245,17 @@ def extra_frame_ancestors(config: dict) -> list[str]: return [origin for origin in unique_https_origins(embed.get("frame_ancestors")) if origin != self_origin] +def office_frame_ancestors_csp(config: dict) -> str: + """CSP frame-ancestors for the document editor (nested iframes check every ancestor).""" + parts = ["'self'", f"https://{config['opencloud']['domain']}"] + seen = set(parts) + for origin in extra_frame_ancestors(config): + if origin not in seen: + seen.add(origin) + parts.append(origin) + return " ".join(parts) + + def load_config(path: Path = DEPLOY_PATH) -> dict: if not path.exists(): raise FileNotFoundError( @@ -857,17 +868,19 @@ def build_caddy_site_blocks(config: dict) -> tuple[str, str]: else "collabora:9980" ) if office_domain: + ancestors = office_frame_ancestors_csp(config) euro_block = f""" {office_domain} {{ reverse_proxy {upstream} {{ header_down -X-Frame-Options + header_down -Content-Security-Policy }} header {{ X-Content-Type-Options nosniff Referrer-Policy strict-origin-when-cross-origin -Server -X-Frame-Options - Content-Security-Policy "frame-ancestors 'self' https://{opencloud_domain}" + Content-Security-Policy "frame-ancestors {ancestors}" }} encode gzip log diff --git a/tests/test_apply.py b/tests/test_apply.py index 5338286..b6da4dc 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -22,6 +22,7 @@ discover_ldap_server_ip, extra_frame_ancestors, ldap_data_dir, + office_frame_ancestors_csp, opencloud_admin_user_id, render_caddyfile, render_csp_yaml, @@ -499,8 +500,28 @@ def test_caddy_drops_x_frame_options_when_embed_parents_set(): def test_caddy_keeps_sameorigin_without_embed_parents(): - oc_block, _euro = build_caddy_site_blocks(_base_config()) + oc_block, euro_block = build_caddy_site_blocks(_base_config()) assert "X-Frame-Options SAMEORIGIN" in oc_block + assert "frame-ancestors 'self' https://cloud.test.example" in euro_block + assert "webmail.test.example" not in euro_block + + +def test_office_caddy_allows_webmail_nested_iframe(): + _oc, euro_block = build_caddy_site_blocks( + _base_config(embed={"frame_ancestors": ["https://webmail.test.example"]}) + ) + assert ( + "frame-ancestors 'self' https://cloud.test.example https://webmail.test.example" + in euro_block + ) + assert "header_down -Content-Security-Policy" in euro_block + + +def test_office_frame_ancestors_csp_dedupes_opencloud_origin(): + config = _base_config(embed={"frame_ancestors": ["cloud.test.example", "webmail.test.example"]}) + assert office_frame_ancestors_csp(config) == ( + "'self' https://cloud.test.example https://webmail.test.example" + ) def test_apply_engine_embed_sidecar_merges_origins(tmp_path):