Compare commits

..

10 commits

Author SHA1 Message Date
Simon Bärlocher
14c81657d7
docs(roles): add argument_specs and README for traefik, authentik, drawio, garage, nextcloud
Each of the five roles touched in this branch now ships:

* meta/argument_specs.yml: typed schema for every variable in
  defaults/main.yml plus the optional inputs surfaced via this
  branch (traefik_extra_hosts, authentik_host_rewrite_domains,
  authentik_proxy_apps.mode / .allowed_groups, drawio_extra_domains,
  drawio_authentik_forward_auth*, garage_webui_authentik_forward_auth*).
  All five specs load cleanly through ansible-core's
  ArgumentSpecValidator.

* README.md: replaces the ansible-galaxy boilerplate (where it was
  still in place) with a focused write-up — service vars, required
  secrets, ForwardAuth/idempotency notes, dependencies, and a working
  example playbook. authentik and garage READMEs are rewritten to cover
  the new knobs while preserving their existing content.
2026-05-26 14:16:47 +02:00
Simon Bärlocher
1f9292cc9a
feat(drawio): support extra hostnames via drawio_extra_domains
Add drawio_extra_domains (list, default empty). The traefik Host rule
on the drawio router now expands to Host(<canonical>) || Host(<extra>)
... so the same container can answer on additional FQDNs — e.g. an
internal *.int.* name so a DMZ reverse-proxy can reach drawio via a
backend hostname covered by the local traefik cert.

Empty by default; behaviour unchanged for existing inventories.
2026-05-26 14:16:23 +02:00
Simon Bärlocher
d476bca4f5
fix(nextcloud): in-container patch for UserConfig::getValueBool TypeError
nextcloud/server#59629: under PHP 8.x with OPcache,
UserConfig::getValueBool() passes a non-string from getTypedValue()
straight into strtolower(), throwing a TypeError on every authenticated
request once user_ldap is involved. Fix landed in master (PR #59646)
but no stable33 backport made it into 33.0.4.

Discover all compose-managed nextcloud containers, check whether the
`strtolower((string)` cast is already present, and `sed` it into
`lib/private/Config/UserConfig.php` on the ones that still ship the
broken version. Idempotent via grep guard so re-runs are no-ops.

Remove this block once the deployed image >= 33.0.4 ships the upstream fix.
2026-05-26 14:04:33 +02:00
Simon Bärlocher
aea6dec081
fix(nextcloud): make occ-driven config tasks idempotent
Every `occ config:app:set` / `ldap:set-config` / `notify_push:setup`
call previously fired on every play, marking changed even when the
stored value already matched. Now we read the current value first and
only invoke the setter when it differs:

* richdocuments (collabora): pre-read wopi_url, public_wopi_url,
  disable_certificate_verification, wopi_allowlist into a fact map;
  guard each `config:app:set` and tag `richdocuments:activate-config`
  with `changed_when: false` since it's a discovery refresh.

* drawio: same pattern for DrawioUrl, DrawioTheme, DrawioOffline,
  comparing as strings (occ stores booleans as "1"/"0").

* user_ldap: pre-read `ldap:show-config s01 --output=json`, parse JSON
  defensively (occ logs interleave on stderr), and skip per-key
  `ldap:set-config` calls when the stored value already equals the
  desired one.

* notify_push: skip `notify_push:setup` when the stored base_endpoint
  already matches the computed URL.

* plugins: `app:install`/`app:enable` were treating "already installed/
  enabled" output as a change. Add the negative match to `changed_when`
  so re-runs of a fully-provisioned site report ok rather than changed.
2026-05-26 14:04:17 +02:00
Simon Bärlocher
1157448d59
fix(garage): make bootstrap & provision idempotent across reruns
* bootstrap: `garage layout show` truncates node IDs to 16 chars, but
  the membership check compared against the full hex. After the first
  successful join, subsequent runs no longer found the short ID in
  `layout show` and re-issued `layout assign`, marking the task
  changed every time. Compare against both the truncated and the full
  form so a configured node stays detected. Also tag the read-only
  `garage node id` / `layout show` probes with `changed_when: false`.

* provision keys: the old parser sliced `stdout_lines[1:]` to drop the
  header but missed that INFO log lines and ANSI escapes can interleave
  with table rows. Replace with an explicit `^GK[0-9a-fA-F]+` filter
  after stripping ANSI, so probe-output noise no longer corrupts the
  existing-keys set and triggers spurious `key new` calls.

* provision buckets: same class of fix — match `^[0-9a-f]{16}\s` data
  rows instead of slicing `[2:]`, which broke when the table header
  wasn't exactly two lines.

* provision permissions: pre-read `bucket info` for each (key, bucket)
  pair and only run `bucket allow` when the current `RWO` flag set for
  that key ID doesn't already match the desired permissions. Previously
  `bucket allow` ran unconditionally and reported changed every play.

* `changed_when: false` on all read-only probes (`key list`, `key info`,
  `bucket list`).
2026-05-26 14:03:58 +02:00
Simon Bärlocher
c27584cd9c
feat(drawio,garage): optional Authentik ForwardAuth in front of UIs
Add `*_authentik_forward_auth` + `*_authentik_forward_auth_url` knobs to
both roles. When enabled:

* drawio: traefik attaches a ForwardAuth middleware pointing at the
  authentik embedded outpost; unauthenticated requests get redirected
  to log in and downstream sees X-Authentik-* identity headers.

* garage WebUI: same ForwardAuth wiring, and `AUTH_USER_PASS` is dropped
  from the container env so authentik is the only gate. Tasks now key
  the htpasswd hash workflow off `_garage_webui_htpasswd_active`
  (`webui_enabled AND NOT authentik_forward_auth`); when authentik
  fronts the UI we skip hashing entirely. htpasswd hash is also now
  cached on disk and re-verified via `htpasswd -vbB` so unchanged
  passwords stop showing as `changed=true` on every run.

Both knobs default to `false`, preserving existing htpasswd/plain behaviour.
2026-05-26 14:03:38 +02:00
Simon Bärlocher
da103a59f2
feat(authentik): split-horizon host rewrite + proxy-app mode/group bindings
* `authentik_host_rewrite_domains`: extra hostnames that reach the
  authentik container but make it generate URLs (OIDC issuer, reset
  links) as if requested from the canonical `authentik_domains[0]`.
  Each entry gets its own traefik router and a URL-based loadbalancer
  service that disables passHostHeader and pins X-Forwarded-Host via
  middleware, so server-to-server calls on internal FQDNs keep traffic
  in the LAN while the iss claim stays aligned with the public host.
  Uses a network alias on the canonical FQDN so traefik (sharing the
  network) resolves the URL upstream to this very container.

* proxy-app blueprint:
  - `mode` (default `forward_single`) lets callers pick between proxy,
    forward_single and forward_domain providers in one template.
  - `allowed_groups`: when set, emit one PolicyBinding per group on
    the application; authentik OR-evaluates bindings, so users in any
    listed group pass and others are denied.

Existing inventories with an empty list see no behavioural change.
2026-05-26 14:03:05 +02:00
Simon Bärlocher
afe5950d77
feat(traefik): configurable extra_hosts for container DNS overrides
Add `traefik_extra_hosts` (list of `host:ip`) that maps straight into
the traefik container's compose `extra_hosts`. Needed when a downstream
middleware (e.g. ForwardAuth to authentik on a sibling LAN) has to
resolve a public FQDN to an internal IP because the DMZ doesn't hairpin
the public address back inside.

Empty by default; behaviour unchanged for existing inventories.
2026-05-26 14:02:43 +02:00
Simon Bärlocher
02d45026a5
feat: drop blanket recreates, ACME-DNS knobs, notify_push override
- Drop `recreate: always` from collabora/drawio/homarr/opencloud/traefik
  handlers and the authentik_outpost_ldap start task. `up -d` with
  `state: present` already recreates exactly the services whose
  compose definition changed; the blanket recreate was forcing
  restarts even when nothing relevant moved.
- Rewrite the `*_domains` Traefik Host loop to the `Host(\`a\`) ||
  Host(\`b\`)` form across authentik/collabora/garage/nextcloud so the
  rule still matches when traefik can't normalize the comma-form into
  the same canonical shape.
- Traefik: add `traefik_acme_tcp_only` (sets LEGO_EXPERIMENTAL_DNS_TCP_ONLY)
  and `traefik_acme_disable_ans_checks` (disables lego's authoritative-NS
  propagation check) for environments where the DNS path between the
  traefik container and the zone's nameservers is constrained.
- Traefik DMZ collector: two-step merge so a `traefik_dmz_exposed_services`
  entry that sets its own `backend_host` wins over the host fallback;
  lets a route target an internal FQDN covered by the backend cert's
  SANs instead of the raw IP.
- Nextcloud: add `nextcloud_notify_push_domain` override for the
  `occ notify_push:setup` call so the setup check can hit an internal
  FQDN instead of hairpinning through the DMZ. Push router now matches
  every entry in `nextcloud_domains`.
- Nextcloud: also %2F-escape slashes in the postgres user/password
  inside the notify_push DATABASE_URL.
2026-05-20 22:44:41 +02:00
Simon Bärlocher
36e3a4b688
feat: domain list refactor + demo-gymburgdorf fixes
- Refactor: collapse `*_domain` + `*_extra_domains` into a single
  `*_domains` list across authentik, collabora, garage and nextcloud
  roles. First entry is the canonical FQDN (used for OVERWRITEHOST,
  BASE_URL, notify_push setup and garage root_domain).
- Authentik blueprint: guard the OAuth sources block so an empty
  `authentik_login_sources` no longer renders an invalid YAML key.
- Nextcloud: introduce `nextcloud_collabora_public_domain` and set
  Collabora's `public_wopi_url` separately from the server-to-server
  `wopi_url` so browsers can reach Collabora via the public name while
  Nextcloud still talks to it on the internal one.
- Nextcloud: URL-encode the postgres user/password in DATABASE_URL.
2026-05-20 22:13:34 +02:00
99 changed files with 648 additions and 4761 deletions

3
.gitignore vendored
View file

@ -3,6 +3,3 @@ __pycache__/
*.pyc *.pyc
plugins/lookup/__pycache__/ plugins/lookup/__pycache__/
# Local Ansible collection cache (galaxy/collection resolver)
/.ansible/

View file

@ -1,68 +1,3 @@
# Ansible Collection digitalboard.core # Ansible Collection - digitalboard.core
This collection bundles the Ansible roles used to deploy the Documentation for the collection.
[Digitalboard](https://git.digitalboard.ch/Digitalboard) platform: a set of
self-hosted, Docker-Compose-based services running behind Traefik, with
single sign-on provided by authentik or Keycloak.
Each role provisions one service (or building block) as a self-contained
Docker Compose stack. Roles are consumed from the deployment repository
[reference-ansible](https://git.digitalboard.ch/Digitalboard/reference-ansible),
where inventories and playbooks tie the roles to concrete hosts.
## Roles
| Role | Description |
| --- | --- |
| `base` | Host baseline: Docker, apt packages and convenience tooling on Debian/Ubuntu. |
| `traefik` | Traefik v3 reverse proxy as a public DMZ proxy (file provider) or backend proxy (docker provider). |
| `authentik` | [authentik](https://goauthentik.io) IdP (server + worker + Postgres); resources via blueprints. |
| `authentik_outpost_ldap` | authentik LDAP outpost exposing an LDAP interface for apps that cannot speak OIDC. |
| `keycloak` | [Keycloak](https://www.keycloak.org/) IdP with a PostgreSQL backend. |
| `389ds` | [389 Directory Server](https://www.port389.org/) LDAP directory via Docker Compose. |
| `nextcloud` | [Nextcloud](https://nextcloud.com/) (fpm) + Postgres + Redis, optional Collabora/draw.io/notify_push. |
| `opencloud` | [OpenCloud](https://opencloud.eu/) file platform via Docker Compose. |
| `collabora` | [Collabora Online](https://www.collaboraonline.com/) (CODE), used as the WOPI backend for Nextcloud. |
| `bookstack` | [BookStack](https://www.bookstackapp.com/) wiki (LSIO + MariaDB) with OIDC SSO and daily backups. |
| `drawio` | [draw.io](https://www.drawio.com/) diagram editor, with optional authentik ForwardAuth gating. |
| `homarr` | [Homarr](https://github.com/homarr-labs/homarr) dashboard with seeded admin user and OIDC group. |
| `opnform` | [OpnForm](https://github.com/OpnForm/OpnForm) self-hosted form builder (api + ui + db + redis). |
| `send` | [Send](https://github.com/timvisee/send) (timvisee fork) file sharing with a Redis backend. |
| `garage` | [Garage](https://garagehq.deuxfleurs.fr/) S3-compatible object storage with key/bucket provisioning. |
| `httpbin` | [httpbin](https://httpbin.org/) HTTP request/response testing service for validating Traefik ingress. |
## Usage
Roles are not run from this repository directly. They are consumed from the
deployment repository
[reference-ansible](https://git.digitalboard.ch/Digitalboard/reference-ansible),
which holds the inventories, group/host variables and playbooks. See that
repository's `docs/` directory for getting-started instructions, how to run
Ansible and how secrets are managed.
Per-role variables and their defaults are documented in each role's own
`README.md` and `meta/argument_specs.yml`.
## Requirements
- A Debian/Ubuntu target host (the `base` role bootstraps Docker there).
- ansible-core 2.15 or newer on the controller.
- The `community.docker` collection (used by nearly every role) and
`community.general` (used by the `keycloak` role). Both are declared as
`dependencies` in `galaxy.yml` and pulled in automatically when this
collection is installed via `ansible-galaxy`.
The role READMEs use `community.hashi_vault` lookups in their examples to source
secrets from HashiCorp Vault. That is a documented convention, not a hard
dependency of the roles — supply the variables however you prefer.
## Role ordering
Within a play, apply the roles in dependency order: `base` first (Docker and the
host baseline), then `traefik` (the shared reverse proxy and its Docker network),
then the individual service roles (`authentik`, `keycloak`, `nextcloud`, …),
which attach to Traefik's network and expect Docker to be present.
## License
MIT-0. See individual roles for per-role license metadata.

View file

@ -19,16 +19,15 @@ readme: README.md
authors: authors:
- Bert-Jan Fikse <bert-jan@whatwedo.ch> - Bert-Jan Fikse <bert-jan@whatwedo.ch>
- Tobias Wüst <tobias.wuest@wksbern.ch> - Tobias Wüst <tobias.wuest@wksbern.ch>
- Simon Bärlocher <simon@whatwedo.ch>
### OPTIONAL but strongly recommended ### OPTIONAL but strongly recommended
# A short summary description of the collection # A short summary description of the collection
description: Ansible roles to deploy the Digitalboard self-hosted service platform (Docker Compose + Traefik + SSO) description: your collection description
# Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only # Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only
# accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file' # accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file'
license: license:
- MIT-0 - GPL-2.0-or-later
# The path to the license file for the collection. This path is relative to the root of the collection. This key is # The path to the license file for the collection. This path is relative to the root of the collection. This key is
# mutually exclusive with 'license' # mutually exclusive with 'license'
@ -36,36 +35,25 @@ license_file: ''
# A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character # A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character
# requirements as 'namespace' and 'name' # requirements as 'namespace' and 'name'
tags: tags: []
- digitalboard
- docker
- traefik
- sso
- selfhosted
# Collections that this collection requires to be installed for it to be usable. The key of the dict is the # Collections that this collection requires to be installed for it to be usable. The key of the dict is the
# collection label 'namespace.name'. The value is a version range # collection label 'namespace.name'. The value is a version range
# L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version # L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version
# range specifiers can be set and are separated by ',' # range specifiers can be set and are separated by ','
dependencies: dependencies: {}
# Used by nearly every role: docker_compose_v2, docker_container,
# docker_container_exec, docker_network. Hard runtime dependency.
community.docker: '>=3.0.0'
# Used by the keycloak role (keycloak_realm/client/group/user and
# related modules) in roles/keycloak/tasks/provisioning.yml.
community.general: '>=7.0.0'
# The URL of the originating SCM repository # The URL of the originating SCM repository
repository: https://git.digitalboard.ch/Digitalboard/digitalboard.core repository: https://git.digitalboard.ch/Digitalboard/digitalboard.core
# The URL to any online docs # The URL to any online docs
documentation: https://git.digitalboard.ch/Digitalboard/digitalboard.core documentation: http://docs.example.com
# The URL to the homepage of the collection/project # The URL to the homepage of the collection/project
homepage: https://git.digitalboard.ch/Digitalboard/digitalboard.core homepage: http://example.com
# The URL to the collection issue tracker # The URL to the collection issue tracker
issues: https://git.digitalboard.ch/Digitalboard/digitalboard.core/issues issues: http://example.com/issue/tracker
# A list of file glob-like patterns used to filter any files or directories that should not be included in the build # A list of file glob-like patterns used to filter any files or directories that should not be included in the build
# artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This # artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This

View file

@ -1,9 +1,8 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
--- ---
# Collections must specify a minimum required ansible version to upload # Collections must specify a minimum required ansible version to upload
# to galaxy. Aligned with the highest min_ansible_version declared by the # to galaxy
# roles (the traefik role requires ansible-core 2.15). # requires_ansible: '>=2.9.10'
requires_ansible: '>=2.15.0'
# Content that Ansible needs to load from another location or that has # Content that Ansible needs to load from another location or that has
# been deprecated/removed # been deprecated/removed

View file

@ -1,32 +1,31 @@
# Collection Plugins — digitalboard.core # Collections Plugins Directory
This collection ships a small number of custom plugins that support the roles. This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that
They are addressed by their fully qualified name, `digitalboard.core.<name>`. is named after the type of plugin it is in. It can also include the `module_utils` and `modules` directory that
would contain module utils and modules respectively.
## Filter plugins (`filter/`) Here is an example directory of the majority of plugins currently supported by Ansible:
`homarr_layout` — computes Homarr dashboard grid layouts (desktop / tablet / ```
mobile breakpoints) from a list of apps, returning a ready-to-render data └── plugins
structure for the SQL seed. Used by the `homarr` role. ├── action
├── become
```yaml ├── cache
- name: Compute Homarr app layouts ├── callback
ansible.builtin.set_fact: ├── cliconf
homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}" ├── connection
├── filter
├── httpapi
├── inventory
├── lookup
├── module_utils
├── modules
├── netconf
├── shell
├── strategy
├── terminal
├── test
└── vars
``` ```
## Lookup plugins (`lookup/`) A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible-core/2.19/plugins/plugins.html).
`garage_credentials` — returns S3 credentials (`key_id`, `secret_key`) for a
named Garage key by executing a docker command on the target host. Used to wire
Garage object storage into consuming roles such as `nextcloud`.
```yaml
nextcloud_s3_key: >-
{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='backend')['key_id'] }}
nextcloud_s3_secret: >-
{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='backend')['secret_key'] }}
```
No other plugin types (modules, action, callback, inventory, etc.) are currently
shipped by this collection.

View file

@ -1,43 +1,38 @@
# 389ds Role Name
=========
Deploys [389 Directory Server](https://www.port389.org/) (`389ds/dirsrv`) A brief description of the role goes here.
as an LDAP directory via Docker Compose. After the container starts, the
role creates the configured suffix and a set of base organizational
units (e.g. `users`, `groups`).
## Requirements Requirements
------------
- Docker and Docker Compose on the target host (e.g. via Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
`digitalboard.core.base`)
- Ansible collection: `community.docker`
## Role variables Role Variables
--------------
| Variable | Default | Description | A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
| --- | --- | --- |
| `ds389_image` | `docker.io/389ds/dirsrv:3.1` | Container image. |
| `ds389_suffix` | `dc=example,dc=com` | Root suffix of the directory. |
| `ds389_root_dn` | `cn=Directory Manager` | Directory Manager bind DN. |
| `ds389_root_password` | `changeme` | Directory Manager password — **override this**. |
| `ds389_instance_name` | `localhost` | Directory server instance name (slapd config dir). |
| `ds389_hostname` | `389ds` | Container hostname (defaults to `ds389_service_name`). |
| `ds389_backend_network` | `backend` | Docker network LDAP clients connect over (created by Compose). |
| `ds389_ldap_port` | `3389` | Published LDAP port (container port 3389). |
| `ds389_ldaps_port` | `3636` | Published LDAPS port (container port 3636). |
| `ds389_base_ous` | `[users, groups]` | Base OUs created after startup. |
## Example Dependencies
------------
```yaml A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
- hosts: directory
become: true
roles:
- role: digitalboard.core.389ds
vars:
ds389_suffix: "dc=example,dc=org"
ds389_root_password: "{{ vault_ds389_root_password }}"
```
## License Example Playbook
----------------
MIT-0 Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -1,26 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy 389 Directory Server (LDAP) via Docker Compose description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- 389ds
- ldap # If this a Container Enabled role, provide the minimum Ansible Container version.
- directory # min_ansible_container_version:
- docker
- digitalboard galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -101,13 +101,8 @@ from the list to keep state clean.
## Dependencies ## Dependencies
- Run `digitalboard.core.base` first (Docker) and have the `community.docker` - Traefik network (`authentik_traefik_network`, default `proxy`)
collection installed; the role drives the stack via - Internal backend network (`authentik_backend_network`, default `backend`)
`community.docker.docker_compose_v2`.
- Traefik network (`authentik_traefik_network`, default `proxy`) must exist
beforehand (e.g. created by the traefik role); it is referenced as an
external network in the Compose file.
- Internal backend network (`authentik_backend_network`, default `backend`).
## Example playbook ## Example playbook

View file

@ -1,28 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy authentik (server + worker + Postgres) via Docker Compose with blueprint-provisioned resources description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- authentik
- oidc # If this a Container Enabled role, provide the minimum Ansible Container version.
- sso # min_ansible_container_version:
- idp
- docker galaxy_tags: []
- traefik # List tags for your role here, one per line. A tag is a keyword that describes
- digitalboard # and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -44,14 +44,13 @@ services:
condition: service_healthy condition: service_healthy
networks: networks:
{{ authentik_backend_network }}: {} {{ authentik_backend_network }}: {}
# No alias for the public FQDN here: that would shadow `/etc/hosts` # Network alias so traefik (which shares this network) can resolve
# pins (extra_hosts) in other containers sharing this network and # the canonical FQDN to this container directly. The URL-based
# break OIDC discovery for Node-based clients (c-ares-based # service below uses that to send upstream traffic with a fixed
# resolvers consult Docker DNS before /etc/hosts). The URL-based # Host header equal to the canonical hostname.
# service below addresses this container by its compose service {{ authentik_traefik_network }}:
# name `server`, which Docker exposes as an alias on every network aliases:
# the container joins. - {{ authentik_domains[0] }}
{{ authentik_traefik_network }}: {}
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network={{ authentik_traefik_network }} - traefik.docker.network={{ authentik_traefik_network }}
@ -69,14 +68,14 @@ services:
- traefik.http.services.{{ authentik_service_name }}.loadbalancer.server.port={{ authentik_port }} - traefik.http.services.{{ authentik_service_name }}.loadbalancer.server.port={{ authentik_port }}
{% if authentik_host_rewrite_domains | length > 0 %} {% if authentik_host_rewrite_domains | length > 0 %}
# Server-to-server entry: a separate service points at this very # Server-to-server entry: a separate service points at this very
# container by its compose service name `server` and disables # container by the canonical FQDN (resolved via the network alias
# passHostHeader so the upstream Host header becomes # above) and disables passHostHeader so the upstream Host header
# `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer URLs # becomes `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer
# from X-Forwarded-Host (not Host), so we also pin that header via # URLs from X-Forwarded-Host (not Host), so we also pin that header
# middleware. Together this keeps the iss claim aligned with the # via middleware. Together this keeps the iss claim aligned with
# public hostname browsers see during login, even when the request # the public hostname browsers see during login, even when the
# itself arrived on an internal *.int.* FQDN. # request itself arrived on an internal *.int.* FQDN.
- traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.server.url=http://server:{{ authentik_port }} - traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.server.url=http://{{ authentik_domains[0] }}:{{ authentik_port }}
- traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.passhostheader=false - traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.passhostheader=false
- traefik.http.middlewares.{{ authentik_service_name }}-xfh-rewrite.headers.customrequestheaders.X-Forwarded-Host={{ authentik_domains[0] }} - traefik.http.middlewares.{{ authentik_service_name }}-xfh-rewrite.headers.customrequestheaders.X-Forwarded-Host={{ authentik_domains[0] }}
{% for d in authentik_host_rewrite_domains %} {% for d in authentik_host_rewrite_domains %}

View file

@ -1,44 +1,38 @@
# authentik_outpost_ldap Role Name
=========
Deploys an [authentik](https://goauthentik.io) LDAP outpost via Docker A brief description of the role goes here.
Compose. The outpost exposes an LDAP interface backed by authentik, so
applications that cannot speak OIDC (e.g. Nextcloud or OpenCloud LDAP
backends) can still authenticate against the central IdP.
The outpost connects back to an authentik server using an outpost token Requirements
issued in the authentik admin interface. The image version must match ------------
the authentik server version.
## Requirements Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
- Docker and Docker Compose on the target host (e.g. via Role Variables
`digitalboard.core.base`) --------------
- Ansible collection: `community.docker`
## Role variables A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
| Variable | Default | Description | Dependencies
| --- | --- | --- | ------------
| `authentik_outpost_ldap_image` | `ghcr.io/goauthentik/ldap:2026.2.2` | Outpost image (match the server version). |
| `authentik_outpost_ldap_host` | `https://authentik.local.test` | URL of the authentik server. |
| `authentik_outpost_ldap_token` | `changeme` | Outpost token — **override this**. |
| `authentik_outpost_ldap_insecure` | `"true"` | Skip TLS verification toward the authentik server. |
| `authentik_outpost_ldap_network` | `ldap` | Docker network LDAP clients connect over (created by the role). |
| `authentik_outpost_ldap_authentik_network` | _unset_ | Optional extra external network to the authentik server. |
| `authentik_outpost_ldap_extra_hosts` | `[]` | Extra `host:ip` entries for in-container DNS. |
## Example A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
```yaml Example Playbook
- hosts: directory ----------------
become: true
roles:
- role: digitalboard.core.authentik_outpost_ldap
vars:
authentik_outpost_ldap_host: "https://auth.example.com"
authentik_outpost_ldap_token: "{{ vault_authentik_ldap_outpost_token }}"
```
## License Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
MIT-0 - hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -1,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy an authentik LDAP outpost via Docker Compose for applications that cannot use OIDC description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- authentik
- ldap # If this a Container Enabled role, provide the minimum Ansible Container version.
- outpost # min_ansible_container_version:
- sso
- docker galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -1,45 +1,38 @@
# base Role Name
=========
Host baseline for the Digitalboard platform. Installs Docker (engine, A brief description of the role goes here.
CLI, containerd, buildx, compose plugin) and a small set of apt and
convenience packages on Debian/Ubuntu, and sets the shared directory
layout every other role builds on.
This role is intended to run first on every host, before any Requirements
service role. ------------
## What it does Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
- Installs Docker prerequisites (`apt-transport-https`, `ca-certificates`, Role Variables
`curl`, `gnupg`, `lsb-release`, `apache2-utils` for `htpasswd`) plus --------------
convenience packages (`htop`, `ncdu`, `vim`) and Docker itself
(`docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`,
`docker-compose-plugin`).
- Optionally configures Docker registry mirrors via `/etc/docker/daemon.json`.
- Starts and enables the Docker service and writes a custom `/etc/motd`.
This role defines the shared directory-layout variables A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
(`docker_compose_base_dir`, `docker_volume_base_dir`) that every service
role consumes, but the per-service subdirectories are created by the
respective service roles, not here.
## Role variables Dependencies
------------
| Variable | Default | Description | A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
| --- | --- | --- |
| `docker_compose_base_dir` | `/etc/docker/compose` | Root directory for per-service Compose projects. |
| `docker_volume_base_dir` | `/srv/data` | Root directory for per-service persistent volumes. |
| `docker_registry_mirrors` | `[]` | Optional list of registry mirror URLs; empty disables mirrors. |
## Example Example Playbook
----------------
```yaml Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: all
become: true
roles:
- digitalboard.core.base
```
## License - hosts: servers
roles:
- { role: username.rolename, x: 42 }
MIT-0 License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -1,25 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Host baseline — install Docker, required apt packages and convenience tooling on Debian/Ubuntu description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.1
- base
- docker # If this a Container Enabled role, provide the minimum Ansible Container version.
- bootstrap # min_ansible_container_version:
- digitalboard
galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -1,154 +0,0 @@
# 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` | OIDC client ID (if OIDC on) |
| `bookstack_oidc_client_secret` | OIDC client secret (if OIDC on) |
When OIDC is on, the role also asserts that `bookstack_oidc_issuer`
resolves to a concrete URL. For Entra ID this means setting
`bookstack_entra_tenant_id` (the default issuer interpolates it; an unset
tenant leaves `//v2.0` and fails the assert). For other IdPs (Authentik,
Keycloak) set `bookstack_oidc_issuer` directly instead.
Provide via OpenBao lookup, Ansible Vault or `--extra-vars`. Never commit
real secrets.
## Optional variables
See `defaults/main.yml`. Frequently overridden:
- `bookstack_domain`, `bookstack_base_url`
- `bookstack_extra_domains` (extra Host-rule hostnames, e.g. an internal
`*.int.*` FQDN for a DMZ reverseproxy)
- `bookstack_extra_hosts` (container `/etc/hosts` overrides for
split-horizon IdP access; entries as `host:ip`)
- `bookstack_image`, `bookstack_db_image` (pin in production)
- `bookstack_oidc_enabled` (set `false` to disable OIDC entirely)
- `bookstack_oidc_auto_initiate` (`true` redirects straight to IdP)
- `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-0

View file

@ -1,93 +0,0 @@
#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"
# Additional hostnames the bookstack router answers on (e.g. an internal
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
# by the cert).
bookstack_extra_domains: []
# Container-level /etc/hosts overrides — useful in split-horizon setups
# where the BookStack container needs to reach an IdP's public FQDN
# (used in the OIDC `iss` claim) over the LAN rather than via the DMZ.
bookstack_extra_hosts: []
bookstack_base_url: "https://{{ bookstack_domain }}"
# Images — pin via inventory in production
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"

View file

@ -1,19 +0,0 @@
#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

View file

@ -1,212 +0,0 @@
---
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_extra_domains:
type: list
elements: str
default: []
description:
- Additional hostnames the Traefik router answers on, OR-combined
with C(bookstack_domain). Useful for an internal C(*.int.*) FQDN
so a DMZ reverseproxy can reach a backend hostname covered by the
cert.
bookstack_extra_hosts:
type: list
elements: str
default: []
description:
- Container-level C(/etc/hosts) overrides (Compose C(extra_hosts)
entries, C("host:ip")). Useful in split-horizon setups where the
BookStack container must reach an IdP's public FQDN (used in the
OIDC C(iss) claim) over the LAN rather than via the DMZ.
bookstack_base_url:
type: str
description: Defaults to C("https://{{ bookstack_domain }}").
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.

View file

@ -1,25 +0,0 @@
galaxy_info:
author: digitalboard
description: Deploy BookStack as a self-contained Docker Compose stack behind Traefik
company: digitalboard
license: MIT-0
min_ansible_version: "2.14"
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
galaxy_tags:
- docker
- bookstack
- wiki
- documentation
- digitalboard
dependencies: []

View file

@ -1,229 +0,0 @@
#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)
# Issuer URL must resolve to something concrete. The Entra default
# interpolates bookstack_entra_tenant_id; an unset tenant leaves
# "//v2.0" in the URL. Allow non-Entra IdPs (Authentik, Keycloak)
# that override bookstack_oidc_issuer directly.
- (not bookstack_oidc_enabled) or
(bookstack_oidc_issuer | length > 0 and
'//v2.0' not in bookstack_oidc_issuer)
fail_msg: >-
One or more required secrets are unset. Provide them via OpenBao
lookup, Ansible Vault or --extra-vars. See README for the full list.
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

View file

@ -1,41 +0,0 @@
#!/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"

View file

@ -1,12 +0,0 @@
# {{ 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

View file

@ -1,11 +0,0 @@
# {{ ansible_managed }}
[Unit]
Description=Daily BookStack backup
[Timer]
OnCalendar={{ bookstack_backup_schedule }}
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target

View file

@ -1,93 +0,0 @@
#---------------------------------------------------------------------#
# 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
{% if bookstack_extra_hosts | length > 0 %}
extra_hosts:
{% for host in bookstack_extra_hosts %}
- "{{ host }}"
{% endfor %}
{% endif %}
depends_on:
{{ bookstack_service_name }}-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.docker.network={{ bookstack_traefik_network }}"
- "traefik.http.routers.{{ bookstack_service_name }}.rule={% set _all_domains = [bookstack_domain] + (bookstack_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}"
- "traefik.http.routers.{{ bookstack_service_name }}.entrypoints=websecure"
- "traefik.http.routers.{{ bookstack_service_name }}.tls=true"
- "traefik.http.routers.{{ bookstack_service_name }}.tls.certresolver={{ bookstack_traefik_certresolver }}"
- "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

View file

@ -1 +0,0 @@
localhost

View file

@ -1,5 +0,0 @@
---
- hosts: localhost
remote_user: root
roles:
- bookstack

View file

@ -1,3 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for bookstack

View file

@ -1,42 +1,38 @@
# collabora Role Name
=========
Deploys [Collabora Online](https://www.collaboraonline.com/) (CODE, A brief description of the role goes here.
`collabora/code`) via Docker Compose behind Traefik. Collabora is the
WOPI backend that renders office documents for Nextcloud and OpenCloud.
The role templates `coolwsd.xml` to declare which WOPI hosts may call Requirements
Collabora and which origins may embed it in an iframe. ------------
## Role variables Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
| Variable | Default | Description | Role Variables
| --- | --- | --- | --------------
| `collabora_domains` | `[office.local.test]` | FQDNs the router accepts; first is canonical. |
| `collabora_image` | `collabora/code:latest` | Container image. |
| `collabora_port` | `9980` | Container port Traefik forwards to. |
| `collabora_traefik_network` | `proxy` | Docker network shared with Traefik. |
| `collabora_use_ssl` | `true` | Enable the TLS resolver on the router. |
| `collabora_ssl_verification` | `true` | Verify TLS on WOPI callbacks (false for self-signed). |
| `collabora_allowed_domains` | `[nextcloud.local.test]` | WOPI hosts allowed to call Collabora (regex). |
| `collabora_frame_ancestors` | `[nextcloud.local.test]` | Origins allowed to embed Collabora in an iframe. |
| `collabora_extra_hosts` | `[]` | Extra `host:ip` entries for in-container DNS. |
## Example A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
```yaml Dependencies
- hosts: services ------------
become: true
roles:
- role: digitalboard.core.collabora
vars:
collabora_domains:
- "office.example.com"
collabora_allowed_domains:
- "cloud.example.com"
collabora_frame_ancestors:
- "cloud.example.com"
```
## License A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
MIT-0 Example Playbook
----------------
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -1,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy Collabora Online (CODE) as a WOPI backend via Docker Compose behind Traefik description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- collabora
- office # If this a Container Enabled role, provide the minimum Ansible Container version.
- wopi # min_ansible_container_version:
- nextcloud
- docker galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -1,69 +0,0 @@
# 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

View file

@ -1,77 +0,0 @@
#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"

View file

@ -1,10 +0,0 @@
#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

View file

@ -1,148 +0,0 @@
---
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.

View file

@ -1,15 +0,0 @@
#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: []

View file

@ -1,110 +0,0 @@
#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

View file

@ -1,78 +0,0 @@
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 %}

View file

@ -1,2 +0,0 @@
#SPDX-License-Identifier: MIT-0
localhost

View file

@ -1,6 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
- hosts: localhost
remote_user: root
roles:
- coturn

View file

@ -1,3 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for coturn

View file

@ -1,26 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy the draw.io diagram editor via Docker Compose behind Traefik, with optional authentik ForwardAuth description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- drawio
- diagrams # If this a Container Enabled role, provide the minimum Ansible Container version.
- docker # min_ansible_container_version:
- traefik
- digitalboard galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -19,9 +19,6 @@ services:
{% if drawio_use_ssl %} {% if drawio_use_ssl %}
- traefik.http.routers.{{ drawio_service_name }}.entrypoints=websecure - traefik.http.routers.{{ drawio_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ drawio_service_name }}.tls=true - traefik.http.routers.{{ drawio_service_name }}.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ drawio_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %} {% else %}
- traefik.http.routers.{{ drawio_service_name }}.entrypoints=web - traefik.http.routers.{{ drawio_service_name }}.entrypoints=web
{% endif %} {% endif %}

View file

@ -20,10 +20,8 @@ common overrides:
### Service ### Service
- `garage_s3_domains`: FQDNs the S3 router accepts. The first entry is the - `garage_s3_domains`: FQDNs the S3 router accepts. First entry is the
canonical hostname; `garage.toml` derives the virtual-hosted-style S3 canonical hostname and is used as `root_domain` in `garage.toml`.
`root_domain` from it as `.s3.<first-entry>` (so buckets resolve under
`<bucket>.s3.<first-entry>`).
- `garage_web_domain`, `garage_webui_domain`: separate hostnames for - `garage_web_domain`, `garage_webui_domain`: separate hostnames for
the S3-website endpoint and the console. the S3-website endpoint and the console.
- `garage_image`, `garage_replication_factor`, `garage_db_engine`, - `garage_image`, `garage_replication_factor`, `garage_db_engine`,

View file

@ -14,9 +14,8 @@ garage_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ garage_service_name }
# Garage service configuration # Garage service configuration
garage_image: "dxflrs/garage:v2.1.0" garage_image: "dxflrs/garage:v2.1.0"
# FQDNs the garage S3 router accepts. The first entry is the canonical # FQDNs the garage S3 router accepts. The first entry is the canonical
# domain; garage.toml derives the virtual-hosted-style S3 root_domain # domain and is also used as the virtual-hosted-style root_domain in
# from it as ".s3.<first-entry>"; further entries cover internal # garage.toml; further entries cover internal *.int.* names.
# *.int.* names.
garage_s3_domains: garage_s3_domains:
- "storage.local.test" - "storage.local.test"
garage_web_domain: "web.storage.local.test" garage_web_domain: "web.storage.local.test"

View file

@ -35,9 +35,9 @@ argument_specs:
default: ['storage.local.test'] default: ['storage.local.test']
description: description:
- FQDNs the garage S3 router accepts. The first entry is the - FQDNs the garage S3 router accepts. The first entry is the
canonical domain; C(garage.toml) derives the virtual-hosted-style canonical domain and is used as the virtual-hosted-style
S3 C(root_domain) from it as C(.s3.<first-entry>). Further entries C(root_domain) in C(garage.toml). Further entries cover internal
cover internal C(*.int.*) names. C(*.int.*) names.
garage_web_domain: garage_web_domain:
type: str type: str
default: web.storage.local.test default: web.storage.local.test

View file

@ -1,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy Garage S3-compatible object storage via Docker Compose, with declarative key/bucket provisioning description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.1
- garage
- s3 # If this a Container Enabled role, provide the minimum Ansible Container version.
- storage # min_ansible_container_version:
- object-storage
- docker galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -36,10 +36,8 @@ secrets to version control.**
| `homarr_admin_password` | strong password | `openssl rand -base64 24` | | `homarr_admin_password` | strong password | `openssl rand -base64 24` |
| `homarr_oidc_client_secret` | from your identity provider | — | | `homarr_oidc_client_secret` | from your identity provider | — |
`homarr_oidc_client_secret` is only required when `oidc` is in The `assert` task at the top of the role will fail fast if the encryption
`homarr_auth_providers`; the role asserts it then. The encryption key is key is missing or malformed.
always required — the `assert` task at the top of the role fails fast if it
is missing or malformed.
## Configurable variables ## Configurable variables
@ -48,8 +46,6 @@ See `defaults/main.yml` for the full list. Most useful overrides:
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `homarr_domain` | `homarr.local.test` | Traefik Host rule | | `homarr_domain` | `homarr.local.test` | Traefik Host rule |
| `homarr_extra_domains` | `[]` | Extra Host-rule hostnames (OR-combined), e.g. internal `*.int.*` FQDN |
| `homarr_extra_hosts` | `[]` | Container `/etc/hosts` overrides (`host:ip`) — pin IdP FQDN to LAN IP |
| `homarr_base_url` | `https://home.local.test` | NEXTAUTH_URL / BASE_URL | | `homarr_base_url` | `https://home.local.test` | NEXTAUTH_URL / BASE_URL |
| `homarr_auth_providers` | `credentials` | `credentials`, `oidc`, or both | | `homarr_auth_providers` | `credentials` | `credentials`, `oidc`, or both |
| `homarr_oidc_issuer` | empty | Identity provider issuer URL | | `homarr_oidc_issuer` | empty | Identity provider issuer URL |
@ -116,7 +112,7 @@ The filter is invoked once from `tasks/main.yml`:
```yaml ```yaml
- name: Compute Homarr app layouts - name: Compute Homarr app layouts
ansible.builtin.set_fact: ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}" homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
``` ```
This produces a `homarr_layout` fact with two keys, both consumed by This produces a `homarr_layout` fact with two keys, both consumed by
@ -124,14 +120,14 @@ This produces a `homarr_layout` fact with two keys, both consumed by
| Key | Shape | Purpose | | Key | Shape | Purpose |
|---|---|---| |---|---|---|
| `apps` | list, same order as `homarr_apps` | each entry gains `desktop`/`tablet`/`mobile` dicts of `{x, y, w, h}` | | `apps` | list, same order as `homarr_apps` | each entry enriched with `desktop`, `tablet`, `mobile` sub-dicts of `{x, y, w, h}` |
| `section_height` | dict with `desktop`, `tablet`, `mobile` | minimum height of the parent section so all tiles fit | | `section_height` | dict with `desktop`, `tablet`, `mobile` | minimum height of the parent section so all tiles fit |
The filter signature accepts custom column counts if Homarr ever The filter signature accepts custom column counts if Homarr ever
changes the breakpoint widths: changes the breakpoint widths:
```jinja ```jinja
{{ homarr_apps | digitalboard.core.homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }} {{ homarr_apps | homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }}
``` ```
To debug a layout without running the full deploy, run the play with To debug a layout without running the full deploy, run the play with
@ -245,7 +241,3 @@ and lowercase are accepted.
**App tiles overlap.** Check `homarr_apps` for duplicate `id` values. **App tiles overlap.** Check `homarr_apps` for duplicate `id` values.
The role validates this, but if you bypass the check, the seed will The role validates this, but if you bypass the check, the seed will
still run and Homarr will display only one of the duplicates. still run and Homarr will display only one of the duplicates.
## License
MIT-0

View file

@ -15,14 +15,6 @@ homarr_db: "{{ homarr_appdata_dir }}/db/db.sqlite"
# Service configuration # Service configuration
homarr_domain: "homarr.local.test" homarr_domain: "homarr.local.test"
# Additional hostnames the homarr router answers on (e.g. an internal
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
# by the cert).
homarr_extra_domains: []
# Extra /etc/hosts entries inside the homarr container (format "host:ip").
# Used to pin the IdP's public FQDN to a LAN IP so OIDC discovery stays
# in-network while the issuer URL matches what browsers see.
homarr_extra_hosts: []
homarr_image: "ghcr.io/homarr-labs/homarr:latest" homarr_image: "ghcr.io/homarr-labs/homarr:latest"
homarr_port: 7575 homarr_port: 7575
homarr_use_docker: false homarr_use_docker: false

View file

@ -15,11 +15,7 @@ import sys
# Make the filter importable without having Ansible auto-discovery in # Make the filter importable without having Ansible auto-discovery in
# the way (it would only run during a real `ansible-playbook` invocation). # the way (it would only run during a real `ansible-playbook` invocation).
sys.path.insert( sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
0,
os.path.join(os.path.dirname(__file__), '..', '..', '..', '..',
'plugins', 'filter')
)
import pytest # noqa: E402 import pytest # noqa: E402

View file

@ -1,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy the Homarr dashboard via Docker Compose behind Traefik, with seeded admin user and OIDC group description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- homarr
- dashboard # If this a Container Enabled role, provide the minimum Ansible Container version.
- oidc # min_ansible_container_version:
- docker
- traefik galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -112,17 +112,19 @@
# ===================================================================== # =====================================================================
- name: Generate bcrypt hash for admin password - name: Generate bcrypt hash for admin password
ansible.builtin.shell:
cmd: python3 -c "import bcrypt, sys; print(bcrypt.hashpw(sys.stdin.read().encode(), bcrypt.gensalt(rounds=10)).decode())"
stdin: "{{ homarr_admin_password }}"
stdin_add_newline: false
delegate_to: localhost
become: false
register: bcrypt_result
changed_when: false
no_log: true
- name: Set bcrypt hash fact
ansible.builtin.set_fact: ansible.builtin.set_fact:
# Deterministic salt derived from the password's SHA-256 digest so the homarr_bcrypt_hash: "{{ bcrypt_result.stdout }}"
# hash stays stable across runs (idempotent — no spurious template
# changes / container restarts when the password is unchanged). The
# bcrypt salt alphabet is [./A-Za-z0-9]; the digest's hex chars are
# a strict subset, so we just take the first 22.
homarr_bcrypt_hash: >-
{{ homarr_admin_password
| password_hash('bcrypt', rounds=10,
salt=(homarr_admin_password
| hash('sha256'))[:22]) }}
no_log: true no_log: true
# ===================================================================== # =====================================================================
@ -134,7 +136,7 @@
- name: Compute Homarr app layouts - name: Compute Homarr app layouts
ansible.builtin.set_fact: ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}" homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
- name: Show computed app layouts - name: Show computed app layouts
ansible.builtin.debug: ansible.builtin.debug:

View file

@ -26,22 +26,13 @@ services:
AUTH_OIDC_AUTO_LOGIN: "{{ homarr_oidc_auto_login | default('false') }}" AUTH_OIDC_AUTO_LOGIN: "{{ homarr_oidc_auto_login | default('false') }}"
networks: networks:
- {{ homarr_traefik_network }} - {{ homarr_traefik_network }}
{% if homarr_extra_hosts | default([]) | length > 0 %}
extra_hosts:
{% for h in homarr_extra_hosts %}
- "{{ h }}"
{% endfor %}
{% endif %}
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network={{ homarr_traefik_network }} - traefik.docker.network={{ homarr_traefik_network }}
- traefik.http.routers.homarr.rule={% set _all_domains = [homarr_domain] + (homarr_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} - traefik.http.routers.homarr.rule=Host(`{{ homarr_domain }}`)
{% if homarr_use_ssl %} {% if homarr_use_ssl %}
- traefik.http.routers.homarr.entrypoints=websecure - traefik.http.routers.homarr.entrypoints=websecure
- traefik.http.routers.homarr.tls=true - traefik.http.routers.homarr.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.homarr.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %} {% else %}
- traefik.http.routers.homarr.entrypoints=web - traefik.http.routers.homarr.entrypoints=web
{% endif %} {% endif %}

View file

@ -1,30 +1,38 @@
# httpbin Role Name
=========
Deploys [httpbin](https://httpbin.org/) (`kennethreitz/httpbin`) via A brief description of the role goes here.
Docker Compose behind Traefik. Useful as a throwaway endpoint to verify
that the Traefik ingress path, TLS and routing work end to end.
## Role variables Requirements
------------
| Variable | Default | Description | Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
| --- | --- | --- |
| `httpbin_domain` | `httpbin.local.test` | FQDN the Traefik router matches. |
| `httpbin_image` | `kennethreitz/httpbin` | Container image. |
| `httpbin_port` | `80` | Container port Traefik forwards to. |
| `httpbin_traefik_network` | `proxy` | Docker network shared with Traefik. |
| `httpbin_use_ssl` | `true` | Route via the `websecure` entrypoint with `tls=true` (otherwise `web`). |
## Example Role Variables
--------------
```yaml A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
- hosts: services
become: true
roles:
- role: digitalboard.core.httpbin
vars:
httpbin_domain: "httpbin.example.com"
```
## License Dependencies
------------
MIT-0 A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
Example Playbook
----------------
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -1,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy httpbin HTTP request/response testing service via Docker Compose behind Traefik description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.1
- httpbin
- testing # If this a Container Enabled role, provide the minimum Ansible Container version.
- debug # min_ansible_container_version:
- docker
- traefik galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -1,119 +1,65 @@
# Keycloak Keycloak
=========
Ansible role to deploy Keycloak with a PostgreSQL backend via Docker Ansible role to deploy Keycloak with PostgreSQL database using Docker Compose.
Compose, published behind Traefik. Optionally provisions realm resources
(groups, users, OIDC clients, identity providers, LDAP user federations)
through the `community.general` Keycloak modules.
## Requirements Requirements
------------
- Docker and Docker Compose on the target host (e.g. via - Docker and Docker Compose installed on the target host
`digitalboard.core.base`) - Ansible collection: `community.docker`
- Ansible collections: `community.docker`, and `community.general` when - Traefik reverse proxy (for external access)
`keycloak_provisioning_enabled` is true
- Traefik reverse proxy with the `proxy` network already created (for
external access)
## Role variables Role Variables
--------------
Key variables from `defaults/main.yml`: Key variables defined in `defaults/main.yml`:
### Base configuration **Base Configuration:**
- `docker_compose_base_dir`: Base directory for Docker Compose files (default: `/etc/docker/compose`)
- `docker_volume_base_dir`: Base directory for Docker volumes (default: `/srv/data`)
| Variable | Default | Description | **Keycloak Configuration:**
| --- | --- | --- | - `keycloak_service_name`: Service name (default: `keycloak`)
| `docker_compose_base_dir` | `/etc/docker/compose` | Base dir for Compose projects. | - `keycloak_domain`: Domain name for Keycloak (default: `auth.digitalboard.ch`)
| `docker_volume_base_dir` | `/srv/data` | Base dir for persistent volumes. | - `keycloak_image`: Keycloak Docker image (default: `quay.io/keycloak/keycloak:24.0.1`)
| `keycloak_service_name` | `keycloak` | Compose/service name; builds the per-service paths. | - `keycloak_port`: Internal Keycloak port (default: `8080`)
- `keycloak_admin_user`: Admin username (default: `admin`)
- `keycloak_admin_password`: Admin password (default: `changeme`)
- `keycloak_log_level`: Log level (default: `INFO`)
- `keycloak_proxy_mode`: Proxy mode (default: `edge`)
### Keycloak **PostgreSQL Configuration:**
- `keycloak_postgres_image`: PostgreSQL Docker image (default: `postgres:15`)
- `keycloak_postgres_db`: Database name (default: `keycloak`)
- `keycloak_postgres_user`: Database user (default: `keycloak`)
- `keycloak_postgres_password`: Database password (default: `changeme`)
| Variable | Default | Description | **Traefik Configuration:**
| --- | --- | --- | - `keycloak_traefik_network`: Traefik network name (default: `proxy`)
| `keycloak_domain` | `keycloak.local.test` | Host rule and `KC_HOSTNAME`. | - `keycloak_backend_network`: Backend network name (default: `backend`)
| `keycloak_image` | `quay.io/keycloak/keycloak:24.0.1` | Keycloak image. | - `keycloak_use_ssl`: Enable SSL (default: `true`)
| `keycloak_port` | `8080` | Internal HTTP port advertised to Traefik. | - `keycloak_cert_resolver`: Certificate resolver name (default: `dns`)
| `keycloak_admin_user` | `admin` | Bootstrap admin user. |
| `keycloak_admin_password` | `changeme` | Admin password — **override this**. |
| `keycloak_log_level` | `INFO` | `KC_LOG_LEVEL`. |
| `keycloak_proxy_mode` | `edge` | `KC_PROXY` mode. |
| `keycloak_gzip_enabled` | `false` | Toggle Keycloak GZIP response encoding. |
| `keycloak_truststore_certificates` | `[]` | Host PEM paths mounted into the truststore (`KC_TRUSTSTORE_PATHS`). |
| `keycloak_extra_hosts` | `[]` | Extra `host:ip` entries for the container. |
### PostgreSQL Dependencies
------------
| Variable | Default | Description | This role requires the Traefik reverse proxy to be configured and the `proxy` network to be created.
| --- | --- | --- |
| `keycloak_postgres_image` | `postgres:15` | PostgreSQL image. |
| `keycloak_postgres_db` | `keycloak` | Database name. |
| `keycloak_postgres_user` | `keycloak` | Database user. |
| `keycloak_postgres_password` | `changeme` | Database password — **override this**. |
### Traefik Example Playbook
----------------
| Variable | Default | Description |
| --- | --- | --- |
| `keycloak_traefik_network` | `proxy` | External Traefik network. |
| `keycloak_backend_network` | `backend` | Internal network to PostgreSQL. |
| `keycloak_use_ssl` | `true` | Route on `websecure` with `tls=true` instead of `web`. |
TLS is requested from Traefik via `tls=true`; the role does not set a
certificate resolver, so Traefik issues/serves the certificate according
to its own configuration.
### Provisioning (optional)
Provisioning runs only when `keycloak_provisioning_enabled` is true. The
tasks wait for the `/health/ready` endpoint and then call the
`community.general.keycloak_*` modules, delegated to `localhost` against
`keycloak_auth_url` (derived from `keycloak_use_ssl` + `keycloak_domain`).
| Variable | Default | Description |
| --- | --- | --- |
| `keycloak_provisioning_enabled` | `false` | Enable realm provisioning. |
| `keycloak_realm` | `default` | Target realm; created unless `master`. |
| `keycloak_realm_display_name` | `Default Realm` | Realm display name. |
| `keycloak_auth_url` | derived | API base URL for provisioning. |
| `keycloak_groups` | `[]` | Groups to create. |
| `keycloak_local_users` | `[]` | Local users to create. |
| `keycloak_oidc_clients` | `[]` | OIDC clients to create. |
| `keycloak_identity_providers` | `[]` | Identity providers (e.g. Entra ID). |
| `keycloak_user_federations` | `[]` | LDAP user federations. |
| `keycloak_removed_users` | `[]` | Usernames to delete. |
| `keycloak_removed_groups` | `[]` | Group names to delete. |
| `keycloak_removed_clients` | `[]` | Client IDs to delete. |
| `keycloak_removed_identity_providers` | `[]` | IdP aliases to delete. |
| `keycloak_removed_user_federations` | `[]` | Federation names to delete. |
See `defaults/main.yml` for the full entry shape of each list.
## Dependencies
This role requires the Traefik reverse proxy to be configured and the
`proxy` network to be created beforehand (it is referenced as an external
network in the Compose file). The `backend` network is created by the
Compose project itself.
## Example playbook
```yaml ```yaml
- hosts: backend_servers - hosts: backend_servers
roles: roles:
- role: digitalboard.core.keycloak - role: keycloak
vars: vars:
keycloak_domain: "auth.example.com" keycloak_domain: "auth.example.com"
keycloak_admin_password: "{{ vault_keycloak_admin_password }}" keycloak_admin_password: "secure_password"
keycloak_postgres_password: "{{ vault_keycloak_pg_password }}" keycloak_postgres_password: "secure_db_password"
keycloak_provisioning_enabled: true
keycloak_oidc_clients:
- client_id: nextcloud
name: "Nextcloud"
client_secret: "{{ vault_nextcloud_client_secret }}"
redirect_uris:
- "https://nextcloud.example.com/apps/user_oidc/code"
``` ```
## License License
-------
MIT-0 MIT-0

View file

@ -1,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy Keycloak with a PostgreSQL backend via Docker Compose behind Traefik description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.1
- keycloak
- oidc # If this a Container Enabled role, provide the minimum Ansible Container version.
- sso # min_ansible_container_version:
- docker
- traefik galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -15,10 +15,9 @@ backends.
the stored value differs, so re-runs don't churn the stored value differs, so re-runs don't churn
- Sets up notify_push (when enabled) - Sets up notify_push (when enabled)
- Applies an in-container PHP source workaround for the upstream - Applies an in-container PHP source workaround for the upstream
`UserConfig::getValueBool` TypeError (nextcloud/server#59629, fixed in `UserConfig::getValueBool` TypeError on Nextcloud 33.0.3 (idempotent
master via PR #59646 with no stable33 backport before 33.0.4). via grep guard; remove the patch task once the deployed image
Idempotent via grep guard; remove the patch task once ships the upstream fix)
`nextcloud_image` is >= 33.0.4.
## Requirements ## Requirements

View file

@ -72,26 +72,6 @@ nextcloud_notify_push_image: "icewind1991/notify_push:1.3.1"
# router matches it. # router matches it.
# nextcloud_notify_push_domain: "cloud.int.example.com" # nextcloud_notify_push_domain: "cloud.int.example.com"
# 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 # Non-default apps to install and enable
nextcloud_apps_to_install: nextcloud_apps_to_install:
- groupfolders - groupfolders

View file

@ -1,28 +0,0 @@
#SPDX-License-Identifier: MIT-0
galaxy_info:
author: digitalboard
description: Deploy Nextcloud (fpm) + Redis + Postgres via Docker Compose behind Traefik
company: Digitalboard
license: MIT-0
min_ansible_version: "2.14"
platforms:
- name: Debian
versions:
- bookworm
- name: Ubuntu
versions:
- jammy
- noble
galaxy_tags:
- nextcloud
- files
- collabora
- oidc
- docker
- traefik
- digitalboard
dependencies: []

View file

@ -66,32 +66,13 @@
- name: Check UserConfig.php patch status per container - name: Check UserConfig.php patch status per container
ansible.builtin.shell: ansible.builtin.shell:
# rc 0 -> already patched; rc 1 -> still the unpatched original; rc 2 ->
# neither marker present (upstream drift -> the guard task below fails loud).
cmd: >- cmd: >-
docker exec {{ item }} sh -c ' docker exec {{ item }} grep -q "strtolower((string)" /var/www/html/lib/private/Config/UserConfig.php
grep -q "strtolower((string)\$this->getTypedValue" /var/www/html/lib/private/Config/UserConfig.php && exit 0;
grep -q "strtolower(\$this->getTypedValue" /var/www/html/lib/private/Config/UserConfig.php && exit 1;
exit 2'
loop: "{{ _nextcloud_php_containers.stdout_lines }}" loop: "{{ _nextcloud_php_containers.stdout_lines }}"
register: _nextcloud_userconfig_check register: _nextcloud_userconfig_check
changed_when: false changed_when: false
failed_when: false failed_when: false
- name: Fail if the UserConfig.php source drifted from the expected upstream line
ansible.builtin.fail:
msg: >-
Neither the patched nor the expected original strtolower($this->getTypedValue(...))
line was found in {{ item.item }}:/var/www/html/lib/private/Config/UserConfig.php.
The nextcloud/server#59629 workaround can no longer locate its target — the upstream
source likely changed. Re-verify whether the fix shipped (then drop this block) or
update the sed expression. Silently skipping would let the TypeError regress.
loop: "{{ _nextcloud_userconfig_check.results }}"
loop_control:
label: "{{ item.item }}"
when:
- item.rc | default(2) == 2
- name: Apply UserConfig::getValueBool string-cast workaround - name: Apply UserConfig::getValueBool string-cast workaround
ansible.builtin.shell: ansible.builtin.shell:
cmd: >- cmd: >-
@ -102,7 +83,7 @@
loop_control: loop_control:
label: "{{ item.item }}" label: "{{ item.item }}"
when: when:
- item.rc | default(2) == 1 - item.rc | default(1) != 0
- name: Wait for Nextcloud to be ready - name: Wait for Nextcloud to be ready
ansible.builtin.shell: ansible.builtin.shell:
@ -146,7 +127,3 @@
- name: Configure OIDC providers - name: Configure OIDC providers
ansible.builtin.include_tasks: oidc.yml ansible.builtin.include_tasks: oidc.yml
when: nextcloud_oidc_providers | length > 0 or nextcloud_oidc_providers_removed | length > 0 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

View file

@ -1,70 +0,0 @@
#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 }}"

View file

@ -1,43 +1,38 @@
# opencloud Role Name
=========
Deploys [OpenCloud](https://opencloud.eu/) (`opencloudeu/opencloud`) as a A brief description of the role goes here.
self-contained file platform via Docker Compose behind Traefik. Supports
the built-in IdP or external OIDC, optional S3 storage, external LDAP,
Collabora and draw.io integration, and OIDC-claim-based role assignment.
## Role variables Requirements
------------
A selection of the most relevant variables — see Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
[defaults/main.yml](defaults/main.yml) for the full set.
| Variable | Default | Description | Role Variables
| --- | --- | --- | --------------
| `opencloud_domain` | `opencloud.local.test` | FQDN the Traefik router matches. |
| `opencloud_image` | `opencloudeu/opencloud:latest` | Container image. |
| `opencloud_port` | `9200` | Container port Traefik forwards to. |
| `opencloud_admin_password` | `admin` | Initial admin password — **override this**. |
| `opencloud_traefik_network` | `proxy` | Docker network shared with Traefik. |
| `opencloud_use_ssl` | `true` | Enable the TLS resolver on the router. |
| `opencloud_oidc_issuer` | `""` | External OIDC issuer; empty uses the built-in IdP. |
| `opencloud_use_s3_storage` | `false` | Use S3 storage instead of local disk. |
| `opencloud_ldap_uri` | `""` | External LDAP URI; empty uses the built-in directory. |
| `opencloud_collabora_domain` | `""` | Collabora server domain; set with `opencloud_wopi_domain` to enable editing. |
| `opencloud_wopi_domain` | `""` | WOPI server FQDN; required alongside `opencloud_collabora_domain`. |
| `opencloud_drawio_url` | `""` | draw.io URL; set to enable diagram editing. |
| `opencloud_role_assignment_driver` | `default` | Set to `oidc` to map OIDC claims to roles. |
## Example A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
```yaml Dependencies
- hosts: services ------------
become: true
roles:
- role: digitalboard.core.opencloud
vars:
opencloud_domain: "opencloud.example.com"
opencloud_admin_password: "{{ vault_opencloud_admin_password }}"
```
## License A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
MIT-0 Example Playbook
----------------
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -1,27 +1,35 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy OpenCloud file platform via Docker Compose behind Traefik description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.2
- opencloud
- files # If this a Container Enabled role, provide the minimum Ansible Container version.
- storage # min_ansible_container_version:
- docker
- traefik galaxy_tags: []
- digitalboard # List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -1,225 +0,0 @@
# 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
- Does not migrate existing OpnForm databases — only bootstraps fresh
installs (admin registration + OIDC connection are idempotent)
## 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-byte hex string | `openssl rand -hex 32` |
| `opnform_front_api_secret` | 32-byte hex string | `openssl rand -hex 32` |
| `opnform_db_password` | strong password | `openssl rand -base64 24` |
`opnform_app_key` MUST keep the `base64:` prefix — the validation task
asserts it. `opnform_jwt_secret` and `opnform_front_api_secret` have no
enforced format; any sufficiently random value works.
When `opnform_oidc_enabled` is `true`:
| Variable | Source |
|---|---|
| `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 provisions an
IdentityConnection on the admin's default workspace via
`POST /api/open/workspaces/{id}/oidc-connections`. OpnForm enforces a
single OIDC connection per workspace, so the task is idempotent: it GETs
existing connections first, then either POSTs a new one or PATCHes the
existing one to the desired state. PATCHing (rather than skipping when
one exists) keeps inventory changes — e.g. a corrected issuer — applied
on re-runs instead of leaving stale values in the DB.
**Prerequisite**: the admin bootstrap must be configured
(`opnform_admin_email` + `opnform_admin_password`). The OIDC API
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`.
### Force OIDC-only login
```yaml
opnform_oidc_force_login: true # default false
```
Sets `OIDC_FORCE_LOGIN=true` on the API: password login is disabled and
every user must authenticate via OIDC. The role keeps force-login **off**
during the first deploy (the admin/OIDC bootstrap is password-based) and
switches it on only after the OIDC connection is provisioned, recreating
the API containers. Ensure all real users have addresses under
`opnform_oidc_domain` before enabling — there is no password fallback.
### Direct-SSO entrypoint
OpnForm has no native way to skip the email login form and jump straight
to the IdP. When enabled, the ingress serves a tiny redirect page that
calls `/api/auth/{slug}/redirect` (no domain check) and forwards the
browser to the IdP authorize URL.
```yaml
opnform_oidc_sso_entrypoint: true # default false
opnform_oidc_sso_path: "/sso" # link users to https://<domain>/sso
opnform_oidc_sso_redirect_root: true # default false — root URL 302s to <sso_path>
```
With `opnform_oidc_sso_redirect_root` enabled both the bare hostname
and `/login` jump straight to the IdP. Public form deep-links
(`/forms/<slug>`, `/admin/...`) are not touched. The email form remains
reachable as a break-glass path via `/login?bypass=1`.
## Networking / split-horizon
```yaml
opnform_extra_domains: [] # extra Host-rule hostnames (OR-combined)
opnform_extra_hosts: [] # API container /etc/hosts overrides ("host:ip")
```
`opnform_extra_domains` adds internal `*.int.*` FQDNs so a DMZ
reverseproxy can reach a backend hostname covered by the cert.
`opnform_extra_hosts` lets the API containers reach the IdP's public FQDN
(used in the OIDC `iss` claim) over the LAN when the DMZ has no NAT
loopback.
## Example playbook
```yaml
- 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 }}"
```
## License
MIT-0

View file

@ -1,142 +0,0 @@
#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"
# Additional hostnames the opnform router answers on (e.g. an internal
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
# by the cert).
opnform_extra_domains: []
# Container-level /etc/hosts overrides for the API containers — needed in
# split-horizon setups where the OpnForm API must reach the IdP's public
# FQDN (used in the OIDC discovery/iss claim) over the LAN rather than
# hairpinning through a DMZ that has no NAT loopback to its own public IP.
opnform_extra_hosts: []
opnform_base_url: "https://forms.local.test"
# Images
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: ""
# When true, sets OIDC_FORCE_LOGIN on the api: password-based login is
# disabled entirely and every user must authenticate via OIDC. Only
# rendered when opnform_oidc_enabled is also true. Make sure all real
# users have addresses under opnform_oidc_domain before enabling — there
# is no password fallback once this is on.
opnform_oidc_force_login: false
opnform_oidc_scopes:
- openid
- profile
- 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: []
# Direct-SSO entrypoint. OpnForm has no built-in way to skip the email
# login form and jump straight to the IdP (verified: config/oidc.php only
# exposes force_login; the login form always routes by email domain). When
# this is enabled the ingress serves a tiny page at opnform_oidc_sso_path
# that calls OpnForm's /api/auth/{slug}/redirect endpoint (which performs
# no domain check) and forwards the browser to the returned authorize URL
# — nonce/state included. Link users to https://<domain><sso_path> instead
# of /login. Requires opnform_oidc_enabled.
opnform_oidc_sso_entrypoint: false
opnform_oidc_sso_path: "/sso"
# When true, the ingress 302-redirects the root URL (exact-match on `/`)
# to opnform_oidc_sso_path so visiting https://<domain>/ jumps straight
# to the IdP login without showing OpnForm's email form. Public form
# deep-links (`/forms/<slug>`, `/login`, etc.) are untouched.
# Requires opnform_oidc_sso_entrypoint=true.
opnform_oidc_sso_redirect_root: false
# Traefik configuration
opnform_traefik_network: "proxy"
opnform_use_ssl: true

View file

@ -1,18 +0,0 @@
#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
# nginx.conf is bind-mounted into the ingress container and rendered to
# /etc/nginx/conf.d/default.conf by the envsubst entrypoint on container
# start. Plain `docker restart` re-runs that entrypoint, so the new
# template is picked up without bouncing db/redis/api/ui.
- name: restart opnform ingress
community.docker.docker_container:
name: opnform-ingress
state: started
restart: true

View file

@ -1,275 +0,0 @@
---
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_extra_domains:
type: list
elements: str
default: []
description:
- Additional hostnames the Traefik router answers on, OR-combined
with C(opnform_domain). Useful for an internal C(*.int.*) FQDN so
a DMZ reverseproxy can reach a backend hostname covered by the
cert.
opnform_extra_hosts:
type: list
elements: str
default: []
description:
- Container-level C(/etc/hosts) overrides for the API containers
(Compose C(extra_hosts) entries, C("host:ip")). Needed in
split-horizon setups where the OpnForm API must reach the IdP's
public FQDN (used in the OIDC discovery / C(iss) claim) over the
LAN rather than hairpinning through a DMZ with no NAT loopback.
opnform_base_url:
type: str
default: https://forms.local.test
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_force_login:
type: bool
default: false
description:
- "When true, sets C(OIDC_FORCE_LOGIN=true) on the api container:
password-based login is disabled and every user must authenticate
via OIDC. Only takes effect when C(opnform_oidc_enabled=true).
Ensure all real users have addresses under C(opnform_oidc_domain)
before enabling — there is no password fallback."
opnform_oidc_scopes:
type: list
elements: str
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_oidc_sso_entrypoint:
type: bool
default: false
description:
- When true (and C(opnform_oidc_enabled=true)) the nginx ingress
serves a small redirect page at C(opnform_oidc_sso_path) that
calls OpnForm's C(/api/auth/{slug}/redirect) endpoint and
forwards the browser to the returned IdP authorize URL. Lets
you link users straight to the IdP, skipping OpnForm's
email-based login form. OpnForm has no native option for this.
opnform_oidc_sso_path:
type: str
default: /sso
description:
- Path (on C(opnform_domain)) where the direct-SSO redirect page
is served when C(opnform_oidc_sso_entrypoint=true). Must start
with C(/) and not collide with OpnForm's own routes.
opnform_oidc_sso_redirect_root:
type: bool
default: false
description:
- When true, the nginx ingress 302-redirects the root URL
(exact-match on C(/)) to C(opnform_oidc_sso_path), so visiting
C(https://<domain>/) jumps straight to the IdP without
OpnForm's email login form. Public form deep-links
(C(/forms/<slug>), C(/login), C(/admin/...)) are untouched.
Requires C(opnform_oidc_sso_entrypoint=true).
opnform_traefik_network:
type: str
default: proxy
opnform_use_ssl:
type: bool
default: true

View file

@ -1,16 +0,0 @@
#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: []

View file

@ -1,458 +0,0 @@
#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, opnform_jwt_secret,
opnform_front_api_secret and opnform_db_password.
Generate with:
opnform_app_key='base64:'$(openssl rand -base64 32) (the 'base64:' prefix is required);
opnform_jwt_secret and opnform_front_api_secret via openssl rand -hex 32.
Provide via OpenBao, Ansible Vault or extra-vars.
success_msg: Secrets validation passed
- 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 ingress
# OIDC_FORCE_LOGIN disables OpnForm's password login — including the
# password-based admin/OIDC bootstrap this role performs below. The
# bootstrap must therefore run with force-login OFF. To stay idempotent
# on re-runs (avoid recreating api containers on every apply), we only
# turn force-login OFF when the bootstrap is actually needed (first run
# on a fresh host, no OIDC connection yet). Once the connection exists
# we render the final force-login value straight away, so the compose
# file is byte-identical across re-runs.
- name: Probe whether OpnForm is already bootstrapped
block:
- name: Check if opnform-api container exists and is healthy
ansible.builtin.command:
cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api
register: _opnform_api_health_probe
changed_when: false
failed_when: false
- name: Attempt admin login (only when api is healthy)
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_probe_login
no_log: true
when:
- _opnform_api_health_probe.rc == 0
- _opnform_api_health_probe.stdout == "healthy"
- opnform_admin_email | length > 0
- opnform_admin_password | length > 0
- name: Probe for existing OIDC connection
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces"
method: GET
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ _opnform_probe_login.json.token }}"
status_code: 200
validate_certs: false
register: _opnform_probe_workspaces
no_log: true
when:
- opnform_oidc_enabled | bool
- _opnform_probe_login is defined
- _opnform_probe_login.status | default(0) == 200
- name: Probe OIDC connections on default workspace
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces/{{ _opnform_probe_workspaces.json[0].id }}/oidc-connections"
method: GET
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ _opnform_probe_login.json.token }}"
status_code: 200
validate_certs: false
register: _opnform_probe_oidc
no_log: true
when:
- opnform_oidc_enabled | bool
- _opnform_probe_workspaces is defined
- _opnform_probe_workspaces.json | default([]) | length > 0
- name: Decide whether force-login can render in its final state
ansible.builtin.set_fact:
# True when force-login is desired AND admin+OIDC bootstrap has
# already completed (admin user exists with the configured password,
# OIDC connection is present). On a fresh host both checks fail and
# we fall back to false so the bootstrap below can run.
_opnform_force_login_effective: >-
{{
(opnform_oidc_enabled | bool)
and (opnform_oidc_force_login | bool)
and (_opnform_probe_login.status | default(0) == 200)
and ((_opnform_probe_oidc.json | default([])) | length > 0)
}}
- name: Deploy docker-compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
register: _opnform_compose_rendered
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.
#
# Skipped entirely when force-login already rendered in its final state
# (probe in step 2 confirmed admin + connection exist). Re-running the
# /api/login probe on a force-login-enabled api would 401 and 422, so
# avoid the noise — and avoid spurious "changed" status from a register
# call that won't help anyway.
- 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
- not (_opnform_force_login_effective | bool)
- 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
- not (_opnform_force_login_effective | bool)
- opnform_admin_login.status | default(0) != 200
# =====================================================================
# 6. OIDC IDENTITY CONNECTION (optional)
# =====================================================================
# Provisions a single OIDC connection on the admin's default workspace.
# OpnForm enforces one OIDC connection per workspace, so we GET the
# existing connections first and then either POST a new one or PATCH the
# existing one to the desired state. PATCHing (rather than skipping when
# one exists) keeps inventory changes — e.g. a corrected issuer — applied
# on re-runs instead of leaving stale values in the DB forever.
#
# Skipped on re-applies when force-login is already enabled — the API
# password login required for these calls is disabled, and the connection
# is known to exist (otherwise force-login wouldn't have rendered in its
# final state in step 2). To intentionally re-provision the connection
# from inventory changes on such a host: temporarily set
# opnform_oidc_force_login=false, re-apply, then set it back to true.
- 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
- not (_opnform_force_login_effective | 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
- not (_opnform_force_login_effective | 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
- not (_opnform_force_login_effective | 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
- not (_opnform_force_login_effective | bool)
# Desired connection state shared by both the create (POST) and update
# (PATCH) calls below. client_secret is always sent: OpnForm's update
# endpoint only persists it when present, and on create it is required.
- name: Build desired OIDC connection body
ansible.builtin.set_fact:
_opnform_oidc_body:
name: "{{ opnform_oidc_client_name }}"
slug: "{{ opnform_oidc_slug }}"
domain: "{{ opnform_oidc_domain }}"
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 }}"
no_log: true
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- name: Create OIDC identity connection
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections"
method: POST
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
body_format: json
body: "{{ _opnform_oidc_body }}"
status_code: [201]
validate_certs: false
no_log: true
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- opnform_existing_oidc.json | length == 0
# An OIDC connection already exists: PATCH it to the desired state so
# inventory changes (e.g. a corrected issuer) are applied. OpnForm allows
# exactly one connection per workspace, so the first entry is ours.
- name: Update existing OIDC identity connection
ansible.builtin.uri:
url: >-
https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections/{{ opnform_existing_oidc.json[0].id }}
method: PATCH
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
body_format: json
body: "{{ _opnform_oidc_body }}"
status_code: [200]
validate_certs: false
no_log: true
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- opnform_existing_oidc.json | length > 0
# =====================================================================
# 7. ENABLE FORCE LOGIN (first-run only)
# =====================================================================
# On the very first apply, step 2 rendered the compose file with
# force-login disabled (so the bootstrap above could use the password
# login). Now that the OIDC connection exists, re-render the compose
# file with force-login in its final state and recreate the api
# containers once.
#
# On all subsequent applies the probe in step 2 already rendered the
# final value, the compose file is byte-identical here, and this block
# is a no-op (the template task reports "ok", no recreate).
- name: Enable force login (first run, after OIDC bootstrap)
when:
- opnform_oidc_enabled | bool
- opnform_oidc_force_login | bool
- not (_opnform_force_login_effective | bool)
block:
- name: Re-render compose with force-login enabled
ansible.builtin.set_fact:
_opnform_force_login_effective: true
- name: Deploy docker-compose file with force-login enabled
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
register: _opnform_force_login_compose
- name: Apply force-login by recreating the api containers
community.docker.docker_compose_v2:
project_src: "{{ opnform_docker_compose_dir }}"
state: present
wait: true
wait_timeout: 180
when: _opnform_force_login_compose is changed
- name: Restart ingress so nginx picks up the new api container IPs
community.docker.docker_container:
name: opnform-ingress
state: started
restart: true
when: _opnform_force_login_compose is changed
- 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.
{% if opnform_oidc_sso_entrypoint %}
Login intercept active: {{ opnform_base_url }}/login forwards
directly to the IdP. Use {{ opnform_base_url }}/login?bypass=1
as a break-glass path for the email form when the IdP is down.
{% endif %}
{% else %}
OIDC: disabled (set opnform_oidc_enabled=true to auto-configure)
{% endif %}

View file

@ -1,215 +0,0 @@
#---------------------------------------------------------------------#
# OpnForm — Beautiful open-source form builder #
#---------------------------------------------------------------------#
services:
api: &api-service
image: {{ opnform_api_image }}
container_name: opnform-api
restart: unless-stopped
{% if opnform_extra_hosts | length > 0 %}
extra_hosts:
{% for host in opnform_extra_hosts %}
- "{{ host }}"
{% endfor %}
{% endif %}
volumes:
- {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw
environment: &api-env
APP_ENV: production
APP_KEY: "{{ opnform_app_key }}"
APP_URL: "{{ opnform_base_url }}"
APP_DEBUG: "false"
SELF_HOSTED: "true"
{% if opnform_oidc_enabled and (_opnform_force_login_effective | default(false)) %}
OIDC_FORCE_LOGIN: "true"
{% endif %}
LOG_CHANNEL: errorlog
LOG_LEVEL: info
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 }}"
# Shared secret for trusted SSR requests from the Nuxt UI. The UI
# forwards JWTs server-side with its own user agent; without this
# secret the API's AuthenticateJWT middleware would reject those
# requests (UA mismatch -> token blacklisted -> the next genuine
# browser request 401s). Must match FRONT_API_SECRET on the ui
# service.
FRONT_API_SECRET: "{{ opnform_front_api_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
# Nuxt runtimeConfig.apiSecret is fed by NUXT_API_SECRET (Nuxt
# convention: NUXT_<key> populates runtimeConfig.<key>). The UI
# injects this as `x-api-secret` on SSR-side forwards to Laravel,
# which then short-circuits the UA-fingerprint check in
# AuthenticateJWT — without it every reload would invalidate the
# JWT (UA `node` vs UA at issue time) and 401.
NUXT_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={% set _all_domains = [opnform_domain] + (opnform_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
{% if opnform_use_ssl %}
- traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ opnform_service_name }}.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ opnform_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ opnform_service_name }}.entrypoints=web
{% endif %}
- traefik.http.services.{{ opnform_service_name }}.loadbalancer.server.port=80
networks:
opnform-internal:
driver: bridge
{{ opnform_traefik_network }}:
external: true

View file

@ -1,87 +0,0 @@
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;
# Re-resolve upstream container hostnames via Docker's embedded DNS
# at request time. Without this, nginx caches the first resolution
# forever; if `api` or `ui` get recreated and pick up a new IP, every
# request 502s until the ingress itself is restarted.
resolver 127.0.0.11 valid=10s ipv6=off;
set $upstream_api api;
set $upstream_ui ui;
{% if opnform_oidc_enabled and opnform_oidc_sso_entrypoint %}
# Root → /login. Public forms live under /forms/<slug>, so the bare
# hostname only serves the authenticated dashboard — sending it
# straight to /login (which then jumps to the IdP) saves an extra
# UI-side redirect for anyone who lands there.
location = / {
return 302 /login;
}
# /login intercept: serve a tiny HTML page that calls OpnForm's
# /api/auth/{slug}/redirect endpoint and forwards the browser to the
# IdP authorize URL — skipping the email-based login form entirely.
# Break-glass: /login?bypass=1 falls through to the UI's own login
# form so the email/password path stays reachable when the IdP is
# down. Bypass branches to a named location (`@login_bypass`) because
# `proxy_pass` inside an `if` block is invalid nginx config.
location = /login {
if ($arg_bypass = "1") {
error_page 418 = @login_bypass;
return 418;
}
default_type text/html;
return 200 '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Redirecting to sign-in…</title></head><body style="font-family:sans-serif;text-align:center;padding:3rem;color:#374151"><p id="m">Redirecting to sign-in…</p><script>fetch("/api/auth/{{ opnform_oidc_slug }}/redirect",{method:"POST",headers:{Accept:"application/json"}}).then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json()}).then(function(d){if(d&&d.redirect_url){window.location.replace(d.redirect_url)}else{throw new Error("no redirect_url")}}).catch(function(e){document.getElementById("m").textContent="Sign-in redirect failed: "+e.message+". Open /login?bypass=1 to use the email form.";});</script></body></html>';
}
location @login_bypass {
proxy_http_version 1.1;
proxy_pass http://$upstream_ui:3000/login;
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;
}
{% endif %}
location / {
proxy_http_version 1.1;
proxy_pass http://$upstream_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 $upstream_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;
}
}

View file

@ -1,2 +0,0 @@
#SPDX-License-Identifier: MIT-0
localhost

View file

@ -1,6 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
- hosts: localhost
remote_user: root
roles:
- opnform

View file

@ -1,3 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for opnform

View file

@ -1,64 +0,0 @@
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_domains'][0] }}"
send_s3_bucket: "send"
send_s3_access_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['key_id'] }}"
send_s3_secret_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['secret_key'] }}"
```
When `send_storage_backend: s3`, the role asserts that `send_s3_endpoint`,
`send_s3_bucket`, `send_s3_access_key` and `send_s3_secret_key` are all set,
and fails early otherwise.
License
-------
MIT-0

View file

@ -1,53 +0,0 @@
#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

View file

@ -1,9 +0,0 @@
#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

View file

@ -1,122 +0,0 @@
---
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

View file

@ -1,14 +0,0 @@
#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-0
min_ansible_version: "2.14"
galaxy_tags:
- send
- filesharing
- docker
dependencies: []

View file

@ -1,42 +0,0 @@
#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

View file

@ -1,72 +0,0 @@
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
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ send_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ send_service_name }}.entrypoints=web
{% endif %}
{{ 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

View file

@ -1,3 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for send

View file

@ -1,78 +0,0 @@
# 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.)

View file

@ -1,74 +0,0 @@
#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: []

View file

@ -1,8 +0,0 @@
#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

View file

@ -1,161 +0,0 @@
---
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.

View file

@ -1,15 +0,0 @@
#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: []

View file

@ -1,85 +0,0 @@
#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

View file

@ -1,124 +0,0 @@
{# 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

View file

@ -1,28 +0,0 @@
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"
}
}

View file

@ -1,3 +0,0 @@
general: {
enabled = true
}

View file

@ -1,7 +0,0 @@
general: {
ws = true
ws_port = 8188
ws_interface = "0.0.0.0"
ws_pingpong_trigger = 60
ws_pingpong_timeout = 30
}

View file

@ -1,33 +0,0 @@
[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(',') }}

View file

@ -1,2 +0,0 @@
#SPDX-License-Identifier: MIT-0
localhost

View file

@ -1,6 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
- hosts: localhost
remote_user: root
roles:
- talk

View file

@ -1,3 +0,0 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for talk

View file

@ -54,15 +54,10 @@ common overrides:
## Dependencies ## Dependencies
- Run `digitalboard.core.base` first (or otherwise install Docker and the - Traefik network (`traefik_network`, default `proxy`) must be created
`community.docker` collection); this role manages containers and networks by the `base` role or by hand before this role runs.
through `community.docker`. - In `dmz` mode, the proxied backend services advertise themselves via
- The Traefik network (`traefik_network`, default `proxy`) is created by the `traefik_services` host_var on each backend host.
this role (`community.docker.docker_network`, state present), so no
pre-creation is required.
- In `dmz` mode, backend hosts advertise the services to aggregate via the
`traefik_dmz_exposed_services` host_var; `traefik_services` defines extra
routes directly on the DMZ host (each entry must set `backend_host`).
## Example playbook ## Example playbook

View file

@ -109,11 +109,10 @@ argument_specs:
type: bool type: bool
default: false default: false
description: description:
- "Sets C(propagation.disableANSChecks) to true on the ACME resolver - Disable lego's propagation check against the zone's authoritative
in the static config, disabling lego's propagation check against nameservers (sets C(LEGO_DISABLE_CNAME_SUPPORT=) plus the
the zone's authoritative nameservers. Use when the SOA-listed NS authoritative-NS-check skip). Use when the SOA-listed NS hostname
hostname resolves to an address the proxy host cannot reach; lego resolves to an address the proxy host cannot reach.
still polls via the configured C(resolvers) list."
traefik_selfsigned_cert_dir: traefik_selfsigned_cert_dir:
type: path type: path

View file

@ -1,26 +1,33 @@
#SPDX-License-Identifier: MIT-0 #SPDX-License-Identifier: MIT-0
galaxy_info: galaxy_info:
author: digitalboard author: your name
description: Deploy Traefik v3 as a DMZ or backend reverse proxy via Docker Compose description: your role description
company: Digitalboard company: your company (optional)
license: MIT-0
min_ansible_version: "2.14" # If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
platforms: # Choose a valid license ID from https://spdx.org - some suggested licenses:
- name: Debian # - BSD-3-Clause (default)
versions: # - MIT
- bookworm # - GPL-2.0-or-later
- name: Ubuntu # - GPL-3.0-only
versions: # - Apache-2.0
- jammy # - CC-BY-4.0
- noble license: license (GPL-2.0-or-later, MIT, etc)
galaxy_tags: min_ansible_version: 2.1
- traefik
- reverseproxy # If this a Container Enabled role, provide the minimum Ansible Container version.
- ingress # min_ansible_container_version:
- docker
- digitalboard galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: [] dependencies: []