feat: services bundle and collection documentation #8
86 changed files with 3100 additions and 836 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,3 +3,6 @@ __pycache__/
|
|||
*.pyc
|
||||
|
||||
plugins/lookup/__pycache__/
|
||||
|
||||
# Local Ansible collection cache (galaxy/collection resolver)
|
||||
/.ansible/
|
||||
|
|
|
|||
69
README.md
69
README.md
|
|
@ -1,3 +1,68 @@
|
|||
# Ansible Collection - digitalboard.core
|
||||
# Ansible Collection — digitalboard.core
|
||||
|
||||
Documentation for the collection.
|
||||
This collection bundles the Ansible roles used to deploy the
|
||||
[Digitalboard](https://git.digitalboard.ch/Digitalboard) platform: a set of
|
||||
self-hosted, Docker-Compose-based services running behind Traefik, with
|
||||
single sign-on provided by authentik or Keycloak.
|
||||
|
||||
Each role provisions one service (or building block) as a self-contained
|
||||
Docker Compose stack. Roles are consumed from the deployment repository
|
||||
[reference-ansible](https://git.digitalboard.ch/Digitalboard/reference-ansible),
|
||||
where inventories and playbooks tie the roles to concrete hosts.
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Description |
|
||||
| --- | --- |
|
||||
| `base` | Host baseline: Docker, apt packages and convenience tooling on Debian/Ubuntu. |
|
||||
| `traefik` | Traefik v3 reverse proxy as a public DMZ proxy (file provider) or backend proxy (docker provider). |
|
||||
| `authentik` | [authentik](https://goauthentik.io) IdP (server + worker + Postgres); resources via blueprints. |
|
||||
| `authentik_outpost_ldap` | authentik LDAP outpost exposing an LDAP interface for apps that cannot speak OIDC. |
|
||||
| `keycloak` | [Keycloak](https://www.keycloak.org/) IdP with a PostgreSQL backend. |
|
||||
| `389ds` | [389 Directory Server](https://www.port389.org/) LDAP directory via Docker Compose. |
|
||||
| `nextcloud` | [Nextcloud](https://nextcloud.com/) (fpm) + Postgres + Redis, optional Collabora/draw.io/notify_push. |
|
||||
| `opencloud` | [OpenCloud](https://opencloud.eu/) file platform via Docker Compose. |
|
||||
| `collabora` | [Collabora Online](https://www.collaboraonline.com/) (CODE), used as the WOPI backend for Nextcloud. |
|
||||
| `bookstack` | [BookStack](https://www.bookstackapp.com/) wiki (LSIO + MariaDB) with OIDC SSO and daily backups. |
|
||||
| `drawio` | [draw.io](https://www.drawio.com/) diagram editor, with optional authentik ForwardAuth gating. |
|
||||
| `homarr` | [Homarr](https://github.com/homarr-labs/homarr) dashboard with seeded admin user and OIDC group. |
|
||||
| `opnform` | [OpnForm](https://github.com/OpnForm/OpnForm) self-hosted form builder (api + ui + db + redis). |
|
||||
| `send` | [Send](https://github.com/timvisee/send) (timvisee fork) file sharing with a Redis backend. |
|
||||
| `garage` | [Garage](https://garagehq.deuxfleurs.fr/) S3-compatible object storage with key/bucket provisioning. |
|
||||
| `httpbin` | [httpbin](https://httpbin.org/) HTTP request/response testing service for validating Traefik ingress. |
|
||||
|
||||
## Usage
|
||||
|
||||
Roles are not run from this repository directly. They are consumed from the
|
||||
deployment repository
|
||||
[reference-ansible](https://git.digitalboard.ch/Digitalboard/reference-ansible),
|
||||
which holds the inventories, group/host variables and playbooks. See that
|
||||
repository's `docs/` directory for getting-started instructions, how to run
|
||||
Ansible and how secrets are managed.
|
||||
|
||||
Per-role variables and their defaults are documented in each role's own
|
||||
`README.md` and `meta/argument_specs.yml`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Debian/Ubuntu target host (the `base` role bootstraps Docker there).
|
||||
- ansible-core 2.15 or newer on the controller.
|
||||
- The `community.docker` collection (used by nearly every role) and
|
||||
`community.general` (used by the `keycloak` role). Both are declared as
|
||||
`dependencies` in `galaxy.yml` and pulled in automatically when this
|
||||
collection is installed via `ansible-galaxy`.
|
||||
|
||||
The role READMEs use `community.hashi_vault` lookups in their examples to source
|
||||
secrets from HashiCorp Vault. That is a documented convention, not a hard
|
||||
dependency of the roles — supply the variables however you prefer.
|
||||
|
||||
## Role ordering
|
||||
|
||||
Within a play, apply the roles in dependency order: `base` first (Docker and the
|
||||
host baseline), then `traefik` (the shared reverse proxy and its Docker network),
|
||||
then the individual service roles (`authentik`, `keycloak`, `nextcloud`, …),
|
||||
which attach to Traefik's network and expect Docker to be present.
|
||||
|
||||
## License
|
||||
|
||||
MIT-0. See individual roles for per-role license metadata.
|
||||
|
|
|
|||
25
galaxy.yml
25
galaxy.yml
|
|
@ -23,12 +23,12 @@ authors:
|
|||
|
||||
### OPTIONAL but strongly recommended
|
||||
# A short summary description of the collection
|
||||
description: your collection description
|
||||
description: Ansible roles to deploy the Digitalboard self-hosted service platform (Docker Compose + Traefik + SSO)
|
||||
|
||||
# Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only
|
||||
# accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file'
|
||||
license:
|
||||
- GPL-2.0-or-later
|
||||
- MIT-0
|
||||
|
||||
# The path to the license file for the collection. This path is relative to the root of the collection. This key is
|
||||
# mutually exclusive with 'license'
|
||||
|
|
@ -36,25 +36,36 @@ license_file: ''
|
|||
|
||||
# A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character
|
||||
# requirements as 'namespace' and 'name'
|
||||
tags: []
|
||||
tags:
|
||||
- digitalboard
|
||||
- docker
|
||||
- traefik
|
||||
- sso
|
||||
- selfhosted
|
||||
|
||||
# Collections that this collection requires to be installed for it to be usable. The key of the dict is the
|
||||
# collection label 'namespace.name'. The value is a version range
|
||||
# L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version
|
||||
# range specifiers can be set and are separated by ','
|
||||
dependencies: {}
|
||||
dependencies:
|
||||
# Used by nearly every role: docker_compose_v2, docker_container,
|
||||
# docker_container_exec, docker_network. Hard runtime dependency.
|
||||
community.docker: '>=3.0.0'
|
||||
# Used by the keycloak role (keycloak_realm/client/group/user and
|
||||
# related modules) in roles/keycloak/tasks/provisioning.yml.
|
||||
community.general: '>=7.0.0'
|
||||
|
||||
# The URL of the originating SCM repository
|
||||
repository: https://git.digitalboard.ch/Digitalboard/digitalboard.core
|
||||
|
||||
# The URL to any online docs
|
||||
documentation: http://docs.example.com
|
||||
documentation: https://git.digitalboard.ch/Digitalboard/digitalboard.core
|
||||
|
||||
# The URL to the homepage of the collection/project
|
||||
homepage: http://example.com
|
||||
homepage: https://git.digitalboard.ch/Digitalboard/digitalboard.core
|
||||
|
||||
# The URL to the collection issue tracker
|
||||
issues: http://example.com/issue/tracker
|
||||
issues: https://git.digitalboard.ch/Digitalboard/digitalboard.core/issues
|
||||
|
||||
# A list of file glob-like patterns used to filter any files or directories that should not be included in the build
|
||||
# artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# Collections must specify a minimum required ansible version to upload
|
||||
# to galaxy
|
||||
# requires_ansible: '>=2.9.10'
|
||||
# to galaxy. Aligned with the highest min_ansible_version declared by the
|
||||
# roles (the traefik role requires ansible-core 2.15).
|
||||
requires_ansible: '>=2.15.0'
|
||||
|
||||
# Content that Ansible needs to load from another location or that has
|
||||
# been deprecated/removed
|
||||
|
|
|
|||
|
|
@ -1,31 +1,32 @@
|
|||
# Collections Plugins Directory
|
||||
# Collection Plugins — digitalboard.core
|
||||
|
||||
This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that
|
||||
is named after the type of plugin it is in. It can also include the `module_utils` and `modules` directory that
|
||||
would contain module utils and modules respectively.
|
||||
This collection ships a small number of custom plugins that support the roles.
|
||||
They are addressed by their fully qualified name, `digitalboard.core.<name>`.
|
||||
|
||||
Here is an example directory of the majority of plugins currently supported by Ansible:
|
||||
## Filter plugins (`filter/`)
|
||||
|
||||
```
|
||||
└── plugins
|
||||
├── action
|
||||
├── become
|
||||
├── cache
|
||||
├── callback
|
||||
├── cliconf
|
||||
├── connection
|
||||
├── filter
|
||||
├── httpapi
|
||||
├── inventory
|
||||
├── lookup
|
||||
├── module_utils
|
||||
├── modules
|
||||
├── netconf
|
||||
├── shell
|
||||
├── strategy
|
||||
├── terminal
|
||||
├── test
|
||||
└── vars
|
||||
`homarr_layout` — computes Homarr dashboard grid layouts (desktop / tablet /
|
||||
mobile breakpoints) from a list of apps, returning a ready-to-render data
|
||||
structure for the SQL seed. Used by the `homarr` role.
|
||||
|
||||
```yaml
|
||||
- name: Compute Homarr app layouts
|
||||
ansible.builtin.set_fact:
|
||||
homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}"
|
||||
```
|
||||
|
||||
A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible-core/2.19/plugins/plugins.html).
|
||||
## Lookup plugins (`lookup/`)
|
||||
|
||||
`garage_credentials` — returns S3 credentials (`key_id`, `secret_key`) for a
|
||||
named Garage key by executing a docker command on the target host. Used to wire
|
||||
Garage object storage into consuming roles such as `nextcloud`.
|
||||
|
||||
```yaml
|
||||
nextcloud_s3_key: >-
|
||||
{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='backend')['key_id'] }}
|
||||
nextcloud_s3_secret: >-
|
||||
{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='backend')['secret_key'] }}
|
||||
```
|
||||
|
||||
No other plugin types (modules, action, callback, inventory, etc.) are currently
|
||||
shipped by this collection.
|
||||
|
|
|
|||
|
|
@ -1,38 +1,43 @@
|
|||
Role Name
|
||||
=========
|
||||
# 389ds
|
||||
|
||||
A brief description of the role goes here.
|
||||
Deploys [389 Directory Server](https://www.port389.org/) (`389ds/dirsrv`)
|
||||
as an LDAP directory via Docker Compose. After the container starts, the
|
||||
role creates the configured suffix and a set of base organizational
|
||||
units (e.g. `users`, `groups`).
|
||||
|
||||
Requirements
|
||||
------------
|
||||
## Requirements
|
||||
|
||||
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
|
||||
- Docker and Docker Compose on the target host (e.g. via
|
||||
`digitalboard.core.base`)
|
||||
- Ansible collection: `community.docker`
|
||||
|
||||
Role Variables
|
||||
--------------
|
||||
## Role variables
|
||||
|
||||
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `ds389_image` | `docker.io/389ds/dirsrv:3.1` | Container image. |
|
||||
| `ds389_suffix` | `dc=example,dc=com` | Root suffix of the directory. |
|
||||
| `ds389_root_dn` | `cn=Directory Manager` | Directory Manager bind DN. |
|
||||
| `ds389_root_password` | `changeme` | Directory Manager password — **override this**. |
|
||||
| `ds389_instance_name` | `localhost` | Directory server instance name (slapd config dir). |
|
||||
| `ds389_hostname` | `389ds` | Container hostname (defaults to `ds389_service_name`). |
|
||||
| `ds389_backend_network` | `backend` | Docker network LDAP clients connect over (created by Compose). |
|
||||
| `ds389_ldap_port` | `3389` | Published LDAP port (container port 3389). |
|
||||
| `ds389_ldaps_port` | `3636` | Published LDAPS port (container port 3636). |
|
||||
| `ds389_base_ous` | `[users, groups]` | Base OUs created after startup. |
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
## Example
|
||||
|
||||
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
|
||||
```yaml
|
||||
- hosts: directory
|
||||
become: true
|
||||
roles:
|
||||
- role: digitalboard.core.389ds
|
||||
vars:
|
||||
ds389_suffix: "dc=example,dc=org"
|
||||
ds389_root_password: "{{ vault_ds389_root_password }}"
|
||||
```
|
||||
|
||||
Example Playbook
|
||||
----------------
|
||||
## License
|
||||
|
||||
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
|
||||
|
||||
- hosts: servers
|
||||
roles:
|
||||
- { role: username.rolename, x: 42 }
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
BSD
|
||||
|
||||
Author Information
|
||||
------------------
|
||||
|
||||
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -1,35 +1,26 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Deploy 389 Directory Server (LDAP) via Docker Compose
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.2
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- 389ds
|
||||
- ldap
|
||||
- directory
|
||||
- docker
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
|
|||
|
|
@ -1,28 +1,136 @@
|
|||
# Authentik
|
||||
|
||||
Deploys Authentik identity provider with Docker Compose.
|
||||
Deploys [authentik](https://goauthentik.io) (server + worker + Postgres)
|
||||
as a Docker Compose stack behind Traefik, with all resources provisioned
|
||||
via templated blueprints.
|
||||
|
||||
## What this role does
|
||||
|
||||
- Renders the Compose stack with traefik labels and an optional
|
||||
split-horizon host rewrite (see below)
|
||||
- Provisions local users, groups, OIDC apps, Proxy/ForwardAuth apps,
|
||||
LDAP apps and outposts, and Entra ID OAuth sources via blueprints
|
||||
- Configures the login screen (visible sources, local login fields)
|
||||
- Supports declarative cleanup via `authentik_removed_*` lists
|
||||
|
||||
## Variables
|
||||
|
||||
See `defaults/main.yml` for all available variables.
|
||||
Full spec with types and defaults: `meta/argument_specs.yml`. The most
|
||||
common overrides:
|
||||
|
||||
## Blueprints
|
||||
### Service
|
||||
|
||||
- `authentik_domains` (required, list): FQDNs the router accepts. First
|
||||
entry is the canonical hostname; further entries cover internal
|
||||
`*.int.*` names for server-to-server traffic.
|
||||
- `authentik_secret_key` (required): PG fernet / signing secret.
|
||||
Generate with `openssl rand -base64 60`.
|
||||
- `authentik_postgres_password` (required).
|
||||
- `authentik_image`, `authentik_port`, `authentik_log_level`.
|
||||
|
||||
### Split-horizon host rewrite
|
||||
|
||||
`authentik_host_rewrite_domains` lists hostnames that should reach the
|
||||
authentik container but make it generate URLs (OIDC issuer, password
|
||||
reset links, etc.) as if the request had arrived on
|
||||
`authentik_domains[0]`.
|
||||
|
||||
For each entry the role:
|
||||
|
||||
- Creates a dedicated traefik router on that hostname
|
||||
- Routes it to a URL-based loadbalancer service that disables
|
||||
`passHostHeader`, so the upstream Host header becomes the canonical
|
||||
FQDN
|
||||
- Pins `X-Forwarded-Host` via middleware so the iss claim stays aligned
|
||||
with the public hostname browsers see
|
||||
|
||||
Use case: an internal `auth.int.example.com` keeps server-to-server
|
||||
traffic in the LAN, but Keycloak/Nextcloud/etc. still receive issuer
|
||||
URLs matching `auth.example.com`.
|
||||
|
||||
### Blueprints
|
||||
|
||||
The role renders blueprints for:
|
||||
|
||||
- Local users (`authentik_local_users`)
|
||||
- Groups (`authentik_groups`)
|
||||
- OIDC applications (`authentik_oidc_apps`)
|
||||
- Proxy applications (`authentik_proxy_apps`)
|
||||
- Proxy outposts (`authentik_proxy_outposts`)
|
||||
- LDAP applications (`authentik_ldap_apps`)
|
||||
- LDAP outpost (`authentik_ldap_outpost`)
|
||||
- Entra ID sources (`authentik_entra_sources`)
|
||||
- Login screen sources (`authentik_login_source_ids`)
|
||||
- Login-screen source visibility (`authentik_login_sources`)
|
||||
|
||||
Secrets are passed via `authentik_blueprint_env` using environment variable references.
|
||||
Secrets are passed via the `authentik_blueprint_env` env-var indirection
|
||||
so they never land in rendered blueprint YAML on disk.
|
||||
|
||||
#### Proxy apps: mode and group restrictions
|
||||
|
||||
Each entry in `authentik_proxy_apps` supports:
|
||||
|
||||
- `mode` (default `forward_single`): one of `proxy`, `forward_single`,
|
||||
`forward_domain`
|
||||
- `allowed_groups`: when set, a `PolicyBinding` is emitted per group on
|
||||
the application. authentik OR-evaluates bindings, so users in any
|
||||
listed group pass and users in none are denied.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
authentik_proxy_apps:
|
||||
- slug: drawio
|
||||
name: drawio
|
||||
external_host: "https://drawio.example.com"
|
||||
mode: forward_single
|
||||
allowed_groups:
|
||||
- drawio-users
|
||||
- admins
|
||||
```
|
||||
|
||||
## Removing resources
|
||||
|
||||
To remove resources from Authentik, move slugs to the removal lists:
|
||||
Move slugs from the active list to the matching removal list:
|
||||
|
||||
- `authentik_removed_oidc_apps`
|
||||
- `authentik_removed_proxy_apps`
|
||||
- `authentik_removed_local_users`
|
||||
|
||||
After confirming deletion, remove the slug from the list.
|
||||
After authentik has applied the deletion blueprint, remove the slug
|
||||
from the list to keep state clean.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Run `digitalboard.core.base` first (Docker) and have the `community.docker`
|
||||
collection installed; the role drives the stack via
|
||||
`community.docker.docker_compose_v2`.
|
||||
- Traefik network (`authentik_traefik_network`, default `proxy`) must exist
|
||||
beforehand (e.g. created by the traefik role); it is referenced as an
|
||||
external network in the Compose file.
|
||||
- Internal backend network (`authentik_backend_network`, default `backend`).
|
||||
|
||||
## Example playbook
|
||||
|
||||
```yaml
|
||||
- hosts: identity_servers
|
||||
roles:
|
||||
- role: digitalboard.core.authentik
|
||||
vars:
|
||||
authentik_domains:
|
||||
- "auth.example.com"
|
||||
- "auth.int.example.com"
|
||||
authentik_host_rewrite_domains:
|
||||
- "auth.int.example.com"
|
||||
authentik_secret_key: "{{ vault_authentik_secret_key }}"
|
||||
authentik_postgres_password: "{{ vault_authentik_pg_password }}"
|
||||
authentik_proxy_apps:
|
||||
- slug: drawio
|
||||
name: drawio
|
||||
external_host: "https://drawio.example.com"
|
||||
mode: forward_single
|
||||
allowed_groups: [drawio-users]
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -12,7 +12,20 @@ authentik_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ authentik_servic
|
|||
authentik_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ authentik_service_name }}"
|
||||
|
||||
# Authentik service configuration
|
||||
authentik_domain: "authentik.local.test"
|
||||
# FQDNs the authentik router accepts. The first entry is the canonical
|
||||
# domain; further entries cover internal *.int.* names used for
|
||||
# server-to-server traffic so backend calls don't hairpin via DMZ.
|
||||
authentik_domains:
|
||||
- "authentik.local.test"
|
||||
|
||||
# Hostnames that should reach authentik but make it generate URLs (OIDC
|
||||
# issuer, password reset links, etc.) as if requested from the canonical
|
||||
# `authentik_domains[0]` instead. Used for split-horizon setups where an
|
||||
# internal FQDN (e.g. `auth.int.example.com`) keeps server-to-server
|
||||
# traffic in the LAN but the iss claim must still match the public
|
||||
# hostname that browsers see. Traefik handles each entry via a separate
|
||||
# router that rewrites the Host header before forwarding to authentik.
|
||||
authentik_host_rewrite_domains: []
|
||||
authentik_image: "ghcr.io/goauthentik/server:2026.2.2"
|
||||
authentik_port: 9000
|
||||
authentik_secret_key: "changeme-generate-a-random-string"
|
||||
|
|
|
|||
193
roles/authentik/meta/argument_specs.yml
Normal file
193
roles/authentik/meta/argument_specs.yml
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Deploy authentik (server + worker + Postgres) via Docker Compose.
|
||||
description:
|
||||
- Renders a Compose stack for authentik with traefik labels, optional
|
||||
TLS and a configurable split-horizon host-rewrite that keeps the OIDC
|
||||
issuer URL on the canonical public hostname even when traffic enters
|
||||
on an internal FQDN.
|
||||
- Provisions resources through templated blueprints
|
||||
(local users, groups, OIDC/Proxy/LDAP apps, outposts, OAuth sources).
|
||||
options:
|
||||
docker_compose_base_dir:
|
||||
type: path
|
||||
default: /etc/docker/compose
|
||||
docker_volume_base_dir:
|
||||
type: path
|
||||
default: /srv/data
|
||||
authentik_service_name:
|
||||
type: str
|
||||
default: authentik
|
||||
authentik_docker_compose_dir:
|
||||
type: path
|
||||
description: Defaults to C({{ docker_compose_base_dir }}/{{ authentik_service_name }}).
|
||||
authentik_docker_volume_dir:
|
||||
type: path
|
||||
description: Defaults to C({{ docker_volume_base_dir }}/{{ authentik_service_name }}).
|
||||
|
||||
authentik_domains:
|
||||
type: list
|
||||
elements: str
|
||||
required: true
|
||||
description:
|
||||
- FQDNs the authentik router accepts. The first entry is the
|
||||
canonical (public) hostname and is used for the network alias,
|
||||
the X-Forwarded-Host rewrite target, and as the default OIDC
|
||||
issuer. Further entries cover internal C(*.int.*) names used
|
||||
for server-to-server traffic.
|
||||
authentik_host_rewrite_domains:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description:
|
||||
- Hostnames that should reach authentik but make it generate URLs
|
||||
(OIDC issuer, password reset links, etc.) as if the request had
|
||||
arrived on C(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. Used for split-horizon setups
|
||||
where the LAN keeps server-to-server traffic but the iss claim
|
||||
must match the public hostname browsers see.
|
||||
authentik_image:
|
||||
type: str
|
||||
default: ghcr.io/goauthentik/server:2026.2.2
|
||||
authentik_port:
|
||||
type: int
|
||||
default: 9000
|
||||
authentik_secret_key:
|
||||
type: str
|
||||
required: true
|
||||
description: PG fernet key / signing secret. Generate with C(openssl rand -base64 60).
|
||||
|
||||
authentik_postgres_image:
|
||||
type: str
|
||||
default: postgres:16-alpine
|
||||
authentik_postgres_db:
|
||||
type: str
|
||||
default: authentik
|
||||
authentik_postgres_user:
|
||||
type: str
|
||||
default: authentik
|
||||
authentik_postgres_password:
|
||||
type: str
|
||||
required: true
|
||||
|
||||
authentik_traefik_network:
|
||||
type: str
|
||||
default: proxy
|
||||
authentik_backend_network:
|
||||
type: str
|
||||
default: backend
|
||||
authentik_use_ssl:
|
||||
type: bool
|
||||
default: true
|
||||
|
||||
authentik_log_level:
|
||||
type: str
|
||||
choices: [trace, debug, info, warning, error]
|
||||
default: info
|
||||
authentik_error_reporting_enabled:
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
authentik_proxy_apps:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
description:
|
||||
- Proxy/ForwardAuth applications rendered via the
|
||||
C(blueprint-proxy-app.yaml.j2) template.
|
||||
options:
|
||||
slug:
|
||||
type: str
|
||||
required: true
|
||||
name:
|
||||
type: str
|
||||
required: true
|
||||
internal_host:
|
||||
type: str
|
||||
description: Required when C(mode=proxy).
|
||||
external_host:
|
||||
type: str
|
||||
required: true
|
||||
mode:
|
||||
type: str
|
||||
choices: [proxy, forward_single, forward_domain]
|
||||
default: forward_single
|
||||
description:
|
||||
- "C(proxy): the outpost itself proxies traffic to internal_host."
|
||||
- "C(forward_single): a single app behind an external reverse
|
||||
proxy via ForwardAuth."
|
||||
- "C(forward_domain): wildcard mode — one provider guards every
|
||||
host on a cookie domain."
|
||||
allowed_groups:
|
||||
type: list
|
||||
elements: str
|
||||
description:
|
||||
- If set, PolicyBindings are emitted (one per group, OR-evaluated).
|
||||
Users in none of the listed groups are denied.
|
||||
skip_path_regex:
|
||||
type: str
|
||||
flows:
|
||||
type: dict
|
||||
description: Authentication / authorization / invalidation flow slugs.
|
||||
|
||||
authentik_proxy_outposts:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
|
||||
authentik_ldap_apps:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
authentik_ldap_outpost:
|
||||
type: dict
|
||||
default: {}
|
||||
|
||||
authentik_oidc_apps:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
|
||||
authentik_entra_sources:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
authentik_login_sources:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
authentik_identification_stage_name:
|
||||
type: str
|
||||
default: default-authentication-identification
|
||||
authentik_login_user_fields:
|
||||
type: list
|
||||
elements: str
|
||||
choices: [username, email, upn]
|
||||
default: [username, email]
|
||||
description: Local login fields shown on the login screen. Empty list hides local login.
|
||||
|
||||
authentik_groups:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
authentik_local_users:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
|
||||
authentik_removed_oidc_apps:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description: OIDC application slugs scheduled for deletion.
|
||||
authentik_removed_proxy_apps:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
authentik_removed_local_users:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
|
|
@ -1,35 +1,28 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Deploy authentik (server + worker + Postgres) via Docker Compose with blueprint-provisioned resources
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.2
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- authentik
|
||||
- oidc
|
||||
- sso
|
||||
- idp
|
||||
- docker
|
||||
- traefik
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ entries:
|
|||
{% for field in authentik_login_user_fields %}
|
||||
- {{ field }}
|
||||
{% endfor %}
|
||||
{% if authentik_login_sources %}
|
||||
# OAuth/social login sources (use !Find to reference sources from other blueprints)
|
||||
sources:
|
||||
{% for src in authentik_login_sources %}
|
||||
- !Find [authentik_sources_oauth.oauthsource, [slug, {{ src.slug }}]]
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ entries:
|
|||
|
||||
internal_host: "{{ item.internal_host }}"
|
||||
external_host: "{{ item.external_host }}"
|
||||
{# Provider mode controls how authentik treats the proxy app:
|
||||
- proxy : the outpost itself proxies traffic to internal_host
|
||||
- forward_single : a single app behind an external reverse proxy
|
||||
(traefik forwardauth talks to authentik per-domain)
|
||||
- forward_domain : wildcard mode — one provider guards every host on a
|
||||
cookie domain; configure forward_auth_mode=domain on
|
||||
the outpost in that case. Default to forward_single
|
||||
since that's the common ForwardAuth-with-traefik
|
||||
pattern. #}
|
||||
mode: {{ item.mode | default('forward_single') }}
|
||||
|
||||
{% if item.skip_path_regex is defined and item.skip_path_regex|length > 0 %}
|
||||
skip_path_regex: |
|
||||
|
|
@ -34,3 +44,20 @@ entries:
|
|||
name: "{{ item.name | default(item.slug) }}"
|
||||
slug: {{ item.slug }}
|
||||
provider: !KeyOf proxy-provider-{{ item.slug }}
|
||||
|
||||
{% if item.allowed_groups is defined and item.allowed_groups | length > 0 %}
|
||||
{# Restrict access to listed groups: one PolicyBinding per group, all bound
|
||||
to the application. Authentik treats multiple bindings on the same target
|
||||
as OR (a user matching any binding passes), and a request from a user in
|
||||
none of the bound groups is denied. #}
|
||||
{% for group_name in item.allowed_groups %}
|
||||
- model: authentik_policies.policybinding
|
||||
identifiers:
|
||||
target: !KeyOf app-{{ item.slug }}
|
||||
order: {{ loop.index0 }}
|
||||
group: !Find [authentik_core.group, [name, "{{ group_name }}"]]
|
||||
attrs:
|
||||
enabled: true
|
||||
negate: false
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -43,19 +43,58 @@ services:
|
|||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- {{ authentik_backend_network }}
|
||||
- {{ authentik_traefik_network }}
|
||||
{{ authentik_backend_network }}: {}
|
||||
# No alias for the public FQDN here: that would shadow `/etc/hosts`
|
||||
# pins (extra_hosts) in other containers sharing this network and
|
||||
# break OIDC discovery for Node-based clients (c-ares-based
|
||||
# resolvers consult Docker DNS before /etc/hosts). The URL-based
|
||||
# service below addresses this container by its compose service
|
||||
# name `server`, which Docker exposes as an alias on every network
|
||||
# the container joins.
|
||||
{{ authentik_traefik_network }}: {}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network={{ authentik_traefik_network }}
|
||||
- traefik.http.routers.{{ authentik_service_name }}.rule=Host(`{{ authentik_domain }}`)
|
||||
- traefik.http.routers.{{ authentik_service_name }}.rule={% for d in authentik_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
|
||||
- traefik.http.routers.{{ authentik_service_name }}.service={{ authentik_service_name }}
|
||||
{% if authentik_use_ssl %}
|
||||
- traefik.http.routers.{{ authentik_service_name }}.entrypoints=websecure
|
||||
- traefik.http.routers.{{ authentik_service_name }}.tls=true
|
||||
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
|
||||
- traefik.http.routers.{{ authentik_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- traefik.http.routers.{{ authentik_service_name }}.entrypoints=web
|
||||
{% endif %}
|
||||
- traefik.http.services.{{ authentik_service_name }}.loadbalancer.server.port={{ authentik_port }}
|
||||
{% if authentik_host_rewrite_domains | length > 0 %}
|
||||
# Server-to-server entry: a separate service points at this very
|
||||
# container by its compose service name `server` and disables
|
||||
# passHostHeader so the upstream Host header becomes
|
||||
# `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer URLs
|
||||
# from X-Forwarded-Host (not Host), so we also pin that header via
|
||||
# middleware. Together this keeps the iss claim aligned with the
|
||||
# public hostname browsers see during login, even when the 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.passhostheader=false
|
||||
- traefik.http.middlewares.{{ authentik_service_name }}-xfh-rewrite.headers.customrequestheaders.X-Forwarded-Host={{ authentik_domains[0] }}
|
||||
{% for d in authentik_host_rewrite_domains %}
|
||||
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.rule=Host(`{{ d }}`)
|
||||
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.priority=100
|
||||
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.service={{ authentik_service_name }}-rewrite
|
||||
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.middlewares={{ authentik_service_name }}-xfh-rewrite
|
||||
{% if authentik_use_ssl %}
|
||||
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.entrypoints=websecure
|
||||
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.tls=true
|
||||
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
|
||||
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.entrypoints=web
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
worker:
|
||||
image: {{ authentik_image }}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,44 @@
|
|||
Role Name
|
||||
=========
|
||||
# authentik_outpost_ldap
|
||||
|
||||
A brief description of the role goes here.
|
||||
Deploys an [authentik](https://goauthentik.io) LDAP outpost via Docker
|
||||
Compose. The outpost exposes an LDAP interface backed by authentik, so
|
||||
applications that cannot speak OIDC (e.g. Nextcloud or OpenCloud LDAP
|
||||
backends) can still authenticate against the central IdP.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
The outpost connects back to an authentik server using an outpost token
|
||||
issued in the authentik admin interface. The image version must match
|
||||
the authentik server version.
|
||||
|
||||
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
|
||||
## Requirements
|
||||
|
||||
Role Variables
|
||||
--------------
|
||||
- Docker and Docker Compose on the target host (e.g. via
|
||||
`digitalboard.core.base`)
|
||||
- Ansible collection: `community.docker`
|
||||
|
||||
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
|
||||
## Role variables
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `authentik_outpost_ldap_image` | `ghcr.io/goauthentik/ldap:2026.2.2` | Outpost image (match the server version). |
|
||||
| `authentik_outpost_ldap_host` | `https://authentik.local.test` | URL of the authentik server. |
|
||||
| `authentik_outpost_ldap_token` | `changeme` | Outpost token — **override this**. |
|
||||
| `authentik_outpost_ldap_insecure` | `"true"` | Skip TLS verification toward the authentik server. |
|
||||
| `authentik_outpost_ldap_network` | `ldap` | Docker network LDAP clients connect over (created by the role). |
|
||||
| `authentik_outpost_ldap_authentik_network` | _unset_ | Optional extra external network to the authentik server. |
|
||||
| `authentik_outpost_ldap_extra_hosts` | `[]` | Extra `host:ip` entries for in-container DNS. |
|
||||
|
||||
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
|
||||
## Example
|
||||
|
||||
Example Playbook
|
||||
----------------
|
||||
```yaml
|
||||
- hosts: directory
|
||||
become: true
|
||||
roles:
|
||||
- role: digitalboard.core.authentik_outpost_ldap
|
||||
vars:
|
||||
authentik_outpost_ldap_host: "https://auth.example.com"
|
||||
authentik_outpost_ldap_token: "{{ vault_authentik_ldap_outpost_token }}"
|
||||
```
|
||||
|
||||
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
|
||||
## License
|
||||
|
||||
- hosts: servers
|
||||
roles:
|
||||
- { role: username.rolename, x: 42 }
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
BSD
|
||||
|
||||
Author Information
|
||||
------------------
|
||||
|
||||
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -1,35 +1,27 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Deploy an authentik LDAP outpost via Docker Compose for applications that cannot use OIDC
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.2
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- authentik
|
||||
- ldap
|
||||
- outpost
|
||||
- sso
|
||||
- docker
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
|
|||
|
|
@ -1,38 +1,45 @@
|
|||
Role Name
|
||||
=========
|
||||
# base
|
||||
|
||||
A brief description of the role goes here.
|
||||
Host baseline for the Digitalboard platform. Installs Docker (engine,
|
||||
CLI, containerd, buildx, compose plugin) and a small set of apt and
|
||||
convenience packages on Debian/Ubuntu, and sets the shared directory
|
||||
layout every other role builds on.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
This role is intended to run first on every host, before any
|
||||
service role.
|
||||
|
||||
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
|
||||
## What it does
|
||||
|
||||
Role Variables
|
||||
--------------
|
||||
- Installs Docker prerequisites (`apt-transport-https`, `ca-certificates`,
|
||||
`curl`, `gnupg`, `lsb-release`, `apache2-utils` for `htpasswd`) plus
|
||||
convenience packages (`htop`, `ncdu`, `vim`) and Docker itself
|
||||
(`docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`,
|
||||
`docker-compose-plugin`).
|
||||
- Optionally configures Docker registry mirrors via `/etc/docker/daemon.json`.
|
||||
- Starts and enables the Docker service and writes a custom `/etc/motd`.
|
||||
|
||||
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
|
||||
This role defines the shared directory-layout variables
|
||||
(`docker_compose_base_dir`, `docker_volume_base_dir`) that every service
|
||||
role consumes, but the per-service subdirectories are created by the
|
||||
respective service roles, not here.
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
## Role variables
|
||||
|
||||
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `docker_compose_base_dir` | `/etc/docker/compose` | Root directory for per-service Compose projects. |
|
||||
| `docker_volume_base_dir` | `/srv/data` | Root directory for per-service persistent volumes. |
|
||||
| `docker_registry_mirrors` | `[]` | Optional list of registry mirror URLs; empty disables mirrors. |
|
||||
|
||||
Example Playbook
|
||||
----------------
|
||||
## Example
|
||||
|
||||
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
|
||||
```yaml
|
||||
- hosts: all
|
||||
become: true
|
||||
roles:
|
||||
- digitalboard.core.base
|
||||
```
|
||||
|
||||
- hosts: servers
|
||||
roles:
|
||||
- { role: username.rolename, x: 42 }
|
||||
## License
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
BSD
|
||||
|
||||
Author Information
|
||||
------------------
|
||||
|
||||
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -1,35 +1,25 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Host baseline — install Docker, required apt packages and convenience tooling on Debian/Ubuntu
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.1
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- base
|
||||
- docker
|
||||
- bootstrap
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
|
|||
|
|
@ -22,9 +22,14 @@ The role asserts these are set; the play fails fast if any is empty:
|
|||
| `bookstack_db_root_password` | MariaDB root password |
|
||||
| `bookstack_db_password` | MariaDB user password |
|
||||
| `bookstack_admin_password` | Initial local admin password |
|
||||
| `bookstack_oidc_client_id` | Entra ID App Registration ID (if OIDC on) |
|
||||
| `bookstack_oidc_client_secret` | Entra ID client secret (if OIDC on) |
|
||||
| `bookstack_entra_tenant_id` | Entra tenant UUID (if OIDC on) |
|
||||
| `bookstack_oidc_client_id` | OIDC client ID (if OIDC on) |
|
||||
| `bookstack_oidc_client_secret` | OIDC client secret (if OIDC on) |
|
||||
|
||||
When OIDC is on, the role also asserts that `bookstack_oidc_issuer`
|
||||
resolves to a concrete URL. For Entra ID this means setting
|
||||
`bookstack_entra_tenant_id` (the default issuer interpolates it; an unset
|
||||
tenant leaves `//v2.0` and fails the assert). For other IdPs (Authentik,
|
||||
Keycloak) set `bookstack_oidc_issuer` directly instead.
|
||||
|
||||
Provide via OpenBao lookup, Ansible Vault or `--extra-vars`. Never commit
|
||||
real secrets.
|
||||
|
|
@ -34,6 +39,10 @@ real secrets.
|
|||
See `defaults/main.yml`. Frequently overridden:
|
||||
|
||||
- `bookstack_domain`, `bookstack_base_url`
|
||||
- `bookstack_extra_domains` (extra Host-rule hostnames, e.g. an internal
|
||||
`*.int.*` FQDN for a DMZ reverseproxy)
|
||||
- `bookstack_extra_hosts` (container `/etc/hosts` overrides for
|
||||
split-horizon IdP access; entries as `host:ip`)
|
||||
- `bookstack_image`, `bookstack_db_image` (pin in production)
|
||||
- `bookstack_oidc_enabled` (set `false` to disable OIDC entirely)
|
||||
- `bookstack_oidc_auto_initiate` (`true` redirects straight to IdP)
|
||||
|
|
@ -142,4 +151,4 @@ Restore procedure:
|
|||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ bookstack_backup_dir: "{{ bookstack_docker_volume_dir }}/backup"
|
|||
|
||||
# Service configuration
|
||||
bookstack_domain: "wiki.local.test"
|
||||
# Additional hostnames the bookstack router answers on (e.g. an internal
|
||||
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
|
||||
# by the cert).
|
||||
bookstack_extra_domains: []
|
||||
# Container-level /etc/hosts overrides — useful in split-horizon setups
|
||||
# where the BookStack container needs to reach an IdP's public FQDN
|
||||
# (used in the OIDC `iss` claim) over the LAN rather than via the DMZ.
|
||||
bookstack_extra_hosts: []
|
||||
bookstack_base_url: "https://{{ bookstack_domain }}"
|
||||
|
||||
# Images — pin via inventory in production
|
||||
|
|
|
|||
|
|
@ -37,6 +37,24 @@ argument_specs:
|
|||
type: str
|
||||
default: wiki.local.test
|
||||
description: Hostname used in the Traefik Host rule.
|
||||
bookstack_extra_domains:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description:
|
||||
- Additional hostnames the Traefik router answers on, OR-combined
|
||||
with C(bookstack_domain). Useful for an internal C(*.int.*) FQDN
|
||||
so a DMZ reverseproxy can reach a backend hostname covered by the
|
||||
cert.
|
||||
bookstack_extra_hosts:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description:
|
||||
- Container-level C(/etc/hosts) overrides (Compose C(extra_hosts)
|
||||
entries, C("host:ip")). Useful in split-horizon setups where the
|
||||
BookStack container must reach an IdP's public FQDN (used in the
|
||||
OIDC C(iss) claim) over the LAN rather than via the DMZ.
|
||||
bookstack_base_url:
|
||||
type: str
|
||||
description: Defaults to C("https://{{ bookstack_domain }}").
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ galaxy_info:
|
|||
author: digitalboard
|
||||
description: Deploy BookStack as a self-contained Docker Compose stack behind Traefik
|
||||
company: digitalboard
|
||||
license: MIT
|
||||
license: MIT-0
|
||||
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@
|
|||
- bookstack_admin_password | length > 0
|
||||
- (not bookstack_oidc_enabled) or (bookstack_oidc_client_id | length > 0)
|
||||
- (not bookstack_oidc_enabled) or (bookstack_oidc_client_secret | length > 0)
|
||||
- (not bookstack_oidc_enabled) or (bookstack_entra_tenant_id | length > 0)
|
||||
# Issuer URL must resolve to something concrete. The Entra default
|
||||
# interpolates bookstack_entra_tenant_id; an unset tenant leaves
|
||||
# "//v2.0" in the URL. Allow non-Entra IdPs (Authentik, Keycloak)
|
||||
# that override bookstack_oidc_issuer directly.
|
||||
- (not bookstack_oidc_enabled) or
|
||||
(bookstack_oidc_issuer | length > 0 and
|
||||
'//v2.0' not in bookstack_oidc_issuer)
|
||||
fail_msg: >-
|
||||
One or more required secrets are unset. Provide them via OpenBao
|
||||
lookup, Ansible Vault or --extra-vars. See README for the full list.
|
||||
|
|
|
|||
|
|
@ -45,13 +45,19 @@ services:
|
|||
networks:
|
||||
- {{ bookstack_traefik_network }}
|
||||
- internal
|
||||
{% if bookstack_extra_hosts | length > 0 %}
|
||||
extra_hosts:
|
||||
{% for host in bookstack_extra_hosts %}
|
||||
- "{{ host }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
depends_on:
|
||||
{{ bookstack_service_name }}-db:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network={{ bookstack_traefik_network }}"
|
||||
- "traefik.http.routers.{{ bookstack_service_name }}.rule=Host(`{{ bookstack_domain }}`)"
|
||||
- "traefik.http.routers.{{ bookstack_service_name }}.rule={% set _all_domains = [bookstack_domain] + (bookstack_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}"
|
||||
- "traefik.http.routers.{{ bookstack_service_name }}.entrypoints=websecure"
|
||||
- "traefik.http.routers.{{ bookstack_service_name }}.tls=true"
|
||||
- "traefik.http.routers.{{ bookstack_service_name }}.tls.certresolver={{ bookstack_traefik_certresolver }}"
|
||||
|
|
|
|||
|
|
@ -1,38 +1,42 @@
|
|||
Role Name
|
||||
=========
|
||||
# collabora
|
||||
|
||||
A brief description of the role goes here.
|
||||
Deploys [Collabora Online](https://www.collaboraonline.com/) (CODE,
|
||||
`collabora/code`) via Docker Compose behind Traefik. Collabora is the
|
||||
WOPI backend that renders office documents for Nextcloud and OpenCloud.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
The role templates `coolwsd.xml` to declare which WOPI hosts may call
|
||||
Collabora and which origins may embed it in an iframe.
|
||||
|
||||
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
|
||||
## Role variables
|
||||
|
||||
Role Variables
|
||||
--------------
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `collabora_domains` | `[office.local.test]` | FQDNs the router accepts; first is canonical. |
|
||||
| `collabora_image` | `collabora/code:latest` | Container image. |
|
||||
| `collabora_port` | `9980` | Container port Traefik forwards to. |
|
||||
| `collabora_traefik_network` | `proxy` | Docker network shared with Traefik. |
|
||||
| `collabora_use_ssl` | `true` | Enable the TLS resolver on the router. |
|
||||
| `collabora_ssl_verification` | `true` | Verify TLS on WOPI callbacks (false for self-signed). |
|
||||
| `collabora_allowed_domains` | `[nextcloud.local.test]` | WOPI hosts allowed to call Collabora (regex). |
|
||||
| `collabora_frame_ancestors` | `[nextcloud.local.test]` | Origins allowed to embed Collabora in an iframe. |
|
||||
| `collabora_extra_hosts` | `[]` | Extra `host:ip` entries for in-container DNS. |
|
||||
|
||||
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
|
||||
## Example
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
```yaml
|
||||
- hosts: services
|
||||
become: true
|
||||
roles:
|
||||
- role: digitalboard.core.collabora
|
||||
vars:
|
||||
collabora_domains:
|
||||
- "office.example.com"
|
||||
collabora_allowed_domains:
|
||||
- "cloud.example.com"
|
||||
collabora_frame_ancestors:
|
||||
- "cloud.example.com"
|
||||
```
|
||||
|
||||
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
|
||||
## License
|
||||
|
||||
Example Playbook
|
||||
----------------
|
||||
|
||||
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
|
||||
|
||||
- hosts: servers
|
||||
roles:
|
||||
- { role: username.rolename, x: 42 }
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
BSD
|
||||
|
||||
Author Information
|
||||
------------------
|
||||
|
||||
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ collabora_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ collabora_servic
|
|||
collabora_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ collabora_service_name }}"
|
||||
|
||||
# Service configuration
|
||||
collabora_domain: "office.local.test"
|
||||
# FQDNs the collabora router accepts. The first entry is the canonical
|
||||
# domain; further entries cover internal *.int.* names used for
|
||||
# server-to-server WOPI discovery.
|
||||
collabora_domains:
|
||||
- "office.local.test"
|
||||
collabora_image: "collabora/code:latest"
|
||||
collabora_port: 9980
|
||||
collabora_extra_hosts: []
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@
|
|||
- name: restart collabora
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ collabora_docker_compose_dir }}"
|
||||
state: restarted
|
||||
state: present
|
||||
|
|
@ -1,35 +1,27 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Deploy Collabora Online (CODE) as a WOPI backend via Docker Compose behind Traefik
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.2
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- collabora
|
||||
- office
|
||||
- wopi
|
||||
- nextcloud
|
||||
- docker
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
|
|||
|
|
@ -20,11 +20,14 @@ services:
|
|||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network={{ collabora_traefik_network }}
|
||||
- traefik.http.routers.{{ collabora_service_name }}.rule=Host(`{{ collabora_domain }}`)
|
||||
- traefik.http.routers.{{ collabora_service_name }}.rule={% for d in collabora_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
|
||||
- traefik.http.services.{{ collabora_service_name }}.loadbalancer.server.port={{ collabora_port }}
|
||||
{% if collabora_use_ssl %}
|
||||
- traefik.http.routers.{{ collabora_service_name }}.entrypoints=websecure
|
||||
- traefik.http.routers.{{ collabora_service_name }}.tls=true
|
||||
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
|
||||
- traefik.http.routers.{{ collabora_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- traefik.http.routers.{{ collabora_service_name }}.entrypoints=web
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,60 @@
|
|||
Role Name
|
||||
=========
|
||||
# Drawio
|
||||
|
||||
A brief description of the role goes here.
|
||||
Ansible role to deploy [draw.io](https://www.drawio.com/) (the
|
||||
self-hosted `jgraph/drawio` container) via Docker Compose behind
|
||||
Traefik, with optional authentik ForwardAuth gating.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
## Requirements
|
||||
|
||||
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
|
||||
- Docker and Docker Compose installed on the target host
|
||||
- Ansible collection: `community.docker`
|
||||
- Traefik with a shared `drawio_traefik_network` (default `proxy`)
|
||||
- For ForwardAuth: a reachable authentik embedded outpost endpoint
|
||||
|
||||
Role Variables
|
||||
--------------
|
||||
## Role variables
|
||||
|
||||
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
|
||||
Full spec with types and defaults: `meta/argument_specs.yml`. The most
|
||||
common overrides:
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
### Service
|
||||
|
||||
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.
|
||||
- `drawio_domain`: canonical hostname used in the traefik Host rule
|
||||
(default `drawio.local.test`).
|
||||
- `drawio_extra_domains`: additional hostnames the same container
|
||||
should answer on (e.g. an internal `*.int.*` FQDN so a DMZ proxy
|
||||
can reach drawio via a backend hostname).
|
||||
- `drawio_image`, `drawio_port`, `drawio_use_ssl`.
|
||||
|
||||
Example Playbook
|
||||
----------------
|
||||
### Authentik ForwardAuth
|
||||
|
||||
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
|
||||
- `drawio_authentik_forward_auth`: set to `true` to gate the editor
|
||||
behind authentik.
|
||||
- `drawio_authentik_forward_auth_url`: full URL of the embedded
|
||||
outpost ForwardAuth endpoint, e.g.
|
||||
`https://auth.example.com/outpost.goauthentik.io/auth/traefik`.
|
||||
|
||||
- hosts: servers
|
||||
roles:
|
||||
- { role: username.rolename, x: 42 }
|
||||
When enabled, traefik redirects unauthenticated requests to authentik
|
||||
for login and forwards the resulting `X-Authentik-*` identity headers
|
||||
downstream.
|
||||
|
||||
License
|
||||
-------
|
||||
## Dependencies
|
||||
|
||||
BSD
|
||||
- Traefik network (`drawio_traefik_network`, default `proxy`)
|
||||
- Optional: authentik with a Proxy/ForwardAuth provider for drawio
|
||||
(see the `authentik` role's `authentik_proxy_apps`).
|
||||
|
||||
Author Information
|
||||
------------------
|
||||
## Example playbook
|
||||
|
||||
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
|
||||
```yaml
|
||||
- hosts: app_servers
|
||||
roles:
|
||||
- role: digitalboard.core.drawio
|
||||
vars:
|
||||
drawio_domain: "drawio.example.com"
|
||||
drawio_authentik_forward_auth: true
|
||||
drawio_authentik_forward_auth_url: "https://auth.example.com/outpost.goauthentik.io/auth/traefik"
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -11,10 +11,21 @@ drawio_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ drawio_service_name
|
|||
|
||||
# Service configuration
|
||||
drawio_domain: "drawio.local.test"
|
||||
# Additional hostnames the same drawio container should answer on
|
||||
# (e.g. an internal *.int.* FQDN so a DMZ reverseproxy can reach
|
||||
# drawio via a backend hostname covered by the local traefik cert).
|
||||
drawio_extra_domains: []
|
||||
drawio_image: "jgraph/drawio:latest"
|
||||
drawio_port: 8080
|
||||
drawio_extra_hosts: []
|
||||
|
||||
# Traefik configuration
|
||||
drawio_traefik_network: "proxy"
|
||||
drawio_use_ssl: true
|
||||
drawio_use_ssl: true
|
||||
|
||||
# Optional Authentik ForwardAuth (set to true and provide the URL to gate
|
||||
# drawio behind an authentik proxy provider). Expects the authentik
|
||||
# embedded outpost to expose the /outpost.goauthentik.io/auth/traefik
|
||||
# endpoint on the configured URL (typically the public auth.* FQDN).
|
||||
drawio_authentik_forward_auth: false
|
||||
drawio_authentik_forward_auth_url: ""
|
||||
|
|
@ -5,4 +5,4 @@
|
|||
- name: restart drawio
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ drawio_docker_compose_dir }}"
|
||||
state: restarted
|
||||
state: present
|
||||
64
roles/drawio/meta/argument_specs.yml
Normal file
64
roles/drawio/meta/argument_specs.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Deploy draw.io diagram editor via Docker Compose behind Traefik.
|
||||
description:
|
||||
- Renders a Compose stack for jgraph/drawio with traefik labels, optional
|
||||
TLS and optional authentik ForwardAuth gating.
|
||||
options:
|
||||
docker_compose_base_dir:
|
||||
type: path
|
||||
default: /etc/docker/compose
|
||||
drawio_service_name:
|
||||
type: str
|
||||
default: drawio
|
||||
drawio_docker_compose_dir:
|
||||
type: path
|
||||
description: Defaults to C({{ docker_compose_base_dir }}/{{ drawio_service_name }}).
|
||||
|
||||
drawio_domain:
|
||||
type: str
|
||||
default: drawio.local.test
|
||||
description: Canonical hostname used in the traefik Host rule.
|
||||
drawio_extra_domains:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description:
|
||||
- Additional hostnames the same drawio container should answer on,
|
||||
e.g. an internal C(*.int.*) FQDN so a DMZ reverse-proxy can reach
|
||||
drawio via a backend hostname covered by the local traefik cert.
|
||||
drawio_image:
|
||||
type: str
|
||||
default: jgraph/drawio:latest
|
||||
drawio_port:
|
||||
type: int
|
||||
default: 8080
|
||||
drawio_extra_hosts:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description: C(extra_hosts) entries injected into the container (Docker C(host:ip) syntax).
|
||||
|
||||
drawio_traefik_network:
|
||||
type: str
|
||||
default: proxy
|
||||
drawio_use_ssl:
|
||||
type: bool
|
||||
default: true
|
||||
|
||||
drawio_authentik_forward_auth:
|
||||
type: bool
|
||||
default: false
|
||||
description:
|
||||
- When true, traefik attaches a ForwardAuth middleware pointing at
|
||||
the authentik embedded outpost. Unauthenticated requests are
|
||||
redirected to authentik for login and the resulting
|
||||
C(X-Authentik-*) identity headers are forwarded downstream.
|
||||
drawio_authentik_forward_auth_url:
|
||||
type: str
|
||||
default: ''
|
||||
description:
|
||||
- URL of the authentik ForwardAuth endpoint, typically
|
||||
C(https://auth.example.com/outpost.goauthentik.io/auth/traefik).
|
||||
Required when C(drawio_authentik_forward_auth=true).
|
||||
|
|
@ -1,35 +1,26 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Deploy the draw.io diagram editor via Docker Compose behind Traefik, with optional authentik ForwardAuth
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.2
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- drawio
|
||||
- diagrams
|
||||
- docker
|
||||
- traefik
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
|
|||
|
|
@ -14,14 +14,26 @@ services:
|
|||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network={{ drawio_traefik_network }}
|
||||
- traefik.http.routers.{{ drawio_service_name }}.rule=Host(`{{ drawio_domain }}`)
|
||||
- traefik.http.routers.{{ drawio_service_name }}.rule={% set _all_domains = [drawio_domain] + (drawio_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
|
||||
- traefik.http.services.{{ drawio_service_name }}.loadbalancer.server.port={{ drawio_port }}
|
||||
{% if drawio_use_ssl %}
|
||||
- traefik.http.routers.{{ drawio_service_name }}.entrypoints=websecure
|
||||
- traefik.http.routers.{{ drawio_service_name }}.tls=true
|
||||
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
|
||||
- traefik.http.routers.{{ drawio_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- traefik.http.routers.{{ drawio_service_name }}.entrypoints=web
|
||||
{% endif %}
|
||||
{% if drawio_authentik_forward_auth | default(false) %}
|
||||
# ForwardAuth via the authentik embedded outpost. Unauthenticated
|
||||
# requests get redirected to authentik to log in; authentik then
|
||||
# sets X-Authentik-* headers traefik forwards downstream.
|
||||
- traefik.http.middlewares.{{ drawio_service_name }}-authentik.forwardauth.address={{ drawio_authentik_forward_auth_url }}
|
||||
- traefik.http.middlewares.{{ drawio_service_name }}-authentik.forwardauth.trustForwardHeader=true
|
||||
- traefik.http.middlewares.{{ drawio_service_name }}-authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version
|
||||
- traefik.http.routers.{{ drawio_service_name }}.middlewares={{ drawio_service_name }}-authentik
|
||||
{% endif %}
|
||||
|
||||
networks:
|
||||
{{ drawio_traefik_network }}:
|
||||
|
|
|
|||
|
|
@ -1,113 +1,118 @@
|
|||
Garage
|
||||
======
|
||||
# Garage
|
||||
|
||||
Ansible role to deploy Garage S3-compatible object storage using Docker Compose.
|
||||
Ansible role to deploy [Garage](https://garagehq.deuxfleurs.fr/) S3-compatible
|
||||
object storage via Docker Compose, with declarative key/bucket
|
||||
provisioning and an optional WebUI behind htpasswd or authentik
|
||||
ForwardAuth.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
## Requirements
|
||||
|
||||
- Docker and Docker Compose installed on the target host
|
||||
- Ansible collection: `community.docker`
|
||||
- Traefik reverse proxy (for external access)
|
||||
- `htpasswd` (from `apache2-utils` / `httpd-tools`) when the WebUI is
|
||||
enabled and authentik ForwardAuth is *not* used
|
||||
- Traefik with a shared `garage_traefik_network` (default `proxy`)
|
||||
|
||||
Role Variables
|
||||
--------------
|
||||
## Role variables
|
||||
|
||||
Key variables defined in `defaults/main.yml`:
|
||||
Full spec with types and defaults: `meta/argument_specs.yml`. The most
|
||||
common overrides:
|
||||
|
||||
**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`)
|
||||
### Service
|
||||
|
||||
**Garage Configuration:**
|
||||
- `garage_service_name`: Service name (default: `garage`)
|
||||
- `garage_image`: Garage Docker image (default: `dxflrs/garage:v2.1.0`)
|
||||
- `garage_s3_domain`: Domain for S3 API endpoint (default: `storage.local.test`)
|
||||
- `garage_web_domain`: Domain for S3 web endpoint (default: `web.storage.local.test`)
|
||||
- `garage_webui_domain`: Domain for web console (default: `console.storage.local.test`)
|
||||
- `garage_s3_domains`: FQDNs the S3 router accepts. The first entry is the
|
||||
canonical hostname; `garage.toml` derives the virtual-hosted-style S3
|
||||
`root_domain` from it as `.s3.<first-entry>` (so buckets resolve under
|
||||
`<bucket>.s3.<first-entry>`).
|
||||
- `garage_web_domain`, `garage_webui_domain`: separate hostnames for
|
||||
the S3-website endpoint and the console.
|
||||
- `garage_image`, `garage_replication_factor`, `garage_db_engine`,
|
||||
`garage_s3_region`.
|
||||
|
||||
**Garage Storage Configuration:**
|
||||
- `garage_replication_factor`: Replication factor (default: `1`)
|
||||
- `garage_compression_level`: Compression level (default: `1`)
|
||||
- `garage_db_engine`: Database engine (default: `lmdb`)
|
||||
- `garage_s3_region`: S3 region (default: `us-east-1`)
|
||||
### Required secrets
|
||||
|
||||
**Garage Ports:**
|
||||
- `garage_s3_api_port`: S3 API port (default: `3900`)
|
||||
- `garage_s3_web_port`: S3 web port (default: `3902`)
|
||||
- `garage_admin_port`: Admin API port (default: `3903`)
|
||||
- `garage_rpc_port`: RPC port (default: `3901`)
|
||||
Generate with `openssl rand -hex 32` (32 bytes / 64 hex chars):
|
||||
|
||||
**Garage Security:**
|
||||
- `garage_rpc_secret`: RPC secret for node communication
|
||||
- `garage_admin_token`: Admin API token
|
||||
- `garage_metrics_token`: Metrics API token
|
||||
- `garage_rpc_secret`: node-to-node RPC secret
|
||||
- `garage_admin_token`: admin API token
|
||||
- `garage_metrics_token`: metrics endpoint token
|
||||
|
||||
**Garage WebUI Configuration:**
|
||||
- `garage_webui_enabled`: Enable web UI (default: `true`)
|
||||
- `garage_webui_image`: WebUI Docker image (default: `khairul169/garage-webui:latest`)
|
||||
- `garage_webui_port`: WebUI port (default: `3909`)
|
||||
- `garage_webui_username`: WebUI username (default: `admin`)
|
||||
- `garage_webui_password`: WebUI password in plaintext (default: `admin`)
|
||||
### WebUI authentication
|
||||
|
||||
**Traefik Configuration:**
|
||||
- `garage_traefik_network`: Traefik network name (default: `proxy`)
|
||||
- `garage_internal_network`: Internal network name (default: `internal`)
|
||||
- `garage_use_ssl`: Enable SSL (default: `true`)
|
||||
Three modes:
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
1. **htpasswd** (default): `garage_webui_username` / `garage_webui_password`
|
||||
in plaintext. The role hashes the password with
|
||||
`htpasswd -nbBC 10`, persists the hash on disk, and re-verifies with
|
||||
`htpasswd -vbB` so unchanged passwords don't churn the play.
|
||||
2. **authentik ForwardAuth**: set
|
||||
`garage_webui_authentik_forward_auth: true` and
|
||||
`garage_webui_authentik_forward_auth_url:
|
||||
"https://auth.example.com/outpost.goauthentik.io/auth/traefik"`.
|
||||
`AUTH_USER_PASS` is dropped from the container env so authentik is
|
||||
the only gate.
|
||||
3. **Disabled**: `garage_webui_enabled: false`.
|
||||
|
||||
This role requires:
|
||||
- Traefik reverse proxy to be configured and the `proxy` network to be created
|
||||
- `htpasswd` utility (from `apache2-utils` package) for generating bcrypt password hashes
|
||||
### Layout bootstrap
|
||||
|
||||
Example Playbook
|
||||
----------------
|
||||
Setting `garage_bootstrap_enabled: true` runs the bootstrap task, which
|
||||
joins the local node to the layout (`zone: garage_bootstrap_zone`,
|
||||
capacity: `garage_bootstrap_capacity`) on the first run. The check
|
||||
tolerates the 16-char truncation that `garage layout show` performs.
|
||||
|
||||
### Declarative S3 keys and buckets
|
||||
|
||||
```yaml
|
||||
garage_s3_keys:
|
||||
- name: nextcloud
|
||||
buckets:
|
||||
- name: nextcloud-data
|
||||
permissions: [read, write]
|
||||
- name: backup
|
||||
buckets:
|
||||
- name: restic-prod
|
||||
permissions: [read, write, owner]
|
||||
```
|
||||
|
||||
The role:
|
||||
|
||||
- Lists existing keys (`garage key list`), creates missing ones
|
||||
- Lists existing buckets (`garage bucket list`), creates missing ones
|
||||
- Reads current permissions via `garage bucket info` and runs
|
||||
`garage bucket allow` only when the current RWO flags for the key
|
||||
don't already match the desired permissions
|
||||
|
||||
`stdout` parsing is hardened against ANSI escapes and interleaved INFO
|
||||
log lines, so probe noise no longer produces spurious changes.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Traefik network (`garage_traefik_network`, default `proxy`)
|
||||
- Internal network (`garage_internal_network`, default `internal`)
|
||||
|
||||
## Example playbook
|
||||
|
||||
```yaml
|
||||
- hosts: storage_servers
|
||||
roles:
|
||||
- role: garage
|
||||
- role: digitalboard.core.garage
|
||||
vars:
|
||||
garage_s3_domain: "storage.example.com"
|
||||
garage_rpc_secret: "your-secure-rpc-secret"
|
||||
garage_admin_token: "your-admin-token"
|
||||
garage_webui_enabled: true
|
||||
garage_webui_username: "admin"
|
||||
garage_webui_password: "secure-password"
|
||||
garage_s3_domains:
|
||||
- "storage.example.com"
|
||||
- "storage.int.example.com"
|
||||
garage_rpc_secret: "{{ vault_garage_rpc_secret }}"
|
||||
garage_admin_token: "{{ vault_garage_admin_token }}"
|
||||
garage_metrics_token: "{{ vault_garage_metrics_token }}"
|
||||
garage_bootstrap_enabled: true
|
||||
garage_webui_authentik_forward_auth: true
|
||||
garage_webui_authentik_forward_auth_url: "https://auth.example.com/outpost.goauthentik.io/auth/traefik"
|
||||
garage_s3_keys:
|
||||
- name: nextcloud
|
||||
buckets:
|
||||
- name: nextcloud-data
|
||||
permissions: [read, write]
|
||||
```
|
||||
|
||||
**Note:** The WebUI password is specified in plaintext and will be automatically hashed using bcrypt during deployment. The role uses `htpasswd` to generate a secure bcrypt hash that is then properly escaped for use in Docker Compose.
|
||||
## License
|
||||
|
||||
Post-Installation
|
||||
-----------------
|
||||
|
||||
After deployment, you need to configure the Garage cluster:
|
||||
|
||||
1. Connect to the node and get the node ID:
|
||||
```bash
|
||||
docker exec -ti garage /garage node id
|
||||
```
|
||||
|
||||
2. Configure the node layout:
|
||||
```bash
|
||||
docker exec -ti garage /garage layout assign -z dc1 -c 1G <node-id>
|
||||
docker exec -ti garage /garage layout apply --version 1
|
||||
```
|
||||
|
||||
3. Create a key for S3 access:
|
||||
```bash
|
||||
docker exec -ti garage /garage key create my-key
|
||||
```
|
||||
|
||||
4. Create a bucket:
|
||||
```bash
|
||||
docker exec -ti garage /garage bucket create my-bucket
|
||||
docker exec -ti garage /garage bucket allow my-bucket --read --write --key my-key
|
||||
```
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
MIT-0
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ garage_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ garage_service_name }
|
|||
|
||||
# Garage service configuration
|
||||
garage_image: "dxflrs/garage:v2.1.0"
|
||||
garage_s3_domain: "storage.local.test"
|
||||
# FQDNs the garage S3 router accepts. The first entry is the canonical
|
||||
# domain; garage.toml derives the virtual-hosted-style S3 root_domain
|
||||
# from it as ".s3.<first-entry>"; further entries cover internal
|
||||
# *.int.* names.
|
||||
garage_s3_domains:
|
||||
- "storage.local.test"
|
||||
garage_web_domain: "web.storage.local.test"
|
||||
garage_webui_domain: "console.storage.local.test"
|
||||
|
||||
|
|
@ -21,10 +26,20 @@ garage_webui_domain: "console.storage.local.test"
|
|||
garage_webui_enabled: true
|
||||
garage_webui_image: "khairul169/garage-webui:latest"
|
||||
garage_webui_port: 3909
|
||||
# WebUI basic auth credentials (plaintext, will be hashed automatically)
|
||||
# WebUI basic auth credentials (plaintext, will be hashed automatically).
|
||||
# Ignored when garage_webui_authentik_forward_auth is true — in that case
|
||||
# authentik handles authentication via the ForwardAuth middleware below.
|
||||
garage_webui_username: "admin"
|
||||
garage_webui_password: "admin"
|
||||
|
||||
# Optional Authentik ForwardAuth in front of the WebUI. When true:
|
||||
# - the AUTH_USER_PASS env-var is dropped from the container so htpasswd
|
||||
# isn't enforced; authentik is the only gate.
|
||||
# - traefik attaches a ForwardAuth middleware pointing at the URL below.
|
||||
# Leave false to keep classic htpasswd protection.
|
||||
garage_webui_authentik_forward_auth: false
|
||||
garage_webui_authentik_forward_auth_url: ""
|
||||
|
||||
# Garage ports
|
||||
garage_s3_api_port: 3900
|
||||
garage_s3_web_port: 3902
|
||||
|
|
|
|||
169
roles/garage/meta/argument_specs.yml
Normal file
169
roles/garage/meta/argument_specs.yml
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Deploy Garage S3-compatible object storage via Docker Compose.
|
||||
description:
|
||||
- Renders a Compose stack for Garage with traefik labels, configures the
|
||||
node layout on first run, and (optionally) provisions S3 keys, buckets
|
||||
and per-key permissions declaratively.
|
||||
- The optional WebUI can be protected by classic htpasswd or by
|
||||
authentik ForwardAuth.
|
||||
options:
|
||||
docker_compose_base_dir:
|
||||
type: path
|
||||
default: /etc/docker/compose
|
||||
docker_volume_base_dir:
|
||||
type: path
|
||||
default: /srv/data
|
||||
garage_service_name:
|
||||
type: str
|
||||
default: garage
|
||||
garage_docker_compose_dir:
|
||||
type: path
|
||||
description: Defaults to C({{ docker_compose_base_dir }}/{{ garage_service_name }}).
|
||||
garage_docker_volume_dir:
|
||||
type: path
|
||||
description: Defaults to C({{ docker_volume_base_dir }}/{{ garage_service_name }}).
|
||||
|
||||
garage_image:
|
||||
type: str
|
||||
default: dxflrs/garage:v2.1.0
|
||||
|
||||
garage_s3_domains:
|
||||
type: list
|
||||
elements: str
|
||||
default: ['storage.local.test']
|
||||
description:
|
||||
- FQDNs the garage S3 router accepts. The first entry is the
|
||||
canonical domain; C(garage.toml) derives the virtual-hosted-style
|
||||
S3 C(root_domain) from it as C(.s3.<first-entry>). Further entries
|
||||
cover internal C(*.int.*) names.
|
||||
garage_web_domain:
|
||||
type: str
|
||||
default: web.storage.local.test
|
||||
description: Hostname serving the S3-website endpoint.
|
||||
garage_webui_domain:
|
||||
type: str
|
||||
default: console.storage.local.test
|
||||
description: Hostname serving the WebUI console.
|
||||
|
||||
garage_webui_enabled:
|
||||
type: bool
|
||||
default: true
|
||||
garage_webui_image:
|
||||
type: str
|
||||
default: khairul169/garage-webui:latest
|
||||
garage_webui_port:
|
||||
type: int
|
||||
default: 3909
|
||||
garage_webui_username:
|
||||
type: str
|
||||
default: admin
|
||||
description: htpasswd username. Ignored when C(garage_webui_authentik_forward_auth=true).
|
||||
garage_webui_password:
|
||||
type: str
|
||||
default: admin
|
||||
description:
|
||||
- Plaintext password; hashed with C(htpasswd -nbBC 10) and persisted
|
||||
on disk so re-runs don't churn. Ignored when authentik ForwardAuth
|
||||
is enabled.
|
||||
garage_webui_authentik_forward_auth:
|
||||
type: bool
|
||||
default: false
|
||||
description:
|
||||
- When true the C(AUTH_USER_PASS) env-var is dropped from the WebUI
|
||||
container and traefik attaches a ForwardAuth middleware pointing
|
||||
at the URL below. authentik is then the only gate; htpasswd is
|
||||
disabled.
|
||||
garage_webui_authentik_forward_auth_url:
|
||||
type: str
|
||||
default: ''
|
||||
description:
|
||||
- Required when C(garage_webui_authentik_forward_auth=true).
|
||||
Typically C(https://auth.example.com/outpost.goauthentik.io/auth/traefik).
|
||||
|
||||
garage_s3_api_port:
|
||||
type: int
|
||||
default: 3900
|
||||
garage_s3_web_port:
|
||||
type: int
|
||||
default: 3902
|
||||
garage_admin_port:
|
||||
type: int
|
||||
default: 3903
|
||||
garage_rpc_port:
|
||||
type: int
|
||||
default: 3901
|
||||
|
||||
garage_replication_factor:
|
||||
type: int
|
||||
default: 1
|
||||
garage_compression_level:
|
||||
type: int
|
||||
default: 1
|
||||
garage_db_engine:
|
||||
type: str
|
||||
choices: [lmdb, sqlite, sled]
|
||||
default: lmdb
|
||||
garage_s3_region:
|
||||
type: str
|
||||
default: us-east-1
|
||||
garage_rpc_secret:
|
||||
type: str
|
||||
required: true
|
||||
description: Hex secret for node-to-node RPC. Generate with C(openssl rand -hex 32).
|
||||
garage_admin_token:
|
||||
type: str
|
||||
required: true
|
||||
garage_metrics_token:
|
||||
type: str
|
||||
required: true
|
||||
|
||||
garage_traefik_network:
|
||||
type: str
|
||||
default: proxy
|
||||
garage_internal_network:
|
||||
type: str
|
||||
default: internal
|
||||
garage_use_ssl:
|
||||
type: bool
|
||||
default: true
|
||||
|
||||
garage_bootstrap_enabled:
|
||||
type: bool
|
||||
default: false
|
||||
description: When true the bootstrap task ensures the node is in the layout.
|
||||
garage_bootstrap_zone:
|
||||
type: str
|
||||
default: dc1
|
||||
description: Zone label assigned during layout bootstrap.
|
||||
garage_bootstrap_capacity:
|
||||
type: str
|
||||
default: 1G
|
||||
description: Capacity string passed to C(garage layout assign -c).
|
||||
|
||||
garage_s3_keys:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
description:
|
||||
- Declarative key + bucket + permission provisioning. The role
|
||||
creates missing keys, missing buckets, and runs C(bucket allow)
|
||||
only when the current RWO flags for a given key don't match.
|
||||
options:
|
||||
name:
|
||||
type: str
|
||||
required: true
|
||||
buckets:
|
||||
type: list
|
||||
elements: dict
|
||||
description: Buckets this key gets access to.
|
||||
options:
|
||||
name:
|
||||
type: str
|
||||
required: true
|
||||
permissions:
|
||||
type: list
|
||||
elements: str
|
||||
choices: [read, write, owner]
|
||||
required: true
|
||||
|
|
@ -1,35 +1,27 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Deploy Garage S3-compatible object storage via Docker Compose, with declarative key/bucket provisioning
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.1
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- garage
|
||||
- s3
|
||||
- storage
|
||||
- object-storage
|
||||
- docker
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
|
|||
|
|
@ -7,21 +7,27 @@
|
|||
container: "{{ garage_service_name }}"
|
||||
command: /garage node id -q
|
||||
register: _garage_node_id
|
||||
changed_when: false
|
||||
|
||||
- name: Extract short node ID
|
||||
ansible.builtin.set_fact:
|
||||
_garage_node_id_short: "{{ _garage_node_id.stdout.split('@')[0] }}"
|
||||
|
||||
- name: Extract truncated node ID (first 16 chars, matches `garage layout show` output)
|
||||
ansible.builtin.set_fact:
|
||||
_garage_node_id_truncated: "{{ _garage_node_id_short[:16] }}"
|
||||
|
||||
- name: Check if node layout is configured
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ garage_service_name }}"
|
||||
command: /garage layout show
|
||||
register: _garage_layout_show
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Check if node is in layout
|
||||
ansible.builtin.set_fact:
|
||||
_node_in_layout: "{{ _garage_node_id_short in _garage_layout_show.stdout }}"
|
||||
_node_in_layout: "{{ (_garage_node_id_truncated in _garage_layout_show.stdout) or (_garage_node_id_short in _garage_layout_show.stdout) }}"
|
||||
|
||||
- name: Configure garage node layout
|
||||
community.docker.docker_container_exec:
|
||||
|
|
|
|||
|
|
@ -26,12 +26,77 @@
|
|||
dest: "{{ garage_docker_compose_dir }}/garage.toml"
|
||||
mode: '0644'
|
||||
|
||||
- name: Generate bcrypt hash for webui password using htpasswd
|
||||
ansible.builtin.shell: |
|
||||
htpasswd -nbBC 10 "{{ garage_webui_username }}" "{{ garage_webui_password }}"
|
||||
register: _garage_webui_password_hash
|
||||
- name: Set webui htpasswd activation fact
|
||||
ansible.builtin.set_fact:
|
||||
# htpasswd only runs when the WebUI is enabled AND authentik ForwardAuth
|
||||
# is not handling authentication. When authentik is in front, the
|
||||
# compose template drops AUTH_USER_PASS so no hash is needed.
|
||||
_garage_webui_htpasswd_active: >-
|
||||
{{
|
||||
garage_webui_enabled
|
||||
and not (garage_webui_authentik_forward_auth | default(false))
|
||||
}}
|
||||
|
||||
- name: Read cached webui htpasswd hash
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ garage_docker_compose_dir }}/webui.htpasswd"
|
||||
register: _garage_webui_htpasswd_cached
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
when: garage_webui_enabled
|
||||
when: _garage_webui_htpasswd_active
|
||||
|
||||
- name: Verify cached webui htpasswd hash still matches password
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- htpasswd
|
||||
- -vbB
|
||||
- "{{ garage_docker_compose_dir }}/webui.htpasswd"
|
||||
- "{{ garage_webui_username }}"
|
||||
- "{{ garage_webui_password }}"
|
||||
register: _garage_webui_htpasswd_verify
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
no_log: true
|
||||
when:
|
||||
- _garage_webui_htpasswd_active
|
||||
- _garage_webui_htpasswd_cached.content is defined
|
||||
|
||||
- name: Generate bcrypt hash for webui password using htpasswd
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- htpasswd
|
||||
- -nbBC
|
||||
- "10"
|
||||
- "{{ garage_webui_username }}"
|
||||
- "{{ garage_webui_password }}"
|
||||
register: _garage_webui_password_hash_new
|
||||
changed_when: true
|
||||
when:
|
||||
- _garage_webui_htpasswd_active
|
||||
- (_garage_webui_htpasswd_cached.content is not defined)
|
||||
or (_garage_webui_htpasswd_verify.rc | default(1) != 0)
|
||||
|
||||
- name: Persist webui htpasswd hash on disk
|
||||
ansible.builtin.copy:
|
||||
content: "{{ _garage_webui_password_hash_new.stdout }}\n"
|
||||
dest: "{{ garage_docker_compose_dir }}/webui.htpasswd"
|
||||
mode: '0600'
|
||||
when:
|
||||
- _garage_webui_htpasswd_active
|
||||
- _garage_webui_password_hash_new is changed
|
||||
|
||||
- name: Load current webui htpasswd hash
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ garage_docker_compose_dir }}/webui.htpasswd"
|
||||
register: _garage_webui_htpasswd_current
|
||||
changed_when: false
|
||||
when: _garage_webui_htpasswd_active
|
||||
|
||||
- name: Expose current webui htpasswd hash to template
|
||||
ansible.builtin.set_fact:
|
||||
_garage_webui_password_hash:
|
||||
stdout: "{{ (_garage_webui_htpasswd_current.content | b64decode).strip() }}"
|
||||
when: _garage_webui_htpasswd_active
|
||||
|
||||
- name: Create docker-compose file for garage
|
||||
template:
|
||||
|
|
|
|||
|
|
@ -4,11 +4,17 @@
|
|||
container: "{{ garage_service_name }}"
|
||||
command: /garage key list
|
||||
register: _existing_keys_output
|
||||
changed_when: false
|
||||
when: garage_s3_keys | length > 0
|
||||
|
||||
- name: Parse existing key names
|
||||
ansible.builtin.set_fact:
|
||||
_existing_keys: "{{ _existing_keys_output.stdout_lines[1:] | select('match', '^GK') | map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+)\\s+.*$', '\\1') | list }}"
|
||||
# `garage key list` columns: ID Created Name Expiration.
|
||||
# Data rows begin with a GK<hex> key ID; header is "ID Created ..."
|
||||
# and INFO log lines may interleave on stderr (kept separate by
|
||||
# docker_container_exec). Strip ANSI escapes defensively, filter to
|
||||
# GK-prefixed rows, then take the 3rd whitespace-separated field.
|
||||
_existing_keys: "{{ _existing_keys_output.stdout_lines | map('regex_replace', '\\x1b\\[[0-9;]*m', '') | select('match', '^GK[0-9a-fA-F]+') | map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+).*$', '\\1') | list }}"
|
||||
when: garage_s3_keys | length > 0
|
||||
|
||||
- name: Create S3 keys
|
||||
|
|
@ -27,6 +33,7 @@
|
|||
command: /garage key info {{ item.name }}
|
||||
loop: "{{ garage_s3_keys }}"
|
||||
register: _key_info_results
|
||||
changed_when: false
|
||||
when: garage_s3_keys | length > 0
|
||||
|
||||
- name: Extract key IDs from info
|
||||
|
|
@ -42,11 +49,21 @@
|
|||
container: "{{ garage_service_name }}"
|
||||
command: /garage bucket list
|
||||
register: _existing_buckets_output
|
||||
changed_when: false
|
||||
when: garage_s3_keys | length > 0
|
||||
|
||||
- name: Parse existing bucket names
|
||||
ansible.builtin.set_fact:
|
||||
_existing_buckets: "{{ _existing_buckets_output.stdout_lines[2:] | map('split') | map('first') | list }}"
|
||||
# `garage bucket list` columns: ID Created Global aliases Local aliases
|
||||
# Data rows start with a hex bucket ID; filter to those and take the
|
||||
# third whitespace-separated field (the global alias = bucket name).
|
||||
_existing_buckets: >-
|
||||
{{
|
||||
_existing_buckets_output.stdout_lines
|
||||
| select('match', '^[0-9a-f]{16}\\s')
|
||||
| map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+).*$', '\\1')
|
||||
| list
|
||||
}}
|
||||
when: garage_s3_keys | length > 0
|
||||
|
||||
- name: Get unique bucket names
|
||||
|
|
@ -64,12 +81,37 @@
|
|||
- item not in _existing_buckets
|
||||
failed_when: false
|
||||
|
||||
- name: Get current bucket permissions
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ garage_service_name }}"
|
||||
command: /garage bucket info {{ item.1.name }}
|
||||
loop: "{{ garage_s3_keys | subelements('buckets', skip_missing=True) }}"
|
||||
loop_control:
|
||||
label: "{{ item.1.name }}"
|
||||
register: _bucket_info_results
|
||||
changed_when: false
|
||||
when: garage_s3_keys | length > 0
|
||||
|
||||
- name: Set bucket permissions using key IDs
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ garage_service_name }}"
|
||||
command: /garage bucket allow {{ item.1.name }} {% for perm in item.1.permissions %}--{{ perm }} {% endfor %}--key {{ _key_id_map[item.0.name] }}
|
||||
loop: "{{ garage_s3_keys | subelements('buckets', skip_missing=True) }}"
|
||||
when: garage_s3_keys | length > 0
|
||||
command: /garage bucket allow {{ item.item.1.name }} {% for perm in item.item.1.permissions %}--{{ perm }} {% endfor %}--key {{ _key_id_map[item.item.0.name] }}
|
||||
loop: "{{ _bucket_info_results.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item.1.name }} -> {{ item.item.0.name }}"
|
||||
when:
|
||||
- garage_s3_keys | length > 0
|
||||
- >-
|
||||
(item.stdout | regex_search(
|
||||
'(?m)^\s*' ~ _wanted_flags ~ '\s+' ~ _key_id_map[item.item.0.name]
|
||||
)) is none
|
||||
vars:
|
||||
_wanted_flags: >-
|
||||
{{
|
||||
('R' if 'read' in item.item.1.permissions else '-')
|
||||
~ ('W' if 'write' in item.item.1.permissions else '-')
|
||||
~ ('O' if 'owner' in item.item.1.permissions else '-')
|
||||
}}
|
||||
|
||||
# Export key credentials for use by other roles
|
||||
- name: Get detailed key information for all keys
|
||||
|
|
@ -78,6 +120,7 @@
|
|||
command: /garage key info {{ item.name }} --show-secret
|
||||
loop: "{{ garage_s3_keys }}"
|
||||
register: _key_details_results
|
||||
changed_when: false
|
||||
when: garage_s3_keys | length > 0
|
||||
|
||||
- name: Build garage S3 credentials map
|
||||
|
|
|
|||
|
|
@ -14,10 +14,13 @@ services:
|
|||
- traefik.enable=true
|
||||
- traefik.docker.network={{ garage_traefik_network }}
|
||||
# S3 API endpoint
|
||||
- traefik.http.routers.{{ garage_service_name }}.rule=Host(`{{ garage_s3_domain }}`)
|
||||
- traefik.http.routers.{{ garage_service_name }}.rule={% for d in garage_s3_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
|
||||
{% if garage_use_ssl %}
|
||||
- traefik.http.routers.{{ garage_service_name }}.entrypoints=websecure
|
||||
- traefik.http.routers.{{ garage_service_name }}.tls=true
|
||||
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
|
||||
- traefik.http.routers.{{ garage_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- traefik.http.routers.{{ garage_service_name }}.entrypoints=web
|
||||
{% endif %}
|
||||
|
|
@ -35,7 +38,9 @@ services:
|
|||
environment:
|
||||
API_BASE_URL: "http://{{ garage_service_name }}:{{ garage_admin_port }}"
|
||||
S3_ENDPOINT_URL: "http://{{ garage_service_name }}:{{ garage_s3_api_port }}"
|
||||
{% if not (garage_webui_authentik_forward_auth | default(false)) %}
|
||||
AUTH_USER_PASS: '{{ _garage_webui_password_hash.stdout | replace("$", "$$") }}'
|
||||
{% endif %}
|
||||
volumes:
|
||||
- {{ garage_docker_compose_dir }}/garage.toml:/etc/garage.toml:ro
|
||||
networks:
|
||||
|
|
@ -48,12 +53,25 @@ services:
|
|||
{% if garage_use_ssl %}
|
||||
- traefik.http.routers.{{ garage_service_name }}-console.entrypoints=websecure
|
||||
- traefik.http.routers.{{ garage_service_name }}-console.tls=true
|
||||
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
|
||||
- traefik.http.routers.{{ garage_service_name }}-console.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- traefik.http.routers.{{ garage_service_name }}-console.entrypoints=web
|
||||
{% endif %}
|
||||
- traefik.http.routers.{{ garage_service_name }}-console.service={{ garage_service_name }}-console
|
||||
- traefik.http.routers.{{ garage_service_name }}-console.priority=10
|
||||
- traefik.http.services.{{ garage_service_name }}-console.loadbalancer.server.port={{ garage_webui_port }}
|
||||
{% if garage_webui_authentik_forward_auth | default(false) %}
|
||||
# ForwardAuth via the authentik embedded outpost. Unauthenticated
|
||||
# requests are redirected to authentik; authentik then forwards
|
||||
# X-Authentik-* identity headers downstream. htpasswd is disabled
|
||||
# in the env block above so authentik is the only gate.
|
||||
- traefik.http.middlewares.{{ garage_service_name }}-console-authentik.forwardauth.address={{ garage_webui_authentik_forward_auth_url }}
|
||||
- traefik.http.middlewares.{{ garage_service_name }}-console-authentik.forwardauth.trustForwardHeader=true
|
||||
- traefik.http.middlewares.{{ garage_service_name }}-console-authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version
|
||||
- traefik.http.routers.{{ garage_service_name }}-console.middlewares={{ garage_service_name }}-console-authentik
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ rpc_secret = "{{ garage_rpc_secret }}"
|
|||
[s3_api]
|
||||
s3_region = "{{ garage_s3_region }}"
|
||||
api_bind_addr = "[::]:{{ garage_s3_api_port }}"
|
||||
root_domain = ".s3.{{ garage_s3_domain }}"
|
||||
root_domain = ".s3.{{ garage_s3_domains[0] }}"
|
||||
|
||||
[s3_web]
|
||||
bind_addr = "[::]:{{ garage_s3_web_port }}"
|
||||
|
|
|
|||
|
|
@ -36,8 +36,10 @@ secrets to version control.**
|
|||
| `homarr_admin_password` | strong password | `openssl rand -base64 24` |
|
||||
| `homarr_oidc_client_secret` | from your identity provider | — |
|
||||
|
||||
The `assert` task at the top of the role will fail fast if the encryption
|
||||
key is missing or malformed.
|
||||
`homarr_oidc_client_secret` is only required when `oidc` is in
|
||||
`homarr_auth_providers`; the role asserts it then. The encryption key is
|
||||
always required — the `assert` task at the top of the role fails fast if it
|
||||
is missing or malformed.
|
||||
|
||||
## Configurable variables
|
||||
|
||||
|
|
@ -46,6 +48,8 @@ See `defaults/main.yml` for the full list. Most useful overrides:
|
|||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `homarr_domain` | `homarr.local.test` | Traefik Host rule |
|
||||
| `homarr_extra_domains` | `[]` | Extra Host-rule hostnames (OR-combined), e.g. internal `*.int.*` FQDN |
|
||||
| `homarr_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_auth_providers` | `credentials` | `credentials`, `oidc`, or both |
|
||||
| `homarr_oidc_issuer` | empty | Identity provider issuer URL |
|
||||
|
|
@ -112,7 +116,7 @@ The filter is invoked once from `tasks/main.yml`:
|
|||
```yaml
|
||||
- name: Compute Homarr app layouts
|
||||
ansible.builtin.set_fact:
|
||||
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
|
||||
homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}"
|
||||
```
|
||||
|
||||
This produces a `homarr_layout` fact with two keys, both consumed by
|
||||
|
|
@ -120,14 +124,14 @@ This produces a `homarr_layout` fact with two keys, both consumed by
|
|||
|
||||
| Key | Shape | Purpose |
|
||||
|---|---|---|
|
||||
| `apps` | list, same order as `homarr_apps` | each entry enriched with `desktop`, `tablet`, `mobile` sub-dicts of `{x, y, w, h}` |
|
||||
| `apps` | list, same order as `homarr_apps` | each entry gains `desktop`/`tablet`/`mobile` dicts of `{x, y, w, h}` |
|
||||
| `section_height` | dict with `desktop`, `tablet`, `mobile` | minimum height of the parent section so all tiles fit |
|
||||
|
||||
The filter signature accepts custom column counts if Homarr ever
|
||||
changes the breakpoint widths:
|
||||
|
||||
```jinja
|
||||
{{ homarr_apps | homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }}
|
||||
{{ homarr_apps | digitalboard.core.homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }}
|
||||
```
|
||||
|
||||
To debug a layout without running the full deploy, run the play with
|
||||
|
|
@ -240,4 +244,8 @@ and lowercase are accepted.
|
|||
|
||||
**App tiles overlap.** Check `homarr_apps` for duplicate `id` values.
|
||||
The role validates this, but if you bypass the check, the seed will
|
||||
still run and Homarr will display only one of the duplicates.
|
||||
still run and Homarr will display only one of the duplicates.
|
||||
|
||||
## License
|
||||
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@ homarr_db: "{{ homarr_appdata_dir }}/db/db.sqlite"
|
|||
|
||||
# Service configuration
|
||||
homarr_domain: "homarr.local.test"
|
||||
# Additional hostnames the homarr router answers on (e.g. an internal
|
||||
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
|
||||
# by the cert).
|
||||
homarr_extra_domains: []
|
||||
# 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_port: 7575
|
||||
homarr_use_docker: false
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@
|
|||
- name: restart homarr
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ homarr_docker_compose_dir }}"
|
||||
state: restarted
|
||||
state: present
|
||||
|
|
@ -1,35 +1,27 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Deploy the Homarr dashboard via Docker Compose behind Traefik, with seeded admin user and OIDC group
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.2
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- homarr
|
||||
- dashboard
|
||||
- oidc
|
||||
- docker
|
||||
- traefik
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
@ -112,19 +112,17 @@
|
|||
# =====================================================================
|
||||
|
||||
- name: Generate bcrypt hash for admin password
|
||||
ansible.builtin.shell:
|
||||
cmd: python3 -c "import bcrypt, sys; print(bcrypt.hashpw(sys.stdin.read().encode(), bcrypt.gensalt(rounds=10)).decode())"
|
||||
stdin: "{{ homarr_admin_password }}"
|
||||
stdin_add_newline: false
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
register: bcrypt_result
|
||||
changed_when: false
|
||||
no_log: true
|
||||
|
||||
- name: Set bcrypt hash fact
|
||||
ansible.builtin.set_fact:
|
||||
homarr_bcrypt_hash: "{{ bcrypt_result.stdout }}"
|
||||
# Deterministic salt derived from the password's SHA-256 digest so the
|
||||
# hash stays stable across runs (idempotent — no spurious template
|
||||
# changes / container restarts when the password is unchanged). The
|
||||
# bcrypt salt alphabet is [./A-Za-z0-9]; the digest's hex chars are
|
||||
# a strict subset, so we just take the first 22.
|
||||
homarr_bcrypt_hash: >-
|
||||
{{ homarr_admin_password
|
||||
| password_hash('bcrypt', rounds=10,
|
||||
salt=(homarr_admin_password
|
||||
| hash('sha256'))[:22]) }}
|
||||
no_log: true
|
||||
|
||||
# =====================================================================
|
||||
|
|
@ -161,4 +159,4 @@
|
|||
register: seed_result
|
||||
changed_when: seed_result.rc == 0
|
||||
when: admin_exists.stdout == ""
|
||||
notify: restart homarr
|
||||
notify: restart homarr
|
||||
|
|
|
|||
|
|
@ -26,13 +26,22 @@ services:
|
|||
AUTH_OIDC_AUTO_LOGIN: "{{ homarr_oidc_auto_login | default('false') }}"
|
||||
networks:
|
||||
- {{ homarr_traefik_network }}
|
||||
{% if homarr_extra_hosts | default([]) | length > 0 %}
|
||||
extra_hosts:
|
||||
{% for h in homarr_extra_hosts %}
|
||||
- "{{ h }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network={{ homarr_traefik_network }}
|
||||
- traefik.http.routers.homarr.rule=Host(`{{ homarr_domain }}`)
|
||||
- traefik.http.routers.homarr.rule={% set _all_domains = [homarr_domain] + (homarr_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
|
||||
{% if homarr_use_ssl %}
|
||||
- traefik.http.routers.homarr.entrypoints=websecure
|
||||
- traefik.http.routers.homarr.tls=true
|
||||
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
|
||||
- traefik.http.routers.homarr.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- traefik.http.routers.homarr.entrypoints=web
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,30 @@
|
|||
Role Name
|
||||
=========
|
||||
# httpbin
|
||||
|
||||
A brief description of the role goes here.
|
||||
Deploys [httpbin](https://httpbin.org/) (`kennethreitz/httpbin`) via
|
||||
Docker Compose behind Traefik. Useful as a throwaway endpoint to verify
|
||||
that the Traefik ingress path, TLS and routing work end to end.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
## Role variables
|
||||
|
||||
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `httpbin_domain` | `httpbin.local.test` | FQDN the Traefik router matches. |
|
||||
| `httpbin_image` | `kennethreitz/httpbin` | Container image. |
|
||||
| `httpbin_port` | `80` | Container port Traefik forwards to. |
|
||||
| `httpbin_traefik_network` | `proxy` | Docker network shared with Traefik. |
|
||||
| `httpbin_use_ssl` | `true` | Route via the `websecure` entrypoint with `tls=true` (otherwise `web`). |
|
||||
|
||||
Role Variables
|
||||
--------------
|
||||
## Example
|
||||
|
||||
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
|
||||
```yaml
|
||||
- hosts: services
|
||||
become: true
|
||||
roles:
|
||||
- role: digitalboard.core.httpbin
|
||||
vars:
|
||||
httpbin_domain: "httpbin.example.com"
|
||||
```
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
## License
|
||||
|
||||
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
|
||||
|
||||
Example Playbook
|
||||
----------------
|
||||
|
||||
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
|
||||
|
||||
- hosts: servers
|
||||
roles:
|
||||
- { role: username.rolename, x: 42 }
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
BSD
|
||||
|
||||
Author Information
|
||||
------------------
|
||||
|
||||
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -1,35 +1,27 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Deploy httpbin HTTP request/response testing service via Docker Compose behind Traefik
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.1
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- httpbin
|
||||
- testing
|
||||
- debug
|
||||
- docker
|
||||
- traefik
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
|
|||
|
|
@ -1,65 +1,119 @@
|
|||
Keycloak
|
||||
=========
|
||||
# Keycloak
|
||||
|
||||
Ansible role to deploy Keycloak with PostgreSQL database using Docker Compose.
|
||||
Ansible role to deploy Keycloak with a PostgreSQL backend via Docker
|
||||
Compose, published behind Traefik. Optionally provisions realm resources
|
||||
(groups, users, OIDC clients, identity providers, LDAP user federations)
|
||||
through the `community.general` Keycloak modules.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
## Requirements
|
||||
|
||||
- Docker and Docker Compose installed on the target host
|
||||
- Ansible collection: `community.docker`
|
||||
- Traefik reverse proxy (for external access)
|
||||
- Docker and Docker Compose on the target host (e.g. via
|
||||
`digitalboard.core.base`)
|
||||
- Ansible collections: `community.docker`, and `community.general` when
|
||||
`keycloak_provisioning_enabled` is true
|
||||
- Traefik reverse proxy with the `proxy` network already created (for
|
||||
external access)
|
||||
|
||||
Role Variables
|
||||
--------------
|
||||
## Role variables
|
||||
|
||||
Key variables defined in `defaults/main.yml`:
|
||||
Key variables from `defaults/main.yml`:
|
||||
|
||||
**Base Configuration:**
|
||||
- `docker_compose_base_dir`: Base directory for Docker Compose files (default: `/etc/docker/compose`)
|
||||
- `docker_volume_base_dir`: Base directory for Docker volumes (default: `/srv/data`)
|
||||
### Base configuration
|
||||
|
||||
**Keycloak Configuration:**
|
||||
- `keycloak_service_name`: Service name (default: `keycloak`)
|
||||
- `keycloak_domain`: Domain name for Keycloak (default: `auth.digitalboard.ch`)
|
||||
- `keycloak_image`: Keycloak Docker image (default: `quay.io/keycloak/keycloak:24.0.1`)
|
||||
- `keycloak_port`: Internal Keycloak port (default: `8080`)
|
||||
- `keycloak_admin_user`: Admin username (default: `admin`)
|
||||
- `keycloak_admin_password`: Admin password (default: `changeme`)
|
||||
- `keycloak_log_level`: Log level (default: `INFO`)
|
||||
- `keycloak_proxy_mode`: Proxy mode (default: `edge`)
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `docker_compose_base_dir` | `/etc/docker/compose` | Base dir for Compose projects. |
|
||||
| `docker_volume_base_dir` | `/srv/data` | Base dir for persistent volumes. |
|
||||
| `keycloak_service_name` | `keycloak` | Compose/service name; builds the per-service paths. |
|
||||
|
||||
**PostgreSQL Configuration:**
|
||||
- `keycloak_postgres_image`: PostgreSQL Docker image (default: `postgres:15`)
|
||||
- `keycloak_postgres_db`: Database name (default: `keycloak`)
|
||||
- `keycloak_postgres_user`: Database user (default: `keycloak`)
|
||||
- `keycloak_postgres_password`: Database password (default: `changeme`)
|
||||
### Keycloak
|
||||
|
||||
**Traefik Configuration:**
|
||||
- `keycloak_traefik_network`: Traefik network name (default: `proxy`)
|
||||
- `keycloak_backend_network`: Backend network name (default: `backend`)
|
||||
- `keycloak_use_ssl`: Enable SSL (default: `true`)
|
||||
- `keycloak_cert_resolver`: Certificate resolver name (default: `dns`)
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `keycloak_domain` | `keycloak.local.test` | Host rule and `KC_HOSTNAME`. |
|
||||
| `keycloak_image` | `quay.io/keycloak/keycloak:24.0.1` | Keycloak image. |
|
||||
| `keycloak_port` | `8080` | Internal HTTP port advertised to Traefik. |
|
||||
| `keycloak_admin_user` | `admin` | Bootstrap admin user. |
|
||||
| `keycloak_admin_password` | `changeme` | Admin password — **override this**. |
|
||||
| `keycloak_log_level` | `INFO` | `KC_LOG_LEVEL`. |
|
||||
| `keycloak_proxy_mode` | `edge` | `KC_PROXY` mode. |
|
||||
| `keycloak_gzip_enabled` | `false` | Toggle Keycloak GZIP response encoding. |
|
||||
| `keycloak_truststore_certificates` | `[]` | Host PEM paths mounted into the truststore (`KC_TRUSTSTORE_PATHS`). |
|
||||
| `keycloak_extra_hosts` | `[]` | Extra `host:ip` entries for the container. |
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
### PostgreSQL
|
||||
|
||||
This role requires the Traefik reverse proxy to be configured and the `proxy` network to be created.
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `keycloak_postgres_image` | `postgres:15` | PostgreSQL image. |
|
||||
| `keycloak_postgres_db` | `keycloak` | Database name. |
|
||||
| `keycloak_postgres_user` | `keycloak` | Database user. |
|
||||
| `keycloak_postgres_password` | `changeme` | Database password — **override this**. |
|
||||
|
||||
Example Playbook
|
||||
----------------
|
||||
### Traefik
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `keycloak_traefik_network` | `proxy` | External Traefik network. |
|
||||
| `keycloak_backend_network` | `backend` | Internal network to PostgreSQL. |
|
||||
| `keycloak_use_ssl` | `true` | Route on `websecure` with `tls=true` instead of `web`. |
|
||||
|
||||
TLS is requested from Traefik via `tls=true`; the role does not set a
|
||||
certificate resolver, so Traefik issues/serves the certificate according
|
||||
to its own configuration.
|
||||
|
||||
### Provisioning (optional)
|
||||
|
||||
Provisioning runs only when `keycloak_provisioning_enabled` is true. The
|
||||
tasks wait for the `/health/ready` endpoint and then call the
|
||||
`community.general.keycloak_*` modules, delegated to `localhost` against
|
||||
`keycloak_auth_url` (derived from `keycloak_use_ssl` + `keycloak_domain`).
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `keycloak_provisioning_enabled` | `false` | Enable realm provisioning. |
|
||||
| `keycloak_realm` | `default` | Target realm; created unless `master`. |
|
||||
| `keycloak_realm_display_name` | `Default Realm` | Realm display name. |
|
||||
| `keycloak_auth_url` | derived | API base URL for provisioning. |
|
||||
| `keycloak_groups` | `[]` | Groups to create. |
|
||||
| `keycloak_local_users` | `[]` | Local users to create. |
|
||||
| `keycloak_oidc_clients` | `[]` | OIDC clients to create. |
|
||||
| `keycloak_identity_providers` | `[]` | Identity providers (e.g. Entra ID). |
|
||||
| `keycloak_user_federations` | `[]` | LDAP user federations. |
|
||||
| `keycloak_removed_users` | `[]` | Usernames to delete. |
|
||||
| `keycloak_removed_groups` | `[]` | Group names to delete. |
|
||||
| `keycloak_removed_clients` | `[]` | Client IDs to delete. |
|
||||
| `keycloak_removed_identity_providers` | `[]` | IdP aliases to delete. |
|
||||
| `keycloak_removed_user_federations` | `[]` | Federation names to delete. |
|
||||
|
||||
See `defaults/main.yml` for the full entry shape of each list.
|
||||
|
||||
## Dependencies
|
||||
|
||||
This role requires the Traefik reverse proxy to be configured and the
|
||||
`proxy` network to be created beforehand (it is referenced as an external
|
||||
network in the Compose file). The `backend` network is created by the
|
||||
Compose project itself.
|
||||
|
||||
## Example playbook
|
||||
|
||||
```yaml
|
||||
- hosts: backend_servers
|
||||
roles:
|
||||
- role: keycloak
|
||||
- role: digitalboard.core.keycloak
|
||||
vars:
|
||||
keycloak_domain: "auth.example.com"
|
||||
keycloak_admin_password: "secure_password"
|
||||
keycloak_postgres_password: "secure_db_password"
|
||||
keycloak_admin_password: "{{ vault_keycloak_admin_password }}"
|
||||
keycloak_postgres_password: "{{ vault_keycloak_pg_password }}"
|
||||
keycloak_provisioning_enabled: true
|
||||
keycloak_oidc_clients:
|
||||
- client_id: nextcloud
|
||||
name: "Nextcloud"
|
||||
client_secret: "{{ vault_nextcloud_client_secret }}"
|
||||
redirect_uris:
|
||||
- "https://nextcloud.example.com/apps/user_oidc/code"
|
||||
```
|
||||
|
||||
License
|
||||
-------
|
||||
## License
|
||||
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -1,35 +1,27 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Deploy Keycloak with a PostgreSQL backend via Docker Compose behind Traefik
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.1
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- keycloak
|
||||
- oidc
|
||||
- sso
|
||||
- docker
|
||||
- traefik
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
|
|||
124
roles/nextcloud/README.md
Normal file
124
roles/nextcloud/README.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Nextcloud
|
||||
|
||||
Ansible role to deploy [Nextcloud](https://nextcloud.com/) (fpm) with
|
||||
Postgres and Redis via Docker Compose, optional Collabora WOPI
|
||||
integration, optional draw.io integration, optional notify_push
|
||||
companion, optional S3 primary storage, plus OIDC and LDAP user
|
||||
backends.
|
||||
|
||||
## What this role does
|
||||
|
||||
- Renders the Compose stack with traefik labels and TLS
|
||||
- Installs and enables a configurable list of Nextcloud apps idempotently
|
||||
- Configures Collabora (richdocuments), draw.io, OIDC providers and
|
||||
LDAP via `occ` — every setting is read first and only written when
|
||||
the stored value differs, so re-runs don't churn
|
||||
- Sets up notify_push (when enabled)
|
||||
- Applies an in-container PHP source workaround for the upstream
|
||||
`UserConfig::getValueBool` TypeError (nextcloud/server#59629, fixed in
|
||||
master via PR #59646 with no stable33 backport before 33.0.4).
|
||||
Idempotent via grep guard; remove the patch task once
|
||||
`nextcloud_image` is >= 33.0.4.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker and Docker Compose installed on the target host
|
||||
- Ansible collection: `community.docker`
|
||||
- Traefik with a shared `nextcloud_traefik_network` (default `proxy`)
|
||||
|
||||
## Role variables
|
||||
|
||||
Full spec with types and defaults: `meta/argument_specs.yml`. The most
|
||||
common overrides:
|
||||
|
||||
### Service
|
||||
|
||||
- `nextcloud_domains`: FQDNs the router accepts. First entry is the
|
||||
canonical hostname (used for `OVERWRITEHOST` and notify_push setup).
|
||||
Further entries cover internal `*.int.*` names so Collabora's WOPI
|
||||
callback hits the instance on a name with a valid cert.
|
||||
- `nextcloud_admin_password`, `nextcloud_postgres_password` (required).
|
||||
- `nextcloud_memory_limit_mb`, `nextcloud_upload_limit_mb`.
|
||||
|
||||
### Collabora
|
||||
|
||||
- `nextcloud_enable_collabora`: toggle integration with a separately
|
||||
deployed Collabora server (see the `collabora` role).
|
||||
- `nextcloud_collabora_domain`: server-to-server hostname.
|
||||
- `nextcloud_collabora_public_domain` (optional): browser-facing
|
||||
hostname when split-horizon uses different names.
|
||||
|
||||
### Draw.io
|
||||
|
||||
- `nextcloud_enable_drawio`: enable the `integration_drawio` app.
|
||||
- `nextcloud_drawio_url`: public draw.io URL.
|
||||
- `nextcloud_drawio_theme`, `nextcloud_drawio_offline`.
|
||||
|
||||
### Notify push
|
||||
|
||||
- `nextcloud_enable_notify_push`: deploy the notify_push companion.
|
||||
- `nextcloud_notify_push_domain` (optional): override the hostname
|
||||
used by `occ notify_push:setup` to avoid hairpinning through the DMZ.
|
||||
|
||||
### S3 primary storage
|
||||
|
||||
Set `nextcloud_use_s3_storage: true` plus the `nextcloud_s3_*` block to
|
||||
point Nextcloud at an external S3-compatible store (e.g. Garage, MinIO).
|
||||
|
||||
### OIDC
|
||||
|
||||
`nextcloud_oidc_providers` is a list of OIDC providers registered with
|
||||
`user_oidc`. Required fields per entry: `identifier`, `display_name`,
|
||||
`client_id`, `client_secret`, `discovery_url`.
|
||||
|
||||
### LDAP
|
||||
|
||||
Set `nextcloud_ldap_enabled: true` and provide `nextcloud_ldap_config`
|
||||
as a dict of `occ ldap:set-config s01 KEY VALUE` pairs. The role reads
|
||||
the current LDAP config via `occ ldap:show-config s01 --output=json`
|
||||
and only calls `ldap:set-config` for keys whose stored value differs.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Traefik network (`nextcloud_traefik_network`, default `proxy`)
|
||||
- Optional: `collabora`, `drawio`, `garage` roles for the corresponding
|
||||
integrations
|
||||
- Optional: an OIDC provider (Keycloak, authentik) reachable from
|
||||
Nextcloud and a 389ds LDAP server when using `user_ldap`
|
||||
|
||||
## Example playbook
|
||||
|
||||
```yaml
|
||||
- hosts: app_servers
|
||||
roles:
|
||||
- role: digitalboard.core.nextcloud
|
||||
vars:
|
||||
nextcloud_domains:
|
||||
- "cloud.example.com"
|
||||
- "cloud.int.example.com"
|
||||
nextcloud_admin_password: "{{ vault_nextcloud_admin_password }}"
|
||||
nextcloud_postgres_password: "{{ vault_nextcloud_pg_password }}"
|
||||
|
||||
nextcloud_enable_collabora: true
|
||||
nextcloud_collabora_domain: "office.int.example.com"
|
||||
nextcloud_collabora_public_domain: "office.example.com"
|
||||
|
||||
nextcloud_enable_notify_push: true
|
||||
nextcloud_notify_push_domain: "cloud.int.example.com"
|
||||
|
||||
nextcloud_oidc_providers:
|
||||
- identifier: authentik
|
||||
display_name: "Login with Authentik"
|
||||
client_id: nextcloud
|
||||
client_secret: "{{ vault_nextcloud_oidc_secret }}"
|
||||
discovery_url: "https://auth.example.com/application/o/nextcloud/.well-known/openid-configuration"
|
||||
mapping:
|
||||
uid: preferred_username
|
||||
display_name: name
|
||||
email: email
|
||||
groups: groups
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT-0
|
||||
|
|
@ -9,7 +9,12 @@ nextcloud_service_name: nextcloud
|
|||
nextcloud_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ nextcloud_service_name }}"
|
||||
nextcloud_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ nextcloud_service_name }}"
|
||||
|
||||
nextcloud_domain: "nextcloud.local.test"
|
||||
# FQDNs the nextcloud router accepts. The first entry is the canonical
|
||||
# domain (used for OVERWRITEHOST and the notify_push setup); further
|
||||
# entries cover internal *.int.* names so collabora's WOPI callback
|
||||
# hits us on a name with a valid cert.
|
||||
nextcloud_domains:
|
||||
- "nextcloud.local.test"
|
||||
nextcloud_image: "nextcloud:fpm"
|
||||
nextcloud_redis_image: "redis:latest"
|
||||
nextcloud_port: 80
|
||||
|
|
@ -60,6 +65,12 @@ nextcloud_trusted_proxies: "172.16.0.0/12"
|
|||
# File locking and real-time push notifications
|
||||
nextcloud_enable_notify_push: false
|
||||
nextcloud_notify_push_image: "icewind1991/notify_push:1.3.1"
|
||||
# Domain used when calling `occ notify_push:setup`. Defaults to the
|
||||
# first nextcloud_domains entry (the canonical public name). Override
|
||||
# with an internal FQDN to avoid hairpinning the setup check through
|
||||
# the DMZ; the FQDN must also be in nextcloud_domains so the push
|
||||
# router matches it.
|
||||
# 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.
|
||||
|
|
|
|||
253
roles/nextcloud/meta/argument_specs.yml
Normal file
253
roles/nextcloud/meta/argument_specs.yml
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Deploy Nextcloud (fpm) + Redis + Postgres via Docker Compose.
|
||||
description:
|
||||
- Renders a Compose stack for Nextcloud with traefik labels, optional
|
||||
Collabora WOPI integration, optional draw.io integration, optional
|
||||
notify_push companion, optional S3 primary storage, OIDC providers
|
||||
and LDAP user backend.
|
||||
- "All C(occ)-driven configuration tasks are idempotent: each setting
|
||||
is read with C(config:app:get) (or C(ldap:show-config)) first and
|
||||
only written when the stored value differs."
|
||||
options:
|
||||
docker_compose_base_dir:
|
||||
type: path
|
||||
default: /etc/docker/compose
|
||||
docker_volume_base_dir:
|
||||
type: path
|
||||
default: /srv/data
|
||||
nextcloud_service_name:
|
||||
type: str
|
||||
default: nextcloud
|
||||
nextcloud_docker_compose_dir:
|
||||
type: path
|
||||
nextcloud_docker_volume_dir:
|
||||
type: path
|
||||
|
||||
nextcloud_domains:
|
||||
type: list
|
||||
elements: str
|
||||
default: ['nextcloud.local.test']
|
||||
description:
|
||||
- FQDNs the nextcloud router accepts. The first entry is the
|
||||
canonical domain (used for C(OVERWRITEHOST) and the
|
||||
C(notify_push) setup). Further entries cover internal C(*.int.*)
|
||||
names so Collabora's WOPI callback hits the instance on a name
|
||||
with a valid certificate.
|
||||
nextcloud_image:
|
||||
type: str
|
||||
default: nextcloud:fpm
|
||||
nextcloud_redis_image:
|
||||
type: str
|
||||
default: redis:latest
|
||||
nextcloud_port:
|
||||
type: int
|
||||
default: 80
|
||||
nextcloud_extra_hosts:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
nextcloud_extra_networks:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
nextcloud_allow_local_remote_servers:
|
||||
type: bool
|
||||
default: false
|
||||
description: Allow requests to local network from Nextcloud (dev only).
|
||||
|
||||
nextcloud_postgres_image:
|
||||
type: str
|
||||
default: postgres:15
|
||||
nextcloud_postgres_db:
|
||||
type: str
|
||||
default: nextcloud
|
||||
nextcloud_postgres_user:
|
||||
type: str
|
||||
default: nextcloud
|
||||
nextcloud_postgres_password:
|
||||
type: str
|
||||
required: true
|
||||
|
||||
nextcloud_backend_network:
|
||||
type: str
|
||||
default: nextcloud-internal
|
||||
nextcloud_traefik_network:
|
||||
type: str
|
||||
default: proxy
|
||||
nextcloud_use_ssl:
|
||||
type: bool
|
||||
default: true
|
||||
|
||||
nextcloud_enable_collabora:
|
||||
type: bool
|
||||
default: true
|
||||
nextcloud_collabora_domain:
|
||||
type: str
|
||||
default: office.local.test
|
||||
description: Hostname Nextcloud uses to talk to Collabora server-to-server.
|
||||
nextcloud_collabora_public_domain:
|
||||
type: str
|
||||
description:
|
||||
- Optional browser-facing hostname for Collabora; defaults to
|
||||
C(nextcloud_collabora_domain) when unset. Set when split-horizon
|
||||
uses different names for browser and server traffic.
|
||||
nextcloud_collabora_disable_cert_verification:
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
nextcloud_enable_drawio:
|
||||
type: bool
|
||||
default: false
|
||||
description: Enable the integration_drawio Nextcloud app and configure the URL/theme.
|
||||
nextcloud_drawio_url:
|
||||
type: str
|
||||
default: ''
|
||||
description: Public draw.io URL used by the integration_drawio app.
|
||||
nextcloud_drawio_theme:
|
||||
type: str
|
||||
choices: [kennedy, atlas, dark, sketch, min]
|
||||
default: kennedy
|
||||
nextcloud_drawio_offline:
|
||||
type: str
|
||||
choices: ['yes', 'no']
|
||||
default: 'yes'
|
||||
|
||||
nextcloud_use_s3_storage:
|
||||
type: bool
|
||||
default: false
|
||||
description: Use S3 primary object storage instead of the local data dir.
|
||||
nextcloud_s3_key:
|
||||
type: str
|
||||
default: changeme
|
||||
nextcloud_s3_secret:
|
||||
type: str
|
||||
default: changeme
|
||||
nextcloud_s3_region:
|
||||
type: str
|
||||
default: us-east-1
|
||||
nextcloud_s3_bucket:
|
||||
type: str
|
||||
default: nextcloud
|
||||
nextcloud_s3_host:
|
||||
type: str
|
||||
default: s3.example.com
|
||||
nextcloud_s3_port:
|
||||
type: int
|
||||
default: 443
|
||||
nextcloud_s3_ssl:
|
||||
type: bool
|
||||
default: true
|
||||
nextcloud_s3_usepath_style:
|
||||
type: bool
|
||||
default: true
|
||||
nextcloud_s3_autocreate:
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
nextcloud_admin_user:
|
||||
type: str
|
||||
default: admin
|
||||
nextcloud_admin_password:
|
||||
type: str
|
||||
required: true
|
||||
nextcloud_memory_limit_mb:
|
||||
type: int
|
||||
default: 1024
|
||||
nextcloud_upload_limit_mb:
|
||||
type: int
|
||||
default: 2048
|
||||
nextcloud_scale_factor:
|
||||
type: int
|
||||
default: 2
|
||||
|
||||
nextcloud_trusted_proxies:
|
||||
type: str
|
||||
default: '172.16.0.0/12'
|
||||
description: Trusted proxy CIDR(s) — by default the Docker internal range.
|
||||
|
||||
nextcloud_enable_notify_push:
|
||||
type: bool
|
||||
default: false
|
||||
nextcloud_notify_push_image:
|
||||
type: str
|
||||
default: icewind1991/notify_push:1.3.1
|
||||
nextcloud_notify_push_domain:
|
||||
type: str
|
||||
description:
|
||||
- Hostname used when calling C(occ notify_push:setup). Defaults to
|
||||
the first C(nextcloud_domains) entry. Override with an internal
|
||||
FQDN to avoid hairpinning the setup check through the DMZ; the
|
||||
FQDN must also be in C(nextcloud_domains).
|
||||
|
||||
nextcloud_apps_to_install:
|
||||
type: list
|
||||
elements: str
|
||||
default:
|
||||
- groupfolders
|
||||
- richdocuments
|
||||
- spreed
|
||||
- user_ldap
|
||||
- user_oidc
|
||||
- whiteboard
|
||||
- files_lock
|
||||
- notify_push
|
||||
description:
|
||||
- Non-default Nextcloud apps to install + enable.
|
||||
Install/enable detection is idempotent — re-runs report C(ok)
|
||||
when the app is already present and enabled.
|
||||
|
||||
nextcloud_oidc_allow_selfsigned:
|
||||
type: bool
|
||||
default: false
|
||||
nextcloud_oidc_providers:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
description: OIDC providers registered with the user_oidc app.
|
||||
options:
|
||||
identifier:
|
||||
type: str
|
||||
required: true
|
||||
display_name:
|
||||
type: str
|
||||
required: true
|
||||
client_id:
|
||||
type: str
|
||||
required: true
|
||||
client_secret:
|
||||
type: str
|
||||
required: true
|
||||
discovery_url:
|
||||
type: str
|
||||
required: true
|
||||
scope:
|
||||
type: str
|
||||
default: openid email profile
|
||||
unique_uid:
|
||||
type: bool
|
||||
default: true
|
||||
check_bearer:
|
||||
type: bool
|
||||
default: false
|
||||
send_id_token_hint:
|
||||
type: bool
|
||||
default: true
|
||||
mapping:
|
||||
type: dict
|
||||
nextcloud_oidc_providers_removed:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
|
||||
nextcloud_ldap_enabled:
|
||||
type: bool
|
||||
default: false
|
||||
nextcloud_ldap_config:
|
||||
type: dict
|
||||
default: {}
|
||||
description:
|
||||
- Key/value pairs passed to C(occ ldap:set-config s01 KEY VALUE).
|
||||
The role reads the current config first and only invokes
|
||||
C(set-config) when a stored value differs.
|
||||
28
roles/nextcloud/meta/main.yml
Normal file
28
roles/nextcloud/meta/main.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: digitalboard
|
||||
description: Deploy Nextcloud (fpm) + Redis + Postgres via Docker Compose behind Traefik
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
galaxy_tags:
|
||||
- nextcloud
|
||||
- files
|
||||
- collabora
|
||||
- oidc
|
||||
- docker
|
||||
- traefik
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
|
|
@ -1,22 +1,55 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
---
|
||||
# tasks file for configuring Collabora in Nextcloud
|
||||
- name: Configure Collabora WOPI URL
|
||||
- name: Read current richdocuments config values
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ config:app:get richdocuments {{ item }}
|
||||
loop:
|
||||
- wopi_url
|
||||
- public_wopi_url
|
||||
- disable_certificate_verification
|
||||
- wopi_allowlist
|
||||
register: _richdocuments_current
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Build map of current richdocuments config
|
||||
ansible.builtin.set_fact:
|
||||
_richdocuments_cfg: "{{ _richdocuments_cfg | default({}) | combine({item.item: (item.stdout | default('')).strip()}) }}"
|
||||
loop: "{{ _richdocuments_current.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
|
||||
- name: Configure Collabora WOPI URL (server-to-server)
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ config:app:set richdocuments wopi_url --value=https://{{ nextcloud_collabora_domain }}
|
||||
when: _richdocuments_cfg.wopi_url != ('https://' ~ nextcloud_collabora_domain)
|
||||
|
||||
- name: Configure Collabora public WOPI URL (browser-facing)
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ config:app:set richdocuments public_wopi_url --value=https://{{ nextcloud_collabora_public_domain }}
|
||||
when:
|
||||
- nextcloud_collabora_public_domain is defined
|
||||
- nextcloud_collabora_public_domain != nextcloud_collabora_domain
|
||||
- _richdocuments_cfg.public_wopi_url != ('https://' ~ nextcloud_collabora_public_domain)
|
||||
|
||||
- name: Configure certificate verification for Collabora
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ config:app:set richdocuments disable_certificate_verification --value={{ nextcloud_collabora_disable_cert_verification | ternary('yes', 'no') }}
|
||||
when: _richdocuments_cfg.disable_certificate_verification != (nextcloud_collabora_disable_cert_verification | ternary('yes', 'no'))
|
||||
|
||||
- name: Set Collabora WOPI allowlist
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ config:app:set richdocuments wopi_allowlist --value=''
|
||||
when: _richdocuments_cfg.wopi_allowlist | default('') != ''
|
||||
|
||||
- name: Activate richdocuments configuration (fetch discovery from Collabora)
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ richdocuments:activate-config
|
||||
command: php /var/www/html/occ richdocuments:activate-config
|
||||
changed_when: false
|
||||
|
|
@ -2,18 +2,41 @@
|
|||
---
|
||||
# tasks file for configuring draw.io in Nextcloud
|
||||
|
||||
- name: Read current drawio config values
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ config:app:get drawio {{ item }}
|
||||
loop:
|
||||
- DrawioUrl
|
||||
- DrawioTheme
|
||||
- DrawioOffline
|
||||
register: _drawio_current
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Build map of current drawio config
|
||||
ansible.builtin.set_fact:
|
||||
_drawio_cfg: "{{ _drawio_cfg | default({}) | combine({item.item: (item.stdout | default('')).strip()}) }}"
|
||||
loop: "{{ _drawio_current.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
|
||||
- name: Configure draw.io URL
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ config:app:set drawio DrawioUrl --value={{ nextcloud_drawio_url }}
|
||||
when: nextcloud_drawio_url | length > 0
|
||||
when:
|
||||
- nextcloud_drawio_url | length > 0
|
||||
- _drawio_cfg.DrawioUrl != nextcloud_drawio_url
|
||||
|
||||
- name: Configure draw.io theme
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ config:app:set drawio DrawioTheme --value={{ nextcloud_drawio_theme }}
|
||||
when: _drawio_cfg.DrawioTheme != (nextcloud_drawio_theme | string)
|
||||
|
||||
- name: Configure draw.io offline mode
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ config:app:set drawio DrawioOffline --value={{ nextcloud_drawio_offline }}
|
||||
command: php /var/www/html/occ config:app:set drawio DrawioOffline --value={{ nextcloud_drawio_offline }}
|
||||
when: _drawio_cfg.DrawioOffline != (nextcloud_drawio_offline | string)
|
||||
|
|
@ -15,6 +15,24 @@
|
|||
command: php /var/www/html/occ ldap:create-empty-config
|
||||
when: "'s01' not in ldap_show_config.stdout"
|
||||
|
||||
- name: Read current LDAP config for s01
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_service_name }}-nextcloud-1"
|
||||
command: php /var/www/html/occ ldap:show-config s01 --output=json
|
||||
register: _ldap_show_s01
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Parse current LDAP config
|
||||
ansible.builtin.set_fact:
|
||||
_ldap_current: >-
|
||||
{{
|
||||
(_ldap_show_s01.stdout | from_json) if (
|
||||
(_ldap_show_s01.stdout | default('') | trim) is match('^[\\[{]')
|
||||
) else {}
|
||||
}}
|
||||
when: _ldap_show_s01.rc | default(1) == 0
|
||||
|
||||
- name: Configure LDAP settings
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_service_name }}-nextcloud-1"
|
||||
|
|
@ -29,6 +47,7 @@
|
|||
loop_control:
|
||||
label: "{{ item.key }}"
|
||||
no_log: true
|
||||
when: ((_ldap_current | default({})).get(item.key) | default(none) | string) != (item.value | string)
|
||||
|
||||
- name: Test LDAP configuration
|
||||
community.docker.docker_container_exec:
|
||||
|
|
|
|||
|
|
@ -49,6 +49,61 @@
|
|||
project_src: "{{ nextcloud_docker_compose_dir }}"
|
||||
state: present
|
||||
|
||||
# nextcloud/server#59629: UserConfig::getValueBool() passes a non-string from
|
||||
# getTypedValue() into strtolower() under PHP 8.x + OPcache, throwing a
|
||||
# TypeError on every authenticated request once user_ldap is involved. Fix
|
||||
# is in master (PR #59646) but no stable33 backport landed before 33.0.4.
|
||||
# Apply the (string) cast in-container; idempotent via grep guard. Remove
|
||||
# this block once nextcloud_image >= 33.0.4.
|
||||
- name: Discover nextcloud php containers needing the UserConfig patch
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
docker ps --filter "label=com.docker.compose.project={{ nextcloud_docker_compose_dir | basename }}"
|
||||
--filter "label=com.docker.compose.service=nextcloud"
|
||||
--format '{% raw %}{{.Names}}{% endraw %}'
|
||||
register: _nextcloud_php_containers
|
||||
changed_when: false
|
||||
|
||||
- name: Check UserConfig.php patch status per container
|
||||
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: >-
|
||||
docker exec {{ item }} sh -c '
|
||||
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 }}"
|
||||
register: _nextcloud_userconfig_check
|
||||
changed_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
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
docker exec {{ item.item }}
|
||||
sed -i 's|$b = strtolower($this->getTypedValue|$b = strtolower((string)$this->getTypedValue|'
|
||||
/var/www/html/lib/private/Config/UserConfig.php
|
||||
loop: "{{ _nextcloud_userconfig_check.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
when:
|
||||
- item.rc | default(2) == 1
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
ansible.builtin.shell:
|
||||
cmd: docker compose exec -T nextcloud php /var/www/html/occ status --output=json
|
||||
|
|
|
|||
|
|
@ -2,7 +2,16 @@
|
|||
---
|
||||
# tasks file for configuring notify_push in Nextcloud
|
||||
|
||||
- name: Read current notify_push base endpoint
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ config:app:get notify_push base_endpoint
|
||||
register: _notify_push_current
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Configure notify_push base endpoint
|
||||
community.docker.docker_container_exec:
|
||||
container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1"
|
||||
command: php /var/www/html/occ notify_push:setup https://{{ nextcloud_domain }}/push
|
||||
command: php /var/www/html/occ notify_push:setup https://{{ nextcloud_notify_push_domain | default(nextcloud_domains[0]) }}/push
|
||||
when: (_notify_push_current.stdout | default('') | trim) != ('https://' ~ (nextcloud_notify_push_domain | default(nextcloud_domains[0])) ~ '/push')
|
||||
|
|
@ -8,7 +8,9 @@
|
|||
chdir: "{{ nextcloud_docker_compose_dir }}"
|
||||
loop: "{{ nextcloud_apps_to_install }}"
|
||||
register: app_install_result
|
||||
changed_when: "'installed' in app_install_result.stdout"
|
||||
changed_when:
|
||||
- "'already installed' not in app_install_result.stdout"
|
||||
- "'installed' in app_install_result.stdout"
|
||||
failed_when:
|
||||
- app_install_result.rc != 0
|
||||
- "'already installed' not in app_install_result.stdout"
|
||||
|
|
@ -19,7 +21,9 @@
|
|||
chdir: "{{ nextcloud_docker_compose_dir }}"
|
||||
loop: "{{ nextcloud_apps_to_install }}"
|
||||
register: app_enable_result
|
||||
changed_when: "'enabled' in app_enable_result.stdout"
|
||||
changed_when:
|
||||
- "'already enabled' not in app_enable_result.stdout"
|
||||
- "'enabled' in app_enable_result.stdout"
|
||||
failed_when:
|
||||
- app_enable_result.rc != 0
|
||||
- "'already enabled' not in app_enable_result.stdout"
|
||||
|
|
|
|||
|
|
@ -35,10 +35,13 @@ services:
|
|||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network={{ nextcloud_traefik_network }}
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}.rule=Host(`{{ nextcloud_domain }}`)
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}.rule={% for d in nextcloud_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
|
||||
{% if nextcloud_use_ssl %}
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}.entrypoints=websecure
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}.tls=true
|
||||
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}.entrypoints=web
|
||||
{% endif %}
|
||||
|
|
@ -60,7 +63,7 @@ services:
|
|||
PHP_MEMORY_LIMIT: {{ nextcloud_memory_limit_mb }}M
|
||||
PHP_UPLOAD_LIMIT: {{ nextcloud_upload_limit_mb }}M
|
||||
OVERWRITEPROTOCOL: https
|
||||
OVERWRITEHOST: {{ nextcloud_domain }}
|
||||
OVERWRITEHOST: {{ nextcloud_domains[0] }}
|
||||
TRUSTED_PROXIES: "{{ nextcloud_trusted_proxies }}"
|
||||
volumes:
|
||||
- {{ nextcloud_docker_volume_dir }}/nextcloud/:/var/www/html
|
||||
|
|
@ -69,6 +72,12 @@ services:
|
|||
{% for net in nextcloud_extra_networks %}
|
||||
- {{ net }}
|
||||
{% endfor %}
|
||||
{% if nextcloud_extra_hosts is defined and nextcloud_extra_hosts | length > 0 %}
|
||||
extra_hosts:
|
||||
{% for host in nextcloud_extra_hosts %}
|
||||
- "{{ host }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
nextcloud:
|
||||
image: {{ nextcloud_image }}
|
||||
|
|
@ -88,7 +97,7 @@ services:
|
|||
PHP_MEMORY_LIMIT: {{ nextcloud_memory_limit_mb }}M
|
||||
PHP_UPLOAD_LIMIT: {{ nextcloud_upload_limit_mb }}M
|
||||
OVERWRITEPROTOCOL: https
|
||||
OVERWRITEHOST: {{ nextcloud_domain }}
|
||||
OVERWRITEHOST: {{ nextcloud_domains[0] }}
|
||||
TRUSTED_PROXIES: "{{ nextcloud_trusted_proxies }}"
|
||||
{% if nextcloud_use_s3_storage %}
|
||||
OBJECTSTORE_S3_KEY: {{ nextcloud_s3_key }}
|
||||
|
|
@ -127,7 +136,7 @@ services:
|
|||
environment:
|
||||
PORT: "7867"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
DATABASE_URL: "postgres://{{ nextcloud_postgres_user }}:{{ nextcloud_postgres_password }}@db:5432/{{ nextcloud_postgres_db }}"
|
||||
DATABASE_URL: "postgres://{{ nextcloud_postgres_user | urlencode | replace('/', '%2F') }}:{{ nextcloud_postgres_password | urlencode | replace('/', '%2F') }}@db:5432/{{ nextcloud_postgres_db }}"
|
||||
DATABASE_PREFIX: "oc_"
|
||||
NEXTCLOUD_URL: "http://nginx"
|
||||
networks:
|
||||
|
|
@ -136,11 +145,14 @@ services:
|
|||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network={{ nextcloud_traefik_network }}
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}-push.rule=Host(`{{ nextcloud_domain }}`) && PathPrefix(`/push`)
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}-push.rule=({% for d in nextcloud_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor %}) && PathPrefix(`/push`)
|
||||
- traefik.http.services.{{ nextcloud_service_name }}-push.loadbalancer.server.port=7867
|
||||
{% if nextcloud_use_ssl %}
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}-push.entrypoints=websecure
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}-push.tls=true
|
||||
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}-push.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- traefik.http.routers.{{ nextcloud_service_name }}-push.entrypoints=web
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,43 @@
|
|||
Role Name
|
||||
=========
|
||||
# opencloud
|
||||
|
||||
A brief description of the role goes here.
|
||||
Deploys [OpenCloud](https://opencloud.eu/) (`opencloudeu/opencloud`) as a
|
||||
self-contained file platform via Docker Compose behind Traefik. Supports
|
||||
the built-in IdP or external OIDC, optional S3 storage, external LDAP,
|
||||
Collabora and draw.io integration, and OIDC-claim-based role assignment.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
## Role variables
|
||||
|
||||
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
|
||||
A selection of the most relevant variables — see
|
||||
[defaults/main.yml](defaults/main.yml) for the full set.
|
||||
|
||||
Role Variables
|
||||
--------------
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `opencloud_domain` | `opencloud.local.test` | FQDN the Traefik router matches. |
|
||||
| `opencloud_image` | `opencloudeu/opencloud:latest` | Container image. |
|
||||
| `opencloud_port` | `9200` | Container port Traefik forwards to. |
|
||||
| `opencloud_admin_password` | `admin` | Initial admin password — **override this**. |
|
||||
| `opencloud_traefik_network` | `proxy` | Docker network shared with Traefik. |
|
||||
| `opencloud_use_ssl` | `true` | Enable the TLS resolver on the router. |
|
||||
| `opencloud_oidc_issuer` | `""` | External OIDC issuer; empty uses the built-in IdP. |
|
||||
| `opencloud_use_s3_storage` | `false` | Use S3 storage instead of local disk. |
|
||||
| `opencloud_ldap_uri` | `""` | External LDAP URI; empty uses the built-in directory. |
|
||||
| `opencloud_collabora_domain` | `""` | Collabora server domain; set with `opencloud_wopi_domain` to enable editing. |
|
||||
| `opencloud_wopi_domain` | `""` | WOPI server FQDN; required alongside `opencloud_collabora_domain`. |
|
||||
| `opencloud_drawio_url` | `""` | draw.io URL; set to enable diagram editing. |
|
||||
| `opencloud_role_assignment_driver` | `default` | Set to `oidc` to map OIDC claims to roles. |
|
||||
|
||||
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
|
||||
## Example
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
```yaml
|
||||
- hosts: services
|
||||
become: true
|
||||
roles:
|
||||
- role: digitalboard.core.opencloud
|
||||
vars:
|
||||
opencloud_domain: "opencloud.example.com"
|
||||
opencloud_admin_password: "{{ vault_opencloud_admin_password }}"
|
||||
```
|
||||
|
||||
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
|
||||
## License
|
||||
|
||||
Example Playbook
|
||||
----------------
|
||||
|
||||
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
|
||||
|
||||
- hosts: servers
|
||||
roles:
|
||||
- { role: username.rolename, x: 42 }
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
BSD
|
||||
|
||||
Author Information
|
||||
------------------
|
||||
|
||||
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@
|
|||
- name: restart opencloud
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ opencloud_docker_compose_dir }}"
|
||||
state: restarted
|
||||
state: present
|
||||
|
|
@ -1,35 +1,27 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Deploy OpenCloud file platform via Docker Compose behind Traefik
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.2
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- opencloud
|
||||
- files
|
||||
- storage
|
||||
- docker
|
||||
- traefik
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
|
||||
# if you add dependencies to this list.
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ Docker Compose stack behind Traefik.
|
|||
- Integrates the ingress container with an existing Traefik proxy network
|
||||
- Waits for the API container to become healthy before returning
|
||||
|
||||
## What this role does NOT do (stage 1)
|
||||
## What this role does NOT do
|
||||
|
||||
- Does not pre-configure OIDC / identity_connections — set up via Admin UI
|
||||
- Does not migrate existing OpnForm databases — only bootstraps fresh
|
||||
installs (admin registration + OIDC connection are idempotent)
|
||||
|
||||
## Architecture note: why two reverse proxies?
|
||||
|
||||
|
|
@ -45,10 +46,14 @@ secrets to version control.**
|
|||
| Variable | Format | Generate with |
|
||||
|---|---|---|
|
||||
| `opnform_app_key` | `base64:<32 bytes base64>` | `echo "base64:$(openssl rand -base64 32)"` |
|
||||
| `opnform_jwt_secret` | 32 bytes base64 | `openssl rand -base64 32` |
|
||||
| `opnform_front_api_secret` | 32 bytes base64 | `openssl rand -base64 32` |
|
||||
| `opnform_jwt_secret` | 32-byte hex string | `openssl rand -hex 32` |
|
||||
| `opnform_front_api_secret` | 32-byte hex string | `openssl rand -hex 32` |
|
||||
| `opnform_db_password` | strong password | `openssl rand -base64 24` |
|
||||
|
||||
`opnform_app_key` MUST keep the `base64:` prefix — the validation task
|
||||
asserts it. `opnform_jwt_secret` and `opnform_front_api_secret` have no
|
||||
enforced format; any sufficiently random value works.
|
||||
|
||||
When `opnform_oidc_enabled` is `true`:
|
||||
|
||||
| Variable | Source |
|
||||
|
|
@ -91,11 +96,14 @@ Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit
|
|||
|
||||
## OIDC setup
|
||||
|
||||
Set `opnform_oidc_enabled: true` and the role creates an
|
||||
Set `opnform_oidc_enabled: true` and the role provisions an
|
||||
IdentityConnection on the admin's default workspace via
|
||||
`POST /api/open/workspaces/{id}/oidc-connections`. OpnForm enforces a
|
||||
single OIDC connection per workspace, so the task is idempotent (GETs
|
||||
existing connections first and skips if any exist).
|
||||
single OIDC connection per workspace, so the task is idempotent: it GETs
|
||||
existing connections first, then either POSTs a new one or PATCHes the
|
||||
existing one to the desired state. PATCHing (rather than skipping when
|
||||
one exists) keeps inventory changes — e.g. a corrected issuer — applied
|
||||
on re-runs instead of leaving stale values in the DB.
|
||||
|
||||
**Prerequisite**: the admin bootstrap must be configured
|
||||
(`opnform_admin_email` + `opnform_admin_password`). The OIDC API
|
||||
|
|
@ -138,6 +146,50 @@ 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
|
||||
|
|
@ -167,3 +219,7 @@ opnform_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get',
|
|||
'digitalboard/opnform',
|
||||
mount_point='kv').data.data.db_password }}"
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis"
|
|||
|
||||
# Service configuration
|
||||
opnform_domain: "forms.local.test"
|
||||
# Additional hostnames the opnform router answers on (e.g. an internal
|
||||
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
|
||||
# by the cert).
|
||||
opnform_extra_domains: []
|
||||
# Container-level /etc/hosts overrides for the API containers — needed in
|
||||
# split-horizon setups where the OpnForm API must reach the IdP's public
|
||||
# FQDN (used in the OIDC discovery/iss claim) over the LAN rather than
|
||||
# hairpinning through a DMZ that has no NAT loopback to its own public IP.
|
||||
opnform_extra_hosts: []
|
||||
opnform_base_url: "https://forms.local.test"
|
||||
|
||||
# Images
|
||||
|
|
@ -92,6 +101,12 @@ opnform_oidc_slug: "oidc"
|
|||
# with @example.com emails are redirected to the IdP). Required when
|
||||
# opnform_oidc_enabled is true.
|
||||
opnform_oidc_domain: ""
|
||||
# When true, sets OIDC_FORCE_LOGIN on the api: password-based login is
|
||||
# disabled entirely and every user must authenticate via OIDC. Only
|
||||
# rendered when opnform_oidc_enabled is also true. Make sure all real
|
||||
# users have addresses under opnform_oidc_domain before enabling — there
|
||||
# is no password fallback once this is on.
|
||||
opnform_oidc_force_login: false
|
||||
opnform_oidc_scopes:
|
||||
- openid
|
||||
- profile
|
||||
|
|
@ -104,6 +119,24 @@ opnform_oidc_admin_group: "opnform-admins"
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -6,3 +6,13 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -38,6 +38,25 @@ argument_specs:
|
|||
type: str
|
||||
default: forms.local.test
|
||||
description: Hostname used in the traefik Host rule.
|
||||
opnform_extra_domains:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description:
|
||||
- Additional hostnames the Traefik router answers on, OR-combined
|
||||
with C(opnform_domain). Useful for an internal C(*.int.*) FQDN so
|
||||
a DMZ reverseproxy can reach a backend hostname covered by the
|
||||
cert.
|
||||
opnform_extra_hosts:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description:
|
||||
- Container-level C(/etc/hosts) overrides for the API containers
|
||||
(Compose C(extra_hosts) entries, C("host:ip")). Needed in
|
||||
split-horizon setups where the OpnForm API must reach the IdP's
|
||||
public FQDN (used in the OIDC discovery / C(iss) claim) over the
|
||||
LAN rather than hairpinning through a DMZ with no NAT loopback.
|
||||
opnform_base_url:
|
||||
type: str
|
||||
default: https://forms.local.test
|
||||
|
|
@ -184,6 +203,15 @@ argument_specs:
|
|||
description:
|
||||
- Email domain that triggers OIDC for matching users. Required
|
||||
when C(opnform_oidc_enabled=true).
|
||||
opnform_oidc_force_login:
|
||||
type: bool
|
||||
default: false
|
||||
description:
|
||||
- "When true, sets C(OIDC_FORCE_LOGIN=true) on the api container:
|
||||
password-based login is disabled and every user must authenticate
|
||||
via OIDC. Only takes effect when C(opnform_oidc_enabled=true).
|
||||
Ensure all real users have addresses under C(opnform_oidc_domain)
|
||||
before enabling — there is no password fallback."
|
||||
opnform_oidc_scopes:
|
||||
type: list
|
||||
elements: str
|
||||
|
|
@ -211,6 +239,33 @@ argument_specs:
|
|||
type: str
|
||||
required: true
|
||||
choices: [owner, admin, editor, member]
|
||||
opnform_oidc_sso_entrypoint:
|
||||
type: bool
|
||||
default: false
|
||||
description:
|
||||
- When true (and C(opnform_oidc_enabled=true)) the nginx ingress
|
||||
serves a small redirect page at C(opnform_oidc_sso_path) that
|
||||
calls OpnForm's C(/api/auth/{slug}/redirect) endpoint and
|
||||
forwards the browser to the returned IdP authorize URL. Lets
|
||||
you link users straight to the IdP, skipping OpnForm's
|
||||
email-based login form. OpnForm has no native option for this.
|
||||
opnform_oidc_sso_path:
|
||||
type: str
|
||||
default: /sso
|
||||
description:
|
||||
- Path (on C(opnform_domain)) where the direct-SSO redirect page
|
||||
is served when C(opnform_oidc_sso_entrypoint=true). Must start
|
||||
with C(/) and not collide with OpnForm's own routes.
|
||||
opnform_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
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@
|
|||
- opnform_front_api_secret | length > 0
|
||||
- opnform_db_password | length > 0
|
||||
fail_msg: >-
|
||||
OpnForm requires opnform_app_key (prefix 'base64:'), opnform_jwt_secret,
|
||||
OpnForm requires opnform_app_key, opnform_jwt_secret,
|
||||
opnform_front_api_secret and opnform_db_password.
|
||||
Generate with: openssl rand -base64 32
|
||||
The app_key MUST be prefixed with "base64:"
|
||||
Generate with:
|
||||
opnform_app_key='base64:'$(openssl rand -base64 32) (the 'base64:' prefix is required);
|
||||
opnform_jwt_secret and opnform_front_api_secret via openssl rand -hex 32.
|
||||
Provide via OpenBao, Ansible Vault or extra-vars.
|
||||
success_msg: Secrets validation passed
|
||||
|
||||
|
|
@ -74,13 +75,97 @@
|
|||
src: nginx.conf.j2
|
||||
dest: "{{ opnform_docker_compose_dir }}/nginx.conf"
|
||||
mode: '0644'
|
||||
notify: restart opnform
|
||||
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
|
||||
|
||||
# =====================================================================
|
||||
|
|
@ -113,6 +198,12 @@
|
|||
# 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:
|
||||
|
|
@ -130,6 +221,7 @@
|
|||
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:
|
||||
|
|
@ -150,14 +242,25 @@
|
|||
when:
|
||||
- opnform_admin_email | length > 0
|
||||
- opnform_admin_password | length > 0
|
||||
- opnform_admin_login.status != 200
|
||||
- not (_opnform_force_login_effective | bool)
|
||||
- opnform_admin_login.status | default(0) != 200
|
||||
|
||||
# =====================================================================
|
||||
# 6. OIDC IDENTITY CONNECTION (optional)
|
||||
# =====================================================================
|
||||
# Creates a single OIDC connection on the admin's default workspace.
|
||||
# OpnForm enforces one OIDC connection per workspace, so this block is
|
||||
# idempotent: we GET existing connections first and skip if any exists.
|
||||
# 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:
|
||||
|
|
@ -173,7 +276,9 @@
|
|||
validate_certs: false
|
||||
register: opnform_oidc_token
|
||||
no_log: true
|
||||
when: opnform_oidc_enabled | bool
|
||||
when:
|
||||
- opnform_oidc_enabled | bool
|
||||
- not (_opnform_force_login_effective | bool)
|
||||
|
||||
- name: Fetch admin's workspaces
|
||||
ansible.builtin.uri:
|
||||
|
|
@ -186,7 +291,9 @@
|
|||
validate_certs: false
|
||||
register: opnform_workspaces
|
||||
no_log: true
|
||||
when: opnform_oidc_enabled | bool
|
||||
when:
|
||||
- opnform_oidc_enabled | bool
|
||||
- not (_opnform_force_login_effective | bool)
|
||||
|
||||
- name: Fetch existing OIDC connections for the default workspace
|
||||
ansible.builtin.uri:
|
||||
|
|
@ -199,7 +306,9 @@
|
|||
validate_certs: false
|
||||
register: opnform_existing_oidc
|
||||
no_log: true
|
||||
when: opnform_oidc_enabled | bool
|
||||
when:
|
||||
- opnform_oidc_enabled | bool
|
||||
- not (_opnform_force_login_effective | bool)
|
||||
|
||||
- name: Resolve OIDC group-role mappings
|
||||
ansible.builtin.set_fact:
|
||||
|
|
@ -211,17 +320,16 @@
|
|||
([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}]
|
||||
if (opnform_oidc_admin_group | length > 0) else [])
|
||||
}}
|
||||
when: opnform_oidc_enabled | bool
|
||||
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:
|
||||
# Desired connection state shared by both the create (POST) and update
|
||||
# (PATCH) calls below. client_secret is always sent: OpnForm's update
|
||||
# endpoint only persists it when present, and on create it is required.
|
||||
- name: Build desired OIDC connection body
|
||||
ansible.builtin.set_fact:
|
||||
_opnform_oidc_body:
|
||||
name: "{{ opnform_oidc_client_name }}"
|
||||
slug: "{{ opnform_oidc_slug }}"
|
||||
domain: "{{ opnform_oidc_domain }}"
|
||||
|
|
@ -233,13 +341,93 @@
|
|||
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: |-
|
||||
|
|
@ -260,6 +448,11 @@
|
|||
(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 %}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ services:
|
|||
image: {{ opnform_api_image }}
|
||||
container_name: opnform-api
|
||||
restart: unless-stopped
|
||||
{% if opnform_extra_hosts | length > 0 %}
|
||||
extra_hosts:
|
||||
{% for host in opnform_extra_hosts %}
|
||||
- "{{ host }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
volumes:
|
||||
- {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw
|
||||
environment: &api-env
|
||||
|
|
@ -14,6 +20,9 @@ services:
|
|||
APP_URL: "{{ opnform_base_url }}"
|
||||
APP_DEBUG: "false"
|
||||
SELF_HOSTED: "true"
|
||||
{% if opnform_oidc_enabled and (_opnform_force_login_effective | default(false)) %}
|
||||
OIDC_FORCE_LOGIN: "true"
|
||||
{% endif %}
|
||||
|
||||
LOG_CHANNEL: errorlog
|
||||
LOG_LEVEL: info
|
||||
|
|
@ -51,6 +60,14 @@ services:
|
|||
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 }}"
|
||||
|
|
@ -105,7 +122,13 @@ services:
|
|||
NUXT_PUBLIC_API_BASE: "/api"
|
||||
NUXT_PRIVATE_API_BASE: "http://ingress/api"
|
||||
NUXT_PUBLIC_ENV: production
|
||||
FRONT_API_SECRET: "{{ opnform_front_api_secret }}"
|
||||
# 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
|
||||
|
|
@ -173,10 +196,13 @@ services:
|
|||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network={{ opnform_traefik_network }}
|
||||
- traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`)
|
||||
- traefik.http.routers.{{ opnform_service_name }}.rule={% set _all_domains = [opnform_domain] + (opnform_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
|
||||
{% if opnform_use_ssl %}
|
||||
- traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure
|
||||
- traefik.http.routers.{{ opnform_service_name }}.tls=true
|
||||
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
|
||||
- traefik.http.routers.{{ opnform_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- traefik.http.routers.{{ opnform_service_name }}.entrypoints=web
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,53 @@ server {
|
|||
|
||||
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://ui:3000;
|
||||
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;
|
||||
|
|
@ -34,7 +78,7 @@ server {
|
|||
|
||||
location ~ \.php$ {
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass api:9000;
|
||||
fastcgi_pass $upstream_api:9000;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php;
|
||||
|
|
|
|||
|
|
@ -48,13 +48,17 @@ With S3 (Garage) backend:
|
|||
|
||||
```yaml
|
||||
send_storage_backend: s3
|
||||
send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domain'] }}"
|
||||
send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domains'][0] }}"
|
||||
send_s3_bucket: "send"
|
||||
send_s3_access_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['key_id'] }}"
|
||||
send_s3_secret_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['secret_key'] }}"
|
||||
```
|
||||
|
||||
When `send_storage_backend: s3`, the role asserts that `send_s3_endpoint`,
|
||||
`send_s3_bucket`, `send_s3_access_key` and `send_s3_secret_key` are all set,
|
||||
and fails early otherwise.
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
MIT
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
galaxy_info:
|
||||
author: digitalboard
|
||||
description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose
|
||||
license: MIT
|
||||
license: MIT-0
|
||||
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ services:
|
|||
{% if send_use_ssl %}
|
||||
- traefik.http.routers.{{ send_service_name }}.entrypoints=websecure
|
||||
- traefik.http.routers.{{ send_service_name }}.tls=true
|
||||
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
|
||||
- traefik.http.routers.{{ send_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
- traefik.http.routers.{{ send_service_name }}.entrypoints=web
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,103 @@
|
|||
Role Name
|
||||
=========
|
||||
# Traefik
|
||||
|
||||
A brief description of the role goes here.
|
||||
Ansible role to deploy Traefik v3 as a reverse proxy via Docker Compose,
|
||||
either as a public-facing DMZ proxy (file provider) or as a backend
|
||||
application proxy (docker provider).
|
||||
|
||||
Requirements
|
||||
------------
|
||||
## Requirements
|
||||
|
||||
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
|
||||
- Docker and Docker Compose installed on the target host
|
||||
- Ansible collection: `community.docker`
|
||||
- For ACME DNS-01: an RFC2136-capable nameserver with a delegated zone
|
||||
for `_acme-challenge` records and a TSIG key
|
||||
|
||||
Role Variables
|
||||
--------------
|
||||
## Role variables
|
||||
|
||||
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
|
||||
Full list with types and defaults: `meta/argument_specs.yml`. The most
|
||||
common overrides:
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
### Deployment mode
|
||||
|
||||
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.
|
||||
- `traefik_mode`: `dmz` (file provider, routes to external backends) or
|
||||
`backend` (docker provider, discovers local containers). Default `backend`.
|
||||
- `traefik_backend_servers_to_proxy`: in `dmz` mode, restrict which
|
||||
inventory hosts the DMZ aggregates services from. Empty = all members
|
||||
of `backend_servers`.
|
||||
|
||||
Example Playbook
|
||||
----------------
|
||||
### Networking
|
||||
|
||||
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
|
||||
- `traefik_network`: docker network connecting traefik to its containers
|
||||
(default `proxy`).
|
||||
- `traefik_extra_hosts`: list of `host:ip` entries injected as the
|
||||
container's `extra_hosts`. Use when a downstream middleware
|
||||
(e.g. ForwardAuth to authentik on a sibling LAN) must resolve a public
|
||||
FQDN to an internal IP because the DMZ does not hairpin the public
|
||||
address back inside.
|
||||
|
||||
- hosts: servers
|
||||
roles:
|
||||
- { role: username.rolename, x: 42 }
|
||||
### Certificates
|
||||
|
||||
License
|
||||
-------
|
||||
- `traefik_cert_mode`: `acme` (Let's Encrypt via DNS-01) or `selfsigned`
|
||||
(local wildcard). Default `selfsigned`.
|
||||
- `traefik_acme_dns_zone`, `traefik_acme_dns_nameserver`,
|
||||
`traefik_acme_tsig_key`, `traefik_acme_tsig_secret`: RFC2136 / TSIG
|
||||
configuration for the ACME DNS-01 challenge.
|
||||
- `traefik_acme_tcp_only`: force lego's DNS lookups onto TCP/53 when the
|
||||
container cannot reach the nameserver over UDP.
|
||||
- `traefik_acme_disable_ans_checks`: skip the authoritative-NS
|
||||
propagation check when the SOA-listed NS resolves to an unreachable IP.
|
||||
|
||||
BSD
|
||||
### Dashboard
|
||||
|
||||
Author Information
|
||||
------------------
|
||||
- `traefik_enable_dashboard`: expose the traefik dashboard.
|
||||
- `traefik_dashboard_domain`: when set, publish the dashboard on this
|
||||
Host rule instead of the insecure port.
|
||||
|
||||
An optional section for the role authors to include contact information, or a website (HTML is not allowed).
|
||||
## Dependencies
|
||||
|
||||
- Run `digitalboard.core.base` first (or otherwise install Docker and the
|
||||
`community.docker` collection); this role manages containers and networks
|
||||
through `community.docker`.
|
||||
- The Traefik network (`traefik_network`, default `proxy`) is created by
|
||||
this role (`community.docker.docker_network`, state present), so no
|
||||
pre-creation is required.
|
||||
- In `dmz` mode, backend hosts advertise the services to aggregate via the
|
||||
`traefik_dmz_exposed_services` host_var; `traefik_services` defines extra
|
||||
routes directly on the DMZ host (each entry must set `backend_host`).
|
||||
|
||||
## Example playbook
|
||||
|
||||
Backend mode (one app server per host, docker provider):
|
||||
|
||||
```yaml
|
||||
- hosts: app_servers
|
||||
roles:
|
||||
- role: digitalboard.core.traefik
|
||||
vars:
|
||||
traefik_mode: backend
|
||||
traefik_cert_mode: acme
|
||||
traefik_ssl_email: ops@example.com
|
||||
traefik_acme_dns_zone: "_acme.example.com."
|
||||
traefik_acme_dns_nameserver: "10.0.0.53:53"
|
||||
traefik_acme_tsig_key: "acme-key"
|
||||
traefik_acme_tsig_secret: "{{ vault_traefik_tsig_secret }}"
|
||||
```
|
||||
|
||||
DMZ mode (aggregates services from `backend_servers`):
|
||||
|
||||
```yaml
|
||||
- hosts: dmz_servers
|
||||
roles:
|
||||
- role: digitalboard.core.traefik
|
||||
vars:
|
||||
traefik_mode: dmz
|
||||
traefik_cert_mode: acme
|
||||
traefik_backend_servers_to_proxy:
|
||||
- app01
|
||||
- app02
|
||||
traefik_extra_hosts:
|
||||
- "auth.example.com:172.16.19.101"
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT-0
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ service_name: traefik
|
|||
docker_compose_dir: "{{ docker_compose_base_dir }}/{{ service_name }}"
|
||||
docker_volume_dir: "{{ docker_volume_base_dir }}/{{ service_name }}"
|
||||
|
||||
# Optional /etc/hosts entries injected into the traefik container. Useful
|
||||
# when downstream middlewares (e.g. ForwardAuth to an authentik instance
|
||||
# running on a sibling LAN) need a public FQDN to resolve to an internal
|
||||
# IP because the DMZ doesn't hairpin the public address back inside.
|
||||
# Example: ["auth.example.com:172.16.19.101"]
|
||||
traefik_extra_hosts: []
|
||||
|
||||
# Deployment mode: 'dmz' or 'backend'
|
||||
# - dmz: Public-facing reverse proxy that routes to backend servers using file provider
|
||||
# - backend: Application server with docker provider for local container discovery
|
||||
|
|
@ -33,6 +40,18 @@ traefik_acme_tsig_secret: "" # TSIG secret
|
|||
traefik_acme_propagation_timeout: "120"
|
||||
traefik_acme_polling_interval: "2"
|
||||
traefik_acme_ttl: "60"
|
||||
# Force lego's DNS lookups (SOA resolution, propagation checks) onto
|
||||
# TCP instead of UDP. Useful when container egress can reach the
|
||||
# nameserver on TCP/53 but UDP/53 is blocked or unreliable. Sets the
|
||||
# upstream env var LEGO_EXPERIMENTAL_DNS_TCP_ONLY=true on the
|
||||
# traefik container.
|
||||
traefik_acme_tcp_only: false
|
||||
# Disable lego's propagation check against the zone's authoritative
|
||||
# nameservers. Use when the SOA-listed NS hostname resolves to an
|
||||
# address that isn't reachable from this traefik host (e.g. a DMZ
|
||||
# box that can only see the internal NS IP, not the public one).
|
||||
# lego still polls via the configured `resolvers:` list.
|
||||
traefik_acme_disable_ans_checks: false
|
||||
|
||||
# Self-signed certificate configuration (for vagrant/testing)
|
||||
traefik_selfsigned_cert_dir: "{{ docker_volume_dir }}/certs"
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@
|
|||
- name: restart traefik
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ docker_compose_dir }}"
|
||||
state: restarted
|
||||
state: present
|
||||
|
|
|
|||
216
roles/traefik/meta/argument_specs.yml
Normal file
216
roles/traefik/meta/argument_specs.yml
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Deploy Traefik v3 as DMZ or backend reverse proxy via Docker Compose.
|
||||
description:
|
||||
- Renders a Docker Compose stack for Traefik with either the file provider
|
||||
(DMZ mode, routes to external backends) or the docker provider (backend
|
||||
mode, discovers local containers via labels).
|
||||
- Supports ACME DNS-01 issuance (RFC2136 / TSIG) or a self-signed cert
|
||||
bundle for local/Vagrant setups.
|
||||
options:
|
||||
docker_compose_base_dir:
|
||||
type: path
|
||||
default: /etc/docker/compose
|
||||
description: Base directory under which the per-service compose dir is created.
|
||||
docker_volume_base_dir:
|
||||
type: path
|
||||
default: /srv/data
|
||||
description: Base directory under which the per-service volume dir is created.
|
||||
service_name:
|
||||
type: str
|
||||
default: traefik
|
||||
description: Compose project / service name; also used to build the per-service paths.
|
||||
docker_compose_dir:
|
||||
type: path
|
||||
description: Compose project directory; defaults to C({{ docker_compose_base_dir }}/{{ service_name }}).
|
||||
docker_volume_dir:
|
||||
type: path
|
||||
description: Per-service volume directory; defaults to C({{ docker_volume_base_dir }}/{{ service_name }}).
|
||||
|
||||
traefik_extra_hosts:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description:
|
||||
- Entries injected as C(extra_hosts) on the traefik container.
|
||||
- Each entry has the Docker syntax C("host:ip").
|
||||
- Useful when a downstream middleware (e.g. ForwardAuth to authentik
|
||||
on a sibling LAN) must resolve a public FQDN to an internal IP
|
||||
because the DMZ does not hairpin the public address.
|
||||
|
||||
traefik_mode:
|
||||
type: str
|
||||
choices: [dmz, backend]
|
||||
default: backend
|
||||
description:
|
||||
- C(dmz) configures the file provider so the proxy forwards to
|
||||
backend hosts (typically aggregated from the C(backend_servers) group).
|
||||
- C(backend) configures the docker provider for local container discovery.
|
||||
|
||||
traefik_use_ssl:
|
||||
type: bool
|
||||
default: true
|
||||
description: Toggle TLS on the websecure entrypoint.
|
||||
traefik_ssl_email:
|
||||
type: str
|
||||
default: admin@example.com
|
||||
description: Contact e-mail used by the ACME resolver.
|
||||
traefik_ssl_cert_resolver:
|
||||
type: str
|
||||
default: dns
|
||||
description: Certificate resolver name referenced in router labels.
|
||||
traefik_cert_mode:
|
||||
type: str
|
||||
choices: [acme, selfsigned]
|
||||
default: selfsigned
|
||||
description: C(acme) for Let's Encrypt via DNS-01, C(selfsigned) for a locally generated bundle.
|
||||
|
||||
traefik_acme_dns_zone:
|
||||
type: str
|
||||
default: ''
|
||||
description: Delegated zone used for the TSIG-signed updates (e.g. C(_acme.example.com.)).
|
||||
traefik_acme_dns_nameserver:
|
||||
type: str
|
||||
default: ''
|
||||
description: Nameserver lego talks to for the DNS challenge (C(host:port)).
|
||||
traefik_acme_tsig_algorithm:
|
||||
type: str
|
||||
default: hmac-sha256
|
||||
description: TSIG algorithm.
|
||||
traefik_acme_tsig_key:
|
||||
type: str
|
||||
default: ''
|
||||
description: TSIG key name.
|
||||
traefik_acme_tsig_secret:
|
||||
type: str
|
||||
default: ''
|
||||
description: TSIG secret (base64).
|
||||
traefik_acme_propagation_timeout:
|
||||
type: str
|
||||
default: '120'
|
||||
description: lego DNS propagation timeout in seconds.
|
||||
traefik_acme_polling_interval:
|
||||
type: str
|
||||
default: '2'
|
||||
description: lego DNS propagation polling interval in seconds.
|
||||
traefik_acme_ttl:
|
||||
type: str
|
||||
default: '60'
|
||||
description: TTL applied to the C(_acme-challenge) TXT records.
|
||||
traefik_acme_tcp_only:
|
||||
type: bool
|
||||
default: false
|
||||
description:
|
||||
- Sets C(LEGO_EXPERIMENTAL_DNS_TCP_ONLY=true) on the container so SOA
|
||||
resolution and propagation checks use TCP/53. Use when UDP/53 is
|
||||
blocked or unreliable on the container egress path.
|
||||
traefik_acme_disable_ans_checks:
|
||||
type: bool
|
||||
default: false
|
||||
description:
|
||||
- "Sets C(propagation.disableANSChecks) to true on the ACME resolver
|
||||
in the static config, disabling lego's propagation check against
|
||||
the zone's authoritative nameservers. Use when the SOA-listed NS
|
||||
hostname resolves to an address the proxy host cannot reach; lego
|
||||
still polls via the configured C(resolvers) list."
|
||||
|
||||
traefik_selfsigned_cert_dir:
|
||||
type: path
|
||||
description: Output directory for the self-signed bundle.
|
||||
traefik_selfsigned_cert_days:
|
||||
type: int
|
||||
default: 365
|
||||
description: Validity in days for the self-signed bundle.
|
||||
traefik_selfsigned_common_name:
|
||||
type: str
|
||||
default: '*.local.test'
|
||||
description: CN/SAN of the self-signed wildcard cert.
|
||||
|
||||
traefik_enable_dashboard:
|
||||
type: bool
|
||||
default: false
|
||||
description: Expose the traefik dashboard.
|
||||
traefik_dashboard_domain:
|
||||
type: str
|
||||
default: ''
|
||||
description:
|
||||
- When non-empty, the dashboard is published on this Host rule instead
|
||||
of the insecure port 8080.
|
||||
|
||||
traefik_enable_access_logs:
|
||||
type: bool
|
||||
default: true
|
||||
traefik_access_log_format:
|
||||
type: str
|
||||
choices: [common, json]
|
||||
default: common
|
||||
traefik_log_level:
|
||||
type: str
|
||||
choices: [DEBUG, INFO, WARN, ERROR, FATAL, PANIC]
|
||||
default: INFO
|
||||
|
||||
traefik_network:
|
||||
type: str
|
||||
default: proxy
|
||||
description: Docker network connecting traefik to its routable containers.
|
||||
|
||||
traefik_dmz_exposed_services:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
description:
|
||||
- In C(dmz) mode, services collected from backend host_vars are
|
||||
published via the file provider. Each entry needs C(name),
|
||||
C(domain), C(port); C(protocol) and C(backend_host) are optional.
|
||||
options:
|
||||
name:
|
||||
type: str
|
||||
required: true
|
||||
domain:
|
||||
type: str
|
||||
required: true
|
||||
port:
|
||||
type: int
|
||||
required: true
|
||||
protocol:
|
||||
type: str
|
||||
choices: [http, https]
|
||||
default: http
|
||||
backend_host:
|
||||
type: str
|
||||
description: Override the auto-selected backend host.
|
||||
|
||||
traefik_services:
|
||||
type: list
|
||||
elements: dict
|
||||
default: []
|
||||
description:
|
||||
- Services defined directly on the DMZ proxy (not auto-discovered
|
||||
from a backend host). Each entry must set C(backend_host).
|
||||
options:
|
||||
name:
|
||||
type: str
|
||||
required: true
|
||||
domain:
|
||||
type: str
|
||||
required: true
|
||||
backend_host:
|
||||
type: str
|
||||
required: true
|
||||
port:
|
||||
type: int
|
||||
required: true
|
||||
protocol:
|
||||
type: str
|
||||
choices: [http, https]
|
||||
default: http
|
||||
|
||||
traefik_backend_servers_to_proxy:
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
description:
|
||||
- In C(dmz) mode, explicit list of backend hosts the DMZ proxy
|
||||
should aggregate exposed services from. Empty means all members
|
||||
of the C(backend_servers) inventory group.
|
||||
|
|
@ -1,33 +1,26 @@
|
|||
#SPDX-License-Identifier: MIT-0
|
||||
galaxy_info:
|
||||
author: your name
|
||||
description: your role description
|
||||
company: your company (optional)
|
||||
author: digitalboard
|
||||
description: Deploy Traefik v3 as a DMZ or backend reverse proxy via Docker Compose
|
||||
company: Digitalboard
|
||||
license: MIT-0
|
||||
|
||||
# If the issue tracker for your role is not on github, uncomment the
|
||||
# next line and provide a value
|
||||
# issue_tracker_url: http://example.com/issue/tracker
|
||||
min_ansible_version: "2.14"
|
||||
|
||||
# Choose a valid license ID from https://spdx.org - some suggested licenses:
|
||||
# - BSD-3-Clause (default)
|
||||
# - MIT
|
||||
# - GPL-2.0-or-later
|
||||
# - GPL-3.0-only
|
||||
# - Apache-2.0
|
||||
# - CC-BY-4.0
|
||||
license: license (GPL-2.0-or-later, MIT, etc)
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions:
|
||||
- bookworm
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- jammy
|
||||
- noble
|
||||
|
||||
min_ansible_version: 2.1
|
||||
|
||||
# If this a Container Enabled role, provide the minimum Ansible Container version.
|
||||
# min_ansible_container_version:
|
||||
|
||||
galaxy_tags: []
|
||||
# List tags for your role here, one per line. A tag is a keyword that describes
|
||||
# and categorizes the role. Users find roles by searching for tags. Be sure to
|
||||
# remove the '[]' above, if you add tags to this list.
|
||||
#
|
||||
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
|
||||
# Maximum 20 tags per role.
|
||||
galaxy_tags:
|
||||
- traefik
|
||||
- reverseproxy
|
||||
- ingress
|
||||
- docker
|
||||
- digitalboard
|
||||
|
||||
dependencies: []
|
||||
|
|
|
|||
|
|
@ -9,7 +9,18 @@
|
|||
|
||||
- name: Build service registry from backend servers (DMZ mode)
|
||||
set_fact:
|
||||
proxied_services: "{{ proxied_services | default([]) + hostvars[item].traefik_dmz_exposed_services | default([]) | map('combine', {'backend_host': hostvars[item].ansible_host | default(item)}) | list }}"
|
||||
# Two-step merge so a service entry's own `backend_host` wins:
|
||||
# entries that set it pass through unchanged, entries that don't
|
||||
# get the backend host's ansible_host as fallback. The override
|
||||
# lets a route target an internal FQDN covered by the backend
|
||||
# cert's SANs instead of the raw IP (which would fail backend
|
||||
# TLS verification at the proxy hop).
|
||||
proxied_services: >-
|
||||
{{
|
||||
proxied_services | default([])
|
||||
+ (hostvars[item].traefik_dmz_exposed_services | default([]) | selectattr('backend_host', 'defined') | list)
|
||||
+ (hostvars[item].traefik_dmz_exposed_services | default([]) | rejectattr('backend_host', 'defined') | map('combine', {'backend_host': hostvars[item].ansible_host | default(item)}) | list)
|
||||
}}
|
||||
loop: "{{ _backend_servers | default([]) }}"
|
||||
when: traefik_mode == 'dmz'
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ services:
|
|||
RFC2136_PROPAGATION_TIMEOUT: "{{ traefik_acme_propagation_timeout }}"
|
||||
RFC2136_POLLING_INTERVAL: "{{ traefik_acme_polling_interval }}"
|
||||
RFC2136_TTL: "{{ traefik_acme_ttl }}"
|
||||
{% if traefik_acme_tcp_only | default(false) %}
|
||||
LEGO_EXPERIMENTAL_DNS_TCP_ONLY: "true"
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
ports:
|
||||
- "80:80"
|
||||
|
|
@ -30,6 +33,12 @@ services:
|
|||
{% endif %}
|
||||
networks:
|
||||
- {{ traefik_network }}
|
||||
{% if traefik_extra_hosts | default([]) | length > 0 %}
|
||||
extra_hosts:
|
||||
{% for h in traefik_extra_hosts %}
|
||||
- "{{ h }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
networks:
|
||||
{{ traefik_network }}:
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ certificatesResolvers:
|
|||
provider: rfc2136
|
||||
resolvers:
|
||||
- "{{ traefik_acme_dns_nameserver }}"
|
||||
{% if traefik_acme_disable_ans_checks | default(false) %}
|
||||
propagation:
|
||||
disableANSChecks: true
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if traefik_use_ssl %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue