This commit is contained in:
Tiara Rodney 2026-03-14 05:38:45 +01:00
commit 883f31932e
No known key found for this signature in database
GPG key ID: 5CD8EC1D46106723
169 changed files with 5676 additions and 0 deletions

View file

@ -0,0 +1,5 @@
---
ssl_cert_tiararodney: /etc/letsencrypt/live/tiararodney.com/fullchain.pem
ssl_key_tiararodney: /etc/letsencrypt/live/tiararodney.com/privkey.pem
ssl_cert_administratrix: /etc/letsencrypt/live/administratrix.io/fullchain.pem
ssl_key_administratrix: /etc/letsencrypt/live/administratrix.io/privkey.pem

View file

@ -0,0 +1,6 @@
---
-
name: reload apache
service:
name: "{{ apache_service }}"
state: reloaded

View file

@ -0,0 +1,2 @@
---
dependencies: []

View file

@ -0,0 +1,18 @@
---
-
name: Ensure Apache is installed
include_tasks: main.yml
-
name: "Deploy {{ vhost_name }} reverse proxy vhost"
template:
src: reverse-proxy-vhost.conf.j2
dest: "{{ apache_sites_available }}/{{ vhost_name }}.conf"
notify: reload apache
-
name: "Enable {{ vhost_name }} site"
command: "{{ apache_enable_site_cmd }} {{ vhost_name }}"
args:
creates: "{{ apache_sites_enabled }}/{{ vhost_name }}.conf"
notify: reload apache

View file

@ -0,0 +1,27 @@
---
-
name: Ensure Apache is installed
include_tasks: main.yml
-
name: Ensure document root exists
file:
path: "{{ document_root }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
-
name: Deploy vhost configuration
template:
src: static-site-vhost.conf.j2
dest: "{{ apache_sites_available }}/{{ name }}.conf"
notify: reload apache
-
name: Enable site
command: "{{ apache_enable_site_cmd }} {{ name }}"
args:
creates: "{{ apache_sites_enabled }}/{{ name }}.conf"
notify: reload apache

View file

@ -0,0 +1,83 @@
---
-
name: Ensure letsencrypt directory exists
file:
path: /etc/letsencrypt
state: directory
mode: "0700"
-
name: Deploy SSL certificates
unarchive:
src: "{{ letsencrypt_archive }}"
dest: /etc/letsencrypt/
when: letsencrypt_archive is defined
notify: reload apache
-
name: Ensure SSL private keys are readable by containers
shell: find /etc/letsencrypt -name 'privkey*.pem' -exec chmod 644 {} +
changed_when: false
when: letsencrypt_archive is defined
-
name: Install Apache
apt:
name: "{{ apache_package }}"
state: present
update_cache: yes
-
name: Enable Apache modules
community.general.apache2_module:
name: "{{ item }}"
state: present
loop:
- proxy
- proxy_http
- proxy_wstunnel
- ssl
- rewrite
- headers
- auth_basic
- autoindex
notify: reload apache
-
name: Disable default site
command: "{{ apache_disable_site_cmd }} 000-default"
args:
removes: "{{ apache_sites_enabled }}/000-default.conf"
notify: reload apache
-
name: Ensure tiararodney.com document root exists
file:
path: /var/www/tiararodney.com
state: directory
mode: "0755"
-
name: Deploy tiararodney.com vhost
template:
src: 000-default-redirect.conf.j2
dest: "{{ apache_sites_available }}/000-default-redirect.conf"
notify: reload apache
-
name: Enable tiararodney.com redirect vhost
command: "{{ apache_enable_site_cmd }} 000-default-redirect"
args:
creates: "{{ apache_sites_enabled }}/000-default-redirect.conf"
notify: reload apache
-
name: Ensure Apache is started and enabled
service:
name: "{{ apache_service }}"
state: started
enabled: yes
-
name: Ensure Apache is reloaded with current config
meta: flush_handlers

View file

@ -0,0 +1,8 @@
---
-
name: Load OS-specific variables
ansible.builtin.include_vars: "{{ ansible_os_family }}.yml"
-
name: Install and configure Apache
ansible.builtin.include_tasks: install-apache.yml

View file

@ -0,0 +1,18 @@
<VirtualHost *:80>
ServerName tiararodney.com
Redirect permanent / https://tiararodney.com/
</VirtualHost>
<VirtualHost *:443>
ServerName tiararodney.com
SSLEngine on
SSLCertificateFile {{ ssl_cert_tiararodney }}
SSLCertificateKeyFile {{ ssl_key_tiararodney }}
DocumentRoot /var/www/tiararodney.com
<Directory /var/www/tiararodney.com>
Options FollowSymLinks
AllowOverride None
Require all granted
</Directory>
</VirtualHost>

View file

@ -0,0 +1,31 @@
<VirtualHost *:80>
ServerName {{ server_name }}
Redirect permanent / https://{{ server_name }}/
</VirtualHost>
<VirtualHost *:443>
ServerName {{ server_name }}
SSLEngine on
SSLCertificateFile {{ ssl_cert }}
SSLCertificateKeyFile {{ ssl_key }}
{% for loc in restricted_locations | default([]) %}
<Location {{ loc.path }}>
{% for ip in loc.allowed_ips %}
Require ip {{ ip }}
{% endfor %}
</Location>
{% endfor %}
ProxyPreserveHost on
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Ssl "on"
{% if websocket | default(false) %}
RewriteEngine on
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/(.*) ws://{{ backend_host | default('127.0.0.1') }}:{{ backend_port }}/$1 [P,L]
{% endif %}
ProxyPass / http://{{ backend_host | default('127.0.0.1') }}:{{ backend_port }}/
ProxyPassReverse / http://{{ backend_host | default('127.0.0.1') }}:{{ backend_port }}/
</VirtualHost>

View file

@ -0,0 +1,21 @@
<VirtualHost *:80>
ServerName {{ server_name }}
Redirect permanent / https://{{ server_name }}/
</VirtualHost>
<VirtualHost *:443>
ServerName {{ server_name }}
SSLEngine on
SSLCertificateFile {{ ssl_cert }}
SSLCertificateKeyFile {{ ssl_key }}
DocumentRoot {{ document_root }}
<Directory {{ document_root }}>
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
{% if directory_index is defined %}
DirectoryIndex {{ directory_index }}
{% endif %}
</VirtualHost>

View file

@ -0,0 +1,7 @@
---
apache_package: apache2
apache_service: apache2
apache_sites_available: /etc/apache2/sites-available
apache_sites_enabled: /etc/apache2/sites-enabled
apache_enable_site_cmd: a2ensite
apache_disable_site_cmd: a2dissite

View file

@ -0,0 +1,9 @@
---
install_dir: /opt/authentik
bind_address: "0.0.0.0"
port: 9000
postgres_shared_buffers: 64MB
postgres_work_mem: 4MB
postgres_maintenance_work_mem: 32MB
postgres_effective_cache_size: 256MB
redis_maxmemory: 80mb

View file

@ -0,0 +1,4 @@
---
dependencies:
-
role: docker

View file

@ -0,0 +1,92 @@
---
-
name: Ensure install directory exists
file:
path: "{{ install_dir }}"
state: directory
mode: "0755"
-
name: Deploy environment file
template:
src: env.j2
dest: "{{ install_dir }}/.env"
-
name: Ensure blueprints directory exists
file:
path: "{{ install_dir }}/blueprints"
state: directory
mode: "0755"
-
name: Deploy OAuth2 blueprint
template:
src: blueprint-oauth2.yml.j2
dest: "{{ install_dir }}/blueprints/oauth2-applications.yaml"
when: oauth_applications is defined and oauth_applications | length > 0
-
name: Deploy enrollment blueprint
template:
src: blueprint-enrollment.yml.j2
dest: "{{ install_dir }}/blueprints/enrollment.yaml"
-
name: Deploy social login blueprint
template:
src: blueprint-social-logins.yml.j2
dest: "{{ install_dir }}/blueprints/social-logins.yaml"
when: social_login_sources is defined and social_login_sources | length > 0
-
name: Ensure media directory exists
file:
path: "{{ install_dir }}/media/public"
state: directory
mode: "0755"
-
name: Copy branding assets
copy:
src: branding/
dest: "{{ install_dir }}/media/public/"
mode: "0644"
when: branding_assets | default(false)
-
name: Ensure custom-templates email directory exists
file:
path: "{{ install_dir }}/custom-templates/email"
state: directory
mode: "0755"
-
name: Deploy custom email templates
template:
src: "email/{{ item }}.j2"
dest: "{{ install_dir }}/custom-templates/email/{{ item }}"
loop:
- account-confirmation.html
- password-reset.html
-
name: Deploy docker-compose file
template:
src: docker-compose.yml.j2
dest: "{{ install_dir }}/docker-compose.yml"
-
name: Start Authentik stack
include_role:
name: docker
tasks_from: start-compose
vars:
compose_project_dir: "{{ install_dir }}"
-
name: Deploy Authentik backup script
template:
src: backup.sh.j2
dest: /etc/restic/pre-backup.d/authentik.sh
mode: "0755"

View file

@ -0,0 +1,4 @@
---
-
name: Deploy Authentik
ansible.builtin.include_tasks: deploy-authentik.yml

View file

@ -0,0 +1,46 @@
---
-
name: Set backup staging directory
set_fact:
_authentik_backup_dir: "{{ backup_staging_dir | default('/var/backups') }}/authentik"
-
name: Stop Authentik stack
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: absent
-
name: Restore config files
copy:
src: "{{ _authentik_backup_dir }}/{{ item }}"
dest: "{{ install_dir }}/{{ item }}"
remote_src: yes
mode: "0600"
loop:
- .env
- docker-compose.yml
-
name: Start Postgres only
command: >
docker compose -f {{ install_dir }}/docker-compose.yml
up -d postgres
-
name: Wait for Postgres to be ready
pause:
seconds: 10
-
name: Restore Postgres dump
shell: >
docker compose -f {{ install_dir }}/docker-compose.yml
exec -T postgres psql -U authentik authentik
< {{ _authentik_backup_dir }}/authentik.sql
-
name: Start full Authentik stack
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: present

View file

@ -0,0 +1,7 @@
#!/bin/bash
set -euo pipefail
BACKUP_DIR="{{ backup_staging_dir | default('/var/backups') }}/authentik"
mkdir -p "$BACKUP_DIR"
docker compose -f {{ install_dir }}/docker-compose.yml exec -T postgres pg_dump -U authentik authentik > "$BACKUP_DIR/authentik.sql"
cp "{{ install_dir }}/.env" "$BACKUP_DIR/.env"
cp "{{ install_dir }}/docker-compose.yml" "$BACKUP_DIR/docker-compose.yml"

View file

@ -0,0 +1,326 @@
version: 1
metadata:
name: enrollment-flow
entries:
# --- Brand configuration ---
- model: authentik_brands.brand
identifiers:
domain: authentik-default
state: present
attrs:
branding_title: {{ domain }}
{% if branding_logo is defined %}
branding_logo: {{ branding_logo }}
{% endif %}
{% if branding_favicon is defined %}
branding_favicon: {{ branding_favicon }}
{% endif %}
# --- Enrollment flow ---
- model: authentik_flows.flow
id: enrollment-flow
identifiers:
slug: default-enrollment-flow
attrs:
name: Sign Up
title: Create an account
designation: enrollment
authentication: require_unauthenticated
# --- Recovery flow ---
- model: authentik_flows.flow
id: recovery-flow
identifiers:
slug: default-recovery-flow
attrs:
name: Password Recovery
title: Reset your password
designation: recovery
authentication: require_unauthenticated
# --- Prompt fields ---
- model: authentik_stages_prompt.prompt
id: field-email
identifiers:
name: enrollment-field-email
attrs:
field_key: email
label: Email
type: email
required: true
placeholder: Email
placeholder_expression: false
order: 0
- model: authentik_stages_prompt.prompt
id: field-username
identifiers:
name: enrollment-field-username
attrs:
field_key: username
label: Username
type: username
required: true
placeholder: Username
placeholder_expression: false
order: 1
- model: authentik_stages_prompt.prompt
id: field-password
identifiers:
name: enrollment-field-password
attrs:
field_key: password
label: Password
type: password
required: true
placeholder: Password
placeholder_expression: false
order: 2
- model: authentik_stages_prompt.prompt
id: field-password-repeat
identifiers:
name: enrollment-field-password-repeat
attrs:
field_key: password_repeat
label: Password (repeat)
type: password
required: true
placeholder: Password (repeat)
placeholder_expression: false
order: 3
# --- Password policy ---
- model: authentik_policies_password.passwordpolicy
id: password-policy
identifiers:
name: enrollment-password-policy
attrs:
name: enrollment-password-policy
length_min: 10
amount_uppercase: 1
amount_lowercase: 1
amount_digits: 1
amount_symbols: 1
check_static_rules: true
check_have_i_been_pwned: true
check_zxcvbn: true
zxcvbn_score_threshold: 3
error_message: "Password must be at least 10 characters with uppercase, lowercase, digit, and symbol."
# --- Enrollment stages ---
- model: authentik_stages_prompt.promptstage
id: enrollment-prompt-stage
identifiers:
name: enrollment-prompt
attrs:
fields:
- !KeyOf field-email
- !KeyOf field-username
- !KeyOf field-password
- !KeyOf field-password-repeat
validation_policies:
- !KeyOf password-policy
- model: authentik_stages_user_write.userwritestage
id: enrollment-user-write
identifiers:
name: enrollment-user-write
attrs:
user_creation_mode: always_create
create_users_as_inactive: true
- model: authentik_stages_email.emailstage
id: enrollment-email-verification
identifiers:
name: enrollment-email-verification
attrs:
use_global_settings: true
activate_user_on_success: true
subject: Verify your email address
template: email/account-confirmation.html
- model: authentik_stages_user_login.userloginstage
id: enrollment-user-login
identifiers:
name: enrollment-user-login
# --- Enrollment flow stage bindings ---
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf enrollment-flow
stage: !KeyOf enrollment-prompt-stage
order: 10
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf enrollment-flow
stage: !KeyOf enrollment-user-write
order: 20
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf enrollment-flow
stage: !KeyOf enrollment-email-verification
order: 30
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf enrollment-flow
stage: !KeyOf enrollment-user-login
order: 100
# --- Recovery stages ---
- model: authentik_stages_identification.identificationstage
id: recovery-identification
identifiers:
name: recovery-identification
attrs:
user_fields:
- email
- model: authentik_stages_email.emailstage
id: recovery-email
identifiers:
name: recovery-email
attrs:
use_global_settings: true
subject: Reset your password
template: email/password-reset.html
- model: authentik_stages_prompt.prompt
id: field-recovery-password
identifiers:
name: recovery-field-password
attrs:
field_key: password
label: New Password
type: password
required: true
placeholder: New Password
placeholder_expression: false
order: 0
- model: authentik_stages_prompt.prompt
id: field-recovery-password-repeat
identifiers:
name: recovery-field-password-repeat
attrs:
field_key: password_repeat
label: New Password (repeat)
type: password
required: true
placeholder: New Password (repeat)
placeholder_expression: false
order: 1
- model: authentik_stages_prompt.promptstage
id: recovery-password-stage
identifiers:
name: recovery-password-prompt
attrs:
fields:
- !KeyOf field-recovery-password
- !KeyOf field-recovery-password-repeat
validation_policies:
- !KeyOf password-policy
- model: authentik_stages_user_write.userwritestage
id: recovery-user-write
identifiers:
name: recovery-user-write
attrs:
user_creation_mode: never_create
- model: authentik_stages_user_login.userloginstage
id: recovery-user-login
identifiers:
name: recovery-user-login
# --- Recovery flow stage bindings ---
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf recovery-flow
stage: !KeyOf recovery-identification
order: 10
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf recovery-flow
stage: !KeyOf recovery-email
order: 20
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf recovery-flow
stage: !KeyOf recovery-password-stage
order: 30
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf recovery-flow
stage: !KeyOf recovery-user-write
order: 40
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf recovery-flow
stage: !KeyOf recovery-user-login
order: 100
# --- Unenrollment (account deletion) flow ---
- model: authentik_flows.flow
id: unenrollment-flow
identifiers:
slug: default-unenrollment-flow
attrs:
name: Delete Account
title: Delete your account
designation: unenrollment
- model: authentik_stages_consent.consentstage
id: unenrollment-consent
identifiers:
name: unenrollment-consent
attrs:
mode: always_require
- model: authentik_stages_user_delete.userdeletestage
id: unenrollment-user-delete
identifiers:
name: unenrollment-user-delete
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf unenrollment-flow
stage: !KeyOf unenrollment-consent
order: 10
- model: authentik_flows.flowstagebinding
identifiers:
target: !KeyOf unenrollment-flow
stage: !KeyOf unenrollment-user-delete
order: 20
# --- Set recovery and unenrollment flows on brand ---
- model: authentik_brands.brand
identifiers:
domain: authentik-default
state: present
attrs:
flow_recovery: !KeyOf recovery-flow
flow_unenrollment: !KeyOf unenrollment-flow
{% if social_login_sources is not defined or social_login_sources | length == 0 %}
# --- Bind enrollment flow to the default login identification stage ---
- model: authentik_stages_identification.identificationstage
identifiers:
name: default-authentication-identification
state: present
attrs:
user_fields:
- email
- username
enrollment_flow: !KeyOf enrollment-flow
{% endif %}

View file

@ -0,0 +1,67 @@
version: 1
metadata:
name: oauth2-applications
entries:
{% for app in oauth_applications %}
- model: authentik_providers_oauth2.oauth2provider
identifiers:
name: {{ app.name }}
state: present
attrs:
name: {{ app.name }}
client_type: {{ app.client_type | default('confidential') }}
client_id: {{ app.client_id }}
{% if app.client_secret is defined %}
client_secret: {{ app.client_secret }}
{% endif %}
authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
redirect_uris:
{% for uri in app.redirect_uris %}
- url: "{{ uri }}"
matching_mode: strict
{% endfor %}
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
- model: authentik_core.application
identifiers:
slug: {{ app.slug }}
state: present
attrs:
name: {{ app.name }}
slug: {{ app.slug }}
provider: !Find [authentik_providers_oauth2.oauth2provider, [name, {{ app.name }}]]
policy_engine_mode: any
{% endfor %}
- model: authentik_policies_expression.expressionpolicy
identifiers:
name: enforce-unique-email
state: present
attrs:
name: enforce-unique-email
expression: |
from authentik.core.models import User
email = request.context.get("prompt_data", {}).get("email", "")
if not email:
return True
if User.objects.filter(email=email).exists():
ak_message("This email address is already in use.")
return False
return True
execution_logging: true
- model: authentik_stages_prompt.promptstage
identifiers:
name: default-source-enrollment-prompt
state: present
attrs:
fields:
- !Find [authentik_stages_prompt.prompt, [name, default-source-enrollment-field-username]]
validation_policies:
- !Find [authentik_policies_expression.expressionpolicy, [name, enforce-unique-email]]

View file

@ -0,0 +1,35 @@
version: 1
metadata:
name: social-login-sources
entries:
{% for source in social_login_sources %}
- model: authentik_sources_oauth.oauthsource
id: source-{{ source.slug }}
identifiers:
slug: {{ source.slug }}
state: present
attrs:
name: {{ source.name }}
slug: {{ source.slug }}
provider_type: {{ source.provider_type }}
consumer_key: {{ source.client_id }}
consumer_secret: {{ source.client_secret }}
authentication_flow: !Find [authentik_flows.flow, [slug, default-source-authentication]]
enrollment_flow: !Find [authentik_flows.flow, [slug, default-source-enrollment]]
{% endfor %}
# --- Add social login sources to the login identification stage ---
- model: authentik_stages_identification.identificationstage
identifiers:
name: default-authentication-identification
state: present
attrs:
user_fields:
- email
- username
enrollment_flow: !Find [authentik_flows.flow, [slug, default-enrollment-flow]]
recovery_flow: !Find [authentik_flows.flow, [slug, default-recovery-flow]]
sources:
{% for source in social_login_sources %}
- !KeyOf source-{{ source.slug }}
{% endfor %}

View file

@ -0,0 +1,51 @@
services:
postgres:
image: postgres:15
environment:
POSTGRES_PASSWORD: "${AUTHENTIK_POSTGRESQL__PASSWORD}"
POSTGRES_USER: "authentik"
POSTGRES_DB: "authentik"
volumes:
- postgres_data:/var/lib/postgresql/data
command: >
postgres -c shared_buffers={{ postgres_shared_buffers }}
-c work_mem={{ postgres_work_mem }}
-c maintenance_work_mem={{ postgres_maintenance_work_mem }}
-c effective_cache_size={{ postgres_effective_cache_size }}
restart: unless-stopped
redis:
image: redis:7
command: ["redis-server", "--maxmemory", "{{ redis_maxmemory }}", "--maxmemory-policy", "allkeys-lru"]
restart: unless-stopped
server:
image: ghcr.io/goauthentik/server:{{ version }}
command: server
env_file: .env
ports:
- "{{ bind_address }}:{{ port }}:9000"
volumes:
- ./blueprints:/blueprints/custom:ro
- ./media:/media:ro
- ./custom-templates:/templates:ro
depends_on:
- postgres
- redis
restart: unless-stopped
worker:
image: ghcr.io/goauthentik/server:{{ version }}
command: worker
env_file: .env
volumes:
- ./blueprints:/blueprints/custom:ro
- ./media:/media:ro
- ./custom-templates:/templates:ro
depends_on:
- postgres
- redis
restart: unless-stopped
volumes:
postgres_data:

View file

@ -0,0 +1,35 @@
{% raw %}{% load i18n %}
{% load humanize %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% blocktrans with domain=branding_title %}Verify your email - {{ domain }}{% endblocktrans %}</title>
<style>
body { margin: 0; padding: 0; background-color: #f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.card { background-color: #ffffff; border-radius: 8px; padding: 40px; margin-top: 20px; }
.btn { display: inline-block; background-color: #4050b5; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 6px; font-weight: 600; }
.footer { text-align: center; color: #888; font-size: 13px; margin-top: 24px; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>{% blocktrans with domain=branding_title %}Welcome to {{ domain }}{% endblocktrans %}</h1>
<p>{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %}</p>
<p>{% blocktrans %}Please click the button below to verify your email address and activate your account.{% endblocktrans %}</p>
<p style="text-align: center; margin: 32px 0;">
<a href="{{ url }}" class="btn">{% trans "Verify Email" %}</a>
</p>
<p>{% blocktrans with expiry=expires|naturaltime %}This link will expire {{ expiry }}.{% endblocktrans %}</p>
<p style="color: #888; font-size: 13px;">{% blocktrans %}If you did not create an account, you can safely ignore this email.{% endblocktrans %}</p>
</div>
<div class="footer">
<p>{{ branding_title }}</p>
</div>
</div>
</body>
</html>{% endraw %}

View file

@ -0,0 +1,35 @@
{% raw %}{% load i18n %}
{% load humanize %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% blocktrans with domain=branding_title %}Reset your password - {{ domain }}{% endblocktrans %}</title>
<style>
body { margin: 0; padding: 0; background-color: #f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.card { background-color: #ffffff; border-radius: 8px; padding: 40px; margin-top: 20px; }
.btn { display: inline-block; background-color: #4050b5; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 6px; font-weight: 600; }
.footer { text-align: center; color: #888; font-size: 13px; margin-top: 24px; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>{% trans "Reset your password" %}</h1>
<p>{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %}</p>
<p>{% blocktrans with domain=branding_title %}A password reset was requested for your account on {{ domain }}.{% endblocktrans %}</p>
<p style="text-align: center; margin: 32px 0;">
<a href="{{ url }}" class="btn">{% trans "Reset Password" %}</a>
</p>
<p>{% blocktrans with expiry=expires|naturaltime %}This link will expire {{ expiry }}.{% endblocktrans %}</p>
<p style="color: #888; font-size: 13px;">{% blocktrans %}If you did not request a password reset, you can safely ignore this email.{% endblocktrans %}</p>
</div>
<div class="footer">
<p>{{ branding_title }}</p>
</div>
</div>
</body>
</html>{% endraw %}

View file

@ -0,0 +1,19 @@
AUTHENTIK_SECRET_KEY={{ secret_key }}
AUTHENTIK_POSTGRESQL__PASSWORD={{ pg_password }}
AUTHENTIK_POSTGRESQL__HOST=postgres
AUTHENTIK_POSTGRESQL__USER=authentik
AUTHENTIK_POSTGRESQL__NAME=authentik
AUTHENTIK_REDIS__HOST=redis
AUTHENTIK_HOST={{ domain }}
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS=10.0.0.0/24,172.16.0.0/12
AUTHENTIK_DISABLE_STARTUP_ANALYTICS=true
AUTHENTIK_DISABLE_UPDATE_CHECK=true
AUTHENTIK_ERROR_REPORTING__ENABLED=false
{% if smtp_host is defined %}
AUTHENTIK_EMAIL__HOST={{ smtp_host }}
AUTHENTIK_EMAIL__PORT={{ smtp_port | default(587) }}
AUTHENTIK_EMAIL__USERNAME={{ smtp_username }}
AUTHENTIK_EMAIL__PASSWORD={{ smtp_password }}
AUTHENTIK_EMAIL__USE_TLS=true
AUTHENTIK_EMAIL__FROM={{ smtp_from | default(smtp_username) }}
{% endif %}

View file

@ -0,0 +1,9 @@
---
install_dir: /opt/bugzilla
bugzilla_dir: /opt/bugzilla/bugzilla
bugzilla_download_url: "https://ftp.mozilla.org/pub/webtools/bugzilla/5.0-branch/bugzilla-{{ version }}.tar.gz"
db_port: 5433
db_name: bugzilla
db_user: bugzilla
ssl_cert: /etc/letsencrypt/live/tiararodney.com/fullchain.pem
ssl_key: /etc/letsencrypt/live/tiararodney.com/privkey.pem

View file

@ -0,0 +1,6 @@
---
dependencies:
-
role: docker
-
role: apache

View file

@ -0,0 +1,160 @@
---
-
name: Include OS-specific variables
include_vars: "{{ ansible_os_family }}.yml"
-
name: Ensure install directory exists
file:
path: "{{ install_dir }}"
state: directory
mode: "0755"
-
name: Install Bugzilla Perl dependencies
apt:
name: "{{ bugzilla_packages }}"
state: present
update_cache: yes
-
name: Enable Apache modules for Bugzilla
community.general.apache2_module:
name: "{{ item }}"
state: present
loop:
- cgid
- expires
- auth_openidc
notify: reload apache
-
name: Deploy docker-compose file
template:
src: docker-compose.yml.j2
dest: "{{ install_dir }}/docker-compose.yml"
-
name: Start bugzilla database
include_role:
name: docker
tasks_from: start-compose
vars:
compose_project_dir: "{{ install_dir }}"
-
name: Download Bugzilla
unarchive:
src: "{{ bugzilla_download_url }}"
dest: "{{ install_dir }}"
remote_src: yes
creates: "{{ install_dir }}/bugzilla-{{ version }}"
-
name: Symlink versioned directory to bugzilla_dir
file:
src: "{{ install_dir }}/bugzilla-{{ version }}"
dest: "{{ bugzilla_dir }}"
state: link
when: bugzilla_dir != install_dir + '/bugzilla-' + version
-
name: Deploy localconfig
template:
src: localconfig.j2
dest: "{{ bugzilla_dir }}/localconfig"
mode: "0640"
group: www-data
-
name: Deploy checksetup answers file
template:
src: checksetup-answers.j2
dest: "{{ install_dir }}/checksetup-answers.pl"
mode: "0600"
-
name: Wait for PostgreSQL to be ready
wait_for:
host: 127.0.0.1
port: "{{ db_port }}"
delay: 2
timeout: 30
-
name: Run Bugzilla checksetup
command:
cmd: "perl checksetup.pl {{ install_dir }}/checksetup-answers.pl"
chdir: "{{ bugzilla_dir }}"
register: checksetup_result
retries: 3
delay: 5
until: checksetup_result.rc == 0
-
name: Run Bugzilla checksetup again to generate params.json
command:
cmd: "perl checksetup.pl {{ install_dir }}/checksetup-answers.pl"
chdir: "{{ bugzilla_dir }}"
creates: "{{ bugzilla_dir }}/data/params.json"
-
name: Configure Bugzilla Env auth login class
replace:
path: "{{ bugzilla_dir }}/data/params.json"
regexp: '"user_info_class"\s*:\s*"CGI"'
replace: '"user_info_class" : "Env,CGI"'
when: oauth_client_id is defined
-
name: Configure Bugzilla Env auth email variable
replace:
path: "{{ bugzilla_dir }}/data/params.json"
regexp: '"auth_env_email"\s*:\s*""'
replace: '"auth_env_email" : "OIDC_CLAIM_email"'
when: oauth_client_id is defined
-
name: Configure Bugzilla Env auth realname variable
replace:
path: "{{ bugzilla_dir }}/data/params.json"
regexp: '"auth_env_realname"\s*:\s*""'
replace: '"auth_env_realname" : "OIDC_CLAIM_name"'
when: oauth_client_id is defined
-
name: Set Bugzilla file ownership
file:
path: "{{ install_dir }}/bugzilla-{{ version }}"
state: directory
owner: www-data
group: www-data
recurse: yes
-
name: Deploy bugzilla vhost
template:
src: bugzilla-vhost.conf.j2
dest: "{{ apache_sites_available }}/bugzilla.conf"
notify: reload apache
-
name: Enable bugzilla site
command: "{{ apache_enable_site_cmd }} bugzilla"
args:
creates: "{{ apache_sites_enabled }}/bugzilla.conf"
notify: reload apache
-
name: Deploy bugzilla backup script
include_role:
name: docker
tasks_from: deploy-backup
vars:
backup_name: bugzilla
backup_hook_dir: /etc/restic/pre-backup.d
backup_volumes:
- bugzilla_postgres_data
backup_files:
- "{{ install_dir }}/docker-compose.yml"
- "{{ bugzilla_dir }}/localconfig"

View file

@ -0,0 +1,4 @@
---
-
name: Deploy bugzilla
ansible.builtin.include_tasks: deploy-bugzilla.yml

View file

@ -0,0 +1,42 @@
---
-
name: Set backup staging directory
set_fact:
_bugzilla_backup_dir: "{{ backup_staging_dir | default('/var/backups') }}/bugzilla"
-
name: Stop bugzilla database
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: absent
-
name: Restore docker-compose file
copy:
src: "{{ _bugzilla_backup_dir }}/docker-compose.yml"
dest: "{{ install_dir }}/docker-compose.yml"
remote_src: yes
mode: "0600"
-
name: Restore localconfig
copy:
src: "{{ _bugzilla_backup_dir }}/localconfig"
dest: "{{ bugzilla_dir }}/localconfig"
remote_src: yes
mode: "0640"
group: www-data
-
name: Restore bugzilla postgres volume
command: >
docker run --rm
-v bugzilla_postgres_data:/data
-v {{ _bugzilla_backup_dir }}:/backup
alpine sh -c "rm -rf /data/* && tar xzf /backup/postgres_data.tar.gz -C /data"
-
name: Start bugzilla database
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: present

View file

@ -0,0 +1,52 @@
<VirtualHost *:80>
ServerName {{ domain }}
Redirect permanent / https://{{ domain }}/
</VirtualHost>
<VirtualHost *:443>
ServerName {{ domain }}
SSLEngine on
SSLCertificateFile {{ ssl_cert }}
SSLCertificateKeyFile {{ ssl_key }}
{% if oauth_client_id is defined %}
OIDCProviderIssuer {{ oauth_issuer_url }}
OIDCProviderAuthorizationEndpoint {{ oauth_authorize_url }}
OIDCProviderTokenEndpoint {{ oauth_token_url }}
OIDCProviderTokenEndpointAuth client_secret_post
OIDCProviderUserInfoEndpoint {{ oauth_userinfo_url }}
OIDCProviderJwksUri {{ oauth_jwks_url }}
OIDCClientID {{ oauth_client_id }}
OIDCClientSecret {{ oauth_client_secret }}
OIDCRedirectURI https://{{ domain }}/oidc-callback
OIDCCryptoPassphrase {{ oauth_crypto_passphrase }}
OIDCScope "openid profile email"
OIDCRemoteUserClaim preferred_username
OIDCPassClaimsAs environment
OIDCSSLValidateServer Off
OIDCProviderEndSessionEndpoint {{ oauth_issuer_url }}/end-session/
<Location />
AuthType openid-connect
Require valid-user
</Location>
<Location /oidc-callback>
AuthType openid-connect
Require valid-user
</Location>
RedirectMatch ^/logout$ /oidc-callback?logout=https%3A%2F%2F{{ domain }}%2F
{% endif %}
DocumentRoot {{ bugzilla_dir }}
<Directory {{ bugzilla_dir }}>
AddHandler cgi-script .cgi
Options +ExecCGI +FollowSymLinks
DirectoryIndex index.cgi index.html
AllowOverride All
{% if oauth_client_id is not defined %}
Require all granted
{% endif %}
</Directory>
</VirtualHost>

View file

@ -0,0 +1,4 @@
$answer{'ADMIN_EMAIL'} = '{{ admin_email }}';
$answer{'ADMIN_PASSWORD'} = '{{ admin_pwd }}';
$answer{'ADMIN_REALNAME'} = '{{ admin_name | default("Admin") }}';
$answer{'NO_PAUSE'} = 1;

View file

@ -0,0 +1,15 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: {{ db_name }}
POSTGRES_USER: {{ db_user }}
POSTGRES_PASSWORD: "{{ db_password }}"
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "127.0.0.1:{{ db_port }}:5432"
restart: unless-stopped
volumes:
postgres_data:

View file

@ -0,0 +1,15 @@
$db_driver = 'Pg';
$db_host = '127.0.0.1';
$db_port = {{ db_port }};
$db_name = '{{ db_name }}';
$db_user = '{{ db_user }}';
$db_pass = '{{ db_password }}';
$webservergroup = 'www-data';
{% if smtp_host is defined %}
$smtp_server = '{{ smtp_host }}';
$smtp_port = {{ smtp_port | default(587) }};
$smtp_username = '{{ smtp_username }}';
$smtp_password = '{{ smtp_password }}';
$smtp_ssl = 'On';
$mailfrom = '{{ smtp_from | default(smtp_username) }}';
{% endif %}

View file

@ -0,0 +1,31 @@
---
bugzilla_packages:
- build-essential
- libgd-dev
- libappconfig-perl
- libdate-calc-perl
- libtemplate-perl
- libmime-tools-perl
- libdbi-perl
- libdbd-pg-perl
- libcgi-pm-perl
- libmath-random-isaac-perl
- libapache2-mod-perl2
- libchart-perl
- libxml-twig-perl
- libgd-perl
- libgd-graph-perl
- libtemplate-plugin-gd-perl
- libsoap-lite-perl
- libhtml-scrubber-perl
- libemail-sender-perl
- libemail-mime-perl
- libjson-xs-perl
- libencode-detect-perl
- libtheschwartz-perl
- libdaemon-generic-perl
- libdatetime-perl
- libdatetime-timezone-perl
- libemail-address-perl
- perlmagick
- libapache2-mod-auth-openidc

View file

@ -0,0 +1,5 @@
---
install_dir: /opt/comentario
port: 8060
ssl_cert: /etc/letsencrypt/live/tiararodney.com/fullchain.pem
ssl_key: /etc/letsencrypt/live/tiararodney.com/privkey.pem

View file

@ -0,0 +1,6 @@
---
dependencies:
-
role: docker
-
role: apache

View file

@ -0,0 +1,52 @@
---
-
name: Ensure install directory exists
file:
path: "{{ install_dir }}"
state: directory
mode: "0755"
-
name: Deploy secrets file
template:
src: secrets.yaml.j2
dest: "{{ install_dir }}/secrets.yaml"
mode: "0600"
-
name: Deploy docker-compose file
template:
src: docker-compose.yml.j2
dest: "{{ install_dir }}/docker-compose.yml"
-
name: Start comentario stack
include_role:
name: docker
tasks_from: start-compose
vars:
compose_project_dir: "{{ install_dir }}"
-
name: Deploy comentario vhost
include_role:
name: apache
tasks_from: deploy-reverse-proxy
vars:
vhost_name: comentario
server_name: "{{ domain }}"
backend_port: "{{ port }}"
-
name: Deploy comentario backup script
include_role:
name: docker
tasks_from: deploy-backup
vars:
backup_name: comentario
backup_hook_dir: /etc/restic/pre-backup.d
backup_volumes:
- comentario_comentario_postgres_data
backup_files:
- "{{ install_dir }}/docker-compose.yml"
- "{{ install_dir }}/secrets.yaml"

View file

@ -0,0 +1,4 @@
---
-
name: Deploy comentario
ansible.builtin.include_tasks: deploy-comentario.yml

View file

@ -0,0 +1,41 @@
---
-
name: Set backup staging directory
set_fact:
_comentario_backup_dir: "{{ backup_staging_dir | default('/var/backups') }}/comentario"
-
name: Stop comentario stack
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: absent
-
name: Restore docker-compose file
copy:
src: "{{ _comentario_backup_dir }}/docker-compose.yml"
dest: "{{ install_dir }}/docker-compose.yml"
remote_src: yes
mode: "0600"
-
name: Restore secrets file
copy:
src: "{{ _comentario_backup_dir }}/secrets.yaml"
dest: "{{ install_dir }}/secrets.yaml"
remote_src: yes
mode: "0600"
-
name: Restore comentario postgres volume
command: >
docker run --rm
-v comentario_comentario_postgres_data:/data
-v {{ _comentario_backup_dir }}:/backup
alpine sh -c "rm -rf /data/* && tar xzf /backup/comentario_postgres_data.tar.gz -C /data"
-
name: Start comentario stack
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: present

View file

@ -0,0 +1,26 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: comentario
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
comentario:
image: registry.gitlab.com/comentario/comentario:{{ version }}
ports:
- "127.0.0.1:{{ port }}:80"
environment:
BASE_URL: https://{{ domain }}
SECRETS_FILE: /secrets.yaml
volumes:
- ./secrets.yaml:/secrets.yaml:ro
depends_on:
- postgres
restart: unless-stopped
volumes:
postgres_data:

View file

@ -0,0 +1,27 @@
postgres:
host: postgres
port: 5432
database: comentario
username: postgres
password: postgres
{% if smtp_host is defined %}
smtp:
host: {{ smtp_host }}
port: {{ smtp_port | default(587) }}
username: {{ smtp_username }}
password: {{ smtp_password }}
from: {{ smtp_from | default(smtp_username) }}
{% endif %}
{% if oauth_client_id is defined %}
idp:
oidc:
- id: authentik
name: Authentik
url: {{ oauth_issuer_url }}
key: {{ oauth_client_id }}
secret: {{ oauth_client_secret }}
scopes:
- openid
- profile
- email
{% endif %}

View file

@ -0,0 +1,5 @@
---
install_dir: /var/www/chat.tiararodney.com
ssl_cert: /etc/letsencrypt/live/tiararodney.com/fullchain.pem
ssl_key: /etc/letsencrypt/live/tiararodney.com/privkey.pem
prosody_port: 5280

View file

@ -0,0 +1,4 @@
---
dependencies:
-
role: apache

View file

@ -0,0 +1,49 @@
---
-
name: Ensure converse.js document root exists
file:
path: "{{ install_dir }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
-
name: Download converse.js release
unarchive:
src: "https://github.com/conversejs/converse.js/releases/download/v{{ version }}/converse.js-{{ version }}.tgz"
dest: "{{ install_dir }}"
remote_src: yes
extra_opts: ["--strip-components=1"]
owner: www-data
group: www-data
-
name: Download libsignal-protocol for OMEMO
get_url:
url: "https://cdn.conversejs.org/3rdparty/libsignal-protocol.min.js"
dest: "{{ install_dir }}/dist/libsignal-protocol.min.js"
owner: www-data
group: www-data
-
name: Deploy converse.js index page
template:
src: index.html.j2
dest: "{{ install_dir }}/index.html"
owner: www-data
group: www-data
-
name: Deploy chat vhost
template:
src: vhost.conf.j2
dest: "{{ apache_sites_available }}/chat.conf"
notify: reload apache
-
name: Enable chat site
command: "{{ apache_enable_site_cmd }} chat"
args:
creates: "{{ apache_sites_enabled }}/chat.conf"
notify: reload apache

View file

@ -0,0 +1,4 @@
---
-
name: Deploy conversejs
ansible.builtin.include_tasks: deploy-conversejs.yml

View file

@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat - {{ domain }}</title>
<link rel="stylesheet" href="dist/converse.min.css">
<script src="dist/libsignal-protocol.min.js"></script>
{% if oauth_client_id is defined %}
<style>
#oauth-login {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #2e3436;
}
#oauth-login a {
padding: 16px 32px;
background: #3584e4;
color: white;
text-decoration: none;
border-radius: 8px;
font-size: 18px;
font-family: sans-serif;
}
#oauth-login a:hover { background: #1c71d8; }
</style>
{% endif %}
</head>
<body>
{% if oauth_client_id is defined %}
<div id="oauth-login">
<a id="login-btn" href="#">Login with Authentik</a>
</div>
<script>
(function() {
const CLIENT_ID = '{{ oauth_client_id }}';
const AUTHORIZE_URL = '{{ oauth_authorize_url }}';
const TOKEN_URL = '{{ oauth_token_url }}';
const REDIRECT_URI = window.location.origin + window.location.pathname;
const DOMAIN = '{{ domain }}';
function generateCodeVerifier() {
const arr = new Uint8Array(32);
crypto.getRandomValues(arr);
return btoa(String.fromCharCode(...arr))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function startOAuth() {
const state = generateCodeVerifier();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('oauth_code_verifier', codeVerifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = AUTHORIZE_URL + '?' + params.toString();
}
async function exchangeCode(code) {
const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
const resp = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code: code,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier
})
});
const data = await resp.json();
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_code_verifier');
return data.access_token;
}
function parseJwt(token) {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(base64));
}
function loadConverse() {
return new Promise(function(resolve) {
var s = document.createElement('script');
s.src = 'dist/converse.min.js';
s.onload = resolve;
document.body.appendChild(s);
});
}
function isTokenExpired(token) {
try {
const claims = parseJwt(token);
return claims.exp && (claims.exp * 1000) < Date.now();
} catch (e) {
return true;
}
}
function initConverse(token) {
if (isTokenExpired(token)) {
sessionStorage.removeItem('oauth_token');
startOAuth();
return;
}
document.getElementById('oauth-login').style.display = 'none';
const claims = parseJwt(token);
const jid = claims.preferred_username + '@' + DOMAIN;
loadConverse().then(function() {
converse.initialize({
bosh_service_url: 'https://' + DOMAIN + '/http-bind',
websocket_url: 'wss://' + DOMAIN + '/xmpp-websocket',
view_mode: 'fullscreen',
authentication: 'login',
locked_domain: DOMAIN,
muc_domain: 'conference.' + DOMAIN,
locked_muc_domain: 'hidden',
muc_instant_rooms: true,
muc_show_logs_before_join: true,
visible_toolbar_buttons: { toggle_occupants: true },
jid: jid,
password: token,
auto_login: true,
keepalive: true,
omemo_default: true
});
converse.listen.on('logout', function() {
sessionStorage.removeItem('oauth_token');
window.location.reload();
});
});
}
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (code && state === sessionStorage.getItem('oauth_state')) {
history.replaceState(null, '', window.location.pathname);
exchangeCode(code).then(function(token) {
if (token) {
sessionStorage.setItem('oauth_token', token);
initConverse(token);
}
});
} else if (sessionStorage.getItem('oauth_token')) {
initConverse(sessionStorage.getItem('oauth_token'));
} else {
document.getElementById('login-btn').addEventListener('click', function(e) {
e.preventDefault();
startOAuth();
});
}
})();
</script>
{% else %}
<script src="dist/converse.min.js"></script>
<script>
converse.initialize({
bosh_service_url: 'https://{{ domain }}/http-bind',
websocket_url: 'wss://{{ domain }}/xmpp-websocket',
view_mode: 'fullscreen',
authentication: 'login',
locked_domain: '{{ domain }}',
muc_domain: 'conference.{{ domain }}',
locked_muc_domain: 'hidden',
muc_instant_rooms: false,
muc_show_logs_before_join: true,
visible_toolbar_buttons: {
toggle_occupants: true
},
omemo_default: true
});
</script>
{% endif %}
</body>
</html>

View file

@ -0,0 +1,31 @@
<VirtualHost *:80>
ServerName {{ domain }}
Redirect permanent / https://{{ domain }}/
</VirtualHost>
<VirtualHost *:443>
ServerName {{ domain }}
SSLEngine on
SSLCertificateFile {{ ssl_cert }}
SSLCertificateKeyFile {{ ssl_key }}
DocumentRoot {{ install_dir }}
<Directory {{ install_dir }}>
Options FollowSymLinks
AllowOverride None
Require all granted
</Directory>
# Proxy BOSH requests to Prosody
ProxyPreserveHost on
ProxyPass /http-bind http://127.0.0.1:{{ prosody_port }}/http-bind
ProxyPassReverse /http-bind http://127.0.0.1:{{ prosody_port }}/http-bind
# Proxy WebSocket requests to Prosody
RewriteEngine on
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/xmpp-websocket(.*) ws://127.0.0.1:{{ prosody_port }}/xmpp-websocket$1 [P,L]
ProxyPass /xmpp-websocket http://127.0.0.1:{{ prosody_port }}/xmpp-websocket
ProxyPassReverse /xmpp-websocket http://127.0.0.1:{{ prosody_port }}/xmpp-websocket
</VirtualHost>

View file

@ -0,0 +1,5 @@
---
install_dir: /opt/devpi
port: 3141
ssl_cert: /etc/letsencrypt/live/tiararodney.com/fullchain.pem
ssl_key: /etc/letsencrypt/live/tiararodney.com/privkey.pem

View file

@ -0,0 +1,11 @@
FROM python:3.12-slim
RUN pip install --no-cache-dir devpi-server devpi-web
RUN devpi-init --serverdir /data || true
EXPOSE 3141
VOLUME /data
CMD ["devpi-server", "--serverdir", "/data", "--host", "0.0.0.0", "--port", "3141"]

View file

@ -0,0 +1,6 @@
---
dependencies:
-
role: docker
-
role: apache

View file

@ -0,0 +1,51 @@
---
-
name: Ensure install directory exists
file:
path: "{{ install_dir }}"
state: directory
mode: "0755"
-
name: Copy Dockerfile
copy:
src: Dockerfile
dest: "{{ install_dir }}/Dockerfile"
-
name: Deploy docker-compose file
template:
src: docker-compose.yml.j2
dest: "{{ install_dir }}/docker-compose.yml"
-
name: Start devpi stack
include_role:
name: docker
tasks_from: start-compose
vars:
compose_project_dir: "{{ install_dir }}"
compose_build: policy
-
name: Deploy devpi vhost
include_role:
name: apache
tasks_from: deploy-reverse-proxy
vars:
vhost_name: devpi
server_name: "{{ hostname }}"
backend_port: "{{ port }}"
-
name: Deploy devpi backup script
include_role:
name: docker
tasks_from: deploy-backup
vars:
backup_name: devpi
backup_hook_dir: /etc/restic/pre-backup.d
backup_volumes:
- devpi_devpi_data
backup_files:
- "{{ install_dir }}/docker-compose.yml"

View file

@ -0,0 +1,4 @@
---
-
name: Deploy devpi
ansible.builtin.include_tasks: deploy-devpi.yml

View file

@ -0,0 +1,33 @@
---
-
name: Set backup staging directory
set_fact:
_devpi_backup_dir: "{{ backup_staging_dir | default('/var/backups') }}/devpi"
-
name: Stop devpi stack
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: absent
-
name: Restore docker-compose file
copy:
src: "{{ _devpi_backup_dir }}/docker-compose.yml"
dest: "{{ install_dir }}/docker-compose.yml"
remote_src: yes
mode: "0600"
-
name: Restore devpi data volume
command: >
docker run --rm
-v devpi_devpi_data:/data
-v {{ _devpi_backup_dir }}:/backup
alpine sh -c "rm -rf /data/* && tar xzf /backup/devpi_data.tar.gz -C /data"
-
name: Start devpi stack
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: present

View file

@ -0,0 +1,11 @@
services:
devpi:
build: .
ports:
- "127.0.0.1:{{ port }}:3141"
volumes:
- devpi_data:/data
restart: unless-stopped
volumes:
devpi_data:

View file

@ -0,0 +1,7 @@
---
dns_listen_address: "10.0.0.1"
dns_port: 53
dns_upstream:
- "1.1.1.1"
- "1.0.0.1"
dns_records: []

View file

@ -0,0 +1,6 @@
---
-
name: restart dnsmasq
service:
name: dnsmasq
state: restarted

View file

@ -0,0 +1,20 @@
---
-
name: Install dnsmasq
apt:
name: dnsmasq
state: present
-
name: Deploy dnsmasq configuration
template:
src: dnsmasq.conf.j2
dest: /etc/dnsmasq.d/local.conf
notify: restart dnsmasq
-
name: Ensure dnsmasq is running
service:
name: dnsmasq
state: started
enabled: yes

View file

@ -0,0 +1,11 @@
listen-address={{ dns_listen_address }}
port={{ dns_port }}
bind-interfaces
no-resolv
no-hosts
{% for server in dns_upstream %}
server={{ server }}
{% endfor %}
{% for record in dns_records %}
address=/{{ record.domain }}/{{ record.ip }}
{% endfor %}

View file

@ -0,0 +1,11 @@
---
-
name: restart containerd
service:
name: containerd
state: restarted
-
name: restart docker
service:
name: docker
state: restarted

View file

@ -0,0 +1,2 @@
---
dependencies: []

View file

@ -0,0 +1,29 @@
---
-
name: Add registry mirror host entries
lineinfile:
path: /etc/hosts
regexp: "{{ item.mirror | urlsplit('hostname') | regex_escape }}"
line: "{{ registry_mirror_ip }} {{ item.mirror | urlsplit('hostname') }}"
loop: "{{ registry_mirrors }}"
when: registry_mirror_ip is defined
-
name: Configure Docker Hub registry mirror
copy:
dest: /etc/docker/daemon.json
content: |
{
"registry-mirrors": [
{% for item in registry_mirrors if item.upstream == 'docker.io' %}
"{{ item.mirror }}"{% if not loop.last %},{% endif %}
{% endfor %}
]
}
mode: "0644"
notify: restart docker
-
name: Ensure Docker is restarted if mirror changed
meta: flush_handlers

View file

@ -0,0 +1,7 @@
---
-
name: "Deploy {{ backup_name }} docker volume backup script"
template:
src: backup-docker-volumes.sh.j2
dest: "{{ backup_hook_dir }}/{{ backup_name }}.sh"
mode: "0755"

View file

@ -0,0 +1,34 @@
---
-
name: Install Docker prerequisites
apt:
name: "{{ docker_prerequisites }}"
state: present
update_cache: yes
-
name: Add Docker GPG key
apt_key:
url: "{{ docker_gpg_url }}"
state: present
-
name: Add Docker repository
apt_repository:
repo: "{{ docker_repo }}"
state: present
-
name: Install Docker Engine and Compose plugin
apt:
name: "{{ docker_packages }}"
state: present
update_cache: yes
-
name: Ensure Docker service is running
service:
name: docker
state: started
enabled: yes

View file

@ -0,0 +1,13 @@
---
-
name: Load OS-specific variables
ansible.builtin.include_vars: "{{ ansible_os_family }}.yml"
-
name: Install and configure Docker
ansible.builtin.include_tasks: install-docker.yml
-
name: Configure registry mirrors
ansible.builtin.include_tasks: configure-mirror.yml
when: registry_mirrors is defined

View file

@ -0,0 +1,13 @@
---
-
name: "Create {{ compose_project_dir }} directory"
file:
path: "{{ compose_project_dir }}"
state: directory
-
name: "Start docker compose stack in {{ compose_project_dir }}"
community.docker.docker_compose_v2:
project_src: "{{ compose_project_dir }}"
state: present
build: "{{ compose_build | default(omit) }}"

View file

@ -0,0 +1,10 @@
#!/bin/bash
set -euo pipefail
BACKUP_DIR="{{ backup_staging_dir | default('/var/backups') }}/{{ backup_name }}"
mkdir -p "$BACKUP_DIR"
{% for vol in backup_volumes | default([]) %}
docker run --rm -v {{ vol }}:/data:ro -v "$BACKUP_DIR":/backup alpine sh -c "tar czf /backup/{{ vol }}.tar.gz -C /data . || [ \$? -eq 1 ]"
{% endfor %}
{% for f in backup_files | default([]) %}
cp "{{ f }}" "$BACKUP_DIR/"
{% endfor %}

View file

@ -0,0 +1,15 @@
---
docker_prerequisites:
- apt-transport-https
- ca-certificates
- curl
- gnupg
- lsb-release
docker_packages:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
docker_repo: "deb [arch=amd64] https://download.docker.com/linux/debian {{ ansible_facts['distribution_release'] }} stable"
docker_gpg_url: "https://download.docker.com/linux/debian/gpg"

View file

@ -0,0 +1,5 @@
---
install_dir: /opt/docker-registry
port: 5050
ssl_cert: /etc/letsencrypt/live/tiararodney.com/fullchain.pem
ssl_key: /etc/letsencrypt/live/tiararodney.com/privkey.pem

View file

@ -0,0 +1,11 @@
---
-
name: restart docker-registry
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: restarted
-
name: reload apache
service:
name: "{{ apache_service }}"
state: reloaded

View file

@ -0,0 +1,61 @@
---
-
name: Ensure install directory exists
file:
path: "{{ install_dir }}"
state: directory
mode: "0755"
-
name: Deploy registry configuration
template:
src: config.yml.j2
dest: "{{ install_dir }}/config.yml"
notify: restart docker-registry
-
name: Deploy docker-compose file
template:
src: docker-compose.yml.j2
dest: "{{ install_dir }}/docker-compose.yml"
-
name: Start registry stack
include_role:
name: docker
tasks_from: start-compose
vars:
compose_project_dir: "{{ install_dir }}"
-
name: Load Apache variables
include_vars:
file: "{{ role_path }}/../apache/vars/{{ ansible_os_family }}.yml"
-
name: Deploy registry vhost
template:
src: vhost.conf.j2
dest: "{{ apache_sites_available }}/docker-registry-{{ hostname | regex_replace('\\..*', '') }}.conf"
notify: reload apache
-
name: Enable registry vhost
command: "{{ apache_enable_site_cmd }} docker-registry-{{ hostname | regex_replace('\\..*', '') }}"
args:
creates: "{{ apache_sites_enabled }}/docker-registry-{{ hostname | regex_replace('\\..*', '') }}.conf"
notify: reload apache
-
name: Deploy registry backup script
include_role:
name: docker
tasks_from: deploy-backup
vars:
backup_name: docker-registry
backup_hook_dir: /etc/restic/pre-backup.d
backup_volumes:
- docker-registry_registry_data
backup_files:
- "{{ install_dir }}/docker-compose.yml"
- "{{ install_dir }}/config.yml"

View file

@ -0,0 +1,4 @@
---
-
name: Deploy registry
ansible.builtin.include_tasks: deploy-registry.yml

View file

@ -0,0 +1,22 @@
---
-
name: Check if registry backup archive exists
stat:
path: /var/backups/docker-registry/docker-registry_registry_data.tar.gz
register: registry_backup
-
name: Restore registry volume from backup
command: >
docker run --rm
-v docker-registry_registry_data:/data
-v /var/backups/docker-registry:/backup:ro
alpine sh -c "tar xzf /backup/docker-registry_registry_data.tar.gz -C /data"
when: registry_backup.stat.exists
-
name: Restart registry after restore
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: restarted
when: registry_backup.stat.exists

View file

@ -0,0 +1,13 @@
version: 0.1
log:
fields:
service: registry
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: true
http:
addr: :5000
proxy:
remoteurl: {{ remote_url | default('https://registry-1.docker.io') }}

View file

@ -0,0 +1,12 @@
services:
registry:
image: registry:2
ports:
- "127.0.0.1:{{ port }}:5000"
volumes:
- registry_data:/var/lib/registry
- ./config.yml:/etc/docker/registry/config.yml:ro
restart: unless-stopped
volumes:
registry_data:

View file

@ -0,0 +1,28 @@
<VirtualHost *:80>
ServerName {{ hostname }}
Redirect permanent / https://{{ hostname }}/
</VirtualHost>
<VirtualHost *:443>
ServerName {{ hostname }}
SSLEngine on
SSLCertificateFile {{ ssl_cert }}
SSLCertificateKeyFile {{ ssl_key }}
# Return an empty OCI index for referrers requests.
# registry:2 does not support the OCI referrers API and proxies
# the request to upstream which may return HTML error pages,
# causing Docker 29+ to fail with "failed to decode referrers index".
RewriteEngine on
RewriteRule "^/v2/.*/referrers/" - [R=200,L,E=REFERRERS:1]
Header always set Content-Type "application/vnd.oci.image.index.v1+json" env=REFERRERS
<LocationMatch "^/v2/.*/referrers/">
ErrorDocument 200 '{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[]}'
</LocationMatch>
ProxyPreserveHost on
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Ssl "on"
ProxyPass / http://127.0.0.1:{{ port }}/
ProxyPassReverse / http://127.0.0.1:{{ port }}/
</VirtualHost>

View file

@ -0,0 +1,3 @@
---
admin_user: tiara
admin_shell: /bin/bash

View file

@ -0,0 +1,11 @@
---
-
name: restart sshd
service:
name: sshd
state: restarted
-
name: restart zramswap
systemd:
name: zramswap
state: restarted

View file

@ -0,0 +1,2 @@
---
dependencies: []

View file

@ -0,0 +1,13 @@
---
-
name: Load OS-specific variables
ansible.builtin.include_vars: "{{ ansible_os_family }}.yml"
-
name: Set up admin user
ansible.builtin.include_tasks: setup-admin.yml
when: ssh_pubkey_dir is defined
-
name: Set up base system
ansible.builtin.include_tasks: setup-base.yml

View file

@ -0,0 +1,35 @@
---
-
name: Create admin user
user:
name: "{{ admin_user }}"
shell: "{{ admin_shell }}"
groups: sudo
append: yes
create_home: yes
-
name: Allow admin user passwordless sudo
copy:
dest: "/etc/sudoers.d/{{ admin_user }}"
content: "{{ admin_user }} ALL=(ALL) NOPASSWD:ALL\n"
mode: "0440"
validate: "visudo -cf %s"
-
name: Find SSH public keys
find:
paths: "{{ ssh_pubkey_dir }}"
patterns: "*.pub"
delegate_to: localhost
become: no
register: ssh_pubkeys
-
name: Deploy SSH authorized keys
authorized_key:
user: "{{ admin_user }}"
key: "{{ lookup('file', item.path) }}"
loop: "{{ ssh_pubkeys.files }}"
loop_control:
label: "{{ item.path | basename }}"

View file

@ -0,0 +1,69 @@
---
-
name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 0
-
name: Install base packages
apt:
name: "{{ host_base_packages }}"
state: present
-
name: Disable SSH password authentication
lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?PasswordAuthentication"
line: "PasswordAuthentication no"
notify: restart sshd
-
name: Disable SSH root login
lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?PermitRootLogin"
line: "PermitRootLogin no"
notify: restart sshd
-
name: Allow SSH through UFW
community.general.ufw:
rule: allow
port: "22"
proto: tcp
-
name: Allow additional UFW ports
community.general.ufw:
rule: allow
port: "{{ item.port }}"
proto: "{{ item.proto | default('tcp') }}"
from_ip: "{{ item.from | default('any') }}"
loop: "{{ ufw_allow | default([]) }}"
-
name: Enable UFW with default deny
community.general.ufw:
state: enabled
default: deny
direction: incoming
-
name: Configure fail2ban backend
copy:
dest: /etc/fail2ban/jail.local
content: |
[DEFAULT]
backend = {{ fail2ban_backend }}
owner: root
group: root
mode: "0644"
-
name: Ensure fail2ban is running
service:
name: fail2ban
state: restarted
enabled: yes

View file

@ -0,0 +1,37 @@
---
-
name: Ensure swap exists
command: fallocate -l {{ swap_size | default('2G') }} /swapfile
args:
creates: /swapfile
-
name: Set swap permissions
file:
path: /swapfile
mode: '0600'
-
name: Make swap
command: mkswap /swapfile
args:
creates: /swapfile.swap
-
name: Mark swapfile as initialized
file:
path: /swapfile.swap
state: touch
-
name: Enable swap
command: swapon /swapfile
register: swap_on
failed_when: false
-
name: Add swap to fstab
lineinfile:
path: /etc/fstab
line: "/swapfile none swap sw 0 0"
state: present

View file

@ -0,0 +1,37 @@
---
-
name: Install zram-tools
apt:
name: zram-tools
state: present
-
name: Configure zram
copy:
dest: /etc/default/zramswap
content: |
ALGO={{ zram_algorithm | default('zstd') }}
PERCENT={{ zram_percent | default(50) }}
PRIORITY={{ zram_priority | default(100) }}
mode: '0644'
notify: restart zramswap
-
name: Enable zramswap service
systemd:
name: zramswap
enabled: true
state: started
-
name: Disable file-backed swap if present
command: swapoff /swapfile
failed_when: false
changed_when: false
-
name: Remove file-backed swap from fstab
lineinfile:
path: /etc/fstab
line: "/swapfile none swap sw 0 0"
state: absent

View file

@ -0,0 +1,9 @@
---
host_base_packages:
- fail2ban
- unattended-upgrades
- ufw
- vim
- curl
- rsync
fail2ban_backend: systemd

View file

@ -0,0 +1,5 @@
---
install_dir: /opt/kellnr
port: 8000
ssl_cert: /etc/letsencrypt/live/tiararodney.com/fullchain.pem
ssl_key: /etc/letsencrypt/live/tiararodney.com/privkey.pem

View file

@ -0,0 +1,6 @@
---
dependencies:
-
role: docker
-
role: apache

View file

@ -0,0 +1,44 @@
---
-
name: Ensure install directory exists
file:
path: "{{ install_dir }}"
state: directory
mode: "0755"
-
name: Deploy docker-compose file
template:
src: docker-compose.yml.j2
dest: "{{ install_dir }}/docker-compose.yml"
-
name: Start kellnr stack
include_role:
name: docker
tasks_from: start-compose
vars:
compose_project_dir: "{{ install_dir }}"
-
name: Deploy kellnr vhost
include_role:
name: apache
tasks_from: deploy-reverse-proxy
vars:
vhost_name: kellnr
server_name: "{{ hostname }}"
backend_port: "{{ port }}"
-
name: Deploy kellnr backup script
include_role:
name: docker
tasks_from: deploy-backup
vars:
backup_name: kellnr
backup_hook_dir: /etc/restic/pre-backup.d
backup_volumes:
- kellnr_kellnr_data
backup_files:
- "{{ install_dir }}/docker-compose.yml"

View file

@ -0,0 +1,4 @@
---
-
name: Deploy kellnr
ansible.builtin.include_tasks: deploy-kellnr.yml

View file

@ -0,0 +1,33 @@
---
-
name: Set backup staging directory
set_fact:
_kellnr_backup_dir: "{{ backup_staging_dir | default('/var/backups') }}/kellnr"
-
name: Stop kellnr stack
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: absent
-
name: Restore docker-compose file
copy:
src: "{{ _kellnr_backup_dir }}/docker-compose.yml"
dest: "{{ install_dir }}/docker-compose.yml"
remote_src: yes
mode: "0600"
-
name: Restore kellnr data volume
command: >
docker run --rm
-v kellnr_kellnr_data:/data
-v {{ _kellnr_backup_dir }}:/backup
alpine sh -c "rm -rf /data/* && tar xzf /backup/kellnr_data.tar.gz -C /data"
-
name: Start kellnr stack
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: present

View file

@ -0,0 +1,16 @@
services:
kellnr:
image: ghcr.io/kellnr/kellnr:{{ version }}
ports:
- "127.0.0.1:{{ port }}:8000"
environment:
KELLNR_ORIGIN__HOSTNAME: "{{ hostname }}"
KELLNR_ORIGIN__PORT: "443"
KELLNR_ORIGIN__PROTOCOL: "https"
KELLNR_AUTH__ADMIN_PWD: "{{ admin_pwd }}"
volumes:
- kellnr_data:/var/lib/kellnr
restart: unless-stopped
volumes:
kellnr_data:

View file

@ -0,0 +1,10 @@
---
install_dir: /opt/prosody
bind_address: "0.0.0.0"
bosh_port: 5280
c2s_port: 5222
s2s_port: 5269
proxy65_port: 5000
proxy65_address: "{{ bind_address }}"
http_upload_file_size_limit: 10485760
http_upload_expire_after: 604800

View file

@ -0,0 +1,153 @@
-- Based on https://hg.prosody.im/prosody-modules/file/tip/mod_auth_oauth_external/mod_auth_oauth_external.lua
-- Patched to store email from userinfo in accounts store for mod_offline_email
local http = require "net.http";
local async = require "util.async";
local jid = require "util.jid";
local json = require "util.json";
local sasl = require "util.sasl";
local issuer_identity = module:get_option_string("oauth_external_issuer");
local oidc_discovery_url = module:get_option_string("oauth_external_discovery_url",
issuer_identity and issuer_identity .. "/.well-known/oauth-authorization-server" or nil);
local validation_endpoint = module:get_option_string("oauth_external_validation_endpoint");
local token_endpoint = module:get_option_string("oauth_external_token_endpoint");
local username_field = module:get_option_string("oauth_external_username_field", "preferred_username");
local allow_plain = module:get_option_boolean("oauth_external_resource_owner_password", true);
local client_id = module:get_option_string("oauth_external_client_id");
local client_secret = module:get_option_string("oauth_external_client_secret");
local scope = module:get_option_string("oauth_external_scope", "openid");
local accounts = module:open_store("accounts");
local host = module.host;
local provider = {};
local function not_implemented()
return nil, "method not implemented"
end
provider.test_password = not_implemented;
provider.get_password = not_implemented;
provider.set_password = not_implemented;
provider.create_user = not_implemented;
function provider.delete_user(username)
return accounts:set(username, nil);
end
function provider.get_account_info(username)
local account, err = accounts:get(username);
if not account then return nil, err or "Account not available"; end
return {
created = account.created;
password_updated = account.updated;
enabled = not account.disabled;
};
end
function provider.user_exists(username)
local account, err = accounts:get(username);
if err then
return nil, err;
elseif account then
return true;
else
return false
end
end
function provider.users()
return accounts:users();
end
local function save_account(username, response)
local account_data = { exists = true };
if type(response) == "table" and type(response.email) == "string" then
account_data.email = response.email;
end
accounts:set(username, account_data);
end
function provider.get_sasl_handler()
local profile = {};
profile.http_client = http.default:new({ connection_pooling = true });
local extra = { oidc_discovery_url = oidc_discovery_url };
if token_endpoint and allow_plain then
local map_username = function (username, _realm) return username; end;
function profile:plain_test(username, password, realm)
username = jid.unescape(username);
local tok, err = async.wait_for(self.profile.http_client:request(token_endpoint, {
headers = { ["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8"; ["Accept"] = "application/json" };
body = http.formencode({
grant_type = "password";
client_id = client_id;
client_secret = client_secret;
username = map_username(username, realm);
password = password;
scope = scope;
});
}))
if err or not (tok.code >= 200 and tok.code < 300) then
return false, nil;
end
local token_resp = json.decode(tok.body);
if not token_resp or string.lower(token_resp.token_type or "") ~= "bearer" then
return false, nil;
end
if not validation_endpoint then
self.username = jid.escape(username);
self.token_info = token_resp;
save_account(self.username, nil);
return true, true;
end
local ret, err = async.wait_for(self.profile.http_client:request(validation_endpoint,
{ headers = { ["Authorization"] = "Bearer " .. token_resp.access_token; ["Accept"] = "application/json" } }));
if err then
return false, nil;
end
if not (ret.code >= 200 and ret.code < 300) then
return false, nil;
end
local response = json.decode(ret.body);
if type(response) ~= "table" then
return false, nil, nil;
elseif type(response[username_field]) ~= "string" then
return false, nil, nil;
end
self.username = jid.escape(response[username_field]);
self.token_info = response;
save_account(self.username, response);
return true, true;
end
end
if validation_endpoint then
function profile:oauthbearer(token)
if token == "" then
return false, nil, extra;
end
local ret, err = async.wait_for(self.profile.http_client:request(validation_endpoint, {
headers = { ["Authorization"] = "Bearer " .. token; ["Accept"] = "application/json" };
}));
if err then
return false, nil, extra;
end
local response = ret and json.decode(ret.body);
if not (ret.code >= 200 and ret.code < 300) then
return false, nil, response or extra;
end
if type(response) ~= "table" or type(response[username_field]) ~= "string" then
return false, nil, nil;
end
local username = jid.escape(response[username_field]);
save_account(username, response);
return username, true, response;
end
end
return sasl.new(host, profile);
end
module:provides("auth", provider);

View file

@ -0,0 +1,35 @@
-- Strip PLAIN from SASL mechanisms for BOSH and WebSocket connections.
-- Direct c2s connections keep PLAIN for native XMPP client auth.
module:hook("stream-features", function(event)
local origin, features = event.origin, event.features;
local is_bosh = origin.bosh_version ~= nil;
local is_websocket = origin.websocket_request ~= nil;
if not (is_bosh or is_websocket) then
return;
end
for i, child in ipairs(features.tags) do
if child.name == "mechanisms" then
local dominated = {};
for j, mech in ipairs(child.tags) do
if mech:get_text() == "PLAIN" then
table.insert(dominated, j);
end
end
for k = #dominated, 1, -1 do
local idx = dominated[k];
table.remove(child.tags, idx);
for m = #child, 1, -1 do
if type(child[m]) == "table" and child[m].name == "mechanism" and child[m]:get_text() == "PLAIN" then
table.remove(child, m);
break;
end
end
end
break;
end
end
end, -1);

View file

@ -0,0 +1,16 @@
-- Disconnect c2s sessions after a configurable timeout to force re-authentication.
-- This ensures that expired credentials (e.g. app passwords) are caught promptly.
local timeout = module:get_option_number("session_timeout", 1800); -- default 30 minutes
module:hook("resource-bind", function(event)
local session = event.session;
if not session then return; end
session._timeout_timer = module:add_timer(timeout, function()
if session.type == "c2s" and not session.destroyed then
module:log("info", "Session timeout for %s, forcing re-authentication", session.full_jid);
session:close({ condition = "policy-violation", text = "Session expired, please reconnect" });
end
end);
end);

View file

@ -0,0 +1,4 @@
---
dependencies:
-
role: docker

View file

@ -0,0 +1,84 @@
---
-
name: Ensure install directory exists
file:
path: "{{ install_dir }}"
state: directory
mode: "0755"
-
name: Ensure modules directory exists
file:
path: "{{ install_dir }}/modules"
state: directory
mode: "0755"
when: oauth_client_id is defined or default_contacts is defined or smtp_host is defined or session_timeout is defined
-
name: Deploy mod_auth_oauth_external
copy:
src: mod_auth_oauth_external.lua
dest: "{{ install_dir }}/modules/mod_auth_oauth_external.lua"
when: oauth_client_id is defined
-
name: Deploy mod_sasl_oauthbearer_only_bosh
copy:
src: mod_sasl_oauthbearer_only_bosh.lua
dest: "{{ install_dir }}/modules/mod_sasl_oauthbearer_only_bosh.lua"
when: oauth_client_id is defined
-
name: Deploy mod_default_contacts
template:
src: mod_default_contacts.lua.j2
dest: "{{ install_dir }}/modules/mod_default_contacts.lua"
when: default_contacts is defined
-
name: Deploy mod_session_timeout
copy:
src: mod_session_timeout.lua
dest: "{{ install_dir }}/modules/mod_session_timeout.lua"
when: session_timeout is defined
-
name: Deploy mod_offline_email
template:
src: mod_offline_email.lua.j2
dest: "{{ install_dir }}/modules/mod_offline_email.lua"
when: smtp_host is defined
-
name: Deploy prosody configuration
template:
src: prosody.cfg.lua.j2
dest: "{{ install_dir }}/prosody.cfg.lua"
-
name: Deploy docker-compose file
template:
src: docker-compose.yml.j2
dest: "{{ install_dir }}/docker-compose.yml"
-
name: Start prosody stack
include_role:
name: docker
tasks_from: start-compose
vars:
compose_project_dir: "{{ install_dir }}"
-
name: Deploy prosody backup script
include_role:
name: docker
tasks_from: deploy-backup
vars:
backup_name: prosody
backup_hook_dir: /etc/restic/pre-backup.d
backup_volumes:
- prosody_prosody_data
backup_files:
- "{{ install_dir }}/docker-compose.yml"
- "{{ install_dir }}/prosody.cfg.lua"

View file

@ -0,0 +1,4 @@
---
-
name: Deploy prosody
ansible.builtin.include_tasks: deploy-prosody.yml

View file

@ -0,0 +1,36 @@
---
-
name: Set backup staging directory
set_fact:
_prosody_backup_dir: "{{ backup_staging_dir | default('/var/backups') }}/prosody"
-
name: Stop prosody stack
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: absent
-
name: Restore config files
copy:
src: "{{ _prosody_backup_dir }}/{{ item }}"
dest: "{{ install_dir }}/{{ item }}"
remote_src: yes
mode: "0600"
loop:
- docker-compose.yml
- prosody.cfg.lua
-
name: Restore prosody data volume
command: >
docker run --rm
-v prosody_prosody_data:/data
-v {{ _prosody_backup_dir }}:/backup
alpine sh -c "rm -rf /data/* && tar xzf /backup/prosody_data.tar.gz -C /data"
-
name: Start prosody stack
community.docker.docker_compose_v2:
project_src: "{{ install_dir }}"
state: present

View file

@ -0,0 +1,18 @@
services:
prosody:
image: prosodyim/prosody:{{ version }}
network_mode: host
volumes:
- prosody_data:/var/lib/prosody
- ./prosody.cfg.lua:/etc/prosody/prosody.cfg.lua:ro
{% if oauth_client_id is defined or default_contacts is defined %}
- ./modules:/usr/lib/prosody/custom-modules:ro
{% endif %}
{% if ssl_cert is defined %}
- {{ ssl_cert }}:/etc/prosody/certs/fullchain.pem:ro
- {{ ssl_key }}:/etc/prosody/certs/privkey.pem:ro
{% endif %}
restart: unless-stopped
volumes:
prosody_data:

Some files were not shown because too many files have changed in this diff Show more