diff --git a/README.md b/README.md index c85011c..7b0f1ce 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 @@ -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`. +**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 1. **Create groups** for OpenCloud roles: @@ -129,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, …) ``` @@ -312,7 +314,11 @@ 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`. + +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. 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/config-templates/opencloud/csp.yaml.template b/config-templates/opencloud/csp.yaml.template new file mode 100644 index 0000000..a4676d3 --- /dev/null +++ b/config-templates/opencloud/csp.yaml.template @@ -0,0 +1,48 @@ +# 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_ANCESTORS_EXTRA}} + frame-src: + - '''self''' + - 'blob:' + - 'https://embed.diagrams.net/' + - 'https://{{IDP_DOMAIN}}' + - 'https://{{WEB_OFFICE_DOMAIN}}' + - 'https://docs.opencloud.eu' + img-src: + - '''self''' + - 'data:' + - 'blob:' + - 'https://raw.githubusercontent.com/opencloud-eu/awesome-apps/' + - 'https://tile.openstreetmap.org/' + - 'https://{{WEB_OFFICE_DOMAIN}}' + 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/deploy.yaml.example b/deploy.yaml.example index 8f90114..5ad8238 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 @@ -13,28 +13,40 @@ 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 — Kanidm (same VPS via easydeploy-engine) or another IdP auth: mode: builtin oidc: - issuer_url: https://authentik.example.com/application/o/opencloud/ - account_url: https://authentik.example.com/if/user/ - domain: authentik.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" - role_claim: groups - role_mapping: - admin: opencloud-admin - user: opencloud-user - guest: opencloud-guest + client_scopes: "openid profile email groups groups_name" + # 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 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/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/docs/integrating-engine.md b/docs/integrating-engine.md new file mode 100644 index 0000000..63cf4b2 --- /dev/null +++ b/docs/integrating-engine.md @@ -0,0 +1,54 @@ +# Integrating with easydeploy-engine + +Use this when Kanidm 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: 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 groups_name +``` + +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 + +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 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. 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 + +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. + +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`, 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/easydeploy-lib b/easydeploy-lib index 9285f8e..e496897 160000 --- a/easydeploy-lib +++ b/easydeploy-lib @@ -1 +1 @@ -Subproject commit 9285f8efa0e3d6ce022604708d78c95c3b5bd933 +Subproject commit e496897ab567d0ff9c265c5e0662fbd519f93f8f diff --git a/overlays/idm/kanidm-provider.yml b/overlays/idm/kanidm-provider.yml new file mode 100644 index 0000000..a78f836 --- /dev/null +++ b/overlays/idm/kanidm-provider.yml @@ -0,0 +1,32 @@ +--- +# 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" + # 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" + 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" + 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/overlays/idm/oidc-external.yml b/overlays/idm/oidc-external.yml index c20cb63..756e0b9 100644 --- a/overlays/idm/oidc-external.yml +++ b/overlays/idm/oidc-external.yml @@ -6,7 +6,9 @@ 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: + external: true name: opencloud-net diff --git a/scripts/apply.py b/scripts/apply.py index 434d88f..ca98136 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 @@ -23,17 +24,36 @@ ) 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" 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" +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). +BITNAMI_OPENLDAP_GID = 0 CADDY_DIR = PROJECT_ROOT / "caddy" CADDY_TEMPLATE = CADDY_DIR / "Caddyfile.template" CADDYFILE = CADDY_DIR / "Caddyfile" 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", @@ -52,6 +72,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 {} @@ -76,12 +103,168 @@ 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 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. + + 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 + 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 != "kanidm": + return + auth["mode"] = "oidc" + for key, value in sidecar.items(): + if key == "managed": + continue + oidc[key] = value + 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 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( 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) + apply_engine_embed_sidecar(config) + return config def validate_config(config: dict) -> None: @@ -121,11 +304,25 @@ 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) 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 +343,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 == "kanidm": + files.append("../overlays/idm/kanidm-provider.yml") modules = config.get("modules") or {} if to_bool(modules.get("search")): @@ -162,18 +362,112 @@ 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 _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 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], + 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 + net = networks.get("opencloud-net") + if isinstance(net, dict): + address = _network_address(net) + if address: + return address + return "" + + +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 {} + 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") + if ldap_ip: + extra_hosts.append(f"ldap-server:{ldap_ip}") + 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] 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": + services["ldap-server"] = { + "container_name": "ldap-server", + "networks": ["opencloud-net"], + } + 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() @@ -182,19 +476,35 @@ 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} + 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, + } 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: @@ -254,10 +564,12 @@ 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), - "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") @@ -279,27 +591,37 @@ 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 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"]), - "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 "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", } ) - env["_ROLE_MAPPING"] = yaml.safe_dump(role_mapping, default_flow_style=True) + 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" + else: + env["PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM"] = str( + oidc.get("role_claim") or "groups" + ) return env @@ -335,6 +657,46 @@ 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 = hostfs.ensure_writable_directory(ldap_certs_dir) + 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) + 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: opencloud = config["opencloud"] config_dir = Path(str(opencloud["config_dir"])) @@ -350,7 +712,11 @@ 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": + bootstrap_ldap_tls(ldap_base / "ldap_certs") upstream_config = COMPOSE_DIR / "config" / "opencloud" if not upstream_config.is_dir(): @@ -372,6 +738,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" @@ -381,38 +791,72 @@ 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()}") elif not proxy_path.exists(): proxy_path.write_text(upstream_body) + render_csp_yaml(config) -def render_caddyfile(config: dict) -> None: +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 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.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": 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) + + +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""" 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}} +{proxy_down} }}{oc_security_headers} }}""" euro_block = "" @@ -424,47 +868,89 @@ def render_caddyfile(config: dict) -> None: else "collabora:9980" ) if office_domain: - # Euro Office/Collabora must be embeddable in OpenCloud iframes (cross-origin). + 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 -}}""" +}}""".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"] + 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 = [ - 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) + try: + hostfs.chown_path(path, uid, gid) + except PermissionError: + pass + if auth_mode == "oidc": + 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]: @@ -491,6 +977,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() @@ -548,6 +1060,58 @@ 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 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("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; " + "OpenCloud will fail lookups for ldap-server.", + file=sys.stderr, + ) + return + 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) + if previous_uri != uri: + print(f"Pointing OpenCloud at {uri} (Docker /etc/hosts cannot be edited in-place)…") + run_compose( + COMPOSE_DIR, + "up", + "-d", + "--no-deps", + "--force-recreate", + "opencloud", + env=env, + ) + ensure_container_on_network("opencloud", "opencloud-net") + print(f" OpenCloud LDAP URI is {uri}") + + def reconcile_runtime(env_path: Path, config: dict) -> None: env = {} if env_path.is_file(): @@ -558,13 +1122,21 @@ 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) + 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": @@ -586,6 +1158,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() @@ -594,14 +1171,22 @@ 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") 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: @@ -613,17 +1198,24 @@ 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) 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) @@ -652,14 +1244,20 @@ 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) 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/config_edit.py b/scripts/config_edit.py index de03627..bf572ab 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_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("kanidm") or {}).get("domain") or "").strip() + + +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_KANIDM_DEPLOY") or "").strip() + if env_deploy: + candidates.append(Path(env_deploy).expanduser()) + candidates.append((opencloud_root.parent / "kanidm-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_kanidm_domain(resolved) + if domain: + return {"domain": domain, "deploy": str(resolved)} + + 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_kanidm(opencloud_root: Path = PROJECT_ROOT) -> str: + found = discover_local_kanidm(opencloud_root) + domain = found.get("domain", "") + deploy = found.get("deploy", "") + return ( + f"LOCAL_KANIDM_DOMAIN={shlex.quote(domain)}\n" + f"LOCAL_KANIDM_DEPLOY={shlex.quote(deploy)}\n" + ) + + def update_from_wizard( *, domain: str, @@ -42,6 +88,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, @@ -51,6 +98,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) @@ -58,22 +106,31 @@ 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" 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": { + "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 groups_name" + if (oidc_provider or "").lower() == "kanidm" + else "openid profile email offline_access" + ), "role_claim": "groups", "role_mapping": { "admin": role_admin, @@ -117,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-kanidm", action="store_true") parser.add_argument("--path", type=Path, default=DEFAULT_DEPLOY_PATH) args = parser.parse_args() + if args.print_local_kanidm: + print(emit_local_kanidm(PROJECT_ROOT), end="") + return + if args.show: import json 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 29334e1..b6da4dc 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json +import os import textwrap from pathlib import Path @@ -9,12 +11,25 @@ import yaml 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, + office_frame_ancestors_csp, + opencloud_admin_user_id, render_caddyfile, + render_csp_yaml, render_network_overlay, render_template, validate_config, + web_office_csp_domain, ) @@ -23,13 +38,13 @@ 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", "language": "en", }, - "proxy": {"type": "caddy"}, + "proxy": {"type": "caddy", "mode": "standalone", "integrate": {"network": "easydeploy-net"}}, "auth": {"mode": "builtin"}, "weboffice": { "enabled": True, @@ -51,6 +66,44 @@ 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_kanidm_provider(): + 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", + }, + }, + ) + 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 + + +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" @@ -115,6 +168,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"] @@ -140,10 +197,90 @@ 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"] +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 "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" + 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( @@ -183,10 +320,111 @@ 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"] +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_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"] + 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_bridge_membership(monkeypatch): + payload = [ + { + "Containers": { + "abc": {"Name": "ldap-server", "IPv4Address": "172.21.0.9/16"}, + } + } + ] + + def fake_run(cmd, **_kwargs): + 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.21.0.9" + + +def test_discover_ldap_server_ip_falls_back_to_container_inspect(monkeypatch): + inspect = [ + { + "NetworkSettings": { + "Networks": { + "easydeploy-net": {"IPAddress": "172.21.0.4"}, + "opencloud-net": {"IPAddress": "172.18.0.8"}, + } + } + } + ] + + 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) + 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 @@ -202,5 +440,163 @@ 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_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_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_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): + 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) + 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: 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://idm.test.example/oauth2/openid/opencloud" + 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") + 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 new file mode 100644 index 0000000..80fdebe --- /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_kanidm, emit_local_kanidm, read_kanidm_domain + + +def test_read_kanidm_domain(tmp_path: Path): + deploy = tmp_path / "deploy.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_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" + kanidm = tmp_path / "kanidm-easy-deploy" + opencloud.mkdir() + kanidm.mkdir() + (kanidm / "deploy.yaml").write_text( + yaml.safe_dump({"kanidm": {"domain": "idm.opencomp.eu"}}) + ) + + found = discover_local_kanidm(opencloud) + assert found["domain"] == "idm.opencomp.eu" + assert found["deploy"].endswith("kanidm-easy-deploy/deploy.yaml") + + +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({"kanidm": {"domain": "idm.other.example"}})) + monkeypatch.setenv("EASYDEPLOY_KANIDM_DEPLOY", str(deploy)) + + found = discover_local_kanidm(opencloud) + assert found["domain"] == "idm.other.example" + + +def test_discover_local_kanidm_env_domain_only(tmp_path: Path, monkeypatch): + opencloud = tmp_path / "opencloud-easy-deploy" + opencloud.mkdir() + monkeypatch.setenv("EASYDEPLOY_KANIDM_DOMAIN", "idm.env.example") + monkeypatch.delenv("EASYDEPLOY_KANIDM_DEPLOY", raising=False) + + found = discover_local_kanidm(opencloud) + assert found["domain"] == "idm.env.example" + + +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_kanidm(opencloud) + assert "LOCAL_KANIDM_DOMAIN=''" in text or "LOCAL_KANIDM_DOMAIN=" in text diff --git a/wizard.sh b/wizard.sh index a6560cf..2c5f0a0 100755 --- a/wizard.sh +++ b/wizard.sh @@ -6,7 +6,47 @@ 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="" +FROM_ENGINE=0 + +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" + FROM_ENGINE=1 + 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,22 +61,50 @@ 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 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" + 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}" - 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'" + eval "$(uv run python -m scripts.config_edit --print-local-kanidm)" + use_local_kanidm="n" + oidc_provider="" + kanidm_domain="${LOCAL_KANIDM_DOMAIN:-}" + if [[ -n "$kanidm_domain" ]]; then + if [[ "${FROM_ENGINE}" == "1" ]]; then + use_local_kanidm="y" + info "Using Kanidm on this VPS at https://${kanidm_domain}." + else + ask_yn use_local_kanidm "Use Kanidm at https://${kanidm_domain} as the OpenCloud IdP?" "y" + fi + fi + 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="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" + auth_mode="${auth_mode,,}" + if [[ "$auth_mode" != "builtin" && "$auth_mode" != "oidc" ]]; then + die "auth mode must be 'builtin' or 'oidc'" + fi fi admin_password="" @@ -44,21 +112,27 @@ gather_config() { ask_secret admin_password "Admin password (leave empty to auto-generate on apply)" fi - oidc_issuer="" - oidc_account="" - oidc_domain="" oidc_client_id="opencloud" role_admin="opencloud-admin" role_user="opencloud-user" role_guest="opencloud-guest" + if [[ "$use_local_kanidm" != "y" ]]; then + oidc_issuer="" + oidc_account="" + oidc_domain="" + fi - if [[ "$auth_mode" == "oidc" ]]; then + if [[ "$auth_mode" == "oidc" && "$use_local_kanidm" != "y" ]]; then + default_idp="${kanidm_domain:-idm.${base_domain}}" echo - echo -e "${BOLD} External OIDC (Authentik, Keycloak, …)${RESET}" - ask oidc_issuer "OIDC issuer URL" "https://authentik.${base_domain}/application/o/opencloud/" - ask oidc_account "Account settings URL" "https://authentik.${base_domain}/if/user/" - ask oidc_domain "IdP domain (for CSP)" "authentik.${base_domain}" + 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: 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" ask role_guest "Guest group name" "opencloud-guest" @@ -79,19 +153,37 @@ 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}" + if [[ -n "${PROXY_MODE}" ]]; then + proxy_mode="${PROXY_MODE,,}" + info "Proxy mode: ${proxy_mode} (set by easydeploy-engine)" + else + 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 + die "proxy mode must be 'standalone' or 'integrate'" + fi + echo echo -e "${BOLD} Summary${RESET}" echo " OpenCloud: https://${domain}" if [[ "$weboffice_enabled" == "y" ]]; then echo " Euro Office: https://${weboffice_domain}" fi - echo " Auth: ${auth_mode}" + echo " Auth: ${auth_mode}${oidc_provider:+ (${oidc_provider})}" echo " Data root: ${data_root}" + echo " Proxy mode: ${proxy_mode}" echo echo " Ensure DNS A/AAAA records point to this server before continuing." echo - ask_yn proceed "Write deploy.yaml and deploy now?" "y" + if [[ "${NO_APPLY}" == "1" ]]; then + ask_yn proceed "Write deploy.yaml?" "y" + else + ask_yn proceed "Write deploy.yaml and deploy now?" "y" + fi [[ "$proceed" == "y" ]] || { info "Cancelled." exit 0 @@ -111,6 +203,7 @@ update_from_wizard( oidc_account_url=${oidc_account@Q} or None, oidc_domain=${oidc_domain@Q} or None, oidc_client_id=${oidc_client_id@Q} or None, + oidc_provider=${oidc_provider@Q} or None, role_admin=${role_admin@Q}, role_user=${role_user@Q}, role_guest=${role_guest@Q}, @@ -120,6 +213,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 @@ -128,13 +222,14 @@ PY } main() { - if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - echo "Usage: bash wizard.sh" - exit 0 - fi - bash "${SCRIPT_DIR}/ensure-dependencies.sh" + ensure_docker_group_session "${EASYDEPLOY_INVOKE_ARGS[@]}" + cd "${SCRIPT_DIR}" gather_config + if [[ "${NO_APPLY}" == "1" ]]; then + info "Skipping apply (--no-apply / --from-engine). easydeploy-engine will apply." + return 0 + fi bash "${SCRIPT_DIR}/apply.sh" }