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/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. 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: diff --git a/roles/opnform/README.md b/roles/opnform/README.md new file mode 100644 index 0000000..2dfad2d --- /dev/null +++ b/roles/opnform/README.md @@ -0,0 +1,169 @@ +# opnform + +Deploy [OpnForm](https://github.com/OpnForm/OpnForm) as a self-contained +Docker Compose stack behind Traefik. + +## What this role does + +- Deploys the full official OpnForm stack: `api`, `api-worker`, `api-scheduler`, + `ui`, `db` (Postgres), `redis`, and `ingress` (nginx) +- Configures all environment variables for self-hosted production use +- 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) + +- Does not pre-configure OIDC / identity_connections — set up via Admin UI + +## Architecture note: why two reverse proxies? + +``` +Browser → Traefik (TLS, host routing) → ingress-nginx → api (PHP-FPM) / ui (Nuxt) +``` + +The `ingress` container looks like a redundant proxy next to Traefik but +does a different job. OpnForm's `api` image is **PHP-FPM only** — it +speaks the FastCGI protocol on port 9000, not HTTP. Traefik cannot +translate FastCGI, so the ingress nginx is required to: + +- Translate HTTP `/api/*` requests into FastCGI calls to `api:9000` +- Rewrite request URIs via the `$api_uri` map +- Set Laravel-specific FastCGI params (`SCRIPT_FILENAME`, `REQUEST_URI`) +- Reverse-proxy `/` to the Nuxt UI container on port 3000 + +Both containers run on the same Docker network on the same host, so the +performance overhead of the extra hop is negligible (in-kernel memory +copy, not a real network round-trip). Removing the ingress would require +a custom OpnForm image with a built-in HTTP server, which is out of +scope for this role. + +## Required variables + +Provide via OpenBao, Ansible Vault, or extra-vars. **Never commit real +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_db_password` | strong password | `openssl rand -base64 24` | + +When `opnform_oidc_enabled` is `true`: + +| Variable | Source | +|---|---| +| `opnform_oidc_client_secret` | from your Keycloak/Authentik client | + +The `assert` task at the top of the role will fail fast if any secret is +missing or malformed. + +## First login + +OpnForm in self-hosted mode does **not** ship a pre-seeded admin user. +The first user to register becomes the owner of the default workspace, +and further public registration is disabled afterwards (additional +users must be invited via the Admin UI). + +This role supports two ways to create that first user: + +### Option A — automated bootstrap (recommended) + +Set `opnform_admin_email` and `opnform_admin_password` (ideally from +Vault / OpenBao). The role then POSTs to `/api/register` after the +API container is healthy, skipping the setup page entirely. The task +is idempotent: it does a login check first and only registers if the +user does not already exist. + +```yaml +opnform_admin_name: "Administrator" # default +opnform_admin_email: "admin@example.com" +opnform_admin_password: "{{ vault_opnform_admin_password }}" +``` + +Password rules enforced by OpnForm: minimum 8 characters, at least one +letter, one digit, and one of `@$!%*#?&-_+=.,:;<>^()[]{}|~`. + +### Option B — manual setup page + +Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit +`opnform_base_url` and complete the setup page in the browser. + +## OIDC setup + +Set `opnform_oidc_enabled: true` and the role creates 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). + +**Prerequisite**: the admin bootstrap must be configured +(`opnform_admin_email` + `opnform_admin_password`). The OIDC API +requires an authenticated admin token; the role logs in with those +credentials to make the call. The validation block fails fast if OIDC +is enabled without admin credentials. + +### Required when `opnform_oidc_enabled: true` + +| Variable | Notes | +|---|---| +| `opnform_oidc_client_secret` | from your IdP, never commit | +| `opnform_oidc_domain` | email domain that triggers OIDC (e.g. `example.com`) | + +### Tunables (defaults shown) + +```yaml +opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard" +opnform_oidc_client_id: "opnform-digitalboard" +opnform_oidc_client_name: "Digitalboard" # display name in UI +opnform_oidc_slug: "oidc" # used in /auth/{slug}/callback +opnform_oidc_scopes: [openid, profile, email, groups] +``` + +### Group → role mapping + +Two ways, the list takes precedence: + +```yaml +# Option 1: full list (any number of mappings) +opnform_oidc_group_role_mappings: + - idp_group: "opnform-admins" + role: admin + - idp_group: "opnform-editors" + role: editor + +# Option 2: convenience — single admin group +opnform_oidc_admin_group: "opnform-admins" # mapped to role=admin +``` + +Valid roles: `owner`, `admin`, `editor`, `member`. + +## Example playbook + +```yaml +- name: Deploy OpnForm service + hosts: opnform_servers + become: true + roles: + - digitalboard.core.opnform +``` + +With inventory variables: + +```yaml +# group_vars/opnform_servers.yml +opnform_domain: forms.digitalboard.ch +opnform_base_url: "https://forms.digitalboard.ch" +opnform_app_key: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.app_key }}" +opnform_jwt_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.jwt_secret }}" +opnform_front_api_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.front_api_secret }}" +opnform_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.db_password }}" +``` diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml new file mode 100644 index 0000000..0f61c3a --- /dev/null +++ b/roles/opnform/defaults/main.yml @@ -0,0 +1,109 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for opnform + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# opnform-specific configuration +opnform_service_name: opnform +opnform_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ opnform_service_name }}" +opnform_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ opnform_service_name }}" +opnform_storage_dir: "{{ opnform_docker_volume_dir }}/storage" +opnform_db_data_dir: "{{ opnform_docker_volume_dir }}/db" +opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis" + +# Service configuration +opnform_domain: "forms.local.test" +opnform_base_url: "https://forms.local.test" + +# Images +opnform_api_image: "jhumanj/opnform-api:latest" +opnform_client_image: "jhumanj/opnform-client:latest" +opnform_redis_image: "redis:7" +opnform_db_image: "postgres:16" +opnform_ingress_image: "nginx:1" + +# REQUIRED SECRETS — must be overridden per-inventory. +# Provide via OpenBao lookup, Ansible Vault or extra-vars. +# Never commit real keys to version control. +# +# Generate with: +# opnform_app_key: echo "base64:$(openssl rand -base64 32)" +# opnform_jwt_secret: openssl rand -hex 32 +# opnform_front_api_secret: openssl rand -hex 32 +# +# opnform_app_key MUST start with the prefix "base64:" — the validate +# task at the top of tasks/main.yml enforces this. +opnform_app_key: "" +opnform_jwt_secret: "" +opnform_front_api_secret: "" + +# Database credentials. opnform_db_password must be overridden; the +# validate task fails fast on an empty value. +opnform_db_name: "opnform" +opnform_db_user: "opnform" +opnform_db_password: "" + +# Admin bootstrap — when email+password are set, the role creates the +# first user via OpnForm's /api/register endpoint, skipping the +# self-hosted setup page. Leave both empty to keep the manual setup flow. +# Password must satisfy OpnForm's rules: min 8 chars, contain a letter, +# a digit and one of @$!%*#?&-_+=.,:;<>^()[]{}|~ +# Provide via OpenBao, Ansible Vault or extra-vars. +opnform_admin_name: "Administrator" +opnform_admin_email: "" +opnform_admin_password: "" +opnform_admin_hear_about_us: "ansible" + +# PHP configuration +opnform_php_memory_limit: "1G" +opnform_php_max_execution_time: "600" +opnform_php_upload_max_filesize: "64M" +opnform_php_post_max_size: "64M" + +# Nginx ingress +opnform_nginx_max_body_size: "64m" + +# Mail configuration (optional — defaults to log driver) +opnform_mail_mailer: "log" +opnform_mail_host: "" +opnform_mail_port: "" +opnform_mail_username: "" +opnform_mail_password: "" +opnform_mail_encryption: "" +opnform_mail_from_address: "noreply@digitalboard.ch" +opnform_mail_from_name: "OpnForm" + +# OIDC configuration — when enabled, the role auto-creates an +# IdentityConnection in the first workspace via OpnForm's API after the +# admin bootstrap. Requires opnform_admin_email/_password to be set +# (the API call needs an authenticated admin token). +opnform_oidc_enabled: false +opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard" +opnform_oidc_client_id: "opnform-digitalboard" +opnform_oidc_client_secret: "" +opnform_oidc_client_name: "Digitalboard" +# OpnForm-side identifier used in /auth/{slug}/callback. Lowercase +# alphanumeric + hyphens, unique across all identity_connections. +opnform_oidc_slug: "oidc" +# Email domain that triggers OIDC login for matching users (e.g. users +# with @example.com emails are redirected to the IdP). Required when +# opnform_oidc_enabled is true. +opnform_oidc_domain: "" +opnform_oidc_scopes: + - openid + - profile + - email + - groups +# Convenience: maps a single IdP group to the OpnForm "admin" role. +# Ignored when opnform_oidc_group_role_mappings is non-empty. +opnform_oidc_admin_group: "opnform-admins" +# Full group-to-role mapping list. Takes precedence over the convenience +# var. Each item: {idp_group: "", role: "owner|admin|editor|member"} +opnform_oidc_group_role_mappings: [] + +# Traefik configuration +opnform_traefik_network: "proxy" +opnform_use_ssl: true diff --git a/roles/opnform/handlers/main.yml b/roles/opnform/handlers/main.yml new file mode 100644 index 0000000..1c0b422 --- /dev/null +++ b/roles/opnform/handlers/main.yml @@ -0,0 +1,8 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for opnform + +- name: restart opnform + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: restarted 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 diff --git a/roles/opnform/meta/main.yml b/roles/opnform/meta/main.yml new file mode 100644 index 0000000..8a56a7b --- /dev/null +++ b/roles/opnform/meta/main.yml @@ -0,0 +1,16 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: Tobias Wüst + description: Deploy OpnForm self-hosted form builder via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 + min_ansible_version: "2.15" + + galaxy_tags: + - opnform + - forms + - docker + - traefik + - oidc + +dependencies: [] diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml new file mode 100644 index 0000000..68e093b --- /dev/null +++ b/roles/opnform/tasks/main.yml @@ -0,0 +1,265 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for opnform + +# ===================================================================== +# 0. VALIDATION +# ===================================================================== + +- name: Validate required secrets + ansible.builtin.assert: + that: + - opnform_app_key | length > 0 + - opnform_app_key is match('^base64:[A-Za-z0-9+/=]+$') + - opnform_jwt_secret | length > 0 + - opnform_front_api_secret | length > 0 + - opnform_db_password | length > 0 + fail_msg: >- + OpnForm requires opnform_app_key (prefix 'base64:'), 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:" + Provide via OpenBao, Ansible Vault or extra-vars. + success_msg: Secrets validation passed + +- name: Validate OIDC configuration when enabled + ansible.builtin.assert: + that: + - opnform_oidc_client_secret | length > 0 + - opnform_oidc_domain | length > 0 + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + fail_msg: >- + When opnform_oidc_enabled is true, you must set: + - opnform_oidc_client_secret + - opnform_oidc_domain (email domain that triggers OIDC) + - opnform_admin_email / opnform_admin_password + (the OIDC API requires an authenticated admin; the role logs in + with these credentials to POST the connection) + when: opnform_oidc_enabled | bool + +# ===================================================================== +# 1. PREPARATION +# ===================================================================== + +- name: Ensure required packages are installed + ansible.builtin.package: + name: + - python3-docker + state: present + +- name: Create docker compose directory + ansible.builtin.file: + path: "{{ opnform_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create OpnForm data directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ opnform_docker_volume_dir }}" + - "{{ opnform_storage_dir }}" + - "{{ opnform_db_data_dir }}" + - "{{ opnform_redis_data_dir }}" + +# ===================================================================== +# 2. CONFIGURATION FILES +# ===================================================================== + +- name: Deploy nginx ingress configuration + ansible.builtin.template: + src: nginx.conf.j2 + dest: "{{ opnform_docker_compose_dir }}/nginx.conf" + mode: '0644' + notify: restart opnform + +- name: Deploy docker-compose file + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + notify: restart opnform + +# ===================================================================== +# 3. CONTAINER STARTUP +# ===================================================================== + +- name: Start opnform containers + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: present + wait: true + wait_timeout: 180 + +# ===================================================================== +# 4. WAIT FOR API READINESS +# ===================================================================== + +- name: Wait for API container to be healthy + ansible.builtin.command: + cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api + register: api_health + until: api_health.stdout == "healthy" + retries: 30 + delay: 10 + changed_when: false + +# ===================================================================== +# 5. ADMIN BOOTSTRAP (optional) +# ===================================================================== +# Skips the self-hosted setup page by registering the first user via +# OpnForm's /api/register endpoint. Idempotent: a successful login +# attempt with the same credentials means the user already exists. + +- name: Check if OpnForm admin user already exists + ansible.builtin.uri: + url: "https://127.0.0.1/api/login" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + status_code: [200, 401, 422] + validate_certs: false + register: opnform_admin_login + when: + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + +- name: Create OpnForm admin user via /api/register + ansible.builtin.uri: + url: "https://127.0.0.1/api/register" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + name: "{{ opnform_admin_name }}" + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + password_confirmation: "{{ opnform_admin_password }}" + hear_about_us: "{{ opnform_admin_hear_about_us }}" + status_code: [200, 201] + validate_certs: false + no_log: true + when: + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + - opnform_admin_login.status != 200 + +# ===================================================================== +# 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. + +- name: Log in as admin to obtain OIDC API token + ansible.builtin.uri: + url: "https://127.0.0.1/api/login" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + status_code: 200 + validate_certs: false + register: opnform_oidc_token + no_log: true + when: opnform_oidc_enabled | bool + +- name: Fetch admin's workspaces + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces" + method: GET + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + status_code: 200 + validate_certs: false + register: opnform_workspaces + no_log: true + when: opnform_oidc_enabled | bool + +- name: Fetch existing OIDC connections for the default workspace + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" + method: GET + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + status_code: 200 + validate_certs: false + register: opnform_existing_oidc + no_log: true + when: opnform_oidc_enabled | bool + +- name: Resolve OIDC group-role mappings + ansible.builtin.set_fact: + _opnform_oidc_group_role_mappings: >- + {{ + opnform_oidc_group_role_mappings + if (opnform_oidc_group_role_mappings | length > 0) + else + ([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}] + if (opnform_oidc_admin_group | length > 0) else []) + }} + 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: + name: "{{ opnform_oidc_client_name }}" + slug: "{{ opnform_oidc_slug }}" + domain: "{{ opnform_oidc_domain }}" + issuer: "{{ opnform_oidc_issuer }}" + client_id: "{{ opnform_oidc_client_id }}" + client_secret: "{{ opnform_oidc_client_secret }}" + scopes: "{{ opnform_oidc_scopes }}" + enabled: true + options: + require_state: true + group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}" + status_code: [201] + validate_certs: false + no_log: true + when: + - opnform_oidc_enabled | bool + - opnform_existing_oidc.json | length == 0 + +- name: Display deployment info + ansible.builtin.debug: + msg: |- + OpnForm deployed at {{ opnform_base_url }} + + {% if opnform_admin_email | length > 0 %} + Admin user bootstrapped: + Email: {{ opnform_admin_email }} + Password: (from opnform_admin_password) + {% else %} + No admin bootstrap configured — visit {{ opnform_base_url }} and + complete the self-hosted setup page to create the first user. + Set opnform_admin_email + opnform_admin_password to automate this. + {% endif %} + + {% if opnform_oidc_enabled %} + OIDC: connection "{{ opnform_oidc_client_name }}" bootstrapped + (slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }}) + Users with @{{ opnform_oidc_domain }} addresses will be + redirected to {{ opnform_oidc_issuer }} on login. + {% 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 new file mode 100644 index 0000000..de88a33 --- /dev/null +++ b/roles/opnform/templates/docker-compose.yml.j2 @@ -0,0 +1,189 @@ +#---------------------------------------------------------------------# +# OpnForm — Beautiful open-source form builder # +#---------------------------------------------------------------------# +services: + api: &api-service + image: {{ opnform_api_image }} + container_name: opnform-api + restart: unless-stopped + volumes: + - {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw + environment: &api-env + APP_ENV: production + APP_KEY: "{{ opnform_app_key }}" + APP_URL: "{{ opnform_base_url }}" + APP_DEBUG: "false" + SELF_HOSTED: "true" + + LOG_CHANNEL: errorlog + LOG_LEVEL: info + + DB_CONNECTION: pgsql + DB_HOST: db + DB_PORT: "5432" + DB_DATABASE: "{{ opnform_db_name }}" + DB_USERNAME: "{{ opnform_db_user }}" + DB_PASSWORD: "{{ opnform_db_password }}" + + REDIS_HOST: redis + REDIS_PORT: "6379" + + CACHE_STORE: redis + CACHE_DRIVER: redis + QUEUE_CONNECTION: redis + SESSION_DRIVER: redis + SESSION_LIFETIME: "120" + BROADCAST_CONNECTION: log + + FILESYSTEM_DISK: local + FILESYSTEM_DRIVER: local + LOCAL_FILESYSTEM_VISIBILITY: public + + MAIL_MAILER: "{{ opnform_mail_mailer }}" + MAIL_HOST: "{{ opnform_mail_host }}" + MAIL_PORT: "{{ opnform_mail_port }}" + MAIL_USERNAME: "{{ opnform_mail_username }}" + MAIL_PASSWORD: "{{ opnform_mail_password }}" + MAIL_ENCRYPTION: "{{ opnform_mail_encryption }}" + MAIL_FROM_ADDRESS: "{{ opnform_mail_from_address }}" + MAIL_FROM_NAME: "{{ opnform_mail_from_name }}" + + JWT_TTL: "1440" + JWT_SECRET: "{{ opnform_jwt_secret }}" + + PHP_MEMORY_LIMIT: "{{ opnform_php_memory_limit }}" + PHP_MAX_EXECUTION_TIME: "{{ opnform_php_max_execution_time }}" + PHP_UPLOAD_MAX_FILESIZE: "{{ opnform_php_upload_max_filesize }}" + PHP_POST_MAX_SIZE: "{{ opnform_php_post_max_size }}" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"] + interval: 30s + timeout: 15s + retries: 3 + start_period: 60s + networks: + - opnform-internal + + api-worker: + <<: *api-service + container_name: opnform-api-worker + command: ["php", "artisan", "queue:work"] + environment: + <<: *api-env + IS_API_WORKER: "true" + healthcheck: + test: ["CMD-SHELL", "pgrep -f 'php artisan queue:work' > /dev/null || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + api-scheduler: + <<: *api-service + container_name: opnform-api-scheduler + command: ["php", "artisan", "schedule:work"] + healthcheck: + test: + - "CMD-SHELL" + - "php /usr/share/nginx/html/artisan app:scheduler-status --mode=check --max-minutes=3 || exit 1" + interval: 60s + timeout: 30s + retries: 3 + start_period: 70s + + ui: + image: {{ opnform_client_image }} + container_name: opnform-ui + restart: unless-stopped + environment: + NUXT_PUBLIC_APP_URL: "{{ opnform_base_url }}" + NUXT_PUBLIC_API_BASE: "/api" + NUXT_PRIVATE_API_BASE: "http://ingress/api" + NUXT_PUBLIC_ENV: production + FRONT_API_SECRET: "{{ opnform_front_api_secret }}" + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/login || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 45s + networks: + - opnform-internal + + redis: + image: {{ opnform_redis_image }} + container_name: opnform-redis + restart: unless-stopped + volumes: + - {{ opnform_redis_data_dir }}:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 30s + timeout: 5s + networks: + - opnform-internal + + db: + image: {{ opnform_db_image }} + container_name: opnform-db + restart: unless-stopped + environment: + POSTGRES_DB: "{{ opnform_db_name }}" + POSTGRES_USER: "{{ opnform_db_user }}" + POSTGRES_PASSWORD: "{{ opnform_db_password }}" + volumes: + - {{ opnform_db_data_dir }}:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {{ opnform_db_user }}"] + interval: 30s + timeout: 5s + networks: + - opnform-internal + + ingress: + image: {{ opnform_ingress_image }} + container_name: opnform-ingress + restart: unless-stopped + volumes: + - ./nginx.conf:/etc/nginx/templates/default.conf.template:ro + environment: + NGINX_MAX_BODY_SIZE: "{{ opnform_nginx_max_body_size }}" + depends_on: + api: + condition: service_started + ui: + condition: service_started + healthcheck: + test: ["CMD-SHELL", "nginx -t && curl -f http://localhost/ || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - opnform-internal + - {{ opnform_traefik_network }} + labels: + - traefik.enable=true + - traefik.docker.network={{ opnform_traefik_network }} + - traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`) +{% if opnform_use_ssl %} + - traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure + - traefik.http.routers.{{ opnform_service_name }}.tls=true +{% else %} + - traefik.http.routers.{{ opnform_service_name }}.entrypoints=web +{% endif %} + - traefik.http.services.{{ opnform_service_name }}.loadbalancer.server.port=80 + +networks: + opnform-internal: + driver: bridge + {{ opnform_traefik_network }}: + external: true diff --git a/roles/opnform/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 new file mode 100644 index 0000000..fa3193b --- /dev/null +++ b/roles/opnform/templates/nginx.conf.j2 @@ -0,0 +1,43 @@ +map $original_uri $api_uri { + ~^/api(/.*$) $1; + default $original_uri; +} + +server { + listen 80; + server_name {{ opnform_domain }}; + root /app/public; + + client_max_body_size {% raw %}${NGINX_MAX_BODY_SIZE}{% endraw %}; + + access_log /dev/stdout; + error_log /dev/stderr error; + + index index.html index.htm index.php; + + location / { + proxy_http_version 1.1; + proxy_pass http://ui:3000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + } + + location ~/(api|open|local\/temp|forms\/assets)/ { + set $original_uri $uri; + try_files $uri $uri/ /index.php$is_args$args; + } + + location ~ \.php$ { + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass api:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php; + fastcgi_param REQUEST_URI $api_uri; + } +} diff --git a/roles/opnform/tests/inventory b/roles/opnform/tests/inventory new file mode 100644 index 0000000..712db59 --- /dev/null +++ b/roles/opnform/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost diff --git a/roles/opnform/tests/test.yml b/roles/opnform/tests/test.yml new file mode 100644 index 0000000..3ff9caa --- /dev/null +++ b/roles/opnform/tests/test.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - opnform \ No newline at end of file diff --git a/roles/opnform/vars/main.yml b/roles/opnform/vars/main.yml new file mode 100644 index 0000000..94900f8 --- /dev/null +++ b/roles/opnform/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for opnform \ No newline at end of file