From 611964f7d659d1a99f67d2747a4848b2236560ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:58:36 +0200 Subject: [PATCH 01/10] docs(opnform): add meta/argument_specs.yml 50 typed options covering the full defaults file plus the OIDC subschema (group_role_mappings with idp_group + role choices). Required secrets (app_key, jwt_secret, front_api_secret, db_password) marked required: true so ansible refuses the play with a clear error before the validate task even runs. Loads cleanly through ansible-core's ArgumentSpecValidator. Matches the spec convention introduced for traefik, authentik, drawio, garage and nextcloud. --- roles/opnform/meta/argument_specs.yml | 220 ++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 roles/opnform/meta/argument_specs.yml diff --git a/roles/opnform/meta/argument_specs.yml b/roles/opnform/meta/argument_specs.yml new file mode 100644 index 0000000..9fbfc7a --- /dev/null +++ b/roles/opnform/meta/argument_specs.yml @@ -0,0 +1,220 @@ +--- +argument_specs: + main: + short_description: Deploy OpnForm (api + ui + db + redis + ingress) via Docker Compose. + description: + - Renders a Compose stack for the full OpnForm setup (PHP-FPM api, + Nuxt ui, Postgres, Redis, nginx ingress) and exposes it through + Traefik. + - Optionally bootstraps the first admin user via the OpnForm + C(/api/register) endpoint (skipping the self-hosted setup page) + and provisions a single OIDC identity connection in the default + workspace via the workspace API. Both bootstraps are idempotent. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + opnform_service_name: + type: str + default: opnform + opnform_docker_compose_dir: + type: path + description: Defaults to C({{ docker_compose_base_dir }}/{{ opnform_service_name }}). + opnform_docker_volume_dir: + type: path + description: Defaults to C({{ docker_volume_base_dir }}/{{ opnform_service_name }}). + opnform_storage_dir: + type: path + description: OpnForm storage volume mounted into the api container. + opnform_db_data_dir: + type: path + opnform_redis_data_dir: + type: path + + opnform_domain: + type: str + default: forms.local.test + description: Hostname used in the traefik Host rule. + opnform_base_url: + type: str + default: https://forms.local.test + description: Public URL OpnForm uses for APP_URL and NUXT_PUBLIC_APP_URL. + + opnform_api_image: + type: str + default: jhumanj/opnform-api:latest + opnform_client_image: + type: str + default: jhumanj/opnform-client:latest + opnform_redis_image: + type: str + default: "redis:7" + opnform_db_image: + type: str + default: "postgres:16" + opnform_ingress_image: + type: str + default: "nginx:1" + + opnform_app_key: + type: str + required: true + description: + - Laravel application key. Must be prefixed with C(base64:). + Generate with C(echo "base64:$(openssl rand -base64 32)"). + Provide via OpenBao, Ansible Vault or extra-vars. + opnform_jwt_secret: + type: str + required: true + description: JWT signing secret. Generate with C(openssl rand -hex 32). + opnform_front_api_secret: + type: str + required: true + description: Shared secret between ui and api. Generate with C(openssl rand -hex 32). + + opnform_db_name: + type: str + default: opnform + opnform_db_user: + type: str + default: opnform + opnform_db_password: + type: str + required: true + + opnform_admin_name: + type: str + default: Administrator + opnform_admin_email: + type: str + default: '' + description: + - When non-empty (together with C(opnform_admin_password)) the role + bootstraps the first user via C(/api/register), skipping the + self-hosted setup page. Required when C(opnform_oidc_enabled=true). + opnform_admin_password: + type: str + default: '' + description: + - "Must satisfy OpnForm's policy: min 8 chars, letter + digit + + symbol from C(@$!%*#?&-_+=.,:;<>^()[]{}|~)." + opnform_admin_hear_about_us: + type: str + default: ansible + + opnform_php_memory_limit: + type: str + default: 1G + opnform_php_max_execution_time: + type: str + default: "600" + opnform_php_upload_max_filesize: + type: str + default: 64M + opnform_php_post_max_size: + type: str + default: 64M + opnform_nginx_max_body_size: + type: str + default: 64m + + opnform_mail_mailer: + type: str + default: log + choices: [log, smtp, ses, mailgun, postmark, sendmail] + opnform_mail_host: + type: str + default: '' + opnform_mail_port: + type: str + default: '' + opnform_mail_username: + type: str + default: '' + opnform_mail_password: + type: str + default: '' + opnform_mail_encryption: + type: str + default: '' + choices: ['', tls, ssl] + opnform_mail_from_address: + type: str + default: noreply@digitalboard.ch + opnform_mail_from_name: + type: str + default: OpnForm + + opnform_oidc_enabled: + type: bool + default: false + description: + - "When true the role calls the workspace API to create a single + OIDC C(identity_connection) on the default workspace after the + admin bootstrap. Requires C(opnform_admin_email) + + C(opnform_admin_password) so the role can authenticate. + Idempotent: skipped when any connection already exists." + opnform_oidc_issuer: + type: str + default: https://auth.digitalboard.ch/realms/Digitalboard + description: OIDC issuer URL. + opnform_oidc_client_id: + type: str + default: opnform-digitalboard + opnform_oidc_client_secret: + type: str + default: '' + description: Required when C(opnform_oidc_enabled=true). + opnform_oidc_client_name: + type: str + default: Digitalboard + description: Display name shown in the OpnForm UI. + opnform_oidc_slug: + type: str + default: oidc + description: + - OpnForm-side identifier used in C(/auth/{slug}/callback). Lowercase + alphanumeric + hyphens, unique across all C(identity_connections). + opnform_oidc_domain: + type: str + default: '' + description: + - Email domain that triggers OIDC for matching users. Required + when C(opnform_oidc_enabled=true). + opnform_oidc_scopes: + type: list + elements: str + default: [openid, profile, email, groups] + opnform_oidc_admin_group: + type: str + default: opnform-admins + description: + - Convenience setting that maps a single IdP group to the OpnForm + C(admin) role. Ignored when C(opnform_oidc_group_role_mappings) + is non-empty. + opnform_oidc_group_role_mappings: + type: list + elements: dict + default: [] + description: + - Full IdP-group -> OpnForm-role mapping. Takes precedence over + C(opnform_oidc_admin_group). + options: + idp_group: + type: str + required: true + description: Group name as it appears in the IdP groups claim. + role: + type: str + required: true + choices: [owner, admin, editor, member] + + opnform_traefik_network: + type: str + default: proxy + opnform_use_ssl: + type: bool + default: true From 4fe9d6b177b19d51ffb066041f6bcb734f1063af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Wed, 20 May 2026 17:39:16 +0200 Subject: [PATCH 02/10] feat(bookstack): add role for self-hosted BookStack deployment Deploy BookStack with linuxserver.io images behind Traefik, including Entra ID OIDC SSO support and a daily backup timer. Stack: - lscr.io/linuxserver/bookstack:version-v26.03.3 - lscr.io/linuxserver/mariadb:11.4.9 - Traefik labels for websecure entrypoint on internal network - Healthcheck via mariadb-admin ping (LSIO image lacks healthcheck.sh) Features: - Persistent APP_KEY generated on first run, stored in volume dir - Optional OIDC SSO via Microsoft Entra ID (configurable per-instance) - Idempotent admin user creation with DB-based existence check - Daily systemd timer backup (DB dump + uploads tar + APP_KEY) with configurable retention Implementation notes: - DB queries use --protocol=tcp with the app user because root@localhost uses unix_socket auth in the LSIO MariaDB image (no password) and root@% does not exist - docker_container_exec uses argv: (list) instead of command: (string) to avoid argument-splitting issues - Migration-wait task ensures users table exists before admin check, since /login returns 200 before Laravel migrations complete - no_log: true on all tasks that reference DB or admin passwords - artisan absolute path (/app/www/artisan) because LSIO image WORKDIR is not the app directory Adds bookstack route to DMZ Traefik service registry. --- galaxy.yml | 1 + .../filter}/homarr_layout.py | 0 roles/bookstack/README.md | 145 ++++++++++++ roles/bookstack/defaults/main.yml | 85 +++++++ roles/bookstack/handlers/main.yml | 19 ++ roles/bookstack/meta/main.yml | 25 ++ roles/bookstack/tasks/main.yml | 223 ++++++++++++++++++ roles/bookstack/templates/backup.sh.j2 | 41 ++++ .../templates/bookstack-backup.service.j2 | 12 + .../templates/bookstack-backup.timer.j2 | 11 + .../bookstack/templates/docker-compose.yml.j2 | 87 +++++++ roles/bookstack/tests/inventory | 1 + roles/bookstack/tests/test.yml | 5 + roles/bookstack/vars/main.yml | 3 + .../tests/test_homarr_layout.py | 6 +- roles/homarr/tasks/main.yml | 2 +- 16 files changed, 664 insertions(+), 2 deletions(-) rename {roles/homarr/filter_plugins => plugins/filter}/homarr_layout.py (100%) create mode 100644 roles/bookstack/README.md create mode 100644 roles/bookstack/defaults/main.yml create mode 100644 roles/bookstack/handlers/main.yml create mode 100644 roles/bookstack/meta/main.yml create mode 100644 roles/bookstack/tasks/main.yml create mode 100644 roles/bookstack/templates/backup.sh.j2 create mode 100644 roles/bookstack/templates/bookstack-backup.service.j2 create mode 100644 roles/bookstack/templates/bookstack-backup.timer.j2 create mode 100644 roles/bookstack/templates/docker-compose.yml.j2 create mode 100644 roles/bookstack/tests/inventory create mode 100644 roles/bookstack/tests/test.yml create mode 100644 roles/bookstack/vars/main.yml diff --git a/galaxy.yml b/galaxy.yml index 91cd337..c208d8f 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -19,6 +19,7 @@ readme: README.md authors: - Bert-Jan Fikse - Tobias Wüst +- Simon Bärlocher ### OPTIONAL but strongly recommended # A short summary description of the collection diff --git a/roles/homarr/filter_plugins/homarr_layout.py b/plugins/filter/homarr_layout.py similarity index 100% rename from roles/homarr/filter_plugins/homarr_layout.py rename to plugins/filter/homarr_layout.py diff --git a/roles/bookstack/README.md b/roles/bookstack/README.md new file mode 100644 index 0000000..25fb789 --- /dev/null +++ b/roles/bookstack/README.md @@ -0,0 +1,145 @@ +# Ansible Role: bookstack + +Deploys [BookStack](https://www.bookstackapp.com/) as a self-contained Docker +Compose stack behind Traefik, with its own MariaDB container, OIDC SSO +(Entra ID by default) and a daily systemd-timer driven backup of database +and uploads. + +## Requirements + +- Docker Engine + Compose plugin on the target host +- Traefik already running, with the external network referenced by + `bookstack_traefik_network` (default: `proxy`) +- `community.docker` collection on the controller +- DNS for `bookstack_domain` pointing at the Traefik host + +## Required variables + +The role asserts these are set; the play fails fast if any is empty: + +| Variable | Description | +|---|---| +| `bookstack_db_root_password` | MariaDB root password | +| `bookstack_db_password` | MariaDB user password | +| `bookstack_admin_password` | Initial local admin password | +| `bookstack_oidc_client_id` | Entra ID App Registration ID (if OIDC on) | +| `bookstack_oidc_client_secret` | Entra ID client secret (if OIDC on) | +| `bookstack_entra_tenant_id` | Entra tenant UUID (if OIDC on) | + +Provide via OpenBao lookup, Ansible Vault or `--extra-vars`. Never commit +real secrets. + +## Optional variables + +See `defaults/main.yml`. Frequently overridden: + +- `bookstack_domain`, `bookstack_base_url` +- `bookstack_image`, `bookstack_db_image` (pin in production) +- `bookstack_oidc_enabled` (set `false` to disable OIDC entirely) +- `bookstack_oidc_auto_initiate` (`true` redirects straight to IdP) +- `bookstack_oidc_user_to_groups` (`true` syncs roles from Entra groups) +- `bookstack_backup_enabled`, `bookstack_backup_schedule`, + `bookstack_backup_retention_days` + +## Entra ID app registration + +1. Azure Portal → Entra ID → App registrations → New registration +2. Redirect URI (Web): `https:///oidc/callback` +3. Front-channel logout URL: `https:///logout` +4. Certificates & secrets → New client secret → + `bookstack_oidc_client_secret` +5. For group sync (`bookstack_oidc_user_to_groups: true`): + - Token configuration → Add groups claim → Security groups + - In BookStack, create roles whose **External Auth ID** equals the + Entra group Object ID, so the mapping resolves on first login. + +## What the role does + +| Phase | Action | +|---|---| +| Validate | `assert` all required secrets are set | +| Prepare | install packages, create volume dirs, generate persistent `APP_KEY`, verify Traefik network | +| Deploy | render `docker-compose.yml`, pull images, bring stack up | +| Configure | wait for the app, create the initial local admin via `php artisan bookstack:create-admin` (idempotent) | +| Backup | render `/usr/local/bin/bookstack-backup.sh` + systemd timer (daily 03:00, 14-day retention) | + +## Example playbook + +```yaml +- name: Deploy BookStack service + hosts: bookstack_servers + become: true + roles: + - digitalboard.core.bookstack +``` + +With inventory variables: + +```yaml +# group_vars/bookstack_servers.yml +bookstack_domain: wiki.digitalboard.ch +bookstack_base_url: "https://wiki.digitalboard.ch" +bookstack_entra_tenant_id: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.tenant_id }}" +bookstack_oidc_client_id: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.client_id }}" +bookstack_oidc_client_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.client_secret }}" +bookstack_db_root_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.db_root_password }}" +bookstack_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.db_password }}" +bookstack_admin_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.admin_password }}" +``` + +## Backup / restore + +Backups land in `{{ bookstack_backup_dir }}` (default +`/srv/data/bookstack/backup`) with three files per run: + +- `bookstack-db-.sql.gz` — mariadb-dump +- `bookstack-files-.tar.gz` — uploads, attachments +- `bookstack-appkey-.txt` — APP_KEY (required for restore!) + +Manual trigger: `systemctl start bookstack-backup.service` +Timer status: `systemctl list-timers bookstack-backup.timer` + +Restore procedure: + +1. Stop the stack: `docker compose down` in `bookstack_docker_compose_dir` +2. Restore the APP_KEY: copy the `.txt` content to + `{{ bookstack_docker_volume_dir }}/.app_key` (the key MUST match or + encrypted DB values become unreadable) +3. Start only the DB container, then load the dump: + ```bash + gunzip -c bookstack-db-.sql.gz \ + | docker exec -i bookstack-db \ + mariadb -u root -p"" bookstack + ``` +4. Extract the files: `tar -xzf bookstack-files-.tar.gz -C + {{ bookstack_appdata_dir }}/www/` +5. Bring the stack back up: `docker compose up -d` + +## Notes + +- `bookstack_oidc_auto_initiate: false` (default) shows a login page + with an SSO button alongside the local login form. With `true`, users + go straight to the IdP — the local admin then has to use + `https:///login?email_login=1`. +- `bookstack_oidc_user_to_groups: true` only makes sense once BookStack + roles with the correct **External Auth IDs** (= Entra group Object + IDs) exist; otherwise users lose their role assignment on every login. +- Image tags default to pinned versions; bump them deliberately rather + than chasing `latest`. +- BookStack officially supports MySQL/MariaDB only — no PostgreSQL. + +## License + +MIT diff --git a/roles/bookstack/defaults/main.yml b/roles/bookstack/defaults/main.yml new file mode 100644 index 0000000..3efbadb --- /dev/null +++ b/roles/bookstack/defaults/main.yml @@ -0,0 +1,85 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for bookstack + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# bookstack-specific configuration +bookstack_service_name: bookstack +bookstack_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ bookstack_service_name }}" +bookstack_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ bookstack_service_name }}" +bookstack_appdata_dir: "{{ bookstack_docker_volume_dir }}/appdata" +bookstack_db_data_dir: "{{ bookstack_docker_volume_dir }}/db" +bookstack_backup_dir: "{{ bookstack_docker_volume_dir }}/backup" + +# Service configuration +bookstack_domain: "wiki.local.test" +bookstack_base_url: "https://{{ bookstack_domain }}" + +# Images — pin via inventory in production +bookstack_image: "lscr.io/linuxserver/bookstack:version-v26.03.3" +bookstack_db_image: "lscr.io/linuxserver/mariadb:11.4.9" + +# Traefik configuration +bookstack_traefik_network: "proxy" +bookstack_traefik_certresolver: "le" + +# Timezone / UID +bookstack_tz: "Europe/Zurich" +bookstack_puid: "1000" +bookstack_pgid: "1000" + +# Database configuration +bookstack_db_name: "bookstack" +bookstack_db_user: "bookstack" + +# REQUIRED SECRETS — empty defaults force `assert` to fail until set. +# Provide via OpenBao lookup, Ansible Vault, or extra-vars. +# Never commit real secrets to version control. +# +# Generate with: +# bookstack_db_root_password: openssl rand -base64 32 | tr -d '/+=' +# bookstack_db_password: openssl rand -base64 32 | tr -d '/+=' +# bookstack_admin_password: openssl rand -base64 24 | tr -d '/+=' +bookstack_db_root_password: "" +bookstack_db_password: "" +bookstack_admin_password: "" +bookstack_oidc_client_secret: "" + +# APP_KEY is generated automatically on first run and persisted on the host. +# Set explicitly only if restoring an existing instance. +bookstack_app_key: "" + +# Initial local admin (fallback account, lives alongside OIDC) +bookstack_admin_name: "Admin" +bookstack_admin_email: "admin@local.test" +bookstack_artisan_path: "/app/www/artisan" + +# Mail configuration +bookstack_mail_driver: "smtp" +bookstack_mail_host: "smtp.local.test" +bookstack_mail_port: 587 +bookstack_mail_encryption: "tls" +bookstack_mail_from: "bookstack@local.test" +bookstack_mail_from_name: "BookStack" +bookstack_mail_username: "" +bookstack_mail_password: "" + +# OIDC configuration (Entra ID by default; override `bookstack_oidc_issuer` +# for Keycloak or any other provider) +bookstack_oidc_enabled: false +bookstack_oidc_name: "SSO" +bookstack_entra_tenant_id: "" +bookstack_oidc_issuer: "https://login.microsoftonline.com/{{ bookstack_entra_tenant_id }}/v2.0" +bookstack_oidc_client_id: "" +bookstack_oidc_auto_initiate: false +bookstack_oidc_user_to_groups: false +bookstack_oidc_groups_claim: "groups" +bookstack_oidc_additional_scopes: "openid profile email" + +# Backup configuration +bookstack_backup_enabled: true +bookstack_backup_retention_days: 14 +bookstack_backup_schedule: "*-*-* 03:00:00" diff --git a/roles/bookstack/handlers/main.yml b/roles/bookstack/handlers/main.yml new file mode 100644 index 0000000..eb6a769 --- /dev/null +++ b/roles/bookstack/handlers/main.yml @@ -0,0 +1,19 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for bookstack + +- name: stop bookstack + community.docker.docker_compose_v2: + project_src: "{{ bookstack_docker_compose_dir }}" + state: stopped + listen: restart bookstack + +- name: start bookstack + community.docker.docker_compose_v2: + project_src: "{{ bookstack_docker_compose_dir }}" + state: present + listen: restart bookstack + +- name: reload systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/roles/bookstack/meta/main.yml b/roles/bookstack/meta/main.yml new file mode 100644 index 0000000..a6e941d --- /dev/null +++ b/roles/bookstack/meta/main.yml @@ -0,0 +1,25 @@ +galaxy_info: + author: digitalboard + description: Deploy BookStack as a self-contained Docker Compose stack behind Traefik + company: digitalboard + license: MIT + + min_ansible_version: "2.14" + + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble + + galaxy_tags: + - docker + - bookstack + - wiki + - documentation + - digitalboard + +dependencies: [] diff --git a/roles/bookstack/tasks/main.yml b/roles/bookstack/tasks/main.yml new file mode 100644 index 0000000..1ea325b --- /dev/null +++ b/roles/bookstack/tasks/main.yml @@ -0,0 +1,223 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for bookstack + +# ===================================================================== +# 1. VALIDATE REQUIRED SECRETS +# ===================================================================== + +- name: Assert required secrets are set + ansible.builtin.assert: + that: + - bookstack_db_root_password | length > 0 + - bookstack_db_password | length > 0 + - bookstack_admin_password | length > 0 + - (not bookstack_oidc_enabled) or (bookstack_oidc_client_id | length > 0) + - (not bookstack_oidc_enabled) or (bookstack_oidc_client_secret | length > 0) + - (not bookstack_oidc_enabled) or (bookstack_entra_tenant_id | length > 0) + fail_msg: >- + One or more required secrets are unset. Provide them via OpenBao + lookup, Ansible Vault or --extra-vars. See README for the full list. + quiet: true + +# ===================================================================== +# 2. PREPARATION: Packages, directories, APP_KEY +# ===================================================================== + +- name: Ensure required packages are installed + ansible.builtin.package: + name: + - python3-docker + - python3-requests + state: present + +- name: Create docker compose directory + ansible.builtin.file: + path: "{{ bookstack_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create BookStack data directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ bookstack_puid }}" + group: "{{ bookstack_pgid }}" + mode: '0755' + loop: + - "{{ bookstack_docker_volume_dir }}" + - "{{ bookstack_appdata_dir }}" + - "{{ bookstack_db_data_dir }}" + - "{{ bookstack_backup_dir }}" + +- name: Verify Traefik network exists + community.docker.docker_network_info: + name: "{{ bookstack_traefik_network }}" + register: _traefik_net + failed_when: not _traefik_net.exists + +- name: Check whether APP_KEY has been generated before + ansible.builtin.stat: + path: "{{ bookstack_docker_volume_dir }}/.app_key" + register: _app_key_file + +- name: Generate persistent APP_KEY on first run + ansible.builtin.shell: | + set -o pipefail + umask 077 + echo "base64:$(openssl rand -base64 32)" > {{ bookstack_docker_volume_dir }}/.app_key + args: + executable: /bin/bash + creates: "{{ bookstack_docker_volume_dir }}/.app_key" + when: + - not _app_key_file.stat.exists + - bookstack_app_key | length == 0 + +- name: Write inventory-provided APP_KEY + ansible.builtin.copy: + content: "{{ bookstack_app_key }}\n" + dest: "{{ bookstack_docker_volume_dir }}/.app_key" + mode: '0600' + when: + - not _app_key_file.stat.exists + - bookstack_app_key | length > 0 + no_log: true + +- name: Read APP_KEY back into a fact + ansible.builtin.slurp: + src: "{{ bookstack_docker_volume_dir }}/.app_key" + register: _app_key_slurp + no_log: true + +- name: Register APP_KEY fact + ansible.builtin.set_fact: + bookstack_resolved_app_key: "{{ _app_key_slurp.content | b64decode | trim }}" + no_log: true + +# ===================================================================== +# 3. DEPLOY: Render compose, bring stack up +# ===================================================================== + +- name: Render docker-compose.yml for BookStack + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ bookstack_docker_compose_dir }}/docker-compose.yml" + mode: '0640' + notify: restart bookstack + +- name: Start BookStack containers + community.docker.docker_compose_v2: + project_src: "{{ bookstack_docker_compose_dir }}" + state: present + pull: always + wait: true + +# ===================================================================== +# 4. CONFIGURE: Wait for app and seed initial admin user +# ===================================================================== + +- name: Wait for BookStack to be ready + ansible.builtin.command: + cmd: docker exec {{ bookstack_service_name }} curl -sf -o /dev/null -w "%{http_code}" http://localhost/login + register: _bookstack_health + retries: 30 + delay: 5 + until: _bookstack_health.stdout == "200" + changed_when: false + +- name: Wait for BookStack migrations to be complete + community.docker.docker_container_exec: + container: "{{ bookstack_service_name }}-db" + argv: + - mariadb + - --protocol=tcp + - -h + - 127.0.0.1 + - -u + - "{{ bookstack_db_user }}" + - "-p{{ bookstack_db_password }}" + - "{{ bookstack_db_name }}" + - -Nse + - "SHOW TABLES LIKE 'users';" + register: _users_table + retries: 30 + delay: 5 + until: _users_table.stdout | trim == 'users' + changed_when: false + no_log: true + +- name: Check whether the initial admin already exists + community.docker.docker_container_exec: + container: "{{ bookstack_service_name }}-db" + argv: + - mariadb + - --protocol=tcp + - -h + - 127.0.0.1 + - -u + - "{{ bookstack_db_user }}" + - "-p{{ bookstack_db_password }}" + - "{{ bookstack_db_name }}" + - -Nse + - "SELECT COUNT(*) FROM users WHERE email = '{{ bookstack_admin_email }}';" + register: _admin_exists + changed_when: false + no_log: true + +- name: Create initial admin user + community.docker.docker_container_exec: + container: "{{ bookstack_service_name }}" + argv: + - php + - "{{ bookstack_artisan_path }}" + - bookstack:create-admin + - "--email={{ bookstack_admin_email }}" + - "--name={{ bookstack_admin_name }}" + - "--password={{ bookstack_admin_password }}" + when: (_admin_exists.stdout | trim | int) == 0 + no_log: true + +# ===================================================================== +# 5. BACKUP: systemd timer for daily DB + uploads dump +# ===================================================================== + +- name: Render backup script + ansible.builtin.template: + src: backup.sh.j2 + dest: /usr/local/bin/bookstack-backup.sh + owner: root + group: root + mode: '0750' + when: bookstack_backup_enabled | bool + +- name: Render backup systemd service + ansible.builtin.template: + src: bookstack-backup.service.j2 + dest: /etc/systemd/system/bookstack-backup.service + mode: '0644' + when: bookstack_backup_enabled | bool + notify: reload systemd + +- name: Render backup systemd timer + ansible.builtin.template: + src: bookstack-backup.timer.j2 + dest: /etc/systemd/system/bookstack-backup.timer + mode: '0644' + when: bookstack_backup_enabled | bool + notify: reload systemd + +- name: Enable and start backup timer + ansible.builtin.systemd: + name: bookstack-backup.timer + enabled: true + state: started + daemon_reload: true + when: bookstack_backup_enabled | bool + +- name: Disable backup timer when feature is off + ansible.builtin.systemd: + name: bookstack-backup.timer + enabled: false + state: stopped + when: not (bookstack_backup_enabled | bool) + failed_when: false diff --git a/roles/bookstack/templates/backup.sh.j2 b/roles/bookstack/templates/backup.sh.j2 new file mode 100644 index 0000000..65217c2 --- /dev/null +++ b/roles/bookstack/templates/backup.sh.j2 @@ -0,0 +1,41 @@ +#!/bin/bash +# {{ ansible_managed }} +set -euo pipefail + +BACKUP_DIR="{{ bookstack_backup_dir }}" +RETENTION_DAYS={{ bookstack_backup_retention_days }} +APPDATA_DIR="{{ bookstack_appdata_dir }}" +STAMP="$(date +%Y%m%d-%H%M%S)" + +mkdir -p "$BACKUP_DIR" + +# --- DB dump (mariadb-dump from inside the DB container) --- +# Use the app user via TCP because root@localhost is unix_socket-auth only +# in the LSIO MariaDB image and root@% does not exist. +docker exec {{ bookstack_service_name }}-db \ + mariadb-dump \ + --protocol=tcp -h 127.0.0.1 \ + -u "{{ bookstack_db_user }}" -p"{{ bookstack_db_password }}" \ + --single-transaction --routines --triggers --quick \ + "{{ bookstack_db_name }}" \ + | gzip -9 > "$BACKUP_DIR/bookstack-db-$STAMP.sql.gz" + +# --- File uploads (images, attachments) --- +# LSIO BookStack stores user uploads under /config/www/{uploads,storage/uploads,files}. +tar --warning=no-file-changed \ + -czf "$BACKUP_DIR/bookstack-files-$STAMP.tar.gz" \ + -C "$APPDATA_DIR/www" \ + uploads storage/uploads files 2>/dev/null || true + +# --- APP_KEY backup (critical for restore!) --- +install -m 0600 "{{ bookstack_docker_volume_dir }}/.app_key" \ + "$BACKUP_DIR/bookstack-appkey-$STAMP.txt" + +# --- Retention --- +find "$BACKUP_DIR" -type f \ + \( -name 'bookstack-db-*.sql.gz' \ + -o -name 'bookstack-files-*.tar.gz' \ + -o -name 'bookstack-appkey-*.txt' \) \ + -mtime +"$RETENTION_DAYS" -delete + +echo "Backup complete: $STAMP" \ No newline at end of file diff --git a/roles/bookstack/templates/bookstack-backup.service.j2 b/roles/bookstack/templates/bookstack-backup.service.j2 new file mode 100644 index 0000000..cb63795 --- /dev/null +++ b/roles/bookstack/templates/bookstack-backup.service.j2 @@ -0,0 +1,12 @@ +# {{ ansible_managed }} +[Unit] +Description=BookStack backup (DB + uploads) +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/bookstack-backup.sh +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 diff --git a/roles/bookstack/templates/bookstack-backup.timer.j2 b/roles/bookstack/templates/bookstack-backup.timer.j2 new file mode 100644 index 0000000..e13238d --- /dev/null +++ b/roles/bookstack/templates/bookstack-backup.timer.j2 @@ -0,0 +1,11 @@ +# {{ ansible_managed }} +[Unit] +Description=Daily BookStack backup + +[Timer] +OnCalendar={{ bookstack_backup_schedule }} +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target diff --git a/roles/bookstack/templates/docker-compose.yml.j2 b/roles/bookstack/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..863e316 --- /dev/null +++ b/roles/bookstack/templates/docker-compose.yml.j2 @@ -0,0 +1,87 @@ +#---------------------------------------------------------------------# +# BookStack - Self-hosted wiki / knowledge base. # +#---------------------------------------------------------------------# +--- +services: + {{ bookstack_service_name }}: + image: {{ bookstack_image }} + container_name: {{ bookstack_service_name }} + restart: unless-stopped + environment: + PUID: "{{ bookstack_puid }}" + PGID: "{{ bookstack_pgid }}" + TZ: "{{ bookstack_tz }}" + APP_URL: "{{ bookstack_base_url }}" + APP_KEY: "{{ bookstack_resolved_app_key }}" + DB_HOST: "{{ bookstack_service_name }}-db" + DB_PORT: "3306" + DB_DATABASE: "{{ bookstack_db_name }}" + DB_USERNAME: "{{ bookstack_db_user }}" + DB_PASSWORD: "{{ bookstack_db_password }}" + MAIL_DRIVER: "{{ bookstack_mail_driver }}" + MAIL_HOST: "{{ bookstack_mail_host }}" + MAIL_PORT: "{{ bookstack_mail_port }}" + MAIL_USERNAME: "{{ bookstack_mail_username }}" + MAIL_PASSWORD: "{{ bookstack_mail_password }}" + MAIL_ENCRYPTION: "{{ bookstack_mail_encryption }}" + MAIL_FROM: "{{ bookstack_mail_from }}" + MAIL_FROM_NAME: "{{ bookstack_mail_from_name }}" +{% if bookstack_oidc_enabled %} + AUTH_METHOD: "oidc" + AUTH_AUTO_INITIATE: "{{ bookstack_oidc_auto_initiate | string | lower }}" + OIDC_NAME: "{{ bookstack_oidc_name }}" + OIDC_DISPLAY_NAME_CLAIMS: "name" + OIDC_CLIENT_ID: "{{ bookstack_oidc_client_id }}" + OIDC_CLIENT_SECRET: "{{ bookstack_oidc_client_secret }}" + OIDC_ISSUER: "{{ bookstack_oidc_issuer }}" + OIDC_ISSUER_DISCOVER: "true" + OIDC_END_SESSION_ENDPOINT: "true" + OIDC_ADDITIONAL_SCOPES: "{{ bookstack_oidc_additional_scopes }}" + OIDC_USER_TO_GROUPS: "{{ bookstack_oidc_user_to_groups | string | lower }}" + OIDC_GROUPS_CLAIM: "{{ bookstack_oidc_groups_claim }}" +{% endif %} + volumes: + - {{ bookstack_appdata_dir }}:/config + networks: + - {{ bookstack_traefik_network }} + - internal + depends_on: + {{ bookstack_service_name }}-db: + condition: service_healthy + labels: + - "traefik.enable=true" + - "traefik.docker.network={{ bookstack_traefik_network }}" + - "traefik.http.routers.{{ bookstack_service_name }}.rule=Host(`{{ bookstack_domain }}`)" + - "traefik.http.routers.{{ bookstack_service_name }}.entrypoints=websecure" + - "traefik.http.routers.{{ bookstack_service_name }}.tls=true" + - "traefik.http.routers.{{ bookstack_service_name }}.tls.certresolver={{ bookstack_traefik_certresolver }}" + - "traefik.http.services.{{ bookstack_service_name }}.loadbalancer.server.port=80" + + {{ bookstack_service_name }}-db: + image: {{ bookstack_db_image }} + container_name: {{ bookstack_service_name }}-db + restart: unless-stopped + environment: + PUID: "{{ bookstack_puid }}" + PGID: "{{ bookstack_pgid }}" + TZ: "{{ bookstack_tz }}" + MYSQL_ROOT_PASSWORD: "{{ bookstack_db_root_password }}" + MYSQL_DATABASE: "{{ bookstack_db_name }}" + MYSQL_USER: "{{ bookstack_db_user }}" + MYSQL_PASSWORD: "{{ bookstack_db_password }}" + volumes: + - {{ bookstack_db_data_dir }}:/config + networks: + - internal + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u root --password=\"$$MYSQL_ROOT_PASSWORD\" --silent"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + +networks: + {{ bookstack_traefik_network }}: + external: true + internal: + driver: bridge diff --git a/roles/bookstack/tests/inventory b/roles/bookstack/tests/inventory new file mode 100644 index 0000000..2fbb50c --- /dev/null +++ b/roles/bookstack/tests/inventory @@ -0,0 +1 @@ +localhost diff --git a/roles/bookstack/tests/test.yml b/roles/bookstack/tests/test.yml new file mode 100644 index 0000000..15b9be3 --- /dev/null +++ b/roles/bookstack/tests/test.yml @@ -0,0 +1,5 @@ +--- +- hosts: localhost + remote_user: root + roles: + - bookstack diff --git a/roles/bookstack/vars/main.yml b/roles/bookstack/vars/main.yml new file mode 100644 index 0000000..e04b89a --- /dev/null +++ b/roles/bookstack/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for bookstack diff --git a/roles/homarr/filter_plugins/tests/test_homarr_layout.py b/roles/homarr/filter_plugins/tests/test_homarr_layout.py index 3a49f2b..a96d672 100644 --- a/roles/homarr/filter_plugins/tests/test_homarr_layout.py +++ b/roles/homarr/filter_plugins/tests/test_homarr_layout.py @@ -15,7 +15,11 @@ import sys # Make the filter importable without having Ansible auto-discovery in # the way (it would only run during a real `ansible-playbook` invocation). -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert( + 0, + os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', + 'plugins', 'filter') +) import pytest # noqa: E402 diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index 9d00cde..ffb0bb7 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -136,7 +136,7 @@ - name: Compute Homarr app layouts ansible.builtin.set_fact: - homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}" + homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}" - name: Show computed app layouts ansible.builtin.debug: From 9cbfab7080ae9cac06ec278b18ade01be82ff310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:13:30 +0200 Subject: [PATCH 03/10] docs(bookstack): add meta/argument_specs.yml 47 typed options covering the full defaults file plus the OIDC and backup-timer subsystems. The three secrets the role asserts on (db_root_password, db_password, admin_password) are marked required: true so ansible refuses the play with a clear error before the validate task even runs. Loads cleanly through ansible-core's ArgumentSpecValidator with 100% defaults/spec coverage. Matches the spec convention used by traefik, authentik, drawio, garage, nextcloud, opnform, coturn, talk and send. --- roles/bookstack/meta/argument_specs.yml | 194 ++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 roles/bookstack/meta/argument_specs.yml diff --git a/roles/bookstack/meta/argument_specs.yml b/roles/bookstack/meta/argument_specs.yml new file mode 100644 index 0000000..8546cde --- /dev/null +++ b/roles/bookstack/meta/argument_specs.yml @@ -0,0 +1,194 @@ +--- +argument_specs: + main: + short_description: Deploy BookStack (LSIO image + MariaDB) via Docker Compose. + description: + - Renders a Compose stack for the linuxserver.io BookStack image + with a sibling MariaDB container behind Traefik, then bootstraps + the initial admin user via C(php artisan bookstack:create-admin) + and optionally enables OIDC SSO (Entra ID by default). + - "Persists the Laravel C(APP_KEY) on the host so the same key is + re-used across deploys (a fresh key would orphan all encrypted + database values: 2FA secrets, API tokens, OIDC client_secret)." + - Ships an optional systemd timer that backs up the database dump, + uploads tarball and APP_KEY daily with configurable retention. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + bookstack_service_name: + type: str + default: bookstack + bookstack_docker_compose_dir: + type: path + bookstack_docker_volume_dir: + type: path + bookstack_appdata_dir: + type: path + bookstack_db_data_dir: + type: path + bookstack_backup_dir: + type: path + + bookstack_domain: + type: str + default: wiki.local.test + description: Hostname used in the Traefik Host rule. + bookstack_base_url: + type: str + description: Defaults to C("https://{{ bookstack_domain }}"). + + bookstack_image: + type: str + default: "lscr.io/linuxserver/bookstack:version-v26.03.3" + bookstack_db_image: + type: str + default: "lscr.io/linuxserver/mariadb:11.4.9" + + bookstack_traefik_network: + type: str + default: proxy + bookstack_traefik_certresolver: + type: str + default: le + + bookstack_tz: + type: str + default: Europe/Zurich + bookstack_puid: + type: str + default: "1000" + bookstack_pgid: + type: str + default: "1000" + + bookstack_db_name: + type: str + default: bookstack + bookstack_db_user: + type: str + default: bookstack + bookstack_db_root_password: + type: str + required: true + description: MariaDB C(root) password. Override per-inventory. + bookstack_db_password: + type: str + required: true + description: MariaDB C(bookstack_db_user) password. Override per-inventory. + + bookstack_admin_password: + type: str + required: true + description: + - Password for the local admin user that the role creates via + C(bookstack:create-admin). Lives alongside any OIDC users. + + bookstack_app_key: + type: str + default: '' + description: + - When empty the role generates a persistent C(APP_KEY) on first + run and stores it under C({{ bookstack_docker_volume_dir }}/.app_key). + Override only when restoring an existing instance — a mismatching + key orphans all encrypted database values. + + bookstack_admin_name: + type: str + default: Admin + bookstack_admin_email: + type: str + default: admin@local.test + bookstack_artisan_path: + type: path + default: /app/www/artisan + description: + - Path to BookStack's C(artisan) script inside the container. The + LSIO image's C(WORKDIR) is not the app directory, so this must + be absolute. + + bookstack_mail_driver: + type: str + choices: [smtp, log, sendmail, mailgun, ses, postmark] + default: smtp + bookstack_mail_host: + type: str + default: smtp.local.test + bookstack_mail_port: + type: int + default: 587 + bookstack_mail_encryption: + type: str + choices: [tls, ssl, ''] + default: tls + bookstack_mail_from: + type: str + default: bookstack@local.test + bookstack_mail_from_name: + type: str + default: BookStack + bookstack_mail_username: + type: str + default: '' + bookstack_mail_password: + type: str + default: '' + + bookstack_oidc_enabled: + type: bool + default: false + bookstack_oidc_name: + type: str + default: SSO + description: Display name of the SSO button on the login page. + bookstack_entra_tenant_id: + type: str + default: '' + description: Entra tenant UUID. Required when C(bookstack_oidc_enabled=true). + bookstack_oidc_issuer: + type: str + description: + - OIDC issuer URL. Defaults to the Entra v2 issuer template + built from C(bookstack_entra_tenant_id). Override for + Keycloak or any other provider. + bookstack_oidc_client_id: + type: str + default: '' + description: Required when C(bookstack_oidc_enabled=true). + bookstack_oidc_client_secret: + type: str + default: '' + description: Required when C(bookstack_oidc_enabled=true). + bookstack_oidc_auto_initiate: + type: bool + default: false + description: + - When true users are redirected straight to the IdP and the + local login is reachable only via C(?email_login=1). + bookstack_oidc_user_to_groups: + type: bool + default: false + description: + - When true BookStack syncs roles from the IdP groups claim + on every login. Requires BookStack roles whose + C(External Auth ID) matches the IdP group's Object ID. + bookstack_oidc_groups_claim: + type: str + default: groups + bookstack_oidc_additional_scopes: + type: str + default: openid profile email + + bookstack_backup_enabled: + type: bool + default: true + bookstack_backup_retention_days: + type: int + default: 14 + bookstack_backup_schedule: + type: str + default: "*-*-* 03:00:00" + description: systemd C(OnCalendar) expression for the backup timer. From e1879e96861de6c932e7dc346ddca3aff9df1b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Wed, 20 May 2026 22:00:32 +0200 Subject: [PATCH 04/10] feat(send): add role for self-hosted Send file-share service Deploys timvisee/send with a Redis backend behind Traefik. Supports local-disk or S3 storage (e.g. via the garage role). Uses the shared `*_domains` list convention so the router can accept internal *.int.* names alongside the canonical BASE_URL host. --- roles/send/README.md | 60 +++++++++++++++++++ roles/send/defaults/main.yml | 53 +++++++++++++++++ roles/send/handlers/main.yml | 9 +++ roles/send/meta/main.yml | 14 +++++ roles/send/tasks/main.yml | 28 +++++++++ roles/send/templates/docker-compose.yml.j2 | 69 ++++++++++++++++++++++ roles/send/vars/main.yml | 3 + 7 files changed, 236 insertions(+) create mode 100644 roles/send/README.md create mode 100644 roles/send/defaults/main.yml create mode 100644 roles/send/handlers/main.yml create mode 100644 roles/send/meta/main.yml create mode 100644 roles/send/tasks/main.yml create mode 100644 roles/send/templates/docker-compose.yml.j2 create mode 100644 roles/send/vars/main.yml diff --git a/roles/send/README.md b/roles/send/README.md new file mode 100644 index 0000000..339628b --- /dev/null +++ b/roles/send/README.md @@ -0,0 +1,60 @@ +Send +==== + +Deploys a self-hosted [Send](https://github.com/timvisee/send) instance +(timvisee fork of the discontinued Mozilla Send) with a Redis backend +behind Traefik, using Docker Compose. + +Requirements +------------ + +- Docker + `docker compose` plugin on the target host +- Traefik (role `digitalboard.core.traefik`) reachable via an external + Docker network named `proxy` (default) +- DNS for each entry in `send_domains` pointing at the reverse proxy +- Optional: a Garage S3 bucket if `send_storage_backend: s3` + +Role Variables +-------------- + +Important defaults (see `defaults/main.yml` for the full list): + +| Variable | Default | Description | +|---|---|---| +| `send_domains` | `["send.local.test"]` | FQDNs the router accepts; first entry is the canonical BASE_URL | +| `send_image` | `registry.gitlab.com/timvisee/send:latest` | Send container image | +| `send_max_file_size` | `1073741824` | Max upload size in bytes (1 GiB) | +| `send_max_expire_seconds` | `604800` | Max share lifetime (7 d) | +| `send_storage_backend` | `local` | `local` (volume) or `s3` | +| `send_s3_*` | `""` | S3 endpoint/bucket/key/secret (when backend is `s3`) | +| `send_use_ssl` | `true` | Issue Traefik labels for the `websecure` entrypoint | + +Dependencies +------------ + +None. + +Example Playbook +---------------- + +```yaml +- hosts: send_servers + become: true + roles: + - digitalboard.core.send +``` + +With S3 (Garage) backend: + +```yaml +send_storage_backend: s3 +send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domain'] }}" +send_s3_bucket: "send" +send_s3_access_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['key_id'] }}" +send_s3_secret_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['secret_key'] }}" +``` + +License +------- + +MIT diff --git a/roles/send/defaults/main.yml b/roles/send/defaults/main.yml new file mode 100644 index 0000000..ba3aecc --- /dev/null +++ b/roles/send/defaults/main.yml @@ -0,0 +1,53 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for send + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# Send-specific configuration +send_service_name: send +send_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ send_service_name }}" +send_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ send_service_name }}" + +# Service configuration +# FQDNs the send router accepts. The first entry is the canonical +# domain (used as BASE_URL); further entries cover internal *.int.* +# names so backend uploads can hit us without hairpinning via DMZ. +send_domains: + - "send.local.test" +send_image: "registry.gitlab.com/timvisee/send:latest" +send_port: 1443 +send_extra_hosts: [] + +# Redis backend +send_redis_image: "redis:7-alpine" +send_redis_service_name: "send-redis" + +# Send application configuration +# https://github.com/timvisee/send/blob/master/server/config.js +send_max_file_size: 1073741824 # 1 GiB in bytes +send_default_downloads: 1 +send_max_downloads: 100 +send_default_expire_seconds: 86400 # 24h +send_max_expire_seconds: 604800 # 7d +send_max_files_per_archive: 64 +send_download_counts: "1,2,3,4,5,20,50,100" +send_expire_times_seconds: "300,3600,86400,604800" + +# Storage backend: "local" (volume) or "s3" +send_storage_backend: "local" + +# S3 backend (only used when send_storage_backend == "s3") +send_s3_endpoint: "" +send_s3_bucket: "" +send_s3_region: "us-east-1" +send_s3_access_key: "" +send_s3_secret_key: "" +send_s3_use_path_style: true + +# Traefik configuration +send_traefik_network: "proxy" +send_internal_network: "send_internal" +send_use_ssl: true diff --git a/roles/send/handlers/main.yml b/roles/send/handlers/main.yml new file mode 100644 index 0000000..cb83189 --- /dev/null +++ b/roles/send/handlers/main.yml @@ -0,0 +1,9 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for send + +- name: restart send + community.docker.docker_compose_v2: + project_src: "{{ send_docker_compose_dir }}" + state: present + recreate: always diff --git a/roles/send/meta/main.yml b/roles/send/meta/main.yml new file mode 100644 index 0000000..79dedb1 --- /dev/null +++ b/roles/send/meta/main.yml @@ -0,0 +1,14 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: digitalboard + description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose + license: MIT + + min_ansible_version: 2.14 + + galaxy_tags: + - send + - filesharing + - docker + +dependencies: [] diff --git a/roles/send/tasks/main.yml b/roles/send/tasks/main.yml new file mode 100644 index 0000000..c79405a --- /dev/null +++ b/roles/send/tasks/main.yml @@ -0,0 +1,28 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for send + +- name: Create docker compose directory + file: + path: "{{ send_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create local upload directory + file: + path: "{{ send_docker_volume_dir }}/uploads" + state: directory + mode: '0755' + when: send_storage_backend == "local" + +- name: Create docker-compose file for send + template: + src: docker-compose.yml.j2 + dest: "{{ send_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + notify: restart send + +- name: Start send container + community.docker.docker_compose_v2: + project_src: "{{ send_docker_compose_dir }}" + state: present diff --git a/roles/send/templates/docker-compose.yml.j2 b/roles/send/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..a6733bb --- /dev/null +++ b/roles/send/templates/docker-compose.yml.j2 @@ -0,0 +1,69 @@ +services: + {{ send_service_name }}: + image: {{ send_image }} + container_name: {{ send_service_name }} + restart: unless-stopped + depends_on: + - {{ send_redis_service_name }} + networks: + - {{ send_traefik_network }} + - {{ send_internal_network }} +{% if send_extra_hosts is defined and send_extra_hosts | length > 0 %} + extra_hosts: +{% for host in send_extra_hosts %} + - "{{ host }}" +{% endfor %} +{% endif %} + environment: +{% if send_use_ssl %} + BASE_URL: "https://{{ send_domains[0] }}" +{% else %} + BASE_URL: "http://{{ send_domains[0] }}" +{% endif %} + REDIS_HOST: "{{ send_redis_service_name }}" + REDIS_PORT: "6379" + MAX_FILE_SIZE: "{{ send_max_file_size }}" + DEFAULT_DOWNLOADS: "{{ send_default_downloads }}" + MAX_DOWNLOADS: "{{ send_max_downloads }}" + DEFAULT_EXPIRE_SECONDS: "{{ send_default_expire_seconds }}" + MAX_EXPIRE_SECONDS: "{{ send_max_expire_seconds }}" + MAX_FILES_PER_ARCHIVE: "{{ send_max_files_per_archive }}" + DOWNLOAD_COUNTS: "{{ send_download_counts }}" + EXPIRE_TIMES_SECONDS: "{{ send_expire_times_seconds }}" +{% if send_storage_backend == "s3" %} + S3_BUCKET: "{{ send_s3_bucket }}" + S3_ENDPOINT: "{{ send_s3_endpoint }}" + S3_USE_PATH_STYLE_ENDPOINT: "{{ 'true' if send_s3_use_path_style else 'false' }}" + AWS_ACCESSKEYID: "{{ send_s3_access_key }}" + AWS_SECRETACCESSKEY: "{{ send_s3_secret_key }}" + AWS_REGION: "{{ send_s3_region }}" +{% else %} + FILE_DIR: "/uploads" + volumes: + - {{ send_docker_volume_dir }}/uploads:/uploads +{% endif %} + labels: + - traefik.enable=true + - traefik.docker.network={{ send_traefik_network }} + - traefik.http.routers.{{ send_service_name }}.rule=Host({% for d in send_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) + - traefik.http.services.{{ send_service_name }}.loadbalancer.server.port={{ send_port }} +{% if send_use_ssl %} + - traefik.http.routers.{{ send_service_name }}.entrypoints=websecure + - traefik.http.routers.{{ send_service_name }}.tls=true +{% else %} + - traefik.http.routers.{{ send_service_name }}.entrypoints=web +{% endif %} + + {{ send_redis_service_name }}: + image: {{ send_redis_image }} + container_name: {{ send_redis_service_name }} + restart: unless-stopped + networks: + - {{ send_internal_network }} + volumes: + - {{ send_docker_volume_dir }}/redis:/data + +networks: + {{ send_internal_network }}: + {{ send_traefik_network }}: + external: true diff --git a/roles/send/vars/main.yml b/roles/send/vars/main.yml new file mode 100644 index 0000000..b2a6b30 --- /dev/null +++ b/roles/send/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for send From 6ee7c2328b2e3570937c2952e580598294667581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:06:58 +0200 Subject: [PATCH 05/10] fix(send): self-review fixes (FQCN, min_ansible_version str) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * tasks/main.yml: prefix all builtin modules with ansible.builtin (file, template) — silences ansible-lint fqcn[action-core] and matches the convention used by the other roles in this collection. * meta/main.yml: change min_ansible_version from the float 2.14 to the string '2.14'. ansible-galaxy's schema requires a string here (ansible-lint schema[meta] complains otherwise — same fix I just applied to the opnform role). --- roles/send/meta/main.yml | 2 +- roles/send/tasks/main.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/roles/send/meta/main.yml b/roles/send/meta/main.yml index 79dedb1..20f9e67 100644 --- a/roles/send/meta/main.yml +++ b/roles/send/meta/main.yml @@ -4,7 +4,7 @@ galaxy_info: description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose license: MIT - min_ansible_version: 2.14 + min_ansible_version: "2.14" galaxy_tags: - send diff --git a/roles/send/tasks/main.yml b/roles/send/tasks/main.yml index c79405a..9ed8dd8 100644 --- a/roles/send/tasks/main.yml +++ b/roles/send/tasks/main.yml @@ -3,20 +3,20 @@ # tasks file for send - name: Create docker compose directory - file: + ansible.builtin.file: path: "{{ send_docker_compose_dir }}" state: directory mode: '0755' - name: Create local upload directory - file: + ansible.builtin.file: path: "{{ send_docker_volume_dir }}/uploads" state: directory mode: '0755' when: send_storage_backend == "local" - name: Create docker-compose file for send - template: + ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ send_docker_compose_dir }}/docker-compose.yml" mode: '0644' From 17155337293fccbdf3993c8b1a20a018e8209da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:07:07 +0200 Subject: [PATCH 06/10] fix(send): use Traefik v3 OR-syntax for multi-domain Host rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router rule joined send_domains with ', ' which is the v2 syntax ('Host(`a`, `b`)'). Traefik v3 expects each Host() to be its own matcher joined with the explicit '||' OR operator. With v3 the comma form is silently ignored — only the first host actually matches. Match the pattern already used in the authentik, drawio and nextcloud roles in this collection. --- roles/send/templates/docker-compose.yml.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/send/templates/docker-compose.yml.j2 b/roles/send/templates/docker-compose.yml.j2 index a6733bb..69a43ab 100644 --- a/roles/send/templates/docker-compose.yml.j2 +++ b/roles/send/templates/docker-compose.yml.j2 @@ -45,7 +45,7 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ send_traefik_network }} - - traefik.http.routers.{{ send_service_name }}.rule=Host({% for d in send_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) + - traefik.http.routers.{{ send_service_name }}.rule={% for d in send_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} - traefik.http.services.{{ send_service_name }}.loadbalancer.server.port={{ send_port }} {% if send_use_ssl %} - traefik.http.routers.{{ send_service_name }}.entrypoints=websecure From 98e40b473096ac59aa0ddede8655d94cb97b871f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:07:17 +0200 Subject: [PATCH 07/10] docs(send): add meta/argument_specs.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 29 typed options with full defaults coverage (no required: true marks — the role works with an empty S3 config when storage_backend=local). Documents the send_domains list convention, the local-vs-s3 storage choice, the timing/size limits and the Traefik / network wiring. Loads through ansible-core's ArgumentSpecValidator. Matches the spec convention used by the other roles in this collection. --- roles/send/meta/argument_specs.yml | 122 +++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 roles/send/meta/argument_specs.yml diff --git a/roles/send/meta/argument_specs.yml b/roles/send/meta/argument_specs.yml new file mode 100644 index 0000000..2e9797e --- /dev/null +++ b/roles/send/meta/argument_specs.yml @@ -0,0 +1,122 @@ +--- +argument_specs: + main: + short_description: Deploy timvisee/send (file-sharing) with a Redis backend via Docker Compose. + description: + - Renders a Compose stack with the C(timvisee/send) container and a + Redis companion behind Traefik. Storage can be local-disk or any + S3-compatible backend (e.g. the C(garage) role). + - Uses the shared C(*_domains) list convention so the router can + accept internal C(*.int.*) hostnames alongside the canonical + BASE_URL host. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + send_service_name: + type: str + default: send + send_docker_compose_dir: + type: path + send_docker_volume_dir: + type: path + + send_domains: + type: list + elements: str + default: ['send.local.test'] + description: + - FQDNs the router accepts. First entry is the canonical hostname + and is used as C(BASE_URL). Further entries cover internal + C(*.int.*) names so backend uploads can hit Send without + hairpinning via the DMZ. + send_image: + type: str + default: "registry.gitlab.com/timvisee/send:latest" + send_port: + type: int + default: 1443 + send_extra_hosts: + type: list + elements: str + default: [] + description: C(extra_hosts) entries injected into the send container (Docker C(host:ip) syntax). + + send_redis_image: + type: str + default: "redis:7-alpine" + send_redis_service_name: + type: str + default: send-redis + + send_max_file_size: + type: int + default: 1073741824 + description: Max upload size in bytes. Default is 1 GiB. + send_default_downloads: + type: int + default: 1 + send_max_downloads: + type: int + default: 100 + send_default_expire_seconds: + type: int + default: 86400 + description: Default share lifetime in seconds (24 h). + send_max_expire_seconds: + type: int + default: 604800 + description: Maximum share lifetime in seconds (7 d). + send_max_files_per_archive: + type: int + default: 64 + send_download_counts: + type: str + default: "1,2,3,4,5,20,50,100" + description: Comma-separated list of download-count options shown in the UI. + send_expire_times_seconds: + type: str + default: "300,3600,86400,604800" + description: Comma-separated list of expire-time options (seconds) shown in the UI. + + send_storage_backend: + type: str + choices: [local, s3] + default: local + description: + - C(local) keeps uploads in a host volume. C(s3) uses an + S3-compatible backend (any of the C(send_s3_*) variables is + required when this is set). + + send_s3_endpoint: + type: str + default: '' + send_s3_bucket: + type: str + default: '' + send_s3_region: + type: str + default: us-east-1 + send_s3_access_key: + type: str + default: '' + send_s3_secret_key: + type: str + default: '' + send_s3_use_path_style: + type: bool + default: true + description: Required for most non-AWS S3-compatible backends (Garage, MinIO). + + send_traefik_network: + type: str + default: proxy + send_internal_network: + type: str + default: send_internal + send_use_ssl: + type: bool + default: true From bb64ccf71efba5677f91ff56fc6434590438ae82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:40:21 +0200 Subject: [PATCH 08/10] fix(send): assert S3 credentials when storage backend is s3 When send_storage_backend=s3 the role previously deployed the container with whatever was in send_s3_* (often empty strings from the defaults). The container would then start, accept uploads, and fail to persist anything silently. Same pattern as the validate blocks in coturn, talk, bookstack and opnform: fail fast at task time with a clear error that points at the four missing variables. Skipped entirely when send_storage_backend=local (the default). --- roles/send/tasks/main.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/roles/send/tasks/main.yml b/roles/send/tasks/main.yml index 9ed8dd8..2ed91c9 100644 --- a/roles/send/tasks/main.yml +++ b/roles/send/tasks/main.yml @@ -2,6 +2,20 @@ --- # tasks file for send +- name: Assert S3 backend configuration when enabled + ansible.builtin.assert: + that: + - send_s3_endpoint | length > 0 + - send_s3_bucket | length > 0 + - send_s3_access_key | length > 0 + - send_s3_secret_key | length > 0 + fail_msg: >- + send_storage_backend is 's3' but one or more of send_s3_endpoint, + send_s3_bucket, send_s3_access_key, send_s3_secret_key is unset. + Provide via OpenBao, Ansible Vault or extra-vars — or switch + send_storage_backend to 'local'. + when: send_storage_backend == "s3" + - name: Create docker compose directory ansible.builtin.file: path: "{{ send_docker_compose_dir }}" From 518d80ec710fab6f9d425fa4a55a4d80aa36aacf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Wed, 27 May 2026 16:18:29 +0200 Subject: [PATCH 09/10] feat(services): multi-domain routing, split-horizon and OIDC hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle of cross-role changes for the gymb services deployment: - Traefik routers: OR-combine opnform/homarr/bookstack Host rules with new *_extra_domains (internal *.int.* FQDNs for a DMZ reverseproxy), and emit tls.certresolver only when traefik_cert_mode == acme (drawio, homarr, opnform, send). - Split-horizon: bookstack_extra_hosts / opnform_extra_hosts add container /etc/hosts overrides so containers reach the IdP public FQDN over the LAN. - bookstack: assert the OIDC issuer resolves concretely (reject "//v2.0"), allowing non-Entra IdPs that override bookstack_oidc_issuer. - homarr: derive the bcrypt salt from the password digest so the admin hash is idempotent — no spurious template changes / container restarts. - opnform: PATCH an existing OIDC connection instead of skipping (applies corrected inventory on re-run); add OIDC_FORCE_LOGIN (enabled only after bootstrap) and an optional direct-SSO ingress entrypoint. Docs: READMEs and meta/argument_specs.yml updated for all new variables. --- roles/bookstack/README.md | 15 ++- roles/bookstack/defaults/main.yml | 8 ++ roles/bookstack/meta/argument_specs.yml | 18 ++++ roles/bookstack/tasks/main.yml | 8 +- .../bookstack/templates/docker-compose.yml.j2 | 8 +- roles/drawio/templates/docker-compose.yml.j2 | 3 + roles/homarr/README.md | 1 + roles/homarr/defaults/main.yml | 4 + roles/homarr/tasks/main.yml | 24 ++--- roles/homarr/templates/docker-compose.yml.j2 | 5 +- roles/opnform/README.md | 52 ++++++++- roles/opnform/defaults/main.yml | 26 +++++ roles/opnform/meta/argument_specs.yml | 45 ++++++++ roles/opnform/tasks/main.yml | 101 +++++++++++++++--- roles/opnform/templates/docker-compose.yml.j2 | 14 ++- roles/opnform/templates/nginx.conf.j2 | 11 ++ roles/send/templates/docker-compose.yml.j2 | 3 + 17 files changed, 309 insertions(+), 37 deletions(-) diff --git a/roles/bookstack/README.md b/roles/bookstack/README.md index 25fb789..6dfd776 100644 --- a/roles/bookstack/README.md +++ b/roles/bookstack/README.md @@ -22,9 +22,14 @@ The role asserts these are set; the play fails fast if any is empty: | `bookstack_db_root_password` | MariaDB root password | | `bookstack_db_password` | MariaDB user password | | `bookstack_admin_password` | Initial local admin password | -| `bookstack_oidc_client_id` | Entra ID App Registration ID (if OIDC on) | -| `bookstack_oidc_client_secret` | Entra ID client secret (if OIDC on) | -| `bookstack_entra_tenant_id` | Entra tenant UUID (if OIDC on) | +| `bookstack_oidc_client_id` | OIDC client ID (if OIDC on) | +| `bookstack_oidc_client_secret` | OIDC client secret (if OIDC on) | + +When OIDC is on, the role also asserts that `bookstack_oidc_issuer` +resolves to a concrete URL. For Entra ID this means setting +`bookstack_entra_tenant_id` (the default issuer interpolates it; an unset +tenant leaves `//v2.0` and fails the assert). For other IdPs (Authentik, +Keycloak) set `bookstack_oidc_issuer` directly instead. Provide via OpenBao lookup, Ansible Vault or `--extra-vars`. Never commit real secrets. @@ -34,6 +39,10 @@ real secrets. See `defaults/main.yml`. Frequently overridden: - `bookstack_domain`, `bookstack_base_url` +- `bookstack_extra_domains` (extra Host-rule hostnames, e.g. an internal + `*.int.*` FQDN for a DMZ reverseproxy) +- `bookstack_extra_hosts` (container `/etc/hosts` overrides for + split-horizon IdP access; entries as `host:ip`) - `bookstack_image`, `bookstack_db_image` (pin in production) - `bookstack_oidc_enabled` (set `false` to disable OIDC entirely) - `bookstack_oidc_auto_initiate` (`true` redirects straight to IdP) diff --git a/roles/bookstack/defaults/main.yml b/roles/bookstack/defaults/main.yml index 3efbadb..ac464b8 100644 --- a/roles/bookstack/defaults/main.yml +++ b/roles/bookstack/defaults/main.yml @@ -16,6 +16,14 @@ bookstack_backup_dir: "{{ bookstack_docker_volume_dir }}/backup" # Service configuration bookstack_domain: "wiki.local.test" +# Additional hostnames the bookstack router answers on (e.g. an internal +# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered +# by the cert). +bookstack_extra_domains: [] +# Container-level /etc/hosts overrides — useful in split-horizon setups +# where the BookStack container needs to reach an IdP's public FQDN +# (used in the OIDC `iss` claim) over the LAN rather than via the DMZ. +bookstack_extra_hosts: [] bookstack_base_url: "https://{{ bookstack_domain }}" # Images — pin via inventory in production diff --git a/roles/bookstack/meta/argument_specs.yml b/roles/bookstack/meta/argument_specs.yml index 8546cde..07f0c06 100644 --- a/roles/bookstack/meta/argument_specs.yml +++ b/roles/bookstack/meta/argument_specs.yml @@ -37,6 +37,24 @@ argument_specs: type: str default: wiki.local.test description: Hostname used in the Traefik Host rule. + bookstack_extra_domains: + type: list + elements: str + default: [] + description: + - Additional hostnames the Traefik router answers on, OR-combined + with C(bookstack_domain). Useful for an internal C(*.int.*) FQDN + so a DMZ reverseproxy can reach a backend hostname covered by the + cert. + bookstack_extra_hosts: + type: list + elements: str + default: [] + description: + - Container-level C(/etc/hosts) overrides (Compose C(extra_hosts) + entries, C("host:ip")). Useful in split-horizon setups where the + BookStack container must reach an IdP's public FQDN (used in the + OIDC C(iss) claim) over the LAN rather than via the DMZ. bookstack_base_url: type: str description: Defaults to C("https://{{ bookstack_domain }}"). diff --git a/roles/bookstack/tasks/main.yml b/roles/bookstack/tasks/main.yml index 1ea325b..73218d2 100644 --- a/roles/bookstack/tasks/main.yml +++ b/roles/bookstack/tasks/main.yml @@ -14,7 +14,13 @@ - bookstack_admin_password | length > 0 - (not bookstack_oidc_enabled) or (bookstack_oidc_client_id | length > 0) - (not bookstack_oidc_enabled) or (bookstack_oidc_client_secret | length > 0) - - (not bookstack_oidc_enabled) or (bookstack_entra_tenant_id | length > 0) + # Issuer URL must resolve to something concrete. The Entra default + # interpolates bookstack_entra_tenant_id; an unset tenant leaves + # "//v2.0" in the URL. Allow non-Entra IdPs (Authentik, Keycloak) + # that override bookstack_oidc_issuer directly. + - (not bookstack_oidc_enabled) or + (bookstack_oidc_issuer | length > 0 and + '//v2.0' not in bookstack_oidc_issuer) fail_msg: >- One or more required secrets are unset. Provide them via OpenBao lookup, Ansible Vault or --extra-vars. See README for the full list. diff --git a/roles/bookstack/templates/docker-compose.yml.j2 b/roles/bookstack/templates/docker-compose.yml.j2 index 863e316..3300826 100644 --- a/roles/bookstack/templates/docker-compose.yml.j2 +++ b/roles/bookstack/templates/docker-compose.yml.j2 @@ -45,13 +45,19 @@ services: networks: - {{ bookstack_traefik_network }} - internal +{% if bookstack_extra_hosts | length > 0 %} + extra_hosts: +{% for host in bookstack_extra_hosts %} + - "{{ host }}" +{% endfor %} +{% endif %} depends_on: {{ bookstack_service_name }}-db: condition: service_healthy labels: - "traefik.enable=true" - "traefik.docker.network={{ bookstack_traefik_network }}" - - "traefik.http.routers.{{ bookstack_service_name }}.rule=Host(`{{ bookstack_domain }}`)" + - "traefik.http.routers.{{ bookstack_service_name }}.rule={% set _all_domains = [bookstack_domain] + (bookstack_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}" - "traefik.http.routers.{{ bookstack_service_name }}.entrypoints=websecure" - "traefik.http.routers.{{ bookstack_service_name }}.tls=true" - "traefik.http.routers.{{ bookstack_service_name }}.tls.certresolver={{ bookstack_traefik_certresolver }}" diff --git a/roles/drawio/templates/docker-compose.yml.j2 b/roles/drawio/templates/docker-compose.yml.j2 index 65eb396..a7e44b7 100644 --- a/roles/drawio/templates/docker-compose.yml.j2 +++ b/roles/drawio/templates/docker-compose.yml.j2 @@ -19,6 +19,9 @@ services: {% if drawio_use_ssl %} - traefik.http.routers.{{ drawio_service_name }}.entrypoints=websecure - traefik.http.routers.{{ drawio_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ drawio_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ drawio_service_name }}.entrypoints=web {% endif %} diff --git a/roles/homarr/README.md b/roles/homarr/README.md index 1e92cba..77d6447 100644 --- a/roles/homarr/README.md +++ b/roles/homarr/README.md @@ -46,6 +46,7 @@ See `defaults/main.yml` for the full list. Most useful overrides: | Variable | Default | Purpose | |---|---|---| | `homarr_domain` | `homarr.local.test` | Traefik Host rule | +| `homarr_extra_domains` | `[]` | Extra Host-rule hostnames (OR-combined), e.g. internal `*.int.*` FQDN | | `homarr_base_url` | `https://home.local.test` | NEXTAUTH_URL / BASE_URL | | `homarr_auth_providers` | `credentials` | `credentials`, `oidc`, or both | | `homarr_oidc_issuer` | empty | Identity provider issuer URL | diff --git a/roles/homarr/defaults/main.yml b/roles/homarr/defaults/main.yml index f6ef75e..3d22ee7 100644 --- a/roles/homarr/defaults/main.yml +++ b/roles/homarr/defaults/main.yml @@ -15,6 +15,10 @@ homarr_db: "{{ homarr_appdata_dir }}/db/db.sqlite" # Service configuration homarr_domain: "homarr.local.test" +# Additional hostnames the homarr router answers on (e.g. an internal +# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered +# by the cert). +homarr_extra_domains: [] homarr_image: "ghcr.io/homarr-labs/homarr:latest" homarr_port: 7575 homarr_use_docker: false diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index ffb0bb7..c7e4cea 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -112,19 +112,17 @@ # ===================================================================== - name: Generate bcrypt hash for admin password - ansible.builtin.shell: - cmd: python3 -c "import bcrypt, sys; print(bcrypt.hashpw(sys.stdin.read().encode(), bcrypt.gensalt(rounds=10)).decode())" - stdin: "{{ homarr_admin_password }}" - stdin_add_newline: false - delegate_to: localhost - become: false - register: bcrypt_result - changed_when: false - no_log: true - -- name: Set bcrypt hash fact ansible.builtin.set_fact: - homarr_bcrypt_hash: "{{ bcrypt_result.stdout }}" + # Deterministic salt derived from the password's SHA-256 digest so the + # hash stays stable across runs (idempotent — no spurious template + # changes / container restarts when the password is unchanged). The + # bcrypt salt alphabet is [./A-Za-z0-9]; the digest's hex chars are + # a strict subset, so we just take the first 22. + homarr_bcrypt_hash: >- + {{ homarr_admin_password + | password_hash('bcrypt', rounds=10, + salt=(homarr_admin_password + | hash('sha256'))[:22]) }} no_log: true # ===================================================================== @@ -161,4 +159,4 @@ register: seed_result changed_when: seed_result.rc == 0 when: admin_exists.stdout == "" - notify: restart homarr \ No newline at end of file + notify: restart homarr diff --git a/roles/homarr/templates/docker-compose.yml.j2 b/roles/homarr/templates/docker-compose.yml.j2 index 2d81063..5907763 100644 --- a/roles/homarr/templates/docker-compose.yml.j2 +++ b/roles/homarr/templates/docker-compose.yml.j2 @@ -29,10 +29,13 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ homarr_traefik_network }} - - traefik.http.routers.homarr.rule=Host(`{{ homarr_domain }}`) + - traefik.http.routers.homarr.rule={% set _all_domains = [homarr_domain] + (homarr_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} {% if homarr_use_ssl %} - traefik.http.routers.homarr.entrypoints=websecure - traefik.http.routers.homarr.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.homarr.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.homarr.entrypoints=web {% endif %} diff --git a/roles/opnform/README.md b/roles/opnform/README.md index 2dfad2d..0722178 100644 --- a/roles/opnform/README.md +++ b/roles/opnform/README.md @@ -11,9 +11,10 @@ Docker Compose stack behind Traefik. - Integrates the ingress container with an existing Traefik proxy network - Waits for the API container to become healthy before returning -## What this role does NOT do (stage 1) +## What this role does NOT do -- Does not pre-configure OIDC / identity_connections — set up via Admin UI +- Does not migrate existing OpnForm databases — only bootstraps fresh + installs (admin registration + OIDC connection are idempotent) ## Architecture note: why two reverse proxies? @@ -91,11 +92,14 @@ Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit ## OIDC setup -Set `opnform_oidc_enabled: true` and the role creates an +Set `opnform_oidc_enabled: true` and the role provisions an IdentityConnection on the admin's default workspace via `POST /api/open/workspaces/{id}/oidc-connections`. OpnForm enforces a -single OIDC connection per workspace, so the task is idempotent (GETs -existing connections first and skips if any exist). +single OIDC connection per workspace, so the task is idempotent: it GETs +existing connections first, then either POSTs a new one or PATCHes the +existing one to the desired state. PATCHing (rather than skipping when +one exists) keeps inventory changes — e.g. a corrected issuer — applied +on re-runs instead of leaving stale values in the DB. **Prerequisite**: the admin bootstrap must be configured (`opnform_admin_email` + `opnform_admin_password`). The OIDC API @@ -138,6 +142,44 @@ opnform_oidc_admin_group: "opnform-admins" # mapped to role=admin Valid roles: `owner`, `admin`, `editor`, `member`. +### Force OIDC-only login + +```yaml +opnform_oidc_force_login: true # default false +``` + +Sets `OIDC_FORCE_LOGIN=true` on the API: password login is disabled and +every user must authenticate via OIDC. The role keeps force-login **off** +during the first deploy (the admin/OIDC bootstrap is password-based) and +switches it on only after the OIDC connection is provisioned, recreating +the API containers. Ensure all real users have addresses under +`opnform_oidc_domain` before enabling — there is no password fallback. + +### Direct-SSO entrypoint + +OpnForm has no native way to skip the email login form and jump straight +to the IdP. When enabled, the ingress serves a tiny redirect page that +calls `/api/auth/{slug}/redirect` (no domain check) and forwards the +browser to the IdP authorize URL. + +```yaml +opnform_oidc_sso_entrypoint: true # default false +opnform_oidc_sso_path: "/sso" # link users to https:///sso +``` + +## Networking / split-horizon + +```yaml +opnform_extra_domains: [] # extra Host-rule hostnames (OR-combined) +opnform_extra_hosts: [] # API container /etc/hosts overrides ("host:ip") +``` + +`opnform_extra_domains` adds internal `*.int.*` FQDNs so a DMZ +reverseproxy can reach a backend hostname covered by the cert. +`opnform_extra_hosts` lets the API containers reach the IdP's public FQDN +(used in the OIDC `iss` claim) over the LAN when the DMZ has no NAT +loopback. + ## Example playbook ```yaml diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml index 0f61c3a..9a79b07 100644 --- a/roles/opnform/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -16,6 +16,15 @@ opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis" # Service configuration opnform_domain: "forms.local.test" +# Additional hostnames the opnform router answers on (e.g. an internal +# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered +# by the cert). +opnform_extra_domains: [] +# Container-level /etc/hosts overrides for the API containers — needed in +# split-horizon setups where the OpnForm API must reach the IdP's public +# FQDN (used in the OIDC discovery/iss claim) over the LAN rather than +# hairpinning through a DMZ that has no NAT loopback to its own public IP. +opnform_extra_hosts: [] opnform_base_url: "https://forms.local.test" # Images @@ -92,6 +101,12 @@ opnform_oidc_slug: "oidc" # with @example.com emails are redirected to the IdP). Required when # opnform_oidc_enabled is true. opnform_oidc_domain: "" +# When true, sets OIDC_FORCE_LOGIN on the api: password-based login is +# disabled entirely and every user must authenticate via OIDC. Only +# rendered when opnform_oidc_enabled is also true. Make sure all real +# users have addresses under opnform_oidc_domain before enabling — there +# is no password fallback once this is on. +opnform_oidc_force_login: false opnform_oidc_scopes: - openid - profile @@ -104,6 +119,17 @@ opnform_oidc_admin_group: "opnform-admins" # var. Each item: {idp_group: "", role: "owner|admin|editor|member"} opnform_oidc_group_role_mappings: [] +# Direct-SSO entrypoint. OpnForm has no built-in way to skip the email +# login form and jump straight to the IdP (verified: config/oidc.php only +# exposes force_login; the login form always routes by email domain). When +# this is enabled the ingress serves a tiny page at opnform_oidc_sso_path +# that calls OpnForm's /api/auth/{slug}/redirect endpoint (which performs +# no domain check) and forwards the browser to the returned authorize URL +# — nonce/state included. Link users to https:// instead +# of /login. Requires opnform_oidc_enabled. +opnform_oidc_sso_entrypoint: false +opnform_oidc_sso_path: "/sso" + # Traefik configuration opnform_traefik_network: "proxy" opnform_use_ssl: true diff --git a/roles/opnform/meta/argument_specs.yml b/roles/opnform/meta/argument_specs.yml index 9fbfc7a..5e1248d 100644 --- a/roles/opnform/meta/argument_specs.yml +++ b/roles/opnform/meta/argument_specs.yml @@ -38,6 +38,25 @@ argument_specs: type: str default: forms.local.test description: Hostname used in the traefik Host rule. + opnform_extra_domains: + type: list + elements: str + default: [] + description: + - Additional hostnames the Traefik router answers on, OR-combined + with C(opnform_domain). Useful for an internal C(*.int.*) FQDN so + a DMZ reverseproxy can reach a backend hostname covered by the + cert. + opnform_extra_hosts: + type: list + elements: str + default: [] + description: + - Container-level C(/etc/hosts) overrides for the API containers + (Compose C(extra_hosts) entries, C("host:ip")). Needed in + split-horizon setups where the OpnForm API must reach the IdP's + public FQDN (used in the OIDC discovery / C(iss) claim) over the + LAN rather than hairpinning through a DMZ with no NAT loopback. opnform_base_url: type: str default: https://forms.local.test @@ -184,6 +203,15 @@ argument_specs: description: - Email domain that triggers OIDC for matching users. Required when C(opnform_oidc_enabled=true). + opnform_oidc_force_login: + type: bool + default: false + description: + - "When true, sets C(OIDC_FORCE_LOGIN=true) on the api container: + password-based login is disabled and every user must authenticate + via OIDC. Only takes effect when C(opnform_oidc_enabled=true). + Ensure all real users have addresses under C(opnform_oidc_domain) + before enabling — there is no password fallback." opnform_oidc_scopes: type: list elements: str @@ -211,6 +239,23 @@ argument_specs: type: str required: true choices: [owner, admin, editor, member] + opnform_oidc_sso_entrypoint: + type: bool + default: false + description: + - When true (and C(opnform_oidc_enabled=true)) the nginx ingress + serves a small redirect page at C(opnform_oidc_sso_path) that + calls OpnForm's C(/api/auth/{slug}/redirect) endpoint and + forwards the browser to the returned IdP authorize URL. Lets + you link users straight to the IdP, skipping OpnForm's + email-based login form. OpnForm has no native option for this. + opnform_oidc_sso_path: + type: str + default: /sso + description: + - Path (on C(opnform_domain)) where the direct-SSO redirect page + is served when C(opnform_oidc_sso_entrypoint=true). Must start + with C(/) and not collide with OpnForm's own routes. opnform_traefik_network: type: str diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml index 68e093b..91901c5 100644 --- a/roles/opnform/tasks/main.yml +++ b/roles/opnform/tasks/main.yml @@ -76,6 +76,15 @@ mode: '0644' notify: restart opnform +# OIDC_FORCE_LOGIN disables OpnForm's password login — including the +# password-based admin/OIDC bootstrap this role performs below. So the +# first compose render always keeps force-login OFF; it is switched on +# only after the bootstrap completes (see step 7). This keeps a first +# deploy on a fresh host working even when opnform_oidc_force_login=true. +- name: Render compose with force-login disabled during bootstrap + ansible.builtin.set_fact: + _opnform_force_login_effective: false + - name: Deploy docker-compose file ansible.builtin.template: src: docker-compose.yml.j2 @@ -155,9 +164,12 @@ # ===================================================================== # 6. OIDC IDENTITY CONNECTION (optional) # ===================================================================== -# Creates a single OIDC connection on the admin's default workspace. -# OpnForm enforces one OIDC connection per workspace, so this block is -# idempotent: we GET existing connections first and skip if any exists. +# Provisions a single OIDC connection on the admin's default workspace. +# OpnForm enforces one OIDC connection per workspace, so we GET the +# existing connections first and then either POST a new one or PATCH the +# existing one to the desired state. PATCHing (rather than skipping when +# one exists) keeps inventory changes — e.g. a corrected issuer — applied +# on re-runs instead of leaving stale values in the DB forever. - name: Log in as admin to obtain OIDC API token ansible.builtin.uri: @@ -213,15 +225,12 @@ }} when: opnform_oidc_enabled | bool -- name: Create OIDC identity connection - ansible.builtin.uri: - url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" - method: POST - headers: - Host: "{{ opnform_domain }}" - Authorization: "Bearer {{ opnform_oidc_token.json.token }}" - body_format: json - body: +# Desired connection state shared by both the create (POST) and update +# (PATCH) calls below. client_secret is always sent: OpnForm's update +# endpoint only persists it when present, and on create it is required. +- name: Build desired OIDC connection body + ansible.builtin.set_fact: + _opnform_oidc_body: name: "{{ opnform_oidc_client_name }}" slug: "{{ opnform_oidc_slug }}" domain: "{{ opnform_oidc_domain }}" @@ -233,6 +242,18 @@ options: require_state: true group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}" + no_log: true + when: opnform_oidc_enabled | bool + +- name: Create OIDC identity connection + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" + method: POST + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + body_format: json + body: "{{ _opnform_oidc_body }}" status_code: [201] validate_certs: false no_log: true @@ -240,6 +261,58 @@ - opnform_oidc_enabled | bool - opnform_existing_oidc.json | length == 0 +# An OIDC connection already exists: PATCH it to the desired state so +# inventory changes (e.g. a corrected issuer) are applied. OpnForm allows +# exactly one connection per workspace, so the first entry is ours. +- name: Update existing OIDC identity connection + ansible.builtin.uri: + url: >- + https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections/{{ opnform_existing_oidc.json[0].id }} + method: PATCH + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + body_format: json + body: "{{ _opnform_oidc_body }}" + status_code: [200] + validate_certs: false + no_log: true + when: + - opnform_oidc_enabled | bool + - opnform_existing_oidc.json | length > 0 + +# ===================================================================== +# 7. ENABLE FORCE LOGIN (optional, must run last) +# ===================================================================== +# OIDC_FORCE_LOGIN disables password login — including the password-based +# admin/OIDC bootstrap above — so it is switched on only now, after the +# connection is provisioned. OpnForm itself only enforces force-login when +# an enabled OIDC connection exists, so the order matters: connection +# first, force-login second. +- name: Enable force login now that the OIDC connection exists + when: + - opnform_oidc_enabled | bool + - opnform_oidc_force_login | bool + block: + - name: Re-render compose with force-login enabled + ansible.builtin.set_fact: + _opnform_force_login_effective: true + + - name: Deploy docker-compose file with force-login enabled + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + register: _opnform_force_login_compose + + - name: Apply force-login by recreating the api containers + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: present + wait: true + wait_timeout: 180 + when: _opnform_force_login_compose is changed + - name: Display deployment info ansible.builtin.debug: msg: |- @@ -260,6 +333,10 @@ (slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }}) Users with @{{ opnform_oidc_domain }} addresses will be redirected to {{ opnform_oidc_issuer }} on login. + {% if opnform_oidc_sso_entrypoint %} + Direct-SSO entrypoint: {{ opnform_base_url }}{{ opnform_oidc_sso_path }} + (link users here to skip the email login form) + {% endif %} {% else %} OIDC: disabled (set opnform_oidc_enabled=true to auto-configure) {% endif %} diff --git a/roles/opnform/templates/docker-compose.yml.j2 b/roles/opnform/templates/docker-compose.yml.j2 index de88a33..6b5866c 100644 --- a/roles/opnform/templates/docker-compose.yml.j2 +++ b/roles/opnform/templates/docker-compose.yml.j2 @@ -6,6 +6,12 @@ services: image: {{ opnform_api_image }} container_name: opnform-api restart: unless-stopped +{% if opnform_extra_hosts | length > 0 %} + extra_hosts: +{% for host in opnform_extra_hosts %} + - "{{ host }}" +{% endfor %} +{% endif %} volumes: - {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw environment: &api-env @@ -14,6 +20,9 @@ services: APP_URL: "{{ opnform_base_url }}" APP_DEBUG: "false" SELF_HOSTED: "true" +{% if opnform_oidc_enabled and (_opnform_force_login_effective | default(false)) %} + OIDC_FORCE_LOGIN: "true" +{% endif %} LOG_CHANNEL: errorlog LOG_LEVEL: info @@ -173,10 +182,13 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ opnform_traefik_network }} - - traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`) + - traefik.http.routers.{{ opnform_service_name }}.rule={% set _all_domains = [opnform_domain] + (opnform_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} {% if opnform_use_ssl %} - traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure - traefik.http.routers.{{ opnform_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ opnform_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ opnform_service_name }}.entrypoints=web {% endif %} diff --git a/roles/opnform/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 index fa3193b..6f62840 100644 --- a/roles/opnform/templates/nginx.conf.j2 +++ b/roles/opnform/templates/nginx.conf.j2 @@ -15,6 +15,17 @@ server { index index.html index.htm index.php; +{% if opnform_oidc_enabled and opnform_oidc_sso_entrypoint %} + # Direct-SSO entrypoint: a tiny page that asks the API for the IdP + # authorize URL (no email/domain check on this endpoint) and forwards + # the browser there. Link users here instead of /login to skip the + # email field entirely. Exact-match so it wins over the `/` prefix. + location = {{ opnform_oidc_sso_path }} { + default_type text/html; + return 200 'Redirecting to sign-in…

Redirecting to sign-in…

'; + } + +{% endif %} location / { proxy_http_version 1.1; proxy_pass http://ui:3000; diff --git a/roles/send/templates/docker-compose.yml.j2 b/roles/send/templates/docker-compose.yml.j2 index 69a43ab..28f1eaa 100644 --- a/roles/send/templates/docker-compose.yml.j2 +++ b/roles/send/templates/docker-compose.yml.j2 @@ -50,6 +50,9 @@ services: {% if send_use_ssl %} - traefik.http.routers.{{ send_service_name }}.entrypoints=websecure - traefik.http.routers.{{ send_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ send_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ send_service_name }}.entrypoints=web {% endif %} From 03bf0efe443a055b136d70f714d30b3ff3d4886b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Wed, 27 May 2026 22:33:42 +0200 Subject: [PATCH 10/10] docs(collection): document all roles and fix metadata drift Replace ansible-galaxy init placeholders across the collection and correct documentation that drifted from the code, after a multi-agent review of every role README against its defaults, tasks and templates. Collection level: - README: role table for all 16 roles, requirements and role-ordering - galaxy.yml: declare community.docker and community.general deps, real description/tags/urls; normalize license to MIT-0 - meta/runtime.yml: requires_ansible '>=2.15.0' - plugins/README: document the homarr_layout filter and garage_credentials lookup instead of scaffold boilerplate Per-role meta/main.yml and README for the placeholder roles (389ds, authentik, authentik_outpost_ldap, base, collabora, drawio, garage, homarr, httpbin, keycloak, nextcloud, opencloud, traefik). Correctness fixes found during review: - keycloak: wrong domain default, drop invented keycloak_cert_resolver, document the provisioning feature - garage: root_domain is .s3., not the bare domain - opnform: jwt/front_api secrets use `openssl rand -hex 32`; align the validation fail_msg in tasks/main.yml accordingly - send: S3 example references garage_s3_domains[0] (was singular) - opencloud: document required opencloud_wopi_domain License normalized to MIT-0 across galaxy.yml, role meta and READMEs to match the SPDX headers. --- README.md | 69 +++++++++- galaxy.yml | 25 ++-- meta/runtime.yml | 5 +- plugins/README.md | 53 ++++---- roles/389ds/README.md | 63 +++++----- roles/389ds/meta/main.yml | 47 +++---- roles/authentik/README.md | 9 +- roles/authentik/meta/main.yml | 49 ++++---- roles/authentik_outpost_ldap/README.md | 62 ++++----- roles/authentik_outpost_ldap/meta/main.yml | 48 +++---- roles/base/README.md | 61 +++++---- roles/base/meta/main.yml | 46 +++---- roles/bookstack/README.md | 2 +- roles/bookstack/meta/main.yml | 2 +- roles/collabora/README.md | 64 +++++----- roles/collabora/meta/main.yml | 48 +++---- roles/drawio/meta/main.yml | 47 +++---- roles/garage/README.md | 6 +- roles/garage/defaults/main.yml | 5 +- roles/garage/meta/argument_specs.yml | 6 +- roles/garage/meta/main.yml | 48 +++---- roles/homarr/README.md | 18 ++- roles/homarr/meta/main.yml | 48 +++---- roles/httpbin/README.md | 54 ++++---- roles/httpbin/meta/main.yml | 48 +++---- roles/keycloak/README.md | 140 ++++++++++++++------- roles/keycloak/meta/main.yml | 48 +++---- roles/nextcloud/README.md | 7 +- roles/nextcloud/meta/main.yml | 28 +++++ roles/opencloud/README.md | 65 +++++----- roles/opencloud/meta/main.yml | 48 +++---- roles/opnform/README.md | 12 +- roles/opnform/tasks/main.yml | 7 +- roles/send/README.md | 8 +- roles/send/meta/main.yml | 2 +- roles/traefik/README.md | 13 +- roles/traefik/meta/argument_specs.yml | 9 +- roles/traefik/meta/main.yml | 45 +++---- 38 files changed, 740 insertions(+), 625 deletions(-) create mode 100644 roles/nextcloud/meta/main.yml diff --git a/README.md b/README.md index 5106324..f3c3168 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,68 @@ -# Ansible Collection - digitalboard.core +# Ansible Collection — digitalboard.core -Documentation for the collection. +This collection bundles the Ansible roles used to deploy the +[Digitalboard](https://git.digitalboard.ch/Digitalboard) platform: a set of +self-hosted, Docker-Compose-based services running behind Traefik, with +single sign-on provided by authentik or Keycloak. + +Each role provisions one service (or building block) as a self-contained +Docker Compose stack. Roles are consumed from the deployment repository +[reference-ansible](https://git.digitalboard.ch/Digitalboard/reference-ansible), +where inventories and playbooks tie the roles to concrete hosts. + +## Roles + +| Role | Description | +| --- | --- | +| `base` | Host baseline: Docker, apt packages and convenience tooling on Debian/Ubuntu. | +| `traefik` | Traefik v3 reverse proxy as a public DMZ proxy (file provider) or backend proxy (docker provider). | +| `authentik` | [authentik](https://goauthentik.io) IdP (server + worker + Postgres); resources via blueprints. | +| `authentik_outpost_ldap` | authentik LDAP outpost exposing an LDAP interface for apps that cannot speak OIDC. | +| `keycloak` | [Keycloak](https://www.keycloak.org/) IdP with a PostgreSQL backend. | +| `389ds` | [389 Directory Server](https://www.port389.org/) LDAP directory via Docker Compose. | +| `nextcloud` | [Nextcloud](https://nextcloud.com/) (fpm) + Postgres + Redis, optional Collabora/draw.io/notify_push. | +| `opencloud` | [OpenCloud](https://opencloud.eu/) file platform via Docker Compose. | +| `collabora` | [Collabora Online](https://www.collaboraonline.com/) (CODE), used as the WOPI backend for Nextcloud. | +| `bookstack` | [BookStack](https://www.bookstackapp.com/) wiki (LSIO + MariaDB) with OIDC SSO and daily backups. | +| `drawio` | [draw.io](https://www.drawio.com/) diagram editor, with optional authentik ForwardAuth gating. | +| `homarr` | [Homarr](https://github.com/homarr-labs/homarr) dashboard with seeded admin user and OIDC group. | +| `opnform` | [OpnForm](https://github.com/OpnForm/OpnForm) self-hosted form builder (api + ui + db + redis). | +| `send` | [Send](https://github.com/timvisee/send) (timvisee fork) file sharing with a Redis backend. | +| `garage` | [Garage](https://garagehq.deuxfleurs.fr/) S3-compatible object storage with key/bucket provisioning. | +| `httpbin` | [httpbin](https://httpbin.org/) HTTP request/response testing service for validating Traefik ingress. | + +## Usage + +Roles are not run from this repository directly. They are consumed from the +deployment repository +[reference-ansible](https://git.digitalboard.ch/Digitalboard/reference-ansible), +which holds the inventories, group/host variables and playbooks. See that +repository's `docs/` directory for getting-started instructions, how to run +Ansible and how secrets are managed. + +Per-role variables and their defaults are documented in each role's own +`README.md` and `meta/argument_specs.yml`. + +## Requirements + +- A Debian/Ubuntu target host (the `base` role bootstraps Docker there). +- ansible-core 2.15 or newer on the controller. +- The `community.docker` collection (used by nearly every role) and + `community.general` (used by the `keycloak` role). Both are declared as + `dependencies` in `galaxy.yml` and pulled in automatically when this + collection is installed via `ansible-galaxy`. + +The role READMEs use `community.hashi_vault` lookups in their examples to source +secrets from HashiCorp Vault. That is a documented convention, not a hard +dependency of the roles — supply the variables however you prefer. + +## Role ordering + +Within a play, apply the roles in dependency order: `base` first (Docker and the +host baseline), then `traefik` (the shared reverse proxy and its Docker network), +then the individual service roles (`authentik`, `keycloak`, `nextcloud`, …), +which attach to Traefik's network and expect Docker to be present. + +## License + +MIT-0. See individual roles for per-role license metadata. diff --git a/galaxy.yml b/galaxy.yml index c208d8f..3413a07 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -23,12 +23,12 @@ authors: ### OPTIONAL but strongly recommended # A short summary description of the collection -description: your collection description +description: Ansible roles to deploy the Digitalboard self-hosted service platform (Docker Compose + Traefik + SSO) # Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only # accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file' license: -- GPL-2.0-or-later +- MIT-0 # The path to the license file for the collection. This path is relative to the root of the collection. This key is # mutually exclusive with 'license' @@ -36,25 +36,36 @@ license_file: '' # A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character # requirements as 'namespace' and 'name' -tags: [] +tags: + - digitalboard + - docker + - traefik + - sso + - selfhosted # Collections that this collection requires to be installed for it to be usable. The key of the dict is the # collection label 'namespace.name'. The value is a version range # L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version # range specifiers can be set and are separated by ',' -dependencies: {} +dependencies: + # Used by nearly every role: docker_compose_v2, docker_container, + # docker_container_exec, docker_network. Hard runtime dependency. + community.docker: '>=3.0.0' + # Used by the keycloak role (keycloak_realm/client/group/user and + # related modules) in roles/keycloak/tasks/provisioning.yml. + community.general: '>=7.0.0' # The URL of the originating SCM repository repository: https://git.digitalboard.ch/Digitalboard/digitalboard.core # The URL to any online docs -documentation: http://docs.example.com +documentation: https://git.digitalboard.ch/Digitalboard/digitalboard.core # The URL to the homepage of the collection/project -homepage: http://example.com +homepage: https://git.digitalboard.ch/Digitalboard/digitalboard.core # The URL to the collection issue tracker -issues: http://example.com/issue/tracker +issues: https://git.digitalboard.ch/Digitalboard/digitalboard.core/issues # A list of file glob-like patterns used to filter any files or directories that should not be included in the build # artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This diff --git a/meta/runtime.yml b/meta/runtime.yml index 936cae9..aafe589 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -1,8 +1,9 @@ #SPDX-License-Identifier: MIT-0 --- # Collections must specify a minimum required ansible version to upload -# to galaxy -# requires_ansible: '>=2.9.10' +# to galaxy. Aligned with the highest min_ansible_version declared by the +# roles (the traefik role requires ansible-core 2.15). +requires_ansible: '>=2.15.0' # Content that Ansible needs to load from another location or that has # been deprecated/removed diff --git a/plugins/README.md b/plugins/README.md index 74076b6..ca7a63d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,31 +1,32 @@ -# Collections Plugins Directory +# Collection Plugins — digitalboard.core -This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that -is named after the type of plugin it is in. It can also include the `module_utils` and `modules` directory that -would contain module utils and modules respectively. +This collection ships a small number of custom plugins that support the roles. +They are addressed by their fully qualified name, `digitalboard.core.`. -Here is an example directory of the majority of plugins currently supported by Ansible: +## Filter plugins (`filter/`) -``` -└── plugins - ├── action - ├── become - ├── cache - ├── callback - ├── cliconf - ├── connection - ├── filter - ├── httpapi - ├── inventory - ├── lookup - ├── module_utils - ├── modules - ├── netconf - ├── shell - ├── strategy - ├── terminal - ├── test - └── vars +`homarr_layout` — computes Homarr dashboard grid layouts (desktop / tablet / +mobile breakpoints) from a list of apps, returning a ready-to-render data +structure for the SQL seed. Used by the `homarr` role. + +```yaml +- name: Compute Homarr app layouts + ansible.builtin.set_fact: + homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}" ``` -A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible-core/2.19/plugins/plugins.html). +## Lookup plugins (`lookup/`) + +`garage_credentials` — returns S3 credentials (`key_id`, `secret_key`) for a +named Garage key by executing a docker command on the target host. Used to wire +Garage object storage into consuming roles such as `nextcloud`. + +```yaml +nextcloud_s3_key: >- + {{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='backend')['key_id'] }} +nextcloud_s3_secret: >- + {{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='backend')['secret_key'] }} +``` + +No other plugin types (modules, action, callback, inventory, etc.) are currently +shipped by this collection. diff --git a/roles/389ds/README.md b/roles/389ds/README.md index 225dd44..65564f6 100644 --- a/roles/389ds/README.md +++ b/roles/389ds/README.md @@ -1,38 +1,43 @@ -Role Name -========= +# 389ds -A brief description of the role goes here. +Deploys [389 Directory Server](https://www.port389.org/) (`389ds/dirsrv`) +as an LDAP directory via Docker Compose. After the container starts, the +role creates the configured suffix and a set of base organizational +units (e.g. `users`, `groups`). -Requirements ------------- +## Requirements -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +- Docker and Docker Compose on the target host (e.g. via + `digitalboard.core.base`) +- Ansible collection: `community.docker` -Role Variables --------------- +## Role variables -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +| Variable | Default | Description | +| --- | --- | --- | +| `ds389_image` | `docker.io/389ds/dirsrv:3.1` | Container image. | +| `ds389_suffix` | `dc=example,dc=com` | Root suffix of the directory. | +| `ds389_root_dn` | `cn=Directory Manager` | Directory Manager bind DN. | +| `ds389_root_password` | `changeme` | Directory Manager password — **override this**. | +| `ds389_instance_name` | `localhost` | Directory server instance name (slapd config dir). | +| `ds389_hostname` | `389ds` | Container hostname (defaults to `ds389_service_name`). | +| `ds389_backend_network` | `backend` | Docker network LDAP clients connect over (created by Compose). | +| `ds389_ldap_port` | `3389` | Published LDAP port (container port 3389). | +| `ds389_ldaps_port` | `3636` | Published LDAPS port (container port 3636). | +| `ds389_base_ous` | `[users, groups]` | Base OUs created after startup. | -Dependencies ------------- +## Example -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +```yaml +- hosts: directory + become: true + roles: + - role: digitalboard.core.389ds + vars: + ds389_suffix: "dc=example,dc=org" + ds389_root_password: "{{ vault_ds389_root_password }}" +``` -Example Playbook ----------------- +## License -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: - - - hosts: servers - roles: - - { role: username.rolename, x: 42 } - -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/389ds/meta/main.yml b/roles/389ds/meta/main.yml index 6f91fd3..37925db 100644 --- a/roles/389ds/meta/main.yml +++ b/roles/389ds/meta/main.yml @@ -1,35 +1,26 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy 389 Directory Server (LDAP) via Docker Compose + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.2 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - 389ds + - ldap + - directory + - docker + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/authentik/README.md b/roles/authentik/README.md index b8e6345..2b909df 100644 --- a/roles/authentik/README.md +++ b/roles/authentik/README.md @@ -101,8 +101,13 @@ from the list to keep state clean. ## Dependencies -- Traefik network (`authentik_traefik_network`, default `proxy`) -- Internal backend network (`authentik_backend_network`, default `backend`) +- Run `digitalboard.core.base` first (Docker) and have the `community.docker` + collection installed; the role drives the stack via + `community.docker.docker_compose_v2`. +- Traefik network (`authentik_traefik_network`, default `proxy`) must exist + beforehand (e.g. created by the traefik role); it is referenced as an + external network in the Compose file. +- Internal backend network (`authentik_backend_network`, default `backend`). ## Example playbook diff --git a/roles/authentik/meta/main.yml b/roles/authentik/meta/main.yml index 6f91fd3..b8aef66 100644 --- a/roles/authentik/meta/main.yml +++ b/roles/authentik/meta/main.yml @@ -1,35 +1,28 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy authentik (server + worker + Postgres) via Docker Compose with blueprint-provisioned resources + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.2 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - authentik + - oidc + - sso + - idp + - docker + - traefik + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/authentik_outpost_ldap/README.md b/roles/authentik_outpost_ldap/README.md index 225dd44..430a4a0 100644 --- a/roles/authentik_outpost_ldap/README.md +++ b/roles/authentik_outpost_ldap/README.md @@ -1,38 +1,44 @@ -Role Name -========= +# authentik_outpost_ldap -A brief description of the role goes here. +Deploys an [authentik](https://goauthentik.io) LDAP outpost via Docker +Compose. The outpost exposes an LDAP interface backed by authentik, so +applications that cannot speak OIDC (e.g. Nextcloud or OpenCloud LDAP +backends) can still authenticate against the central IdP. -Requirements ------------- +The outpost connects back to an authentik server using an outpost token +issued in the authentik admin interface. The image version must match +the authentik server version. -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +## Requirements -Role Variables --------------- +- Docker and Docker Compose on the target host (e.g. via + `digitalboard.core.base`) +- Ansible collection: `community.docker` -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +## Role variables -Dependencies ------------- +| Variable | Default | Description | +| --- | --- | --- | +| `authentik_outpost_ldap_image` | `ghcr.io/goauthentik/ldap:2026.2.2` | Outpost image (match the server version). | +| `authentik_outpost_ldap_host` | `https://authentik.local.test` | URL of the authentik server. | +| `authentik_outpost_ldap_token` | `changeme` | Outpost token — **override this**. | +| `authentik_outpost_ldap_insecure` | `"true"` | Skip TLS verification toward the authentik server. | +| `authentik_outpost_ldap_network` | `ldap` | Docker network LDAP clients connect over (created by the role). | +| `authentik_outpost_ldap_authentik_network` | _unset_ | Optional extra external network to the authentik server. | +| `authentik_outpost_ldap_extra_hosts` | `[]` | Extra `host:ip` entries for in-container DNS. | -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +## Example -Example Playbook ----------------- +```yaml +- hosts: directory + become: true + roles: + - role: digitalboard.core.authentik_outpost_ldap + vars: + authentik_outpost_ldap_host: "https://auth.example.com" + authentik_outpost_ldap_token: "{{ vault_authentik_ldap_outpost_token }}" +``` -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: +## License - - hosts: servers - roles: - - { role: username.rolename, x: 42 } - -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/authentik_outpost_ldap/meta/main.yml b/roles/authentik_outpost_ldap/meta/main.yml index 6f91fd3..5f2a051 100644 --- a/roles/authentik_outpost_ldap/meta/main.yml +++ b/roles/authentik_outpost_ldap/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy an authentik LDAP outpost via Docker Compose for applications that cannot use OIDC + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.2 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - authentik + - ldap + - outpost + - sso + - docker + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/base/README.md b/roles/base/README.md index 225dd44..4d67213 100644 --- a/roles/base/README.md +++ b/roles/base/README.md @@ -1,38 +1,45 @@ -Role Name -========= +# base -A brief description of the role goes here. +Host baseline for the Digitalboard platform. Installs Docker (engine, +CLI, containerd, buildx, compose plugin) and a small set of apt and +convenience packages on Debian/Ubuntu, and sets the shared directory +layout every other role builds on. -Requirements ------------- +This role is intended to run first on every host, before any +service role. -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +## What it does -Role Variables --------------- +- Installs Docker prerequisites (`apt-transport-https`, `ca-certificates`, + `curl`, `gnupg`, `lsb-release`, `apache2-utils` for `htpasswd`) plus + convenience packages (`htop`, `ncdu`, `vim`) and Docker itself + (`docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, + `docker-compose-plugin`). +- Optionally configures Docker registry mirrors via `/etc/docker/daemon.json`. +- Starts and enables the Docker service and writes a custom `/etc/motd`. -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +This role defines the shared directory-layout variables +(`docker_compose_base_dir`, `docker_volume_base_dir`) that every service +role consumes, but the per-service subdirectories are created by the +respective service roles, not here. -Dependencies ------------- +## Role variables -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +| Variable | Default | Description | +| --- | --- | --- | +| `docker_compose_base_dir` | `/etc/docker/compose` | Root directory for per-service Compose projects. | +| `docker_volume_base_dir` | `/srv/data` | Root directory for per-service persistent volumes. | +| `docker_registry_mirrors` | `[]` | Optional list of registry mirror URLs; empty disables mirrors. | -Example Playbook ----------------- +## Example -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: +```yaml +- hosts: all + become: true + roles: + - digitalboard.core.base +``` - - hosts: servers - roles: - - { role: username.rolename, x: 42 } +## License -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/base/meta/main.yml b/roles/base/meta/main.yml index 36b9858..4e2a015 100644 --- a/roles/base/meta/main.yml +++ b/roles/base/meta/main.yml @@ -1,35 +1,25 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Host baseline — install Docker, required apt packages and convenience tooling on Debian/Ubuntu + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.1 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - base + - docker + - bootstrap + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/bookstack/README.md b/roles/bookstack/README.md index 6dfd776..83e0164 100644 --- a/roles/bookstack/README.md +++ b/roles/bookstack/README.md @@ -151,4 +151,4 @@ Restore procedure: ## License -MIT +MIT-0 diff --git a/roles/bookstack/meta/main.yml b/roles/bookstack/meta/main.yml index a6e941d..dad0716 100644 --- a/roles/bookstack/meta/main.yml +++ b/roles/bookstack/meta/main.yml @@ -2,7 +2,7 @@ galaxy_info: author: digitalboard description: Deploy BookStack as a self-contained Docker Compose stack behind Traefik company: digitalboard - license: MIT + license: MIT-0 min_ansible_version: "2.14" diff --git a/roles/collabora/README.md b/roles/collabora/README.md index 225dd44..d216c49 100644 --- a/roles/collabora/README.md +++ b/roles/collabora/README.md @@ -1,38 +1,42 @@ -Role Name -========= +# collabora -A brief description of the role goes here. +Deploys [Collabora Online](https://www.collaboraonline.com/) (CODE, +`collabora/code`) via Docker Compose behind Traefik. Collabora is the +WOPI backend that renders office documents for Nextcloud and OpenCloud. -Requirements ------------- +The role templates `coolwsd.xml` to declare which WOPI hosts may call +Collabora and which origins may embed it in an iframe. -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +## Role variables -Role Variables --------------- +| Variable | Default | Description | +| --- | --- | --- | +| `collabora_domains` | `[office.local.test]` | FQDNs the router accepts; first is canonical. | +| `collabora_image` | `collabora/code:latest` | Container image. | +| `collabora_port` | `9980` | Container port Traefik forwards to. | +| `collabora_traefik_network` | `proxy` | Docker network shared with Traefik. | +| `collabora_use_ssl` | `true` | Enable the TLS resolver on the router. | +| `collabora_ssl_verification` | `true` | Verify TLS on WOPI callbacks (false for self-signed). | +| `collabora_allowed_domains` | `[nextcloud.local.test]` | WOPI hosts allowed to call Collabora (regex). | +| `collabora_frame_ancestors` | `[nextcloud.local.test]` | Origins allowed to embed Collabora in an iframe. | +| `collabora_extra_hosts` | `[]` | Extra `host:ip` entries for in-container DNS. | -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +## Example -Dependencies ------------- +```yaml +- hosts: services + become: true + roles: + - role: digitalboard.core.collabora + vars: + collabora_domains: + - "office.example.com" + collabora_allowed_domains: + - "cloud.example.com" + collabora_frame_ancestors: + - "cloud.example.com" +``` -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +## License -Example Playbook ----------------- - -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: - - - hosts: servers - roles: - - { role: username.rolename, x: 42 } - -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/collabora/meta/main.yml b/roles/collabora/meta/main.yml index 6f91fd3..0dc353e 100644 --- a/roles/collabora/meta/main.yml +++ b/roles/collabora/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy Collabora Online (CODE) as a WOPI backend via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.2 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - collabora + - office + - wopi + - nextcloud + - docker + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/drawio/meta/main.yml b/roles/drawio/meta/main.yml index 6f91fd3..b61826f 100644 --- a/roles/drawio/meta/main.yml +++ b/roles/drawio/meta/main.yml @@ -1,35 +1,26 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy the draw.io diagram editor via Docker Compose behind Traefik, with optional authentik ForwardAuth + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.2 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - drawio + - diagrams + - docker + - traefik + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/garage/README.md b/roles/garage/README.md index 4996eb8..35fdc04 100644 --- a/roles/garage/README.md +++ b/roles/garage/README.md @@ -20,8 +20,10 @@ common overrides: ### Service -- `garage_s3_domains`: FQDNs the S3 router accepts. First entry is the - canonical hostname and is used as `root_domain` in `garage.toml`. +- `garage_s3_domains`: FQDNs the S3 router accepts. The first entry is the + canonical hostname; `garage.toml` derives the virtual-hosted-style S3 + `root_domain` from it as `.s3.` (so buckets resolve under + `.s3.`). - `garage_web_domain`, `garage_webui_domain`: separate hostnames for the S3-website endpoint and the console. - `garage_image`, `garage_replication_factor`, `garage_db_engine`, diff --git a/roles/garage/defaults/main.yml b/roles/garage/defaults/main.yml index 5a207eb..3820a03 100644 --- a/roles/garage/defaults/main.yml +++ b/roles/garage/defaults/main.yml @@ -14,8 +14,9 @@ garage_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ garage_service_name } # Garage service configuration garage_image: "dxflrs/garage:v2.1.0" # FQDNs the garage S3 router accepts. The first entry is the canonical -# domain and is also used as the virtual-hosted-style root_domain in -# garage.toml; further entries cover internal *.int.* names. +# domain; garage.toml derives the virtual-hosted-style S3 root_domain +# from it as ".s3."; further entries cover internal +# *.int.* names. garage_s3_domains: - "storage.local.test" garage_web_domain: "web.storage.local.test" diff --git a/roles/garage/meta/argument_specs.yml b/roles/garage/meta/argument_specs.yml index 8441495..b5cb0f5 100644 --- a/roles/garage/meta/argument_specs.yml +++ b/roles/garage/meta/argument_specs.yml @@ -35,9 +35,9 @@ argument_specs: default: ['storage.local.test'] description: - FQDNs the garage S3 router accepts. The first entry is the - canonical domain and is used as the virtual-hosted-style - C(root_domain) in C(garage.toml). Further entries cover internal - C(*.int.*) names. + canonical domain; C(garage.toml) derives the virtual-hosted-style + S3 C(root_domain) from it as C(.s3.). Further entries + cover internal C(*.int.*) names. garage_web_domain: type: str default: web.storage.local.test diff --git a/roles/garage/meta/main.yml b/roles/garage/meta/main.yml index 36b9858..5442c5f 100644 --- a/roles/garage/meta/main.yml +++ b/roles/garage/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy Garage S3-compatible object storage via Docker Compose, with declarative key/bucket provisioning + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.1 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - garage + - s3 + - storage + - object-storage + - docker + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/homarr/README.md b/roles/homarr/README.md index 77d6447..774b598 100644 --- a/roles/homarr/README.md +++ b/roles/homarr/README.md @@ -36,8 +36,10 @@ secrets to version control.** | `homarr_admin_password` | strong password | `openssl rand -base64 24` | | `homarr_oidc_client_secret` | from your identity provider | — | -The `assert` task at the top of the role will fail fast if the encryption -key is missing or malformed. +`homarr_oidc_client_secret` is only required when `oidc` is in +`homarr_auth_providers`; the role asserts it then. The encryption key is +always required — the `assert` task at the top of the role fails fast if it +is missing or malformed. ## Configurable variables @@ -113,7 +115,7 @@ The filter is invoked once from `tasks/main.yml`: ```yaml - name: Compute Homarr app layouts ansible.builtin.set_fact: - homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}" + homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}" ``` This produces a `homarr_layout` fact with two keys, both consumed by @@ -121,14 +123,14 @@ This produces a `homarr_layout` fact with two keys, both consumed by | Key | Shape | Purpose | |---|---|---| -| `apps` | list, same order as `homarr_apps` | each entry enriched with `desktop`, `tablet`, `mobile` sub-dicts of `{x, y, w, h}` | +| `apps` | list, same order as `homarr_apps` | each entry gains `desktop`/`tablet`/`mobile` dicts of `{x, y, w, h}` | | `section_height` | dict with `desktop`, `tablet`, `mobile` | minimum height of the parent section so all tiles fit | The filter signature accepts custom column counts if Homarr ever changes the breakpoint widths: ```jinja -{{ homarr_apps | homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }} +{{ homarr_apps | digitalboard.core.homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }} ``` To debug a layout without running the full deploy, run the play with @@ -241,4 +243,8 @@ and lowercase are accepted. **App tiles overlap.** Check `homarr_apps` for duplicate `id` values. The role validates this, but if you bypass the check, the seed will -still run and Homarr will display only one of the duplicates. \ No newline at end of file +still run and Homarr will display only one of the duplicates. + +## License + +MIT-0 diff --git a/roles/homarr/meta/main.yml b/roles/homarr/meta/main.yml index faea947..4818e67 100644 --- a/roles/homarr/meta/main.yml +++ b/roles/homarr/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy the Homarr dashboard via Docker Compose behind Traefik, with seeded admin user and OIDC group + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.2 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - homarr + - dashboard + - oidc + - docker + - traefik + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. \ No newline at end of file diff --git a/roles/httpbin/README.md b/roles/httpbin/README.md index 225dd44..45f9286 100644 --- a/roles/httpbin/README.md +++ b/roles/httpbin/README.md @@ -1,38 +1,30 @@ -Role Name -========= +# httpbin -A brief description of the role goes here. +Deploys [httpbin](https://httpbin.org/) (`kennethreitz/httpbin`) via +Docker Compose behind Traefik. Useful as a throwaway endpoint to verify +that the Traefik ingress path, TLS and routing work end to end. -Requirements ------------- +## Role variables -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +| Variable | Default | Description | +| --- | --- | --- | +| `httpbin_domain` | `httpbin.local.test` | FQDN the Traefik router matches. | +| `httpbin_image` | `kennethreitz/httpbin` | Container image. | +| `httpbin_port` | `80` | Container port Traefik forwards to. | +| `httpbin_traefik_network` | `proxy` | Docker network shared with Traefik. | +| `httpbin_use_ssl` | `true` | Route via the `websecure` entrypoint with `tls=true` (otherwise `web`). | -Role Variables --------------- +## Example -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +```yaml +- hosts: services + become: true + roles: + - role: digitalboard.core.httpbin + vars: + httpbin_domain: "httpbin.example.com" +``` -Dependencies ------------- +## License -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. - -Example Playbook ----------------- - -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: - - - hosts: servers - roles: - - { role: username.rolename, x: 42 } - -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/httpbin/meta/main.yml b/roles/httpbin/meta/main.yml index 36b9858..81e9ee6 100644 --- a/roles/httpbin/meta/main.yml +++ b/roles/httpbin/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy httpbin HTTP request/response testing service via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.1 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - httpbin + - testing + - debug + - docker + - traefik + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/keycloak/README.md b/roles/keycloak/README.md index 860a0b1..a94689f 100644 --- a/roles/keycloak/README.md +++ b/roles/keycloak/README.md @@ -1,65 +1,119 @@ -Keycloak -========= +# Keycloak -Ansible role to deploy Keycloak with PostgreSQL database using Docker Compose. +Ansible role to deploy Keycloak with a PostgreSQL backend via Docker +Compose, published behind Traefik. Optionally provisions realm resources +(groups, users, OIDC clients, identity providers, LDAP user federations) +through the `community.general` Keycloak modules. -Requirements ------------- +## Requirements -- Docker and Docker Compose installed on the target host -- Ansible collection: `community.docker` -- Traefik reverse proxy (for external access) +- Docker and Docker Compose on the target host (e.g. via + `digitalboard.core.base`) +- Ansible collections: `community.docker`, and `community.general` when + `keycloak_provisioning_enabled` is true +- Traefik reverse proxy with the `proxy` network already created (for + external access) -Role Variables --------------- +## Role variables -Key variables defined in `defaults/main.yml`: +Key variables from `defaults/main.yml`: -**Base Configuration:** -- `docker_compose_base_dir`: Base directory for Docker Compose files (default: `/etc/docker/compose`) -- `docker_volume_base_dir`: Base directory for Docker volumes (default: `/srv/data`) +### Base configuration -**Keycloak Configuration:** -- `keycloak_service_name`: Service name (default: `keycloak`) -- `keycloak_domain`: Domain name for Keycloak (default: `auth.digitalboard.ch`) -- `keycloak_image`: Keycloak Docker image (default: `quay.io/keycloak/keycloak:24.0.1`) -- `keycloak_port`: Internal Keycloak port (default: `8080`) -- `keycloak_admin_user`: Admin username (default: `admin`) -- `keycloak_admin_password`: Admin password (default: `changeme`) -- `keycloak_log_level`: Log level (default: `INFO`) -- `keycloak_proxy_mode`: Proxy mode (default: `edge`) +| Variable | Default | Description | +| --- | --- | --- | +| `docker_compose_base_dir` | `/etc/docker/compose` | Base dir for Compose projects. | +| `docker_volume_base_dir` | `/srv/data` | Base dir for persistent volumes. | +| `keycloak_service_name` | `keycloak` | Compose/service name; builds the per-service paths. | -**PostgreSQL Configuration:** -- `keycloak_postgres_image`: PostgreSQL Docker image (default: `postgres:15`) -- `keycloak_postgres_db`: Database name (default: `keycloak`) -- `keycloak_postgres_user`: Database user (default: `keycloak`) -- `keycloak_postgres_password`: Database password (default: `changeme`) +### Keycloak -**Traefik Configuration:** -- `keycloak_traefik_network`: Traefik network name (default: `proxy`) -- `keycloak_backend_network`: Backend network name (default: `backend`) -- `keycloak_use_ssl`: Enable SSL (default: `true`) -- `keycloak_cert_resolver`: Certificate resolver name (default: `dns`) +| Variable | Default | Description | +| --- | --- | --- | +| `keycloak_domain` | `keycloak.local.test` | Host rule and `KC_HOSTNAME`. | +| `keycloak_image` | `quay.io/keycloak/keycloak:24.0.1` | Keycloak image. | +| `keycloak_port` | `8080` | Internal HTTP port advertised to Traefik. | +| `keycloak_admin_user` | `admin` | Bootstrap admin user. | +| `keycloak_admin_password` | `changeme` | Admin password — **override this**. | +| `keycloak_log_level` | `INFO` | `KC_LOG_LEVEL`. | +| `keycloak_proxy_mode` | `edge` | `KC_PROXY` mode. | +| `keycloak_gzip_enabled` | `false` | Toggle Keycloak GZIP response encoding. | +| `keycloak_truststore_certificates` | `[]` | Host PEM paths mounted into the truststore (`KC_TRUSTSTORE_PATHS`). | +| `keycloak_extra_hosts` | `[]` | Extra `host:ip` entries for the container. | -Dependencies ------------- +### PostgreSQL -This role requires the Traefik reverse proxy to be configured and the `proxy` network to be created. +| Variable | Default | Description | +| --- | --- | --- | +| `keycloak_postgres_image` | `postgres:15` | PostgreSQL image. | +| `keycloak_postgres_db` | `keycloak` | Database name. | +| `keycloak_postgres_user` | `keycloak` | Database user. | +| `keycloak_postgres_password` | `changeme` | Database password — **override this**. | -Example Playbook ----------------- +### Traefik + +| Variable | Default | Description | +| --- | --- | --- | +| `keycloak_traefik_network` | `proxy` | External Traefik network. | +| `keycloak_backend_network` | `backend` | Internal network to PostgreSQL. | +| `keycloak_use_ssl` | `true` | Route on `websecure` with `tls=true` instead of `web`. | + +TLS is requested from Traefik via `tls=true`; the role does not set a +certificate resolver, so Traefik issues/serves the certificate according +to its own configuration. + +### Provisioning (optional) + +Provisioning runs only when `keycloak_provisioning_enabled` is true. The +tasks wait for the `/health/ready` endpoint and then call the +`community.general.keycloak_*` modules, delegated to `localhost` against +`keycloak_auth_url` (derived from `keycloak_use_ssl` + `keycloak_domain`). + +| Variable | Default | Description | +| --- | --- | --- | +| `keycloak_provisioning_enabled` | `false` | Enable realm provisioning. | +| `keycloak_realm` | `default` | Target realm; created unless `master`. | +| `keycloak_realm_display_name` | `Default Realm` | Realm display name. | +| `keycloak_auth_url` | derived | API base URL for provisioning. | +| `keycloak_groups` | `[]` | Groups to create. | +| `keycloak_local_users` | `[]` | Local users to create. | +| `keycloak_oidc_clients` | `[]` | OIDC clients to create. | +| `keycloak_identity_providers` | `[]` | Identity providers (e.g. Entra ID). | +| `keycloak_user_federations` | `[]` | LDAP user federations. | +| `keycloak_removed_users` | `[]` | Usernames to delete. | +| `keycloak_removed_groups` | `[]` | Group names to delete. | +| `keycloak_removed_clients` | `[]` | Client IDs to delete. | +| `keycloak_removed_identity_providers` | `[]` | IdP aliases to delete. | +| `keycloak_removed_user_federations` | `[]` | Federation names to delete. | + +See `defaults/main.yml` for the full entry shape of each list. + +## Dependencies + +This role requires the Traefik reverse proxy to be configured and the +`proxy` network to be created beforehand (it is referenced as an external +network in the Compose file). The `backend` network is created by the +Compose project itself. + +## Example playbook ```yaml - hosts: backend_servers roles: - - role: keycloak + - role: digitalboard.core.keycloak vars: keycloak_domain: "auth.example.com" - keycloak_admin_password: "secure_password" - keycloak_postgres_password: "secure_db_password" + keycloak_admin_password: "{{ vault_keycloak_admin_password }}" + keycloak_postgres_password: "{{ vault_keycloak_pg_password }}" + keycloak_provisioning_enabled: true + keycloak_oidc_clients: + - client_id: nextcloud + name: "Nextcloud" + client_secret: "{{ vault_nextcloud_client_secret }}" + redirect_uris: + - "https://nextcloud.example.com/apps/user_oidc/code" ``` -License -------- +## License MIT-0 diff --git a/roles/keycloak/meta/main.yml b/roles/keycloak/meta/main.yml index 36b9858..fc62b75 100644 --- a/roles/keycloak/meta/main.yml +++ b/roles/keycloak/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy Keycloak with a PostgreSQL backend via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.1 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - keycloak + - oidc + - sso + - docker + - traefik + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index 79214c4..f4cafa9 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -15,9 +15,10 @@ backends. the stored value differs, so re-runs don't churn - Sets up notify_push (when enabled) - Applies an in-container PHP source workaround for the upstream - `UserConfig::getValueBool` TypeError on Nextcloud 33.0.3 (idempotent - via grep guard; remove the patch task once the deployed image - ships the upstream fix) + `UserConfig::getValueBool` TypeError (nextcloud/server#59629, fixed in + master via PR #59646 with no stable33 backport before 33.0.4). + Idempotent via grep guard; remove the patch task once + `nextcloud_image` is >= 33.0.4. ## Requirements diff --git a/roles/nextcloud/meta/main.yml b/roles/nextcloud/meta/main.yml new file mode 100644 index 0000000..1acd37d --- /dev/null +++ b/roles/nextcloud/meta/main.yml @@ -0,0 +1,28 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: digitalboard + description: Deploy Nextcloud (fpm) + Redis + Postgres via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 + + min_ansible_version: "2.14" + + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble + + galaxy_tags: + - nextcloud + - files + - collabora + - oidc + - docker + - traefik + - digitalboard + +dependencies: [] diff --git a/roles/opencloud/README.md b/roles/opencloud/README.md index 225dd44..6969124 100644 --- a/roles/opencloud/README.md +++ b/roles/opencloud/README.md @@ -1,38 +1,43 @@ -Role Name -========= +# opencloud -A brief description of the role goes here. +Deploys [OpenCloud](https://opencloud.eu/) (`opencloudeu/opencloud`) as a +self-contained file platform via Docker Compose behind Traefik. Supports +the built-in IdP or external OIDC, optional S3 storage, external LDAP, +Collabora and draw.io integration, and OIDC-claim-based role assignment. -Requirements ------------- +## Role variables -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +A selection of the most relevant variables — see +[defaults/main.yml](defaults/main.yml) for the full set. -Role Variables --------------- +| Variable | Default | Description | +| --- | --- | --- | +| `opencloud_domain` | `opencloud.local.test` | FQDN the Traefik router matches. | +| `opencloud_image` | `opencloudeu/opencloud:latest` | Container image. | +| `opencloud_port` | `9200` | Container port Traefik forwards to. | +| `opencloud_admin_password` | `admin` | Initial admin password — **override this**. | +| `opencloud_traefik_network` | `proxy` | Docker network shared with Traefik. | +| `opencloud_use_ssl` | `true` | Enable the TLS resolver on the router. | +| `opencloud_oidc_issuer` | `""` | External OIDC issuer; empty uses the built-in IdP. | +| `opencloud_use_s3_storage` | `false` | Use S3 storage instead of local disk. | +| `opencloud_ldap_uri` | `""` | External LDAP URI; empty uses the built-in directory. | +| `opencloud_collabora_domain` | `""` | Collabora server domain; set with `opencloud_wopi_domain` to enable editing. | +| `opencloud_wopi_domain` | `""` | WOPI server FQDN; required alongside `opencloud_collabora_domain`. | +| `opencloud_drawio_url` | `""` | draw.io URL; set to enable diagram editing. | +| `opencloud_role_assignment_driver` | `default` | Set to `oidc` to map OIDC claims to roles. | -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +## Example -Dependencies ------------- +```yaml +- hosts: services + become: true + roles: + - role: digitalboard.core.opencloud + vars: + opencloud_domain: "opencloud.example.com" + opencloud_admin_password: "{{ vault_opencloud_admin_password }}" +``` -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +## License -Example Playbook ----------------- - -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: - - - hosts: servers - roles: - - { role: username.rolename, x: 42 } - -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/opencloud/meta/main.yml b/roles/opencloud/meta/main.yml index 6f91fd3..3322149 100644 --- a/roles/opencloud/meta/main.yml +++ b/roles/opencloud/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy OpenCloud file platform via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.2 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - opencloud + - files + - storage + - docker + - traefik + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/opnform/README.md b/roles/opnform/README.md index 0722178..3773ed7 100644 --- a/roles/opnform/README.md +++ b/roles/opnform/README.md @@ -46,10 +46,14 @@ secrets to version control.** | Variable | Format | Generate with | |---|---|---| | `opnform_app_key` | `base64:<32 bytes base64>` | `echo "base64:$(openssl rand -base64 32)"` | -| `opnform_jwt_secret` | 32 bytes base64 | `openssl rand -base64 32` | -| `opnform_front_api_secret` | 32 bytes base64 | `openssl rand -base64 32` | +| `opnform_jwt_secret` | 32-byte hex string | `openssl rand -hex 32` | +| `opnform_front_api_secret` | 32-byte hex string | `openssl rand -hex 32` | | `opnform_db_password` | strong password | `openssl rand -base64 24` | +`opnform_app_key` MUST keep the `base64:` prefix — the validation task +asserts it. `opnform_jwt_secret` and `opnform_front_api_secret` have no +enforced format; any sufficiently random value works. + When `opnform_oidc_enabled` is `true`: | Variable | Source | @@ -209,3 +213,7 @@ opnform_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', 'digitalboard/opnform', mount_point='kv').data.data.db_password }}" ``` + +## License + +MIT-0 diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml index 91901c5..71048e5 100644 --- a/roles/opnform/tasks/main.yml +++ b/roles/opnform/tasks/main.yml @@ -15,10 +15,11 @@ - opnform_front_api_secret | length > 0 - opnform_db_password | length > 0 fail_msg: >- - OpnForm requires opnform_app_key (prefix 'base64:'), opnform_jwt_secret, + OpnForm requires opnform_app_key, opnform_jwt_secret, opnform_front_api_secret and opnform_db_password. - Generate with: openssl rand -base64 32 - The app_key MUST be prefixed with "base64:" + Generate with: + opnform_app_key='base64:'$(openssl rand -base64 32) (the 'base64:' prefix is required); + opnform_jwt_secret and opnform_front_api_secret via openssl rand -hex 32. Provide via OpenBao, Ansible Vault or extra-vars. success_msg: Secrets validation passed diff --git a/roles/send/README.md b/roles/send/README.md index 339628b..fbd6362 100644 --- a/roles/send/README.md +++ b/roles/send/README.md @@ -48,13 +48,17 @@ With S3 (Garage) backend: ```yaml send_storage_backend: s3 -send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domain'] }}" +send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domains'][0] }}" send_s3_bucket: "send" send_s3_access_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['key_id'] }}" send_s3_secret_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['secret_key'] }}" ``` +When `send_storage_backend: s3`, the role asserts that `send_s3_endpoint`, +`send_s3_bucket`, `send_s3_access_key` and `send_s3_secret_key` are all set, +and fails early otherwise. + License ------- -MIT +MIT-0 diff --git a/roles/send/meta/main.yml b/roles/send/meta/main.yml index 20f9e67..defda67 100644 --- a/roles/send/meta/main.yml +++ b/roles/send/meta/main.yml @@ -2,7 +2,7 @@ galaxy_info: author: digitalboard description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose - license: MIT + license: MIT-0 min_ansible_version: "2.14" diff --git a/roles/traefik/README.md b/roles/traefik/README.md index 9266d18..b5d8226 100644 --- a/roles/traefik/README.md +++ b/roles/traefik/README.md @@ -54,10 +54,15 @@ common overrides: ## Dependencies -- Traefik network (`traefik_network`, default `proxy`) must be created - by the `base` role or by hand before this role runs. -- In `dmz` mode, the proxied backend services advertise themselves via - the `traefik_services` host_var on each backend host. +- Run `digitalboard.core.base` first (or otherwise install Docker and the + `community.docker` collection); this role manages containers and networks + through `community.docker`. +- The Traefik network (`traefik_network`, default `proxy`) is created by + this role (`community.docker.docker_network`, state present), so no + pre-creation is required. +- In `dmz` mode, backend hosts advertise the services to aggregate via the + `traefik_dmz_exposed_services` host_var; `traefik_services` defines extra + routes directly on the DMZ host (each entry must set `backend_host`). ## Example playbook diff --git a/roles/traefik/meta/argument_specs.yml b/roles/traefik/meta/argument_specs.yml index 3d0442a..d9443ff 100644 --- a/roles/traefik/meta/argument_specs.yml +++ b/roles/traefik/meta/argument_specs.yml @@ -109,10 +109,11 @@ argument_specs: type: bool default: false description: - - Disable lego's propagation check against the zone's authoritative - nameservers (sets C(LEGO_DISABLE_CNAME_SUPPORT=) plus the - authoritative-NS-check skip). Use when the SOA-listed NS hostname - resolves to an address the proxy host cannot reach. + - "Sets C(propagation.disableANSChecks) to true on the ACME resolver + in the static config, disabling lego's propagation check against + the zone's authoritative nameservers. Use when the SOA-listed NS + hostname resolves to an address the proxy host cannot reach; lego + still polls via the configured C(resolvers) list." traefik_selfsigned_cert_dir: type: path diff --git a/roles/traefik/meta/main.yml b/roles/traefik/meta/main.yml index 7c2fc0d..c5ed5b1 100644 --- a/roles/traefik/meta/main.yml +++ b/roles/traefik/meta/main.yml @@ -1,33 +1,26 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy Traefik v3 as a DMZ or backend reverse proxy via Docker Compose + company: Digitalboard + license: MIT-0 - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker + min_ansible_version: "2.14" - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.1 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - traefik + - reverseproxy + - ingress + - docker + - digitalboard dependencies: []