feat(services): multi-domain routing, split-horizon and OIDC hardening

Bundle of cross-role changes for the gymb services deployment:

- Traefik routers: OR-combine opnform/homarr/bookstack Host rules with new
  *_extra_domains (internal *.int.* FQDNs for a DMZ reverseproxy), and emit
  tls.certresolver only when traefik_cert_mode == acme (drawio, homarr,
  opnform, send).
- Split-horizon: bookstack_extra_hosts / opnform_extra_hosts add container
  /etc/hosts overrides so containers reach the IdP public FQDN over the LAN.
- bookstack: assert the OIDC issuer resolves concretely (reject "//v2.0"),
  allowing non-Entra IdPs that override bookstack_oidc_issuer.
- homarr: derive the bcrypt salt from the password digest so the admin hash
  is idempotent — no spurious template changes / container restarts.
- opnform: PATCH an existing OIDC connection instead of skipping (applies
  corrected inventory on re-run); add OIDC_FORCE_LOGIN (enabled only after
  bootstrap) and an optional direct-SSO ingress entrypoint.

Docs: READMEs and meta/argument_specs.yml updated for all new variables.
This commit is contained in:
Simon Bärlocher 2026-05-27 16:18:29 +02:00
parent 1dcff92240
commit 19864d79b2
No known key found for this signature in database
GPG key ID: 63DE20495932047A
17 changed files with 309 additions and 37 deletions

View file

@ -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)

View file

@ -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

View file

@ -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 }}").

View file

@ -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.

View file

@ -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 }}"

View file

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

View file

@ -46,6 +46,7 @@ See `defaults/main.yml` for the full list. Most useful overrides:
| Variable | Default | Purpose |
|---|---|---|
| `homarr_domain` | `homarr.local.test` | Traefik Host rule |
| `homarr_extra_domains` | `[]` | Extra Host-rule hostnames (OR-combined), e.g. internal `*.int.*` FQDN |
| `homarr_base_url` | `https://home.local.test` | NEXTAUTH_URL / BASE_URL |
| `homarr_auth_providers` | `credentials` | `credentials`, `oidc`, or both |
| `homarr_oidc_issuer` | empty | Identity provider issuer URL |

View file

@ -15,6 +15,10 @@ homarr_db: "{{ homarr_appdata_dir }}/db/db.sqlite"
# Service configuration
homarr_domain: "homarr.local.test"
# Additional hostnames the homarr router answers on (e.g. an internal
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
# by the cert).
homarr_extra_domains: []
homarr_image: "ghcr.io/homarr-labs/homarr:latest"
homarr_port: 7575
homarr_use_docker: false

View file

@ -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

View file

@ -29,10 +29,13 @@ services:
labels:
- traefik.enable=true
- traefik.docker.network={{ homarr_traefik_network }}
- traefik.http.routers.homarr.rule=Host(`{{ homarr_domain }}`)
- traefik.http.routers.homarr.rule={% set _all_domains = [homarr_domain] + (homarr_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
{% if homarr_use_ssl %}
- traefik.http.routers.homarr.entrypoints=websecure
- traefik.http.routers.homarr.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.homarr.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.homarr.entrypoints=web
{% endif %}

View file

@ -11,9 +11,10 @@ Docker Compose stack behind Traefik.
- Integrates the ingress container with an existing Traefik proxy network
- Waits for the API container to become healthy before returning
## What this role does NOT do (stage 1)
## What this role does NOT do
- Does not pre-configure OIDC / identity_connections — set up via Admin UI
- Does not migrate existing OpnForm databases — only bootstraps fresh
installs (admin registration + OIDC connection are idempotent)
## Architecture note: why two reverse proxies?
@ -91,11 +92,14 @@ Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit
## OIDC setup
Set `opnform_oidc_enabled: true` and the role creates an
Set `opnform_oidc_enabled: true` and the role provisions an
IdentityConnection on the admin's default workspace via
`POST /api/open/workspaces/{id}/oidc-connections`. OpnForm enforces a
single OIDC connection per workspace, so the task is idempotent (GETs
existing connections first and skips if any exist).
single OIDC connection per workspace, so the task is idempotent: it GETs
existing connections first, then either POSTs a new one or PATCHes the
existing one to the desired state. PATCHing (rather than skipping when
one exists) keeps inventory changes — e.g. a corrected issuer — applied
on re-runs instead of leaving stale values in the DB.
**Prerequisite**: the admin bootstrap must be configured
(`opnform_admin_email` + `opnform_admin_password`). The OIDC API
@ -138,6 +142,44 @@ opnform_oidc_admin_group: "opnform-admins" # mapped to role=admin
Valid roles: `owner`, `admin`, `editor`, `member`.
### Force OIDC-only login
```yaml
opnform_oidc_force_login: true # default false
```
Sets `OIDC_FORCE_LOGIN=true` on the API: password login is disabled and
every user must authenticate via OIDC. The role keeps force-login **off**
during the first deploy (the admin/OIDC bootstrap is password-based) and
switches it on only after the OIDC connection is provisioned, recreating
the API containers. Ensure all real users have addresses under
`opnform_oidc_domain` before enabling — there is no password fallback.
### Direct-SSO entrypoint
OpnForm has no native way to skip the email login form and jump straight
to the IdP. When enabled, the ingress serves a tiny redirect page that
calls `/api/auth/{slug}/redirect` (no domain check) and forwards the
browser to the IdP authorize URL.
```yaml
opnform_oidc_sso_entrypoint: true # default false
opnform_oidc_sso_path: "/sso" # link users to https://<domain>/sso
```
## Networking / split-horizon
```yaml
opnform_extra_domains: [] # extra Host-rule hostnames (OR-combined)
opnform_extra_hosts: [] # API container /etc/hosts overrides ("host:ip")
```
`opnform_extra_domains` adds internal `*.int.*` FQDNs so a DMZ
reverseproxy can reach a backend hostname covered by the cert.
`opnform_extra_hosts` lets the API containers reach the IdP's public FQDN
(used in the OIDC `iss` claim) over the LAN when the DMZ has no NAT
loopback.
## Example playbook
```yaml

View file

@ -16,6 +16,15 @@ opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis"
# Service configuration
opnform_domain: "forms.local.test"
# Additional hostnames the opnform router answers on (e.g. an internal
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
# by the cert).
opnform_extra_domains: []
# Container-level /etc/hosts overrides for the API containers — needed in
# split-horizon setups where the OpnForm API must reach the IdP's public
# FQDN (used in the OIDC discovery/iss claim) over the LAN rather than
# hairpinning through a DMZ that has no NAT loopback to its own public IP.
opnform_extra_hosts: []
opnform_base_url: "https://forms.local.test"
# Images
@ -92,6 +101,12 @@ opnform_oidc_slug: "oidc"
# with @example.com emails are redirected to the IdP). Required when
# opnform_oidc_enabled is true.
opnform_oidc_domain: ""
# When true, sets OIDC_FORCE_LOGIN on the api: password-based login is
# disabled entirely and every user must authenticate via OIDC. Only
# rendered when opnform_oidc_enabled is also true. Make sure all real
# users have addresses under opnform_oidc_domain before enabling — there
# is no password fallback once this is on.
opnform_oidc_force_login: false
opnform_oidc_scopes:
- openid
- profile
@ -104,6 +119,17 @@ opnform_oidc_admin_group: "opnform-admins"
# var. Each item: {idp_group: "<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"
# Traefik configuration
opnform_traefik_network: "proxy"
opnform_use_ssl: true

View file

@ -38,6 +38,25 @@ argument_specs:
type: str
default: forms.local.test
description: Hostname used in the traefik Host rule.
opnform_extra_domains:
type: list
elements: str
default: []
description:
- Additional hostnames the Traefik router answers on, OR-combined
with C(opnform_domain). Useful for an internal C(*.int.*) FQDN so
a DMZ reverseproxy can reach a backend hostname covered by the
cert.
opnform_extra_hosts:
type: list
elements: str
default: []
description:
- Container-level C(/etc/hosts) overrides for the API containers
(Compose C(extra_hosts) entries, C("host:ip")). Needed in
split-horizon setups where the OpnForm API must reach the IdP's
public FQDN (used in the OIDC discovery / C(iss) claim) over the
LAN rather than hairpinning through a DMZ with no NAT loopback.
opnform_base_url:
type: str
default: https://forms.local.test
@ -184,6 +203,15 @@ argument_specs:
description:
- Email domain that triggers OIDC for matching users. Required
when C(opnform_oidc_enabled=true).
opnform_oidc_force_login:
type: bool
default: false
description:
- "When true, sets C(OIDC_FORCE_LOGIN=true) on the api container:
password-based login is disabled and every user must authenticate
via OIDC. Only takes effect when C(opnform_oidc_enabled=true).
Ensure all real users have addresses under C(opnform_oidc_domain)
before enabling — there is no password fallback."
opnform_oidc_scopes:
type: list
elements: str
@ -211,6 +239,23 @@ argument_specs:
type: str
required: true
choices: [owner, admin, editor, member]
opnform_oidc_sso_entrypoint:
type: bool
default: false
description:
- When true (and C(opnform_oidc_enabled=true)) the nginx ingress
serves a small redirect page at C(opnform_oidc_sso_path) that
calls OpnForm's C(/api/auth/{slug}/redirect) endpoint and
forwards the browser to the returned IdP authorize URL. Lets
you link users straight to the IdP, skipping OpnForm's
email-based login form. OpnForm has no native option for this.
opnform_oidc_sso_path:
type: str
default: /sso
description:
- Path (on C(opnform_domain)) where the direct-SSO redirect page
is served when C(opnform_oidc_sso_entrypoint=true). Must start
with C(/) and not collide with OpnForm's own routes.
opnform_traefik_network:
type: str

View file

@ -76,6 +76,15 @@
mode: '0644'
notify: restart opnform
# OIDC_FORCE_LOGIN disables OpnForm's password login — including the
# password-based admin/OIDC bootstrap this role performs below. So the
# first compose render always keeps force-login OFF; it is switched on
# only after the bootstrap completes (see step 7). This keeps a first
# deploy on a fresh host working even when opnform_oidc_force_login=true.
- name: Render compose with force-login disabled during bootstrap
ansible.builtin.set_fact:
_opnform_force_login_effective: false
- name: Deploy docker-compose file
ansible.builtin.template:
src: docker-compose.yml.j2
@ -155,9 +164,12 @@
# =====================================================================
# 6. OIDC IDENTITY CONNECTION (optional)
# =====================================================================
# Creates a single OIDC connection on the admin's default workspace.
# OpnForm enforces one OIDC connection per workspace, so this block is
# idempotent: we GET existing connections first and skip if any exists.
# Provisions a single OIDC connection on the admin's default workspace.
# OpnForm enforces one OIDC connection per workspace, so we GET the
# existing connections first and then either POST a new one or PATCH the
# existing one to the desired state. PATCHing (rather than skipping when
# one exists) keeps inventory changes — e.g. a corrected issuer — applied
# on re-runs instead of leaving stale values in the DB forever.
- name: Log in as admin to obtain OIDC API token
ansible.builtin.uri:
@ -213,15 +225,12 @@
}}
when: opnform_oidc_enabled | bool
- name: Create OIDC identity connection
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections"
method: POST
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
body_format: json
body:
# Desired connection state shared by both the create (POST) and update
# (PATCH) calls below. client_secret is always sent: OpnForm's update
# endpoint only persists it when present, and on create it is required.
- name: Build desired OIDC connection body
ansible.builtin.set_fact:
_opnform_oidc_body:
name: "{{ opnform_oidc_client_name }}"
slug: "{{ opnform_oidc_slug }}"
domain: "{{ opnform_oidc_domain }}"
@ -233,6 +242,18 @@
options:
require_state: true
group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}"
no_log: true
when: opnform_oidc_enabled | bool
- name: Create OIDC identity connection
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections"
method: POST
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
body_format: json
body: "{{ _opnform_oidc_body }}"
status_code: [201]
validate_certs: false
no_log: true
@ -240,6 +261,58 @@
- opnform_oidc_enabled | bool
- opnform_existing_oidc.json | length == 0
# An OIDC connection already exists: PATCH it to the desired state so
# inventory changes (e.g. a corrected issuer) are applied. OpnForm allows
# exactly one connection per workspace, so the first entry is ours.
- name: Update existing OIDC identity connection
ansible.builtin.uri:
url: >-
https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections/{{ opnform_existing_oidc.json[0].id }}
method: PATCH
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ opnform_oidc_token.json.token }}"
body_format: json
body: "{{ _opnform_oidc_body }}"
status_code: [200]
validate_certs: false
no_log: true
when:
- opnform_oidc_enabled | bool
- opnform_existing_oidc.json | length > 0
# =====================================================================
# 7. ENABLE FORCE LOGIN (optional, must run last)
# =====================================================================
# OIDC_FORCE_LOGIN disables password login — including the password-based
# admin/OIDC bootstrap above — so it is switched on only now, after the
# connection is provisioned. OpnForm itself only enforces force-login when
# an enabled OIDC connection exists, so the order matters: connection
# first, force-login second.
- name: Enable force login now that the OIDC connection exists
when:
- opnform_oidc_enabled | bool
- opnform_oidc_force_login | bool
block:
- name: Re-render compose with force-login enabled
ansible.builtin.set_fact:
_opnform_force_login_effective: true
- name: Deploy docker-compose file with force-login enabled
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
register: _opnform_force_login_compose
- name: Apply force-login by recreating the api containers
community.docker.docker_compose_v2:
project_src: "{{ opnform_docker_compose_dir }}"
state: present
wait: true
wait_timeout: 180
when: _opnform_force_login_compose is changed
- name: Display deployment info
ansible.builtin.debug:
msg: |-
@ -260,6 +333,10 @@
(slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }})
Users with @{{ opnform_oidc_domain }} addresses will be
redirected to {{ opnform_oidc_issuer }} on login.
{% if opnform_oidc_sso_entrypoint %}
Direct-SSO entrypoint: {{ opnform_base_url }}{{ opnform_oidc_sso_path }}
(link users here to skip the email login form)
{% endif %}
{% else %}
OIDC: disabled (set opnform_oidc_enabled=true to auto-configure)
{% endif %}

View file

@ -6,6 +6,12 @@ services:
image: {{ opnform_api_image }}
container_name: opnform-api
restart: unless-stopped
{% if opnform_extra_hosts | length > 0 %}
extra_hosts:
{% for host in opnform_extra_hosts %}
- "{{ host }}"
{% endfor %}
{% endif %}
volumes:
- {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw
environment: &api-env
@ -14,6 +20,9 @@ services:
APP_URL: "{{ opnform_base_url }}"
APP_DEBUG: "false"
SELF_HOSTED: "true"
{% if opnform_oidc_enabled and (_opnform_force_login_effective | default(false)) %}
OIDC_FORCE_LOGIN: "true"
{% endif %}
LOG_CHANNEL: errorlog
LOG_LEVEL: info
@ -173,10 +182,13 @@ services:
labels:
- traefik.enable=true
- traefik.docker.network={{ opnform_traefik_network }}
- traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`)
- traefik.http.routers.{{ opnform_service_name }}.rule={% set _all_domains = [opnform_domain] + (opnform_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}
{% if opnform_use_ssl %}
- traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure
- traefik.http.routers.{{ opnform_service_name }}.tls=true
{% if traefik_cert_mode | default('selfsigned') == 'acme' %}
- traefik.http.routers.{{ opnform_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }}
{% endif %}
{% else %}
- traefik.http.routers.{{ opnform_service_name }}.entrypoints=web
{% endif %}

View file

@ -15,6 +15,17 @@ server {
index index.html index.htm index.php;
{% if opnform_oidc_enabled and opnform_oidc_sso_entrypoint %}
# Direct-SSO entrypoint: a tiny page that asks the API for the IdP
# authorize URL (no email/domain check on this endpoint) and forwards
# the browser there. Link users here instead of /login to skip the
# email field entirely. Exact-match so it wins over the `/` prefix.
location = {{ opnform_oidc_sso_path }} {
default_type text/html;
return 200 '<!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+". Go to the login page instead.";var a=document.createElement("a");a.href="/login";a.textContent="Open login page";a.style.display="block";a.style.marginTop="1rem";document.body.appendChild(a)});</script></body></html>';
}
{% endif %}
location / {
proxy_http_version 1.1;
proxy_pass http://ui:3000;

View file

@ -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 %}