Compare commits
15 commits
d526ec382d
...
c11f019aae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11f019aae | ||
|
|
a492c3ee04 | ||
|
|
b19ac2270a | ||
|
|
e1d604effc | ||
|
|
4655c8f037 | ||
|
|
9a9039c4d3 | ||
|
|
dc8f1e2ecd | ||
| 05fb62c75d | |||
|
|
2c2dbbc648 | ||
| 951b1822fe | |||
|
|
30f3c16b59 | ||
|
|
fb81f60f9d | ||
|
|
48d12a1b4a | ||
| 03af64ca2c | |||
| 53e80ad7be |
63 changed files with 3499 additions and 2 deletions
|
|
@ -19,6 +19,7 @@ readme: README.md
|
|||
authors:
|
||||
- Bert-Jan Fikse <bert-jan@whatwedo.ch>
|
||||
- Tobias Wüst <tobias.wuest@wksbern.ch>
|
||||
- Simon Bärlocher <simon@whatwedo.ch>
|
||||
|
||||
### OPTIONAL but strongly recommended
|
||||
# A short summary description of the collection
|
||||
|
|
|
|||
145
roles/bookstack/README.md
Normal file
145
roles/bookstack/README.md
Normal file
|
|
@ -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://<bookstack_domain>/oidc/callback`
|
||||
3. Front-channel logout URL: `https://<bookstack_domain>/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-<stamp>.sql.gz` — mariadb-dump
|
||||
- `bookstack-files-<stamp>.tar.gz` — uploads, attachments
|
||||
- `bookstack-appkey-<stamp>.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-<stamp>.sql.gz \
|
||||
| docker exec -i bookstack-db \
|
||||
mariadb -u root -p"<root-pw>" bookstack
|
||||
```
|
||||
4. Extract the files: `tar -xzf bookstack-files-<stamp>.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://<domain>/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
|
||||
85
roles/bookstack/defaults/main.yml
Normal file
85
roles/bookstack/defaults/main.yml
Normal file
|
|
@ -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"
|
||||
19
roles/bookstack/handlers/main.yml
Normal file
19
roles/bookstack/handlers/main.yml
Normal file
|
|
@ -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
|
||||
194
roles/bookstack/meta/argument_specs.yml
Normal file
194
roles/bookstack/meta/argument_specs.yml
Normal file
|
|
@ -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.
|
||||
25
roles/bookstack/meta/main.yml
Normal file
25
roles/bookstack/meta/main.yml
Normal file
|
|
@ -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: []
|
||||
223
roles/bookstack/tasks/main.yml
Normal file
223
roles/bookstack/tasks/main.yml
Normal file
|
|
@ -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
|
||||
41
roles/bookstack/templates/backup.sh.j2
Normal file
41
roles/bookstack/templates/backup.sh.j2
Normal file
|
|
@ -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"
|
||||
12
roles/bookstack/templates/bookstack-backup.service.j2
Normal file
12
roles/bookstack/templates/bookstack-backup.service.j2
Normal file
|
|
@ -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
|
||||
11
roles/bookstack/templates/bookstack-backup.timer.j2
Normal file
11
roles/bookstack/templates/bookstack-backup.timer.j2
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# {{ ansible_managed }}
|
||||
[Unit]
|
||||
Description=Daily BookStack backup
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ bookstack_backup_schedule }}
|
||||
Persistent=true
|
||||
RandomizedDelaySec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
87
roles/bookstack/templates/docker-compose.yml.j2
Normal file
87
roles/bookstack/templates/docker-compose.yml.j2
Normal file
|
|
@ -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
|
||||
1
roles/bookstack/tests/inventory
Normal file
1
roles/bookstack/tests/inventory
Normal file
|
|
@ -0,0 +1 @@
|
|||
localhost
|
||||
5
roles/bookstack/tests/test.yml
Normal file
5
roles/bookstack/tests/test.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
- hosts: localhost
|
||||
remote_user: root
|
||||
roles:
|
||||
- bookstack
|
||||
3
roles/bookstack/vars/main.yml
Normal file
3
roles/bookstack/vars/main.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# vars file for bookstack
|
||||
69
roles/coturn/README.md
Normal file
69
roles/coturn/README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# coturn
|
||||
|
||||
Deploys a [coturn](https://github.com/coturn/coturn) TURN/STUN server with `network_mode: host`,
|
||||
optionally accompanied by an `acme.sh` sidecar that obtains and renews a public TLS certificate
|
||||
via RFC2136 (`nsupdate`) and restarts coturn on renewal.
|
||||
|
||||
This is the recommended pairing for `digitalboard.core.talk` (Nextcloud Talk HPB).
|
||||
|
||||
## What it does
|
||||
|
||||
- Renders `/etc/docker/compose/coturn/docker-compose.yml`
|
||||
- (acme mode) Deploys the TSIG key from `playbooks/secrets/{{ inventory_hostname }}/nsupdate.key`
|
||||
- (selfsigned mode) Generates an ECC keypair + selfsigned cert in `{{ coturn_cert_dir }}`
|
||||
- Starts the stack via `community.docker.docker_compose_v2`
|
||||
|
||||
## Required variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `coturn_realm` | Public DNS name used as realm + cert CN (e.g. `stun.digitalboard.ch`) |
|
||||
| `coturn_external_ip` | Mapping for `--external-ip`, format `PUBLIC[/PRIVATE]` |
|
||||
| `coturn_static_auth_secret` | Shared secret for HMAC-based credentials; **must match** `talk_turn_secret` on the HPB host |
|
||||
|
||||
## Important variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `coturn_cert_mode` | `file` | One of `acme`, `file`, `selfsigned` |
|
||||
| `coturn_listening_port` | `443` | TCP/UDP non-TLS port |
|
||||
| `coturn_tls_listening_port` | `443` | TLS port (shared with non-TLS via STUN mux) |
|
||||
| `coturn_min_relay_port` / `coturn_max_relay_port` | `49160` / `49200` | UDP relay range |
|
||||
| `coturn_internal_realm` | `""` | Optional second SAN for split-horizon DNS |
|
||||
| `coturn_image` | `coturn/coturn:4.6.2-r5-alpine` | Pinned by default; override as needed |
|
||||
|
||||
## ACME / nsupdate mode
|
||||
|
||||
When `coturn_cert_mode: acme` is set, also configure:
|
||||
|
||||
```yaml
|
||||
coturn_acme_email: "admin@digitalboard.ch"
|
||||
coturn_acme_nsupdate_server: "ns1.digitalboard.ch"
|
||||
coturn_acme_nsupdate_server_ip: "172.16.9.169" # optional pin
|
||||
coturn_acme_nsupdate_zone: "digitalboard._acme.digitalboard.ch"
|
||||
# optional: override the auto-built challenge alias mapping
|
||||
coturn_acme_challenge_aliases:
|
||||
- name: stun.digitalboard.ch
|
||||
alias: stun.digitalboard._acme.digitalboard.ch
|
||||
- name: stun.int.digitalboard.ch
|
||||
alias: stun.int.digitalboard._acme.digitalboard.ch
|
||||
```
|
||||
|
||||
Place your TSIG key at `playbooks/secrets/{{ inventory_hostname }}/nsupdate.key` (mode 0600).
|
||||
|
||||
## Secrets
|
||||
|
||||
Place the static auth secret at:
|
||||
|
||||
```
|
||||
playbooks/secrets/{{ inventory_hostname }}/coturn_static_auth_secret
|
||||
```
|
||||
|
||||
Mode 0600. The same value must be deployed to the HPB host as `talk_turn_secret`.
|
||||
|
||||
## Firewall
|
||||
|
||||
The role does not manage firewall rules. Ensure the host has:
|
||||
|
||||
- `443/tcp` and `443/udp` reachable from the internet
|
||||
- UDP `{{ coturn_min_relay_port }}-{{ coturn_max_relay_port }}` reachable from the internet
|
||||
77
roles/coturn/defaults/main.yml
Normal file
77
roles/coturn/defaults/main.yml
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# defaults file for coturn
|
||||
|
||||
# Base directories (inherited from base role)
|
||||
docker_compose_base_dir: /etc/docker/compose
|
||||
docker_volume_base_dir: /srv/data
|
||||
|
||||
# Service-specific paths
|
||||
coturn_service_name: coturn
|
||||
coturn_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ coturn_service_name }}"
|
||||
coturn_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ coturn_service_name }}"
|
||||
|
||||
# Container images (pin per host_vars in production)
|
||||
coturn_image: "coturn/coturn:4.6.2-r5-alpine"
|
||||
coturn_acme_image: "neilpang/acme.sh:3.1.0"
|
||||
|
||||
# Public DNS name used for the realm and the public certificate
|
||||
coturn_realm: "stun.example.test"
|
||||
# Optional second DNS name issued on the same certificate (for split-horizon "internal" name)
|
||||
coturn_internal_realm: "" # e.g. "stun.int.example.test"
|
||||
|
||||
# Ports
|
||||
# Defaults follow IANA standards (3478/TURN, 5349/TURNS) so coturn can
|
||||
# co-exist with a Traefik instance on the same host. Override to 443/443
|
||||
# in restrictive-network environments where punching through firewalls matters.
|
||||
coturn_listening_port: 3478 # TURN / STUN (TCP+UDP)
|
||||
coturn_tls_listening_port: 5349 # TURNS (TCP+UDP)
|
||||
coturn_min_relay_port: 49160
|
||||
coturn_max_relay_port: 49200
|
||||
|
||||
# IP advertisement: must be set in host_vars for production
|
||||
# Format follows coturn's --external-ip: "PUBLIC_IP" or "PUBLIC_IP/PRIVATE_IP"
|
||||
coturn_external_ip: "" # e.g. "203.0.113.10/172.18.0.2"
|
||||
coturn_listening_ip: "0.0.0.0"
|
||||
|
||||
# Shared secret used by HPB to mint short-lived TURN credentials.
|
||||
# Loaded by default from a plain file in playbooks/secrets/{host}/coturn_static_auth_secret
|
||||
# Override per host_vars if you want to use a vault or different lookup.
|
||||
coturn_static_auth_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/coturn_static_auth_secret') }}"
|
||||
|
||||
# Additional CLI flags (list of strings, appended verbatim to command:)
|
||||
coturn_extra_args: []
|
||||
|
||||
# --- TLS certificate ---
|
||||
# 'acme' : run an acme.sh sidecar that issues + renews via RFC2136 / nsupdate, restarts coturn
|
||||
# 'file' : assume a certificate already lives at {{ coturn_cert_dir }}/{{ coturn_cert_file }} on the host (you manage it)
|
||||
# 'selfsigned' : generate a selfsigned cert on first run (for vagrant/dev only)
|
||||
coturn_cert_mode: "file"
|
||||
|
||||
coturn_cert_dir: "{{ docker_volume_base_dir }}/acme/certs"
|
||||
coturn_cert_file: "fullchain.cer"
|
||||
coturn_key_file: "{{ coturn_realm }}.key"
|
||||
|
||||
# --- acme.sh sidecar (only used when coturn_cert_mode == 'acme') ---
|
||||
coturn_acme_email: "admin@example.test"
|
||||
coturn_acme_directory: "https://acme-v02.api.letsencrypt.org/directory"
|
||||
# Stage URL for testing: "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
coturn_acme_keylength: "ec-256"
|
||||
coturn_acme_dnssleep: 60
|
||||
coturn_acme_data_dir: "{{ docker_volume_base_dir }}/acme/acme"
|
||||
|
||||
# DNS-01 RFC2136 / nsupdate configuration
|
||||
coturn_acme_nsupdate_server: "" # e.g. "ns1.example.test"
|
||||
coturn_acme_nsupdate_server_ip: "" # optional extra_hosts pin (string IP) for the server
|
||||
coturn_acme_nsupdate_zone: "" # e.g. "example._acme.example.test"
|
||||
# Per-name challenge alias zones (one entry per SAN)
|
||||
# When empty (default), built automatically as "{{ realm }}._acme.{{ zone-tail }}"
|
||||
coturn_acme_challenge_aliases: []
|
||||
# Example:
|
||||
# - name: stun.example.test
|
||||
# alias: stun.example._acme.example.test
|
||||
# - name: stun.int.example.test
|
||||
# alias: stun.int.example._acme.example.test
|
||||
|
||||
# Path of the TSIG key file inside the container (mounted from secrets)
|
||||
coturn_acme_nsupdate_key_src: "{{ playbook_dir }}/secrets/{{ inventory_hostname }}/nsupdate.key"
|
||||
10
roles/coturn/handlers/main.yml
Normal file
10
roles/coturn/handlers/main.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# handlers file for coturn
|
||||
|
||||
- name: Restart coturn container
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ coturn_docker_compose_dir }}"
|
||||
state: restarted
|
||||
services:
|
||||
- coturn
|
||||
148
roles/coturn/meta/argument_specs.yml
Normal file
148
roles/coturn/meta/argument_specs.yml
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Deploy a coturn TURN/STUN server with optional acme.sh sidecar.
|
||||
description:
|
||||
- "Renders a Docker Compose stack for coturn running in
|
||||
C(network_mode: host), with an optional C(acme.sh) sidecar that
|
||||
issues + renews a public TLS certificate via RFC2136 / nsupdate
|
||||
and restarts coturn on renewal."
|
||||
- Designed to be paired with the C(digitalboard.core.talk) role
|
||||
(Nextcloud Talk High Performance Backend).
|
||||
options:
|
||||
docker_compose_base_dir:
|
||||
type: path
|
||||
default: /etc/docker/compose
|
||||
docker_volume_base_dir:
|
||||
type: path
|
||||
default: /srv/data
|
||||
coturn_service_name:
|
||||
type: str
|
||||
default: coturn
|
||||
coturn_docker_compose_dir:
|
||||
type: path
|
||||
coturn_docker_volume_dir:
|
||||
type: path
|
||||
|
||||
coturn_image:
|
||||
type: str
|
||||
default: "coturn/coturn:4.6.2-r5-alpine"
|
||||
coturn_acme_image:
|
||||
type: str
|
||||
default: "neilpang/acme.sh:3.1.0"
|
||||
|
||||
coturn_realm:
|
||||
type: str
|
||||
default: stun.example.test
|
||||
description: Public DNS name used for the TURN realm and the public certificate.
|
||||
coturn_internal_realm:
|
||||
type: str
|
||||
default: ''
|
||||
description:
|
||||
- Optional second DNS name issued on the same certificate, used for
|
||||
split-horizon internal access (e.g. C(stun.int.example.test)).
|
||||
|
||||
coturn_listening_port:
|
||||
type: int
|
||||
default: 3478
|
||||
description: TURN/STUN port (TCP + UDP). IANA standard is 3478.
|
||||
coturn_tls_listening_port:
|
||||
type: int
|
||||
default: 5349
|
||||
description: TURNS port (TCP + UDP). IANA standard is 5349.
|
||||
coturn_min_relay_port:
|
||||
type: int
|
||||
default: 49160
|
||||
coturn_max_relay_port:
|
||||
type: int
|
||||
default: 49200
|
||||
|
||||
coturn_external_ip:
|
||||
type: str
|
||||
default: ''
|
||||
description:
|
||||
- coturn C(--external-ip) value. Format C("PUBLIC_IP") or
|
||||
C("PUBLIC_IP/PRIVATE_IP"). Must be set in host_vars for production.
|
||||
coturn_listening_ip:
|
||||
type: str
|
||||
default: '0.0.0.0'
|
||||
|
||||
coturn_static_auth_secret:
|
||||
type: str
|
||||
required: true
|
||||
description:
|
||||
- Shared secret used by the HPB signaling server to mint short-lived
|
||||
TURN credentials. Default lookup reads
|
||||
C(playbooks/secrets/<host>/coturn_static_auth_secret).
|
||||
|
||||
coturn_extra_args:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description: Additional CLI flags appended verbatim to the container C(command:).
|
||||
|
||||
coturn_cert_mode:
|
||||
type: str
|
||||
choices: [acme, file, selfsigned]
|
||||
default: file
|
||||
description:
|
||||
- C(acme) runs an acme.sh sidecar that issues + renews via RFC2136
|
||||
and restarts coturn. C(file) assumes a certificate already lives
|
||||
on the host (you manage it). C(selfsigned) generates one on first
|
||||
run (vagrant/dev only).
|
||||
coturn_cert_dir:
|
||||
type: path
|
||||
coturn_cert_file:
|
||||
type: str
|
||||
default: fullchain.cer
|
||||
coturn_key_file:
|
||||
type: str
|
||||
description: Defaults to C("{{ coturn_realm }}.key").
|
||||
|
||||
coturn_acme_email:
|
||||
type: str
|
||||
default: admin@example.test
|
||||
coturn_acme_directory:
|
||||
type: str
|
||||
default: https://acme-v02.api.letsencrypt.org/directory
|
||||
coturn_acme_keylength:
|
||||
type: str
|
||||
default: ec-256
|
||||
choices: [ec-256, ec-384, '2048', '3072', '4096']
|
||||
coturn_acme_dnssleep:
|
||||
type: int
|
||||
default: 60
|
||||
coturn_acme_data_dir:
|
||||
type: path
|
||||
|
||||
coturn_acme_nsupdate_server:
|
||||
type: str
|
||||
default: ''
|
||||
description: Authoritative nameserver acme.sh sends C(nsupdate) packets to.
|
||||
coturn_acme_nsupdate_server_ip:
|
||||
type: str
|
||||
default: ''
|
||||
description: Optional C(extra_hosts) pin (string IP) for the nsupdate server.
|
||||
coturn_acme_nsupdate_zone:
|
||||
type: str
|
||||
default: ''
|
||||
description: Delegated challenge zone (e.g. C(example._acme.example.test)).
|
||||
coturn_acme_challenge_aliases:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
description:
|
||||
- Per-name challenge alias zones (one entry per SAN). When empty,
|
||||
built automatically as C({{ realm }}._acme.{{ zone-tail }}).
|
||||
options:
|
||||
name:
|
||||
type: str
|
||||
required: true
|
||||
description: SAN the challenge is for.
|
||||
alias:
|
||||
type: str
|
||||
required: true
|
||||
description: CNAME target where the C(_acme-challenge) TXT lives.
|
||||
coturn_acme_nsupdate_key_src:
|
||||
type: path
|
||||
description: Path of the TSIG key file on the controller, mounted into the acme container.
|
||||
15
roles/coturn/meta/main.yml
Normal file
15
roles/coturn/meta/main.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: Digital Board Team
|
||||
description: Deploy a coturn TURN/STUN server with optional acme.sh sidecar (RFC2136/nsupdate)
|
||||
company: digitalboard.ch
|
||||
license: GPL-2.0-or-later
|
||||
min_ansible_version: "2.14"
|
||||
galaxy_tags:
|
||||
- turn
|
||||
- stun
|
||||
- coturn
|
||||
- webrtc
|
||||
- nextcloud
|
||||
- talk
|
||||
dependencies: []
|
||||
110
roles/coturn/tasks/main.yml
Normal file
110
roles/coturn/tasks/main.yml
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# tasks file for coturn
|
||||
|
||||
- name: Assert minimum configuration
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- coturn_realm | length > 0
|
||||
- coturn_external_ip | length > 0
|
||||
- coturn_static_auth_secret | length > 0
|
||||
fail_msg: >
|
||||
coturn_realm, coturn_external_ip and coturn_static_auth_secret must be set.
|
||||
Provide them in host_vars or via a secrets file.
|
||||
|
||||
- name: Create coturn compose directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ coturn_docker_compose_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Create coturn data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ coturn_docker_volume_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Create certificate directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ coturn_cert_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
# --- TLS certificate provisioning -------------------------------------------------
|
||||
|
||||
- name: Configure acme.sh sidecar (TSIG key + acme data dir)
|
||||
when: coturn_cert_mode == 'acme'
|
||||
block:
|
||||
- name: Create acme.sh data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ coturn_acme_data_dir }}"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Deploy nsupdate TSIG key
|
||||
ansible.builtin.copy:
|
||||
src: "{{ coturn_acme_nsupdate_key_src }}"
|
||||
dest: "{{ coturn_docker_compose_dir }}/nsupdate.key"
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
notify: Restart coturn container
|
||||
|
||||
- name: Build effective challenge alias list (default if not provided)
|
||||
ansible.builtin.set_fact:
|
||||
_coturn_challenge_aliases: >-
|
||||
{{ coturn_acme_challenge_aliases
|
||||
if coturn_acme_challenge_aliases | length > 0
|
||||
else (
|
||||
[{'name': coturn_realm,
|
||||
'alias': (coturn_realm.split('.')[:-2] | join('.')) ~ '.' ~ coturn_acme_nsupdate_zone }]
|
||||
+ ([{'name': coturn_internal_realm,
|
||||
'alias': (coturn_internal_realm.split('.')[:-2] | join('.')) ~ '.' ~ coturn_acme_nsupdate_zone }]
|
||||
if coturn_internal_realm | length > 0 else [])
|
||||
)
|
||||
}}
|
||||
|
||||
- name: Generate selfsigned certificate (vagrant / dev only)
|
||||
when: coturn_cert_mode == 'selfsigned'
|
||||
block:
|
||||
- name: Ensure openssl is available
|
||||
ansible.builtin.package:
|
||||
name: openssl
|
||||
state: present
|
||||
|
||||
- name: Generate selfsigned private key
|
||||
community.crypto.openssl_privatekey:
|
||||
path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}"
|
||||
type: ECC
|
||||
curve: secp256r1
|
||||
mode: "0600"
|
||||
|
||||
- name: Generate selfsigned CSR
|
||||
community.crypto.openssl_csr:
|
||||
path: "{{ coturn_cert_dir }}/{{ coturn_realm }}.csr"
|
||||
privatekey_path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}"
|
||||
common_name: "{{ coturn_realm }}"
|
||||
subject_alt_name:
|
||||
- "DNS:{{ coturn_realm }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Issue selfsigned certificate
|
||||
community.crypto.x509_certificate:
|
||||
path: "{{ coturn_cert_dir }}/{{ coturn_cert_file }}"
|
||||
privatekey_path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}"
|
||||
csr_path: "{{ coturn_cert_dir }}/{{ coturn_realm }}.csr"
|
||||
provider: selfsigned
|
||||
mode: "0644"
|
||||
|
||||
# --- Compose + start --------------------------------------------------------------
|
||||
|
||||
- name: Generate docker-compose.yml for coturn
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ coturn_docker_compose_dir }}/docker-compose.yml"
|
||||
mode: "0644"
|
||||
notify: Restart coturn container
|
||||
|
||||
- name: Start coturn stack
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ coturn_docker_compose_dir }}"
|
||||
state: present
|
||||
78
roles/coturn/templates/docker-compose.yml.j2
Normal file
78
roles/coturn/templates/docker-compose.yml.j2
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
services:
|
||||
coturn:
|
||||
image: {{ coturn_image }}
|
||||
container_name: {{ coturn_service_name }}
|
||||
restart: always
|
||||
network_mode: host
|
||||
volumes:
|
||||
- {{ coturn_cert_dir }}:/certs:ro
|
||||
command:
|
||||
- --use-auth-secret
|
||||
- --static-auth-secret={{ coturn_static_auth_secret }}
|
||||
- --realm={{ coturn_realm }}
|
||||
- --fingerprint
|
||||
- --no-multicast-peers
|
||||
- --no-cli
|
||||
- --listening-ip={{ coturn_listening_ip }}
|
||||
- --listening-port={{ coturn_listening_port }}
|
||||
- --tls-listening-port={{ coturn_tls_listening_port }}
|
||||
- --min-port={{ coturn_min_relay_port }}
|
||||
- --max-port={{ coturn_max_relay_port }}
|
||||
- --cert=/certs/{{ coturn_cert_file }}
|
||||
- --pkey=/certs/{{ coturn_key_file }}
|
||||
- --external-ip={{ coturn_external_ip }}
|
||||
{% for arg in coturn_extra_args %}
|
||||
- {{ arg }}
|
||||
{% endfor %}
|
||||
|
||||
{% if coturn_cert_mode == 'acme' %}
|
||||
acme:
|
||||
image: {{ coturn_acme_image }}
|
||||
container_name: acme-{{ coturn_service_name }}
|
||||
restart: always
|
||||
environment:
|
||||
NSUPDATE_SERVER: "{{ coturn_acme_nsupdate_server }}"
|
||||
NSUPDATE_KEY: "/acme.sh/nsupdate.key"
|
||||
ACME_DIRECTORY: "{{ coturn_acme_directory }}"
|
||||
NSUPDATE_ZONE: "{{ coturn_acme_nsupdate_zone }}"
|
||||
{% if coturn_acme_nsupdate_server_ip | length > 0 %}
|
||||
extra_hosts:
|
||||
- "{{ coturn_acme_nsupdate_server }}:{{ coturn_acme_nsupdate_server_ip }}"
|
||||
{% endif %}
|
||||
volumes:
|
||||
- {{ coturn_cert_dir }}:/certs
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- {{ coturn_docker_compose_dir }}/nsupdate.key:/acme.sh/nsupdate.key:ro
|
||||
- {{ coturn_acme_data_dir }}:/acme.sh
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
acme.sh --set-default-ca --server "$$ACME_DIRECTORY"
|
||||
acme.sh --register-account -m {{ coturn_acme_email }} || true
|
||||
set +e
|
||||
acme.sh --issue \
|
||||
{% for san in _coturn_challenge_aliases %}
|
||||
-d {{ san.name }} \
|
||||
--challenge-alias {{ san.alias }} \
|
||||
{% endfor %}
|
||||
--dns dns_nsupdate \
|
||||
--keylength {{ coturn_acme_keylength }} \
|
||||
--dnssleep {{ coturn_acme_dnssleep }}
|
||||
rc=$$?
|
||||
set -e
|
||||
if [ "$$rc" -eq 0 ]; then
|
||||
echo "Issue: success"
|
||||
elif [ "$$rc" -eq 2 ]; then
|
||||
echo "Issue: not due, continuing"
|
||||
else
|
||||
echo "Issue: failed with rc=$$rc"
|
||||
exit "$$rc"
|
||||
fi
|
||||
acme.sh --install-cert -d {{ coturn_realm }} --ecc \
|
||||
--fullchain-file /certs/{{ coturn_cert_file }} \
|
||||
--key-file /certs/{{ coturn_key_file }} \
|
||||
--reloadcmd 'curl --fail --silent --show-error --unix-socket /var/run/docker.sock -X POST http://localhost/v1.41/containers/{{ coturn_service_name }}/restart' || true
|
||||
exec crond -f
|
||||
{% endif %}
|
||||
2
roles/coturn/tests/inventory
Normal file
2
roles/coturn/tests/inventory
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
localhost
|
||||
6
roles/coturn/tests/tests.yml
Normal file
6
roles/coturn/tests/tests.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
- hosts: localhost
|
||||
remote_user: root
|
||||
roles:
|
||||
- coturn
|
||||
3
roles/coturn/vars/main.yml
Normal file
3
roles/coturn/vars/main.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# vars file for coturn
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -61,6 +61,26 @@ nextcloud_trusted_proxies: "172.16.0.0/12"
|
|||
nextcloud_enable_notify_push: false
|
||||
nextcloud_notify_push_image: "icewind1991/notify_push:1.3.1"
|
||||
|
||||
# Nextcloud Talk: register external HPB signaling + TURN + STUN
|
||||
# Set to true to run tasks/talk.yml after Nextcloud is up.
|
||||
nextcloud_enable_talk: false
|
||||
|
||||
# HPB signaling servers to register.
|
||||
# Each item: { server: "https://signaling.example.test", secret: "<hpb_shared_secret>", verify: true }
|
||||
nextcloud_talk_signaling_servers: []
|
||||
# Hostnames/URLs to remove via `talk:signaling:delete` before adding the new set.
|
||||
nextcloud_talk_signaling_servers_removed: []
|
||||
|
||||
# TURN servers to register.
|
||||
# Each item: { server: "stun.example.test:443", secret: "<turn_shared_secret>", schemes: "turn,turns", protocols: "udp,tcp" }
|
||||
nextcloud_talk_turn_servers: []
|
||||
# Clear the spreed.turn_servers config key before re-adding (single source of truth)
|
||||
nextcloud_talk_turn_reset_before_add: true
|
||||
|
||||
# Plain STUN endpoints (host:port). Optional; usually not needed if TURN servers handle STUN too.
|
||||
nextcloud_talk_stun_servers: []
|
||||
nextcloud_talk_stun_servers_removed: []
|
||||
|
||||
# Non-default apps to install and enable
|
||||
nextcloud_apps_to_install:
|
||||
- groupfolders
|
||||
|
|
|
|||
|
|
@ -91,3 +91,7 @@
|
|||
- name: Configure OIDC providers
|
||||
ansible.builtin.include_tasks: oidc.yml
|
||||
when: nextcloud_oidc_providers | length > 0 or nextcloud_oidc_providers_removed | length > 0
|
||||
|
||||
- name: Configure Nextcloud Talk (HPB + TURN + STUN)
|
||||
ansible.builtin.include_tasks: talk.yml
|
||||
when: nextcloud_enable_talk
|
||||
|
|
|
|||
70
roles/nextcloud/tasks/talk.yml
Normal file
70
roles/nextcloud/tasks/talk.yml
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# tasks file for configuring Nextcloud Talk HPB + TURN + STUN registration
|
||||
|
||||
# --- HPB / signaling -----------------------------------------------------------
|
||||
|
||||
- name: Remove HPB signaling servers no longer in use
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ talk:signaling:delete {{ item }}
|
||||
loop: "{{ nextcloud_talk_signaling_servers_removed }}"
|
||||
register: _talk_sig_removed
|
||||
changed_when: "'deleted' in (_talk_sig_removed.stdout | default(''))"
|
||||
failed_when:
|
||||
- _talk_sig_removed.rc != 0
|
||||
- "'is not configured' not in (_talk_sig_removed.stderr | default(''))"
|
||||
|
||||
- name: Register HPB signaling servers
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: >
|
||||
php /var/www/html/occ talk:signaling:add
|
||||
{{ item.server }}
|
||||
{{ item.secret }}
|
||||
{% if item.verify | default(true) %}--verify{% endif %}
|
||||
loop: "{{ nextcloud_talk_signaling_servers }}"
|
||||
no_log: true
|
||||
|
||||
# --- TURN ----------------------------------------------------------------------
|
||||
# `talk:turn:add` appends without deduplication, so on each run we first clear
|
||||
# the list via the underlying app config key (turn_servers, JSON array) and
|
||||
# then re-add the declared set. This keeps the host_vars list as the single
|
||||
# source of truth.
|
||||
|
||||
- name: Reset TURN server list before re-applying
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ config:app:set spreed turn_servers --value='[]'
|
||||
when: nextcloud_talk_turn_reset_before_add | bool
|
||||
|
||||
- name: Register TURN servers
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: >
|
||||
php /var/www/html/occ talk:turn:add
|
||||
{{ item.schemes | default('turn,turns') }}
|
||||
{{ item.server }}
|
||||
{{ item.protocols | default('udp,tcp') }}
|
||||
--secret={{ item.secret }}
|
||||
loop: "{{ nextcloud_talk_turn_servers }}"
|
||||
no_log: true
|
||||
|
||||
# --- STUN ----------------------------------------------------------------------
|
||||
|
||||
- name: Remove STUN servers no longer in use
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ talk:stun:delete {{ item }}
|
||||
loop: "{{ nextcloud_talk_stun_servers_removed }}"
|
||||
register: _talk_stun_removed
|
||||
changed_when: "'deleted' in (_talk_stun_removed.stdout | default(''))"
|
||||
failed_when:
|
||||
- _talk_stun_removed.rc != 0
|
||||
- "'is not configured' not in (_talk_stun_removed.stderr | default(''))"
|
||||
|
||||
- name: Register STUN servers
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ talk:stun:add {{ item }}
|
||||
loop: "{{ nextcloud_talk_stun_servers }}"
|
||||
169
roles/opnform/README.md
Normal file
169
roles/opnform/README.md
Normal file
|
|
@ -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 }}"
|
||||
```
|
||||
109
roles/opnform/defaults/main.yml
Normal file
109
roles/opnform/defaults/main.yml
Normal file
|
|
@ -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: "<group name>", role: "owner|admin|editor|member"}
|
||||
opnform_oidc_group_role_mappings: []
|
||||
|
||||
# Traefik configuration
|
||||
opnform_traefik_network: "proxy"
|
||||
opnform_use_ssl: true
|
||||
8
roles/opnform/handlers/main.yml
Normal file
8
roles/opnform/handlers/main.yml
Normal file
|
|
@ -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
|
||||
220
roles/opnform/meta/argument_specs.yml
Normal file
220
roles/opnform/meta/argument_specs.yml
Normal file
|
|
@ -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
|
||||
16
roles/opnform/meta/main.yml
Normal file
16
roles/opnform/meta/main.yml
Normal file
|
|
@ -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: []
|
||||
265
roles/opnform/tasks/main.yml
Normal file
265
roles/opnform/tasks/main.yml
Normal file
|
|
@ -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 %}
|
||||
189
roles/opnform/templates/docker-compose.yml.j2
Normal file
189
roles/opnform/templates/docker-compose.yml.j2
Normal file
|
|
@ -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
|
||||
43
roles/opnform/templates/nginx.conf.j2
Normal file
43
roles/opnform/templates/nginx.conf.j2
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
2
roles/opnform/tests/inventory
Normal file
2
roles/opnform/tests/inventory
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
localhost
|
||||
6
roles/opnform/tests/test.yml
Normal file
6
roles/opnform/tests/test.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
- hosts: localhost
|
||||
remote_user: root
|
||||
roles:
|
||||
- opnform
|
||||
3
roles/opnform/vars/main.yml
Normal file
3
roles/opnform/vars/main.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# vars file for opnform
|
||||
60
roles/send/README.md
Normal file
60
roles/send/README.md
Normal file
|
|
@ -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
|
||||
53
roles/send/defaults/main.yml
Normal file
53
roles/send/defaults/main.yml
Normal file
|
|
@ -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
|
||||
9
roles/send/handlers/main.yml
Normal file
9
roles/send/handlers/main.yml
Normal file
|
|
@ -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
|
||||
122
roles/send/meta/argument_specs.yml
Normal file
122
roles/send/meta/argument_specs.yml
Normal file
|
|
@ -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
|
||||
14
roles/send/meta/main.yml
Normal file
14
roles/send/meta/main.yml
Normal file
|
|
@ -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: []
|
||||
42
roles/send/tasks/main.yml
Normal file
42
roles/send/tasks/main.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# 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 }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Create local upload directory
|
||||
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
|
||||
ansible.builtin.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
|
||||
69
roles/send/templates/docker-compose.yml.j2
Normal file
69
roles/send/templates/docker-compose.yml.j2
Normal file
|
|
@ -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={% 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
|
||||
- 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
|
||||
3
roles/send/vars/main.yml
Normal file
3
roles/send/vars/main.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# vars file for send
|
||||
78
roles/talk/README.md
Normal file
78
roles/talk/README.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# talk
|
||||
|
||||
Deploys the Nextcloud Talk High Performance Backend (HPB) stack:
|
||||
|
||||
- `nextcloud-spreed-signaling` (Strukturag)
|
||||
- `janus-gateway` (canyan build, WebRTC MCU)
|
||||
- `nats` (internal message broker)
|
||||
|
||||
Designed to be paired with the `digitalboard.core.coturn` role (TURN/STUN) and registered in
|
||||
Nextcloud via the new `digitalboard.core.nextcloud` `talk.yml` task.
|
||||
|
||||
## Required variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `talk_domain` | Public host name (e.g. `signaling.digitalboard.ch`) |
|
||||
| `talk_nextcloud_url` | Base URL of the Nextcloud instance the HPB talks back to |
|
||||
| `talk_janus_public_ip` | Public IP used by Janus for ICE candidate gathering (nat_1_1_mapping) |
|
||||
| `talk_backend_secret` | HMAC secret shared with Nextcloud Talk; loaded from `secrets/{host}/talk_backend_secret` |
|
||||
| `talk_turn_secret` | Shared secret with coturn; loaded from `secrets/{host}/talk_turn_secret` (must equal `coturn_static_auth_secret`) |
|
||||
| `talk_session_hashkey` | 32-byte hex; loaded from `secrets/{host}/talk_session_hashkey` |
|
||||
| `talk_session_blockkey` | 32-byte hex; loaded from `secrets/{host}/talk_session_blockkey` |
|
||||
|
||||
## Important variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `talk_internal_domain` | `""` | Optional split-horizon FQDN (matches the second SAN on the coturn cert) |
|
||||
| `talk_turn_servers` | `turns:.../443?transport=tcp,turn:.../443` | Comma-separated TURN URI list passed to the signaling server |
|
||||
| `talk_turn_realm` | `stun.example.test` | Realm advertised to clients |
|
||||
| `talk_janus_stun_server` | `stun.int.example.test` | STUN endpoint Janus uses for its own ICE; default points at the internal coturn name |
|
||||
| `talk_janus_rtp_port_min/max` | `20000`/`21000` | UDP/TCP relay range opened on the Janus container |
|
||||
| `talk_nextcloud_extra_host_ip` | `""` | Optional pin: bind the Nextcloud FQDN to a specific backend IP (bypasses hairpin/SNI) |
|
||||
| `talk_signaling_image` | `strukturag/nextcloud-spreed-signaling:1.3.4` | Pinned |
|
||||
| `talk_janus_image` | `canyan/janus-gateway:1.2.4` | Pinned |
|
||||
| `talk_nats_image` | `nats:2.10-alpine` | Pinned |
|
||||
|
||||
All defaults can be overridden per host_vars. The configurable image variables exist explicitly because
|
||||
this stack is still under active development upstream and you may want to roll forward independently.
|
||||
|
||||
## Secrets
|
||||
|
||||
The role expects these files under `playbooks/secrets/{{ inventory_hostname }}/`, mode 0600:
|
||||
|
||||
```
|
||||
talk_backend_secret # shared with Nextcloud Talk app (HPB shared secret)
|
||||
talk_turn_secret # = coturn_static_auth_secret on the TURN host
|
||||
talk_session_hashkey # 32-byte hex (openssl rand -hex 32)
|
||||
talk_session_blockkey # 32-byte hex (openssl rand -hex 32)
|
||||
```
|
||||
|
||||
If you prefer a different secret store, override the variables directly in host_vars.
|
||||
|
||||
## What gets registered in Nextcloud
|
||||
|
||||
The matching `digitalboard.core.nextcloud` task `talk.yml` runs:
|
||||
|
||||
- `php occ talk:signaling:add <talk_domain> <talk_backend_secret>` — register HPB
|
||||
- `php occ talk:turn:add` for each entry in `nextcloud_talk_turn_servers` — register TURN
|
||||
|
||||
That part lives in the **nextcloud** role and runs when `nextcloud_enable_talk: true`.
|
||||
|
||||
## Traefik
|
||||
|
||||
The role assumes a `digitalboard.core.traefik` instance in `backend` mode runs on the same host
|
||||
(picks up Docker container labels). The public `talk_domain` then needs to be exposed via the
|
||||
**DMZ Traefik**, by adding an entry to `traefik_dmz_exposed_services` in the signaling host's
|
||||
`host_vars`:
|
||||
|
||||
```yaml
|
||||
traefik_dmz_exposed_services:
|
||||
- name: signaling
|
||||
domain: signaling.digitalboard.ch
|
||||
port: 443
|
||||
protocol: https
|
||||
```
|
||||
|
||||
(The DMZ proxy aggregates exposed services from all `backend_servers` host_vars.)
|
||||
74
roles/talk/defaults/main.yml
Normal file
74
roles/talk/defaults/main.yml
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# defaults file for talk (Nextcloud Talk High Performance Backend)
|
||||
|
||||
# Base directories (inherited from base role)
|
||||
docker_compose_base_dir: /etc/docker/compose
|
||||
docker_volume_base_dir: /srv/data
|
||||
|
||||
talk_service_name: signaling
|
||||
talk_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ talk_service_name }}"
|
||||
talk_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ talk_service_name }}"
|
||||
|
||||
# --- Container images (pinned) ---
|
||||
talk_signaling_image: "strukturag/nextcloud-spreed-signaling:1.3.4"
|
||||
talk_janus_image: "canyan/janus-gateway:1.2.4"
|
||||
talk_nats_image: "nats:2.10-alpine"
|
||||
|
||||
# --- Networking ---
|
||||
talk_traefik_network: "proxy"
|
||||
talk_internal_network: "hpb_internal"
|
||||
|
||||
# --- Public exposure ---
|
||||
talk_use_ssl: true
|
||||
talk_cert_resolver: "dns"
|
||||
talk_domain: "signaling.example.test" # public domain (over DMZ Traefik)
|
||||
talk_internal_domain: "" # optional split-horizon "int" domain (e.g. signaling.int.example.test)
|
||||
|
||||
# --- Backend (Nextcloud) registration ---
|
||||
# Nextcloud base URL the HPB talks back to. Must be reachable from the HPB container.
|
||||
talk_nextcloud_url: "https://cloud.example.test"
|
||||
# Pin Nextcloud domain to a backend IP via extra_hosts to bypass DMZ hairpin/SNI issues
|
||||
talk_nextcloud_extra_host_ip: "" # e.g. "172.16.9.88" — empty disables the pin
|
||||
|
||||
# Backend HMAC secret shared with Nextcloud Talk.
|
||||
# Pattern follows playbooks/secrets/{host}/<name>; override the lookup with vault if desired.
|
||||
talk_backend_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_backend_secret') }}"
|
||||
|
||||
# --- TURN integration ---
|
||||
# Shared secret with coturn (--static-auth-secret). Must match coturn_static_auth_secret on the TURN host.
|
||||
talk_turn_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_turn_secret') }}"
|
||||
# TURN server URI list as understood by the signaling server.
|
||||
# Defaults follow IANA standards (3478/5349). Override to ":443" in restrictive
|
||||
# network environments where coturn binds on 443.
|
||||
talk_turn_servers: "turns:stun.example.test:5349?transport=tcp,turn:stun.example.test:3478"
|
||||
talk_turn_realm: "stun.example.test"
|
||||
talk_turn_apikey: "" # optional; if empty a random one is generated on first run
|
||||
|
||||
# --- Session keys (server.conf [sessions]) ---
|
||||
# 32-byte hex strings. Loaded from secrets dir like the other shared secrets.
|
||||
talk_session_hashkey: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_session_hashkey') }}"
|
||||
talk_session_blockkey: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_session_blockkey') }}"
|
||||
|
||||
# --- MCU (Janus) ---
|
||||
talk_mcu_type: "janus"
|
||||
talk_janus_public_ip: "" # set in host_vars; goes into janus nat_1_1_mapping
|
||||
talk_janus_rtp_port_min: 20000
|
||||
talk_janus_rtp_port_max: 21000
|
||||
# STUN server Janus uses for its own ICE candidate gathering. Default points to internal coturn DNS name.
|
||||
talk_janus_stun_server: "stun.int.example.test"
|
||||
talk_janus_stun_port: 5349
|
||||
talk_janus_ice_lite: true
|
||||
talk_janus_ice_tcp: true
|
||||
|
||||
# --- Trusted proxies / allowed hosts for the signaling [app] section ---
|
||||
talk_trusted_proxies:
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
- "10.0.0.0/8"
|
||||
talk_allowed_hosts:
|
||||
- "172.16.0.0/12"
|
||||
|
||||
# --- Extra hosts forwarded to all three containers ---
|
||||
# Pre-populated with the Nextcloud pin if talk_nextcloud_extra_host_ip is set; you can append more here.
|
||||
talk_extra_hosts: []
|
||||
8
roles/talk/handlers/main.yml
Normal file
8
roles/talk/handlers/main.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# handlers file for talk
|
||||
|
||||
- name: Restart signaling stack
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ talk_docker_compose_dir }}"
|
||||
state: restarted
|
||||
161
roles/talk/meta/argument_specs.yml
Normal file
161
roles/talk/meta/argument_specs.yml
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Deploy the Nextcloud Talk High Performance Backend (HPB) stack.
|
||||
description:
|
||||
- Renders a Docker Compose stack with C(nextcloud-spreed-signaling)
|
||||
(Strukturag), C(janus-gateway) (canyan build) and C(nats) (internal
|
||||
message broker) behind Traefik.
|
||||
- Designed to be paired with the C(digitalboard.core.coturn) role
|
||||
(TURN/STUN) and registered in Nextcloud via
|
||||
C(digitalboard.core.nextcloud)'s C(talk.yml) task.
|
||||
options:
|
||||
docker_compose_base_dir:
|
||||
type: path
|
||||
default: /etc/docker/compose
|
||||
docker_volume_base_dir:
|
||||
type: path
|
||||
default: /srv/data
|
||||
talk_service_name:
|
||||
type: str
|
||||
default: signaling
|
||||
talk_docker_compose_dir:
|
||||
type: path
|
||||
talk_docker_volume_dir:
|
||||
type: path
|
||||
|
||||
talk_signaling_image:
|
||||
type: str
|
||||
default: "strukturag/nextcloud-spreed-signaling:1.3.4"
|
||||
talk_janus_image:
|
||||
type: str
|
||||
default: "canyan/janus-gateway:1.2.4"
|
||||
talk_nats_image:
|
||||
type: str
|
||||
default: "nats:2.10-alpine"
|
||||
|
||||
talk_traefik_network:
|
||||
type: str
|
||||
default: proxy
|
||||
talk_internal_network:
|
||||
type: str
|
||||
default: hpb_internal
|
||||
|
||||
talk_use_ssl:
|
||||
type: bool
|
||||
default: true
|
||||
talk_cert_resolver:
|
||||
type: str
|
||||
default: dns
|
||||
talk_domain:
|
||||
type: str
|
||||
default: signaling.example.test
|
||||
description: Public domain (typically routed through the DMZ Traefik).
|
||||
talk_internal_domain:
|
||||
type: str
|
||||
default: ''
|
||||
description:
|
||||
- Optional split-horizon C(*.int.*) domain for server-to-server
|
||||
traffic (e.g. C(signaling.int.example.test)).
|
||||
|
||||
talk_nextcloud_url:
|
||||
type: str
|
||||
default: https://cloud.example.test
|
||||
description: Nextcloud base URL the HPB talks back to. Must be reachable from the HPB container.
|
||||
talk_nextcloud_extra_host_ip:
|
||||
type: str
|
||||
default: ''
|
||||
description:
|
||||
- Pin the Nextcloud hostname to a backend IP via C(extra_hosts) to bypass
|
||||
DMZ hairpin / SNI issues. Empty disables the pin.
|
||||
|
||||
talk_backend_secret:
|
||||
type: str
|
||||
required: true
|
||||
description:
|
||||
- HMAC secret shared with Nextcloud Talk. Default lookup reads
|
||||
C(playbooks/secrets/<host>/talk_backend_secret).
|
||||
|
||||
talk_turn_secret:
|
||||
type: str
|
||||
required: true
|
||||
description:
|
||||
- Shared secret with coturn (must match C(coturn_static_auth_secret)
|
||||
on the TURN host). Default lookup reads
|
||||
C(playbooks/secrets/<host>/talk_turn_secret).
|
||||
talk_turn_servers:
|
||||
type: str
|
||||
default: "turns:stun.example.test:5349?transport=tcp,turn:stun.example.test:3478"
|
||||
description:
|
||||
- TURN server URI list as understood by the signaling server.
|
||||
Override to C(:443) when coturn binds on 443 in restrictive networks.
|
||||
talk_turn_realm:
|
||||
type: str
|
||||
default: stun.example.test
|
||||
talk_turn_apikey:
|
||||
type: str
|
||||
default: ''
|
||||
description: Optional explicit API key; when empty a random one is generated on first run.
|
||||
|
||||
talk_session_hashkey:
|
||||
type: str
|
||||
required: true
|
||||
description:
|
||||
- 32-byte hex string. Default lookup reads
|
||||
C(playbooks/secrets/<host>/talk_session_hashkey).
|
||||
talk_session_blockkey:
|
||||
type: str
|
||||
required: true
|
||||
description:
|
||||
- 32-byte hex string. Default lookup reads
|
||||
C(playbooks/secrets/<host>/talk_session_blockkey).
|
||||
|
||||
talk_mcu_type:
|
||||
type: str
|
||||
choices: [janus]
|
||||
default: janus
|
||||
talk_janus_public_ip:
|
||||
type: str
|
||||
default: ''
|
||||
description: Must be set in host_vars. Goes into janus C(nat_1_1_mapping).
|
||||
talk_janus_rtp_port_min:
|
||||
type: int
|
||||
default: 20000
|
||||
talk_janus_rtp_port_max:
|
||||
type: int
|
||||
default: 21000
|
||||
talk_janus_stun_server:
|
||||
type: str
|
||||
default: stun.int.example.test
|
||||
description: STUN server janus uses for its own ICE candidate gathering.
|
||||
talk_janus_stun_port:
|
||||
type: int
|
||||
default: 5349
|
||||
talk_janus_ice_lite:
|
||||
type: bool
|
||||
default: true
|
||||
talk_janus_ice_tcp:
|
||||
type: bool
|
||||
default: true
|
||||
|
||||
talk_trusted_proxies:
|
||||
type: list
|
||||
elements: str
|
||||
default:
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
- "10.0.0.0/8"
|
||||
talk_allowed_hosts:
|
||||
type: list
|
||||
elements: str
|
||||
default:
|
||||
- "172.16.0.0/12"
|
||||
|
||||
talk_extra_hosts:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description:
|
||||
- Extra C(host:ip) entries forwarded to all three containers.
|
||||
Pre-populated with the Nextcloud pin when
|
||||
C(talk_nextcloud_extra_host_ip) is set.
|
||||
15
roles/talk/meta/main.yml
Normal file
15
roles/talk/meta/main.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: Digital Board Team
|
||||
description: Deploy Nextcloud Talk High Performance Backend (nextcloud-spreed-signaling + Janus + NATS)
|
||||
company: digitalboard.ch
|
||||
license: GPL-2.0-or-later
|
||||
min_ansible_version: "2.14"
|
||||
galaxy_tags:
|
||||
- nextcloud
|
||||
- talk
|
||||
- signaling
|
||||
- hpb
|
||||
- janus
|
||||
- webrtc
|
||||
dependencies: []
|
||||
85
roles/talk/tasks/main.yml
Normal file
85
roles/talk/tasks/main.yml
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# tasks file for talk (HPB)
|
||||
|
||||
- name: Assert minimum configuration
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- talk_domain | length > 0
|
||||
- talk_nextcloud_url | length > 0
|
||||
- talk_backend_secret | length > 0
|
||||
- talk_turn_secret | length > 0
|
||||
- talk_janus_public_ip | length > 0
|
||||
- talk_session_hashkey | length > 0
|
||||
- talk_session_blockkey | length > 0
|
||||
fail_msg: >
|
||||
Required talk_* variables missing.
|
||||
Set talk_domain, talk_nextcloud_url, talk_janus_public_ip in host_vars
|
||||
and place backend/turn/session secrets in playbooks/secrets/{{ inventory_hostname }}/.
|
||||
|
||||
- name: Create talk compose directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ talk_docker_compose_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Create signaling subdirectories (signaling + janus configs)
|
||||
ansible.builtin.file:
|
||||
path: "{{ talk_docker_compose_dir }}/{{ item }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- signaling
|
||||
- janus
|
||||
|
||||
- name: Create signaling data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ talk_docker_volume_dir }}/signaling/data"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Ensure proxy network exists (created externally by Traefik role normally)
|
||||
community.docker.docker_network:
|
||||
name: "{{ talk_traefik_network }}"
|
||||
state: present
|
||||
|
||||
- name: Render signaling server.conf
|
||||
ansible.builtin.template:
|
||||
src: server.conf.j2
|
||||
dest: "{{ talk_docker_compose_dir }}/signaling/server.conf"
|
||||
mode: "0640"
|
||||
no_log: true
|
||||
notify: Restart signaling stack
|
||||
|
||||
- name: Render Janus main config
|
||||
ansible.builtin.template:
|
||||
src: janus.jcfg.j2
|
||||
dest: "{{ talk_docker_compose_dir }}/janus/janus.jcfg"
|
||||
mode: "0644"
|
||||
notify: Restart signaling stack
|
||||
|
||||
- name: Render Janus websockets transport config
|
||||
ansible.builtin.template:
|
||||
src: janus.transport.websockets.jcfg.j2
|
||||
dest: "{{ talk_docker_compose_dir }}/janus/janus.transport.websockets.jcfg"
|
||||
mode: "0644"
|
||||
notify: Restart signaling stack
|
||||
|
||||
- name: Render Janus logger config
|
||||
ansible.builtin.template:
|
||||
src: janus.logger.jcfg.j2
|
||||
dest: "{{ talk_docker_compose_dir }}/janus/janus.logger.jcfg"
|
||||
mode: "0644"
|
||||
notify: Restart signaling stack
|
||||
|
||||
- name: Render docker-compose.yml
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ talk_docker_compose_dir }}/docker-compose.yml"
|
||||
mode: "0644"
|
||||
notify: Restart signaling stack
|
||||
|
||||
- name: Start signaling stack
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ talk_docker_compose_dir }}"
|
||||
state: present
|
||||
124
roles/talk/templates/docker-compose.yml.j2
Normal file
124
roles/talk/templates/docker-compose.yml.j2
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
{# Build the effective extra_hosts list once #}
|
||||
{% set _extra_hosts = [] %}
|
||||
{% if talk_nextcloud_extra_host_ip | length > 0 %}
|
||||
{% set _ = _extra_hosts.append((talk_nextcloud_url | urlsplit('hostname')) ~ ':' ~ talk_nextcloud_extra_host_ip) %}
|
||||
{% endif %}
|
||||
{% for h in talk_extra_hosts %}
|
||||
{% set _ = _extra_hosts.append(h) %}
|
||||
{% endfor %}
|
||||
networks:
|
||||
{{ talk_traefik_network }}:
|
||||
external: true
|
||||
{{ talk_internal_network }}:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
nats:
|
||||
image: {{ talk_nats_image }}
|
||||
container_name: nats
|
||||
restart: unless-stopped
|
||||
{% if _extra_hosts | length > 0 %}
|
||||
extra_hosts:
|
||||
{% for h in _extra_hosts %}
|
||||
- "{{ h }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
command: >
|
||||
-js
|
||||
-m 8222
|
||||
-p 4222
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "4222"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks:
|
||||
- {{ talk_internal_network }}
|
||||
|
||||
janus:
|
||||
image: {{ talk_janus_image }}
|
||||
container_name: janus
|
||||
restart: unless-stopped
|
||||
{% if _extra_hosts | length > 0 %}
|
||||
extra_hosts:
|
||||
{% for h in _extra_hosts %}
|
||||
- "{{ h }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
environment:
|
||||
PUBLIC_IP: "{{ talk_janus_public_ip }}"
|
||||
RTP_RANGE: "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}"
|
||||
volumes:
|
||||
- ./janus/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro
|
||||
- ./janus/janus.transport.websockets.jcfg:/usr/local/etc/janus/janus.transport.websockets.jcfg:ro
|
||||
- ./janus/janus.logger.jcfg:/usr/local/etc/janus/janus.logger.jcfg:ro
|
||||
networks:
|
||||
- {{ talk_internal_network }}
|
||||
ports:
|
||||
- "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}:{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}/udp"
|
||||
- "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}:{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}/tcp"
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
|
||||
signaling:
|
||||
image: {{ talk_signaling_image }}
|
||||
container_name: signaling
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
nats:
|
||||
condition: service_healthy
|
||||
{% if _extra_hosts | length > 0 %}
|
||||
extra_hosts:
|
||||
{% for h in _extra_hosts %}
|
||||
- "{{ h }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
volumes:
|
||||
- ./signaling/server.conf:/config/server.conf:ro
|
||||
- {{ talk_docker_volume_dir }}/signaling/data:/var/lib/signaling
|
||||
networks:
|
||||
- {{ talk_traefik_network }}
|
||||
- {{ talk_internal_network }}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network={{ talk_traefik_network }}
|
||||
|
||||
# Public WebSocket route (/spreed)
|
||||
- traefik.http.routers.signal-public.rule=Host(`{{ talk_domain }}`) && PathPrefix(`/spreed`)
|
||||
- traefik.http.routers.signal-public.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }}
|
||||
{% if talk_use_ssl %}
|
||||
- traefik.http.routers.signal-public.tls=true
|
||||
- traefik.http.routers.signal-public.tls.certresolver={{ talk_cert_resolver }}
|
||||
{% endif %}
|
||||
- traefik.http.routers.signal-public.service=signal-svc
|
||||
- traefik.http.routers.signal-public.middlewares=signal-ws
|
||||
|
||||
# Public backend API route (/api/)
|
||||
- traefik.http.routers.signal-backend.rule=Host(`{{ talk_domain }}`) && PathPrefix(`/api/`)
|
||||
- traefik.http.routers.signal-backend.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }}
|
||||
{% if talk_use_ssl %}
|
||||
- traefik.http.routers.signal-backend.tls=true
|
||||
- traefik.http.routers.signal-backend.tls.certresolver={{ talk_cert_resolver }}
|
||||
{% endif %}
|
||||
- traefik.http.routers.signal-backend.service=signal-svc
|
||||
|
||||
{% if talk_internal_domain | length > 0 %}
|
||||
# Internal split-horizon route (full host on int domain, WebSocket-aware)
|
||||
- traefik.http.routers.signal-int.rule=Host(`{{ talk_internal_domain }}`)
|
||||
- traefik.http.routers.signal-int.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }}
|
||||
{% if talk_use_ssl %}
|
||||
- traefik.http.routers.signal-int.tls=true
|
||||
- traefik.http.routers.signal-int.tls.certresolver={{ talk_cert_resolver }}
|
||||
{% endif %}
|
||||
- traefik.http.routers.signal-int.service=signal-svc
|
||||
- traefik.http.routers.signal-int.middlewares=signal-ws
|
||||
{% endif %}
|
||||
|
||||
# Common service
|
||||
- traefik.http.services.signal-svc.loadbalancer.server.port=8181
|
||||
|
||||
# WebSocket upgrade headers
|
||||
- traefik.http.middlewares.signal-ws.headers.customrequestheaders.Upgrade=websocket
|
||||
- traefik.http.middlewares.signal-ws.headers.customrequestheaders.Connection=Upgrade
|
||||
28
roles/talk/templates/janus.jcfg.j2
Normal file
28
roles/talk/templates/janus.jcfg.j2
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
general: {
|
||||
configs_folder = "/usr/local/etc/janus"
|
||||
log_to_stdout = true
|
||||
}
|
||||
|
||||
nat: {
|
||||
nat_1_1_mapping = "{{ talk_janus_public_ip }}"
|
||||
ice_lite = {{ talk_janus_ice_lite | string | lower }}
|
||||
ice_tcp = {{ talk_janus_ice_tcp | string | lower }}
|
||||
|
||||
stun_server = "{{ talk_janus_stun_server }}"
|
||||
stun_port = {{ talk_janus_stun_port }}
|
||||
|
||||
rtp_port_range = "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}"
|
||||
}
|
||||
|
||||
media: {
|
||||
rtp_port_range = "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}"
|
||||
}
|
||||
|
||||
transports: {
|
||||
websockets: {
|
||||
ws = true
|
||||
ws_port = 8188
|
||||
ws_interface = "0.0.0.0"
|
||||
ws_ip = "0.0.0.0"
|
||||
}
|
||||
}
|
||||
3
roles/talk/templates/janus.logger.jcfg.j2
Normal file
3
roles/talk/templates/janus.logger.jcfg.j2
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
general: {
|
||||
enabled = true
|
||||
}
|
||||
7
roles/talk/templates/janus.transport.websockets.jcfg.j2
Normal file
7
roles/talk/templates/janus.transport.websockets.jcfg.j2
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
general: {
|
||||
ws = true
|
||||
ws_port = 8188
|
||||
ws_interface = "0.0.0.0"
|
||||
ws_pingpong_trigger = 60
|
||||
ws_pingpong_timeout = 30
|
||||
}
|
||||
33
roles/talk/templates/server.conf.j2
Normal file
33
roles/talk/templates/server.conf.j2
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
[http]
|
||||
listen = 0.0.0.0:8181
|
||||
base_url = https://{{ talk_domain }}
|
||||
|
||||
[backend]
|
||||
backends = cloud
|
||||
|
||||
[cloud]
|
||||
secret = {{ talk_backend_secret }}
|
||||
url = {{ talk_nextcloud_url }}
|
||||
|
||||
[nats]
|
||||
url = nats://nats:4222
|
||||
|
||||
[mcu]
|
||||
type = {{ talk_mcu_type }}
|
||||
url = ws://janus:8188/
|
||||
|
||||
[sessions]
|
||||
hashkey = {{ talk_session_hashkey }}
|
||||
blockkey = {{ talk_session_blockkey }}
|
||||
|
||||
[turn]
|
||||
servers = {{ talk_turn_servers }}
|
||||
realm = {{ talk_turn_realm }}
|
||||
{% if talk_turn_apikey | length > 0 %}
|
||||
apikey = {{ talk_turn_apikey }}
|
||||
{% endif %}
|
||||
secret = {{ talk_turn_secret }}
|
||||
|
||||
[app]
|
||||
trustedproxies = {{ talk_trusted_proxies | join(',') }}
|
||||
allowedhosts = {{ talk_allowed_hosts | join(',') }}
|
||||
2
roles/talk/tests/inventory
Normal file
2
roles/talk/tests/inventory
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
localhost
|
||||
6
roles/talk/tests/test.yml
Normal file
6
roles/talk/tests/test.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
- hosts: localhost
|
||||
remote_user: root
|
||||
roles:
|
||||
- talk
|
||||
3
roles/talk/vars/main.yml
Normal file
3
roles/talk/vars/main.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# vars file for talk
|
||||
Loading…
Add table
Add a link
Reference in a new issue