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

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/letsencrypt/
/.pytest_cache/
/.ssh/
/.local/
/.tox/
/.vault-pass

35
.ssh.vault Normal file
View file

@ -0,0 +1,35 @@
$ANSIBLE_VAULT;1.1;AES256
39316330396162386166363162646363343365343430393266666638356139383832353833326132
6531356666643331363735373863323037303562393437620a303135613738303166346234663137
65303562666362383266323161323832666165323237313033303961363935313561343731363934
3632666365663637640a666666616336343362356164366239373065656239656237373331303166
34343539666565636462343064666163333132373236343065323735333562616434323333386438
38393137306438656463333135316539663264353736636233663035623131623835636534633336
63306666343238656233653232663663323837666437346565616137633638613439393861306636
38646663356237623765626237653732336362393739373835363431393963663065633535366632
33383031303434396334373834663662653735353030336563646236313532656464333863636263
34656262646233646230646335376565666237366138303733326339656166636133333433346432
30616139393936383261393763636563386138393237343738613933303562343837353236626665
37323635343535353032663866666238646635346138646666646562386433303063333862393761
39346234633962326231333032653732393435336434616664663961633062353739383937383866
61343030383530353235346561346531633832613634616533306136653930326634393530383730
35316438636333313930333235316361393863303961666165616130616639356632356330383139
39346335396532636334636336346434613934376131643165333438373630653732643033376537
32383066623565643964623535333262376236316564356666336532613337653333613338613865
34616561613533333636306163303131323834653934666661653866666562363638616630376566
30643330393031613836353936636661653164353531303163623666666563643837346462376436
39653665656164303135343738373436383265613363366664373466333963333532353335323436
38633163656532356435356334376164373864383031366332633131316162336161643034653664
61356538356563356232653964623165373239323664616339383263333365333633616564383138
63343435343630353461376662626365626565616366373937306637346635313462393834393430
35343962316630663238376262396332336639643136626434626336346437373438393963623863
31623237336530376364626634653663313837306165376165346363306166343739393866623537
30633735646434633762303065306231346363306339316636373066373464383764636634323163
31356431333339653266393138353061643261306135336563303462343261393261376139613364
31373861333065646237376535366138353438336463333363623464303431333135353133363461
61646262343734343362643432633837623234646633336639336566623038346561393863303636
36393861636631333363646438376133616461303834653262616565303362396634626630646137
37616631383264656333383035396262656162613162653039366139623634393030656137656563
65613135333364636437376163303230323435353834636262656539363964626331343465373038
64333735643234326665653036303465646561646362626662653966303966623130356433313034
3661666134663430643830613463663761333162393766636263

1
.vault-pass-debug Normal file
View file

@ -0,0 +1 @@
&0geF%B46!H30#fnuY4ad8yCAxpM&C*!

55
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,55 @@
# Contributing
## Code style
- YAML files use 4-space indentation, formatted with `yamlfix`.
- Jinja2 templates use the file extension `.j2`.
- Role names use snake_case (e.g., `docker_registry`).
- Task names are human-readable sentences (e.g., "Ensure SSL private keys are readable by containers").
## Role structure
Each role follows the standard Ansible layout:
```
roles/<name>/
├── defaults/main.yml # Default variables
├── tasks/
│ ├── main.yml # Entry point
│ └── *.yml # Additional task files (deploy, restore, etc.)
├── templates/ # Jinja2 templates (.j2)
├── files/ # Static files
└── meta/main.yml # Role dependencies
```
## Adding a new service
1. Create a new role in `ansible/roles/<service>/`.
2. Add it to the proxy or IDP play in `ansible/playbooks/setup.yml` with a
`tags: [<service>]` annotation.
3. If the role depends on docker or apache, those roles handle themselves
internally — just declare the dependency in `meta/main.yml`.
## Testing changes
Always test locally before deploying to production:
```bash
# Run against local VMs
pipenv run ansible-playbook -i ansible/inventories/debug/hosts.ini ansible/playbooks/setup.yml \
--tags <service>
```
## Committing
- Keep commits focused: one logical change per commit.
- Use conventional-ish commit messages:
- `feat: add offline email notifications to prosody`
- `fix: correct registry vhost name collision`
- `chore: migrate collection roles to local roles/`
## Vault changes
When adding new secrets, add them to the vault file and document the variable
name (but not the value) somewhere discoverable — either in the role's
`defaults/main.yml` as a commented placeholder or in the playbook next to the
role invocation.

168
DEVELOPMENT.md Normal file
View file

@ -0,0 +1,168 @@
# Development
## Prerequisites
```bash
sudo apt install -y qemu-system-x86 libvirt-daemon-system libvirt-clients \
genisoimage ovmf screen
sudo usermod -aG libvirt $(whoami)
sudo usermod -aG kvm $(whoami)
pipenv install --dev
pipenv run ansible-galaxy collection install -r ansible/requirements.yml
```
## Repository layout
```
servers/
├── ansible/
│ ├── inventories/
│ │ ├── debug/ # Local VMs (vm-proxy, vm-idp)
│ │ └── prod/ # Production VPS hosts
│ ├── playbooks/
│ │ ├── setup.yml # Main deployment playbook
│ │ ├── backup.yml # Backup playbook
│ │ └── restore.yml # Restore playbook
│ ├── roles/ # All roles (formerly a separate collection)
│ └── requirements.yml # Ansible Galaxy dependencies
├── scripts/
│ ├── provision.sh # One-time: create VMs + full playbook + snapshot
│ ├── deploy.sh # Iterate: restore + deploy a single service
│ ├── reset.sh # Restore VMs to a snapshot
│ ├── prod-deploy.sh # Deploy to production
│ └── vm/ # Low-level VM lifecycle (wrapping qemu-vm)
├── letsencrypt/ # TLS certificates (not committed)
├── Pipfile # Python dependencies
└── ansible.cfg # Ansible config (roles_path, vault)
```
## Development workflow
The workflow is: develop and test locally against VMs, then deploy to
production once changes are idempotent and working.
All scripts are meant to be run inside `pipenv shell` or via `pipenv run`.
### 1. Provision (once)
Creates VMs, runs the full ansible playbook, and snapshots the result as
`provisioned`. Prompts for sudo to set up the bridge network:
```bash
pipenv run scripts/provision.sh
```
This gives you two VMs:
- **vm-proxy** at `10.10.0.2` (screen session `vm-proxy`)
- **vm-idp** at `10.10.0.3` (screen session `vm-idp`)
Attach to a VM's serial console with `screen -r vm-proxy`.
### 2. Iterate on a service
Deploy a single service to the local VMs:
```bash
# Deploy bugzilla on top of current VM state
pipenv run scripts/deploy.sh bugzilla
# Skip dependencies you know haven't changed
pipenv run scripts/deploy.sh bugzilla --skip docker,apache
# Restore to clean state first, then deploy
pipenv run scripts/deploy.sh prosody --restore
# Restore to a specific snapshot first
pipenv run scripts/deploy.sh authentik --restore initialized
```
### 3. Reset
Go back to a known state without deploying anything:
```bash
# Reset to bare VMs (pre-ansible)
pipenv run scripts/reset.sh
# Reset to fully provisioned state
pipenv run scripts/reset.sh provisioned
```
### 4. Custom snapshots
Save and restore intermediate states during development:
```bash
# Save current state
pipenv run scripts/vm/snapshot.sh after-bugzilla
# Restore it later
pipenv run scripts/reset.sh after-bugzilla
```
### 5. Destroy and recreate
Start completely from scratch:
```bash
pipenv run scripts/vm/destroy.sh
pipenv run scripts/provision.sh
```
### 6. Deploy to production
Once your changes work locally and are idempotent:
```bash
# Full deployment
pipenv run scripts/prod-deploy.sh
# Targeted deployment
pipenv run scripts/prod-deploy.sh prosody
# Skip dependencies
pipenv run scripts/prod-deploy.sh bugzilla --skip docker,apache
```
## Low-level VM management
The `scripts/vm/` directory contains building-block scripts for direct
VM control:
| Script | Purpose |
|--------|---------|
| `vm/start.sh` | Start VMs in screen sessions |
| `vm/stop.sh` | Stop VMs gracefully |
| `vm/status.sh` | Show instances, volumes, screen sessions |
| `vm/snapshot.sh <label>` | Stop + snapshot both volumes |
| `vm/restore.sh <label>` | Restore + start both VMs |
| `vm/create.sh` | First-time VM creation + cloud-init |
| `vm/destroy.sh` | Delete VMs and volumes |
| `vm/setup-network.sh` | Create bridge network (requires root) |
## Working with roles
Roles live directly in `ansible/roles/`. No collection build/install step is
needed — Ansible finds them via `roles_path` in `ansible.cfg`.
Each role is referenced by its directory name (e.g., `include_role: { name: prosody }`).
Cross-role dependencies use the same short names.
## Working with vault
```bash
# Edit encrypted variables
pipenv run ansible-vault edit ansible/inventories/prod/group_vars/all/vault.yml
# Encrypt/decrypt files
ansible-vault-dir encrypt <file>
ansible-vault-dir decrypt <file>
```
## YAML formatting
The project uses `yamlfix` for consistent YAML formatting:
```bash
pipenv run yamlfix ansible/playbooks/setup.yml
```

View file

@ -0,0 +1,60 @@
# containerd-snapshotter: pull-through mirror fails with "failed to decode referrers index"
## Summary
When Docker Engine uses the containerd image store (`"features": {"containerd-snapshotter": true}`)
and a pull-through registry mirror (Docker Distribution `registry:2`), image pulls fail with:
```
failed to decode referrers index: invalid character '<' looking for beginning of value
```
or:
```
failed to unpack image on snapshotter overlayfs: unexpected media type text/html for sha256:...: not found
```
## Root cause
After pulling an image through the mirror, containerd makes a **referrers API request**
(OCI Distribution Spec 1.1) directly to the **upstream registry** (e.g. `registry-1.docker.io`),
bypassing the mirror entirely. The upstream sometimes returns an HTML error page instead of
a valid JSON response for the referrers endpoint, causing containerd to fail the entire pull.
Key observations:
- The image layers and manifests pull successfully through the mirror
- The referrers request goes **directly to upstream**, not through the mirror
- The `hosts.toml` mirror config with `capabilities = ["pull", "resolve"]` does not
cover referrers requests
- The error is **not** intermittent — it happens consistently for certain images
- Without `containerd-snapshotter` (classic Docker storage driver), the issue does not occur
- Affects both Docker 28.x and 29.x when containerd-snapshotter is enabled
## Affected versions
- Docker Engine 28.x and 29.x with `containerd-snapshotter: true`
- containerd 2.x (bundled with Docker)
- Registry mirror: Docker Distribution `registry:2` (v2.8.3)
- Tested on Debian 12 (bookworm), amd64
## Workaround
Disable the containerd image store and use the classic Docker storage driver.
The classic driver does not make referrers API requests.
Remove from `/etc/docker/daemon.json`:
```json
{
"features": {
"containerd-snapshotter": true
}
}
```
Use `registry-mirrors` in `daemon.json` instead of `hosts.toml` for Docker Hub mirroring.
Note: `registry-mirrors` only supports Docker Hub, not per-registry mirrors (e.g. ghcr.io).
## Reproduction
See `scripts/reproduce-containerd-referrers.sh` for a self-contained reproduction script.

16
Pipfile Normal file
View file

@ -0,0 +1,16 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
ansible = "*"
"byteb4rb1e.utils" = {file = "../../byteb4rb1e/sphinx-courseware/sphinxcontrib.h5p.utils.pkg/vendor/py-utils.git"}
"byteb4rb1e.devutils" = {file = "../../byteb4rb1e/devutils"}
[dev-packages]
"byteb4rb1e.utils" = {file = "../../byteb4rb1e/sphinx-courseware/sphinxcontrib.h5p.utils.pkg/vendor/py-utils.git", editable = true}
"byteb4rb1e.devutils" = {file = "../../byteb4rb1e/devutils", editable = true}
[requires]
python_version = "3.13"

402
Pipfile.lock generated Normal file
View file

@ -0,0 +1,402 @@
{
"_meta": {
"hash": {
"sha256": "3ab63f11f8687a954fea0a1a3324efaaf2aaf321f7d09a3eef8f26a3ef128839"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.13"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"ansible": {
"hashes": [
"sha256:6d741511abc1f724443aa16992fb615cc822a22da427ade925ff937ccd691eb1",
"sha256:8c869fcc07954b53c5b125f1e914957cc7b541a92a7d512496207d80385a78eb"
],
"index": "pypi",
"markers": "python_version >= '3.12'",
"version": "==13.4.0"
},
"ansible-core": {
"hashes": [
"sha256:38670ab2d2ffd67ee7e9e562f7c94e0927c8b7b4b0eb69233bc008c29e32d591",
"sha256:7fe2da953192cac5d5eb37bfc984bf9339daa252e0e3d1deb5526eb1f8be9f7f"
],
"markers": "python_version >= '3.12'",
"version": "==2.20.3"
},
"byteb4rb1e.devutils": {
"file": "../../byteb4rb1e/devutils"
},
"byteb4rb1e.utils": {
"file": "../../byteb4rb1e/sphinx-courseware/sphinxcontrib.h5p.utils.pkg/vendor/py-utils.git"
},
"cffi": {
"hashes": [
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
"sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b",
"sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f",
"sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9",
"sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44",
"sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2",
"sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c",
"sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75",
"sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65",
"sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e",
"sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a",
"sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e",
"sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25",
"sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a",
"sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe",
"sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b",
"sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91",
"sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592",
"sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187",
"sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c",
"sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1",
"sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94",
"sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba",
"sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb",
"sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165",
"sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529",
"sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca",
"sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c",
"sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6",
"sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c",
"sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0",
"sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743",
"sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63",
"sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5",
"sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5",
"sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4",
"sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d",
"sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b",
"sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93",
"sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205",
"sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27",
"sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512",
"sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d",
"sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c",
"sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037",
"sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26",
"sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322",
"sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb",
"sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c",
"sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8",
"sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4",
"sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414",
"sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9",
"sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664",
"sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9",
"sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775",
"sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739",
"sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc",
"sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062",
"sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe",
"sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9",
"sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92",
"sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5",
"sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13",
"sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d",
"sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26",
"sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f",
"sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495",
"sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b",
"sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6",
"sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c",
"sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef",
"sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5",
"sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18",
"sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad",
"sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3",
"sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7",
"sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5",
"sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534",
"sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49",
"sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2",
"sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5",
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
],
"markers": "python_version >= '3.9'",
"version": "==2.0.0"
},
"cryptography": {
"hashes": [
"sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
"sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
"sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
"sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
"sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
"sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
"sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
"sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
"sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
"sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
"sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
"sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
"sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
"sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
"sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
"sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
"sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
"sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
"sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
"sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
"sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
"sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
"sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
"sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
"sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
"sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
"sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
"sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
"sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
"sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
"sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
"sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
"sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
"sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
"sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
"sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
"sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
"sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
"sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
"sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
"sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
"sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
"sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
"sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
"sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
"sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
"sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
"sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
"sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==46.0.5"
},
"jinja2": {
"hashes": [
"sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d",
"sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"
],
"markers": "python_version >= '3.7'",
"version": "==3.1.6"
},
"markupsafe": {
"hashes": [
"sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f",
"sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a",
"sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf",
"sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19",
"sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf",
"sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c",
"sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175",
"sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219",
"sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb",
"sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6",
"sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab",
"sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26",
"sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1",
"sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce",
"sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218",
"sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634",
"sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695",
"sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad",
"sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73",
"sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c",
"sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe",
"sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa",
"sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559",
"sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa",
"sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37",
"sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758",
"sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f",
"sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8",
"sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d",
"sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c",
"sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97",
"sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a",
"sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19",
"sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9",
"sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9",
"sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc",
"sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2",
"sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4",
"sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354",
"sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50",
"sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698",
"sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9",
"sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b",
"sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc",
"sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115",
"sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e",
"sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485",
"sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f",
"sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12",
"sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025",
"sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009",
"sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d",
"sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b",
"sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a",
"sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5",
"sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f",
"sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d",
"sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1",
"sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287",
"sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6",
"sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f",
"sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581",
"sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed",
"sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b",
"sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c",
"sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026",
"sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8",
"sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676",
"sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6",
"sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e",
"sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d",
"sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d",
"sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01",
"sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7",
"sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419",
"sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795",
"sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1",
"sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5",
"sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d",
"sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42",
"sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe",
"sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda",
"sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e",
"sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737",
"sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523",
"sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591",
"sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc",
"sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a",
"sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"
],
"markers": "python_version >= '3.9'",
"version": "==3.0.3"
},
"packaging": {
"hashes": [
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
],
"markers": "python_version >= '3.8'",
"version": "==26.0"
},
"pycparser": {
"hashes": [
"sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29",
"sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
],
"markers": "python_version >= '3.10'",
"version": "==3.0"
},
"pyyaml": {
"hashes": [
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
"sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a",
"sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3",
"sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956",
"sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6",
"sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c",
"sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65",
"sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a",
"sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0",
"sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b",
"sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1",
"sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6",
"sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7",
"sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e",
"sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007",
"sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310",
"sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4",
"sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9",
"sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295",
"sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea",
"sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0",
"sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e",
"sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac",
"sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9",
"sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7",
"sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35",
"sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb",
"sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b",
"sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69",
"sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5",
"sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b",
"sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c",
"sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369",
"sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd",
"sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824",
"sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198",
"sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065",
"sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c",
"sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c",
"sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764",
"sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196",
"sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b",
"sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00",
"sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac",
"sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8",
"sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e",
"sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28",
"sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3",
"sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5",
"sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4",
"sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b",
"sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf",
"sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5",
"sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702",
"sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8",
"sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788",
"sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da",
"sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d",
"sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc",
"sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c",
"sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba",
"sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f",
"sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917",
"sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5",
"sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26",
"sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f",
"sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b",
"sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be",
"sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c",
"sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3",
"sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6",
"sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926",
"sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"
],
"markers": "python_version >= '3.8'",
"version": "==6.0.3"
},
"resolvelib": {
"hashes": [
"sha256:7d08a2022f6e16ce405d60b68c390f054efcfd0477d4b9bd019cc941c28fad1c",
"sha256:fb06b66c8da04172d9e72a21d7d06186d8919e32ae5ab5cdf5b9d920be805ac2"
],
"markers": "python_version >= '3.9'",
"version": "==1.2.1"
}
},
"develop": {
"byteb4rb1e.devutils": {
"file": "../../byteb4rb1e/devutils"
},
"byteb4rb1e.utils": {
"file": "../../byteb4rb1e/sphinx-courseware/sphinxcontrib.h5p.utils.pkg/vendor/py-utils.git"
}
}
}

107
README.md Normal file
View file

@ -0,0 +1,107 @@
# servers
Homelab infrastructure managed with Ansible and tested against local QEMU VMs.
Two production VPS hosts (proxy and IDP) are connected via WireGuard and run
everything behind Apache reverse proxies with Let's Encrypt TLS.
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Proxy (10.0.0.1) │
│ ┌───────────┐ ┌──────────┐ ┌────────────────┐ │
│ │ Apache │ │ Prosody │ │ Docker Registry│ │
│ │ (reverse │ │ (XMPP) │ │ (pull-through) │ │
│ │ proxy) │ │ │ │ │ │
│ ├───────────┤ ├──────────┤ ├────────────────┤ │
│ │ ConverseJS│ │ Bugzilla │ │ Comentario │ │
│ │ Kellnr │ │ DevPI │ │ dnsmasq │ │
│ └───────────┘ └──────────┘ └────────────────┘ │
│ WireGuard ─────────────────────────┐ │
└─────────────────────────────────────┼────────────┘
┌─────────────────────────────────────┼────────────┐
│ IDP (10.0.0.2) │ │
│ ┌──────────────────────────────────┘ │
│ │ Authentik (identity provider) │
│ │ PostgreSQL + Redis │
│ └───────────────────────────────────────────────┘
└──────────────────────────────────────────────────┘
```
Clients (phone, workstation) connect over WireGuard and resolve names via
dnsmasq on the proxy.
## Quick start
```bash
# Install Python dependencies
pipenv install
# Install Ansible collection dependencies
pipenv run ansible-galaxy collection install -r ansible/requirements.yml
# Deploy to production
pipenv run ansible-playbook -i ansible/inventories/prod/hosts.ini ansible/playbooks/setup.yml
# Deploy a single service (e.g. bugzilla)
pipenv run ansible-playbook -i ansible/inventories/prod/hosts.ini ansible/playbooks/setup.yml --tags bugzilla
# Deploy a service without its dependencies
pipenv run ansible-playbook -i ansible/inventories/prod/hosts.ini ansible/playbooks/setup.yml --tags bugzilla --skip-tags docker,apache
```
See [DEVELOPMENT.md](DEVELOPMENT.md) for the full local development workflow.
## Available tags
| Tag | What it deploys | Host |
|-----|----------------|------|
| `host` | Base host config (SSH keys, users, swap) | all |
| `docker` | Docker engine + registry mirrors | docker_hosts |
| `wireguard` | WireGuard tunnels | proxy, idp |
| `apache` | Apache + Let's Encrypt certs | proxy |
| `dnsmasq` | DNS resolver | proxy |
| `docker-registry` | Pull-through registry mirrors | proxy |
| `restic` | Backup infrastructure | proxy, idp |
| `prosody` | XMPP server + upload proxy | proxy |
| `conversejs` | Web XMPP client | proxy |
| `kellnr` | Rust crate registry | proxy |
| `devpi` | Python package index | proxy |
| `comentario` | Comment system | proxy |
| `bugzilla` | Bug tracker | proxy |
| `authentik` | Identity provider + reverse proxy | idp, proxy |
| `blog` | Static blog site | proxy |
| `spec` | Static spec site | proxy |
## Backups
```bash
# Run backup
pipenv run ansible-playbook -i ansible/inventories/prod/hosts.ini ansible/playbooks/backup.yml
# Restore
pipenv run ansible-playbook -i ansible/inventories/prod/hosts.ini ansible/playbooks/restore.yml
```
Automated backups run via systemd timer (bi-weekly by default).
## Vault
Sensitive variables live in `ansible/inventories/prod/group_vars/all/vault.yml`,
encrypted with `ansible-vault`. The vault password file is `.vault-pass`
(not committed).
```bash
# Edit vault
pipenv run ansible-vault edit ansible/inventories/prod/group_vars/all/vault.yml
```
## Authentik notes
App passwords with more than 30 minutes expiration require a user or group
attribute:
```
goauthentik.io/user/token-maximum-lifetime: days=365
```

3
ansible.cfg Normal file
View file

@ -0,0 +1,3 @@
[defaults]
roles_path = ansible/roles
vault_password_file = .vault-pass

35
ansible/README.md Normal file
View file

@ -0,0 +1,35 @@
Install collection dependencies:
ansible-galaxy collection install -r requirements.yml
Run setup:
ansible-playbook -i inventories/prod/hosts.ini playbooks/setup.yml
Run backup:
ansible-playbook -i inventories/prod/hosts.ini playbooks/backup.yml
Run restore:
ansible-playbook -i inventories/prod/hosts.ini playbooks/restore.yml
Manual backup:
ansible-playbook -i inventories/prod/hosts.ini playbooks/backup.yml
Automated backups run via systemd timer (bi-weekly by default).
Vault variables (inventories/prod/group_vars/all/vault.yml):
vault_kellnr_admin_pwd: "..."
vault_pg_password: "..."
vault_secret_key: "random-long-django-secret"
vault_restic_password: "..."
vault_accounts_ssh_pubkey: "ssh-ed25519 ..."
vault_accounts_ssh_private_key: |
-----BEGIN OPENSSH PRIVATE KEY-----
...
vault_rclone_proton_username: "user@proton.me"
vault_rclone_proton_password: "rclone-obscured-password"
vault_rclone_proton_2fa: "TOTP-SECRET"

View file

@ -0,0 +1,27 @@
---
vault_kellnr_admin_pwd: debug
vault_pg_password: debug
vault_secret_key: debug
s3_bucket: backups-testing
vault_xmpp_admin_user: tiara
vault_xmpp_oauth_client_id: xmpp-client-id
vault_xmpp_oauth_client_secret: xmpp-client-secret
vault_xmpp_ropc_client_id: xmpp-ropc-client-id
vault_xmpp_ropc_client_secret: xmpp-ropc-client-secret
vault_comentario_oauth_client_id: comentario-client-id
vault_comentario_oauth_client_secret: comentario-client-secret
vault_bugzilla_oauth_client_id: bugzilla-client-id
vault_bugzilla_oauth_client_secret: bugzilla-client-secret
vault_bugzilla_oidc_passphrase: bugzilla-oidc-passphrase
vault_bugzilla_db_password: debug
vault_bugzilla_admin_pwd: debugdebug
vault_social_google_client_id: google-client-id-placeholder
vault_social_google_client_secret: google-client-secret-placeholder
vault_social_microsoft_client_id: microsoft-client-id-placeholder
vault_social_microsoft_client_secret: microsoft-client-secret-placeholder
vault_social_apple_client_id: apple-client-id-placeholder
vault_social_apple_client_secret: apple-client-secret-placeholder
vault_social_facebook_client_id: facebook-client-id-placeholder
vault_social_facebook_client_secret: facebook-client-secret-placeholder
vault_social_twitter_client_id: twitter-client-id-placeholder
vault_social_twitter_client_secret: twitter-client-secret-placeholder

View file

@ -0,0 +1,54 @@
$ANSIBLE_VAULT;1.1;AES256
34646335663961323835326462363434393133316464393063356666396362626637306263626332
3137613332313531333364643331373934363237363231640a393362313738383035373131633537
30623432363962313538323131323836613539643533623364666631663733353134316461373930
6364393465613665620a336261336630346630646530653135336163653136336532333130333338
64626264353737363235353865303039656339653062633335623964643939326364393263393832
36663030646363636130353164313063356133396635643334653561343462333662656237633766
39646363646161303165316439646164613530636266373761396135663830383932663837383661
62636334383861396133353839616239316366303437383934333265343063366139613564346131
39656434653662316534386263383361333131383037313163326464353963643239343661663932
37303065366463653761623062326439633363663030616432346538373461353830353338623230
64306261326135363566656232313330353037613164616264656431363439326331613837376662
36646134366463383665393136616437393339346134363638666431363933636637366632366439
39656138366234646138353131313166656431303463316332663138303330326135666261363533
34393833626239323036663035646239316266323434366435323931366532623639653837373261
37633333663233646332393239613264363562343164653033303865313233393432306539646536
39643264623862393833653362323436396661353065306233646234306235306366666139666130
61643364613133326461383331366664363531643963376137653561376532393736393633313831
31303861373037363766316664623234633563386338353431396663383034343061643962366535
31653833393765663961613338336334366162656466663863306662383838386664326631336434
65366563333533396434333861333937333466313861626661363061303939373538666130326238
62363130633566336163653238356636623066306662326536366463613532626536633564353430
64303165353436626266636131333864666336386535663832313564313533333163633639393038
64633236333236616535396634653162396464363337393163623262653866666664303534376164
35303066626665386535656537666432636132656639646431663463363630333466316534303739
30396466356131616666626130383963333166396339623861303662333535343239643364633535
37656466363535363163643036653464336538623334633565346163343630663463633235383831
66306462396163313634666130666264336138643163643563663635333362623262373330643766
33626633303062656462313862393763616163363231323036306638633536313230656261303835
35333738396430356139346331643839386236343464323137663964336561633766353262353863
32313635323632643065343139366436373864636465376538376365333835366232613032333230
35376134343861373333346537623838303165366639653063303364303638626431316535656466
38353261393262306433383035353261363265653838653661636463636638636465383665383763
34303238323838313364373137613566663737323938306365376565353066656162656135376363
39326366623830616330336163363738326263663832313337323164656331636334333937383737
61653336613033363766636435353365356265373263656165326162316130333134336230353637
36656364646336336163333338663966613934356266313865643934373566343964363062326366
38646562373261313638383338636139333835626239346363336331663536306431636463336630
38656236366436316234393236646234613264353264333030626362343463333863373065343336
63663135363365316265636464663864313765663763613131613030636133643637336364316234
62393061346337383563313866303463633362356263356238323336663262646532343136393439
64333362383461366230663435396164393031616432323537396237383634353363393332376536
39646631386365646266366530623762616264663164346533323239643863363036663565323438
63663537306133363337363962646462323131353065313537626432633534653432393639626337
66393234313835613163663030363332336361316365616238643964333363386134663232323766
33306165626431373730373135306431373937613235333861636266666662336431356265623638
38623065623933646237326339353932353439383932663631346131316431316234323537353062
37353632333961386334646232393930666234373432653463613931643537623536656364626632
61393063636333663961336562326330653461393562313064626566626436383765356264633838
39356232626362323935626362316563363430383134636230373665613930336462383363393831
37356261663238303337383833353930383736386434666539333939616434626532393831643866
35386137663632383639376538386435373065333138643064383136656136373634323839636366
31356561396137326134643562396264303561363639636637313362616438646231663162656332
3739

View file

@ -0,0 +1,4 @@
---
ufw_allow:
- { port: "51820", proto: udp }
- { port: "9000", proto: tcp, from: "10.0.0.0/24" }

View file

@ -0,0 +1,9 @@
---
ufw_allow:
- { port: "80" }
- { port: "443" }
- { port: "51820", proto: udp }
- { port: "5000", proto: tcp }
- { port: "5222", proto: tcp, from: "10.0.0.0/24" }
- { port: "5269", proto: tcp, from: "10.0.0.0/24" }
- { port: "53", proto: udp, from: "10.0.0.0/24" }

View file

@ -0,0 +1,6 @@
---
wg_client_peers:
-
name: "Ansible controller"
public_key: "{{ lookup('file', '/etc/wireguard/public.key') }}"
allowed_ips: "10.0.0.3/32"

View file

@ -0,0 +1,13 @@
[proxy]
vm-proxy ansible_host=10.10.0.2 ansible_user=debian wg_endpoint=10.10.0.2
[idp]
vm-idp ansible_host=10.10.0.3 ansible_user=debian
[docker_hosts:children]
proxy
idp
[wg_peers:children]
proxy
idp

View file

@ -0,0 +1,126 @@
$ANSIBLE_VAULT;1.1;AES256
65656630393666376132613061363366363432303437313961303434323431333832636331633535
3364623638626435326236656563646435396661353939610a366337363936633038623065323134
63353766653532623164353764353761363633633366633538633566323465303835323831656238
3565353965646338630a343232386563313764313465383666356136326336336230303432346538
38316130663339386235343265616237373536613762626338376564666432366464323436353639
36326137373232316438383837633830366163633437643565313863613137323033353430356638
33663736623032323263353831343430313734373065393232663065643432333864323563663265
33386465326335373635333435623764346666353765336630643764373737306637343834656639
35653466366666383332663464626463646235303737306166373832396531623038376532656665
65663964373565303538343732393137396361613330613336326564613633363662306232643534
37393939323563343332613833613339313630396434663235656232643631303031393735396263
63613863366162383637363736393430643233326533383534623539646330313737383332396165
32393134323739363866386633646563393036373235353332333062316639653362626261376434
31366437383465616534653465386636376163653430663764613236366134336435636631623963
37346430393933306331636633333636666239333962323137393466356265626363376361393435
30656432353333646337346662653637303430313739353431666237373037666534316566666564
65663838643461316566616532343461343138323637356231393730393062363164633537363539
36623035386636376239306633303766616636333935336464323636626437393466356139393164
32373039326138346465383736616433343234303762343161313661363563353864663538316662
30613432356438663561626137316636623431656432346238303735313865326230633231633636
33666434343038656266323866663634653063353864366163653737363938336235316565306635
35646137313936303738623630356333623261303839346632363562646263616130643734353066
61663761326264313732396166303362313231333166346231306165346630636331666262373631
61636137356430633566323732303233656635643639353539626238613761386532336136663535
32356561613635633230303733303836336635396338666134303464376437316163633238373365
34363230383933613136343761326563633034306539623866376661336539303535336565393962
62613138623334376134373566623034313365616238353864616265383533363037363463343835
32623334663832333630326565633833373936376633343937363633363533633162646164393537
35663765626362366234626432663064346231386265376132396335353130336432356435336337
37636136303631366333316636313733376137343961393062613762333962616263373233633366
30383236386462393732373464333363376638663465363634396238376564326363383432386438
36363638383437643233393436383539333162373730633533666533343864346432623130626237
33643436653732333532666333613038316539626536366265386332623563353133363663663530
31643131653566323935393932353537343533343131666239663864653232633331363961663031
37346664383031646264316132356433616631373936376435613666653139313630316665636237
61626137653765623638393132303436366364353162333036353138376535626635343731666432
34663032643936393732373966363534303435313234323334393964313235333263373136313962
35386539393238326132346134346333626437353464333538623966363965663237353331363438
34336630343930633363663062316639336630396164653236303538383537333062636535366461
36393534336462633062316535376665303666643538303630653234396533323463323131303365
38336636666238316332646162323164343436303264396335646466643165623937363934363734
33366532396332393231633366353632363561663662633362343838653431356633633462393738
62333236613831316334343837333465303535383036353933396436346235653162326266663934
36633434633630303234333336633834623465613433333833666430353538663332396366316237
39656638303763633535393932326132346266666635353462623132306331626266303336396362
62346564636365353031366264656534376665366664386636633136373738323038663132643034
39303261313333333465656666636435323338336335313166343431396238643738633534366364
34383733383366626265313936353039613630626237636439343930393831323238333161656637
34303763386635373237393463623838303738383434306639303161336337313831653862346535
32363237333932343633653165663138393761656365363632393539356665613837343663396438
62313333306262303435386330616233313230323338666537346265343562343065326362643461
37373330386531646465393039656165333032383735643566366465366130313263653930336434
32346234626561373236333934633130366134626239323963346536626339336139326536346238
38396663323837393330343633313939656430343366626637396564653036303830633065396336
61383234653130623933666262353339363737633230636239323339643032323264306636343161
62636133656364303231666535393764633035393363313732306331663131393331366436373764
32386134303532653934306239323362306637396263363361386638343631633761653534643061
66613761336333643062306635313834333464343936333438636664666561393237303139366666
30653263623738383839613564623635336236356630353031323636333631373339376366323834
63393839656535313966643335636135336366336135376561303661633332363338363266646364
30346536323835326165663336326337336431376337356161613566356562326334333566343963
34316433343231343735313236313432323732616363653634323262393235333063386234376135
62616536383339333461393462313365633834326638656433383032336639633861363466636462
33643066363631633438363432316366313836383864663266633336373865633733353933323531
61373531336130303834643739336237343966626239613731373034313232343764613637356564
66366330346431663031613535633435356239376361663936646334383466373261316531653762
63306138306334616333373139663432326366646534396331613265313465646338323063653735
31653739616534303138363465653966613436613634616166323665613538363263346334336338
34623235636630363332343566616336323636646235663132373633313230623639623962643466
39356137313239653431383339646135616636616437623830353639616634306534663636386232
39363864303733363164313130666536366664303733366132663439656333626562343063386336
33396261663030333332333139353163623133663134363736326538653763373730326563313937
62353434356232376630306538633731626435613639303165616535316164336637343362353465
39633031366231633032313162336437336362626131613363353131663532646266306636396138
62333939383065653866356633663834373265646366353261653661353465613766633638333836
66643335396563316166393535623734303730346138386434323034643735313137313362306636
31386565383032663937323334346462656436376633306638323562376238363339323530353230
38353537333232306536386238653765393061646432646162626438636365323564363363316362
64646662633036316530326437366530353234346231303865306665343061613134613338336664
64313237643137633361643466313938623137366561376532646133366261633637623837653233
33306263666665636136633531393866323232356562656135653464333836363830323634343338
63633836386565336563646131333861666332336261393034326337323836393035346262663631
64666635623362626235373433613132303037396136663630353334353938656434656337386134
31666364626335626639396334396162326138626631353635323364383364616162353735303336
38306330666538326130316635323562393963343635663666333038383564323832646239323062
66356363323664663637656332363461363430393938313930316364623766303437623865633461
31663133626235626265323661353531386232323836353737313138353330653936306339343235
38663563653439626435373361383530646262656238306239643136373161396134336233366566
38663863616139383738303061383431313763323336353463636361613132616363326133323339
37363562363633393664643334386266366337353464336333336363373161663034303835396262
61623233666137343963613161643164333336323130353538636565363735376561356561313133
63333033336666393538396534353463393331363033353631393162653132396532623864333536
61656231353437356532353066663261633861616530313332343263643635613034393563373961
64663631383736613835376633633735626466636661353765303765643038623039303733636264
66353133313464316633626661663665646264666133356133383435646563346332383865306235
64353265393463326633646434633264303734626532663935326665363632393435333565306635
35666464643364313039363264333261623365373330316637653038616639643637663437366663
39643134613465376162396238376562643930323632643465393038333432663062383434626432
34366665663761316564656436326331373165626237383839343736373063383765383032383762
65303433353535393665323638666239336538326239363263646534653239333039643034656362
35353062636334353264353964303938636563306631356664303965306331613134326264653730
33613061633931663066616139646663343334323763343630636432353931313265303931343833
63303831333032383839633263626539653139313561626432393731623932383733666464313061
39393537393563343238306438313032353134383630386436643534306266393364303966393361
32333538383863353031373735353932633633613538316261396164653230336463666165313965
35653533393662393230343736303466643737353437333966653966613861646361303637323933
34393365643235373762323631633930316538333835303230613265366265393938646432613964
62323637326166303936303663313038393133316336653462313931663335316230306536356165
62346432366234623232353666313131613330636436303830363261323465363164366637353238
66656463353633343133326531333131396438663964623861323037623162303666613565663161
66376531373739396463653461356131643131623561663633343331346237393063303932306239
37363533323632323336373934323237656338623962323539376230616139353463336631306264
38383233313261333466613462356234653530356535633439646432303862316236613834393765
38373263636338343030366161316464656362653433353137323532356438643238373665346336
63343230633163633066623936343661656565356533333433613838353937613233326464386462
61613261333739376630363036656564363363616665313639313463323239666636396366303663
38393466613561663436653736393431633530346263363964393961613833376235383664313331
65363233326165643533373139663233636538653937313835316263633834326139386230643338
66663133373739623236633338663435663838376633333534633337366135343438663365626533
63393437613637383033363237633363633531356535366339386334363535643364356564313765
33343039303035303739366239643333326461653736353362373133663331306362616339366236
38636237366230313332336437376539333233626361333138663164383334336138643333363030
63623166366163656431666233323564386236306165623162386639316131633939356136666336
33376366353866343033373264626334303334383766623564313262303135666232313765653536
34653430363436303237373833363765383963613865323566633436653266313036

View file

@ -0,0 +1,4 @@
---
ufw_allow:
- { port: "51820", proto: udp }
- { port: "9000", proto: tcp, from: "10.0.0.0/24" }

View file

@ -0,0 +1,9 @@
---
ufw_allow:
- { port: "80" }
- { port: "443" }
- { port: "51820", proto: udp }
- { port: "5000", proto: tcp }
- { port: "5222", proto: tcp, from: "10.0.0.0/24" }
- { port: "5269", proto: tcp, from: "10.0.0.0/24" }
- { port: "53", proto: udp, from: "10.0.0.0/24" }

View file

@ -0,0 +1,10 @@
---
wg_client_peers:
-
name: "Tiara's Pixel 8 Pro"
public_key: "0bMudTNuiRCaOdFKvWy+N6X2czYWt8nKe7OIiFS5LEY="
allowed_ips: "10.0.0.3/32"
-
name: "Tiara's Workstation"
public_key: "8Cwcyqu3Xo0th6Lkk5arcG7MdgwEejocYrA/+RRbrgA="
allowed_ips: "10.0.0.4/32"

View file

@ -0,0 +1,13 @@
[proxy]
hwsrv-953720.hostwindsdns.com ansible_user=tiara wg_endpoint=tiararodney.com
[idp]
hwsrv-1317920.hostwindsdns.com ansible_user=tiara
[docker_hosts:children]
proxy
idp
[wg_peers:children]
proxy
idp

View file

@ -0,0 +1,20 @@
---
-
hosts: proxy
become: yes
tasks:
-
name: Trigger backup
systemd:
name: restic-backup.service
state: started
-
hosts: idp
become: yes
tasks:
-
name: Trigger backup
systemd:
name: restic-backup.service
state: started

View file

@ -0,0 +1,28 @@
---
-
hosts: proxy
become: yes
tasks:
-
include_role: { name: restic, tasks_from: restore-restic }
vars:
host_id: proxy
-
include_role: { name: kellnr, tasks_from: restore }
-
include_role: { name: devpi, tasks_from: restore }
-
include_role: { name: prosody, tasks_from: restore }
-
include_role: { name: comentario, tasks_from: restore }
-
hosts: idp
become: yes
tasks:
-
include_role: { name: restic, tasks_from: restore-restic }
vars:
host_id: idp
-
include_role: { name: authentik, tasks_from: restore-authentik }

425
ansible/playbooks/setup.yml Normal file
View file

@ -0,0 +1,425 @@
---
-
hosts: all
become: yes
tags: [host]
tasks:
-
include_role: { name: host }
vars:
ssh_pubkey_dir: "{{ playbook_dir }}/../../.ssh"
-
hosts: docker_hosts
become: yes
tags: [docker]
tasks:
-
include_role: { name: docker }
vars:
registry_mirror_ip: "10.0.0.1"
registry_mirrors:
-
upstream: docker.io
mirror: "https://dockerhub.oci.code.tiararodney.com"
-
upstream: ghcr.io
mirror: "https://ghcr.oci.code.tiararodney.com"
-
hosts: proxy
become: yes
tasks:
-
include_role:
name: restic
apply: { tags: [restic] }
tags: [restic]
vars:
host_id: proxy
-
hosts: idp
become: yes
tasks:
-
include_role:
name: restic
apply: { tags: [restic] }
tags: [restic]
vars:
host_id: idp
-
hosts: localhost
connection: local
gather_facts: false
tags: [letsencrypt, apache]
tasks:
-
name: Create letsencrypt certificate archive
command:
cmd: "tar czf /tmp/letsencrypt.tar.gz --dereference -C {{ (playbook_dir + '/../../letsencrypt') | realpath }} ."
creates: /tmp/letsencrypt.tar.gz
-
hosts: wg_peers
become: yes
tags: [wireguard]
tasks:
-
include_role: { name: wireguard }
-
include_role: { name: wireguard, tasks_from: generate-keys }
-
hosts: proxy
become: yes
tags: [wireguard]
tasks:
-
name: Build WireGuard peer list
set_fact:
wg_peers:
-
public_key: "{{ hostvars[groups['idp'][0]]['wg_public_key'] }}"
allowed_ips: "10.0.0.2/32"
when: groups['idp'][0] in hostvars and 'wg_public_key' in hostvars[groups['idp'][0]]
-
name: Append client peers
set_fact:
wg_peers: "{{ wg_peers + wg_client_peers }}"
when: wg_peers is defined
-
include_role: { name: wireguard, tasks_from: deploy-wireguard }
vars:
wg_address: "10.0.0.1/24"
when: wg_peers is defined
-
name: Display proxy WireGuard public key
debug:
msg: "Proxy WG public key: {{ wg_public_key }}"
when: wg_public_key is defined
-
hosts: idp
become: yes
tags: [wireguard]
tasks:
-
name: Build WireGuard peer list
set_fact:
wg_peers:
-
public_key: "{{ hostvars[groups['proxy'][0]]['wg_public_key'] }}"
allowed_ips: "10.0.0.1/32"
endpoint: "{{ hostvars[groups['proxy'][0]]['wg_endpoint'] }}:51820"
persistent_keepalive: true
when: groups['proxy'][0] in hostvars and 'wg_public_key' in hostvars[groups['proxy'][0]]
-
name: Append client peers
set_fact:
wg_peers: "{{ wg_peers + wg_client_peers }}"
when: wg_peers is defined
-
include_role: { name: wireguard, tasks_from: deploy-wireguard }
vars:
wg_address: "10.0.0.2/24"
when: wg_peers is defined
-
hosts: proxy
become: yes
vars:
chat_domain: chat.tiararodney.com
authentik_url: https://accounts.tiararodney.com
authentik_internal_url: http://10.0.0.2:9000
tasks:
-
include_role:
name: host
tasks_from: setup-swap
apply: { tags: [host, swap] }
tags: [host, swap]
-
name: Ensure accounts.tiararodney.com resolves to localhost for mod_auth_openidc
tags: [apache, bugzilla]
lineinfile:
path: /etc/hosts
regexp: "accounts\\.tiararodney\\.com"
line: "127.0.0.1 accounts.tiararodney.com"
-
include_role:
name: dnsmasq
apply: { tags: [dnsmasq] }
tags: [dnsmasq]
vars:
dns_records:
- { domain: tiararodney.com, ip: "10.0.0.1" }
-
include_role:
name: apache
apply: { tags: [apache] }
tags: [apache]
vars:
letsencrypt_archive: /tmp/letsencrypt.tar.gz
-
include_role:
name: docker_registry
apply: { tags: [docker-registry] }
tags: [docker-registry]
vars:
hostname: dockerhub.oci.code.tiararodney.com
-
include_role:
name: docker_registry
apply: { tags: [docker-registry] }
tags: [docker-registry]
vars:
hostname: ghcr.oci.code.tiararodney.com
install_dir: /opt/docker-registry-ghcr
port: 5051
remote_url: "https://ghcr.io"
-
include_role:
name: restic
tasks_from: restore-restic
apply: { tags: [registry-restore, never] }
tags: [registry-restore, never]
vars:
host_id: proxy
restore_include: /var/backups/docker-registry
-
include_role:
name: docker_registry
tasks_from: restore-registry
apply: { tags: [registry-restore, never] }
tags: [registry-restore, never]
-
include_role:
name: apache
tasks_from: deploy-static-site
apply: { tags: [blog] }
tags: [blog]
vars:
name: blog
server_name: blog.tiararodney.com
document_root: /var/www/blog.tiararodney.com
ssl_cert: "{{ ssl_cert_tiararodney }}"
ssl_key: "{{ ssl_key_tiararodney }}"
-
include_role:
name: apache
tasks_from: deploy-static-site
apply: { tags: [spec] }
tags: [spec]
vars:
name: spec
server_name: specs.code.tiararodney.com
document_root: /var/www/specs.code.tiararodney.com
directory_index: "README.html README.md README.txt"
ssl_cert: "{{ ssl_cert_tiararodney }}"
ssl_key: "{{ ssl_key_tiararodney }}"
-
include_role:
name: kellnr
apply: { tags: [kellnr] }
tags: [kellnr]
vars:
version: "6.0.0-rc.2"
hostname: crates.code.tiararodney.com
admin_pwd: "{{ vault_kellnr_admin_pwd }}"
-
include_role:
name: devpi
apply: { tags: [devpi] }
tags: [devpi]
vars:
hostname: pypi.code.tiararodney.com
-
include_role:
name: prosody
apply: { tags: [prosody] }
tags: [prosody]
vars:
version: "13.0"
domain: "{{ chat_domain }}"
admin_jid: "{{ vault_xmpp_admin_user }}@{{ chat_domain }}"
bind_address: "10.0.0.1"
ssl_cert: /etc/letsencrypt/live/tiararodney.com/fullchain.pem
ssl_key: /etc/letsencrypt/live/tiararodney.com/privkey.pem
oauth_client_id: "{{ vault_xmpp_oauth_client_id }}"
oauth_userinfo_url: "{{ authentik_internal_url }}/application/o/userinfo/"
oauth_ropc_client_id: "{{ vault_xmpp_ropc_client_id }}"
oauth_ropc_client_secret: "{{ vault_xmpp_ropc_client_secret }}"
oauth_token_url: "{{ authentik_internal_url }}/application/o/token/"
session_timeout: 1800
smtp_host: "{{ vault_prosody_smtp_hostname }}"
smtp_username: "{{ vault_prosody_smtp_username }}"
smtp_password: "{{ vault_prosody_smtp_password }}"
default_contacts:
-
jid: "{{ vault_xmpp_admin_user }}@{{ chat_domain }}"
name: Tiara
-
include_role:
name: conversejs
apply: { tags: [conversejs] }
tags: [conversejs]
vars:
version: "12.0.0"
domain: "{{ chat_domain }}"
oauth_client_id: "{{ vault_xmpp_oauth_client_id }}"
oauth_authorize_url: "{{ authentik_url }}/application/o/authorize/"
oauth_token_url: "{{ authentik_url }}/application/o/token/"
-
include_role:
name: apache
tasks_from: deploy-reverse-proxy
apply: { tags: [prosody, xmpp-upload] }
tags: [prosody, xmpp-upload]
vars:
vhost_name: xmpp-upload
server_name: "upload.{{ chat_domain }}"
ssl_cert: "{{ ssl_cert_tiararodney }}"
ssl_key: "{{ ssl_key_tiararodney }}"
backend_port: 5280
-
include_role:
name: comentario
apply: { tags: [comentario] }
tags: [comentario]
vars:
version: "latest"
domain: comments.tiararodney.com
oauth_issuer_url: "{{ authentik_url }}/application/o/comentario"
oauth_client_id: "{{ vault_comentario_oauth_client_id }}"
oauth_client_secret: "{{ vault_comentario_oauth_client_secret }}"
smtp_host: "{{ vault_comentario_smtp_hostname }}"
smtp_username: "{{ vault_comentario_smtp_username }}"
smtp_password: "{{ vault_comentario_smtp_password }}"
-
include_role:
name: bugzilla
apply: { tags: [bugzilla] }
tags: [bugzilla]
vars:
version: "5.0.4.1"
domain: bugs.code.tiararodney.com
db_password: "{{ vault_bugzilla_db_password }}"
admin_email: "me@tiararodney.com"
admin_pwd: "{{ vault_bugzilla_admin_pwd }}"
oauth_issuer_url: "{{ authentik_url }}/application/o/bugs"
oauth_authorize_url: "{{ authentik_url }}/application/o/authorize/"
oauth_token_url: "{{ authentik_url }}/application/o/token/"
oauth_userinfo_url: "{{ authentik_url }}/application/o/userinfo/"
oauth_jwks_url: "{{ authentik_url }}/application/o/bugs/jwks/"
oauth_client_id: "{{ vault_bugzilla_oauth_client_id }}"
oauth_client_secret: "{{ vault_bugzilla_oauth_client_secret }}"
oauth_crypto_passphrase: "{{ vault_bugzilla_oidc_passphrase }}"
smtp_host: "{{ vault_bugzilla_smtp_hostname }}"
smtp_username: "{{ vault_bugzilla_smtp_username }}"
smtp_password: "{{ vault_bugzilla_smtp_password }}"
-
include_role:
name: apache
tasks_from: deploy-reverse-proxy
apply: { tags: [authentik] }
tags: [authentik]
vars:
vhost_name: accounts
server_name: accounts.tiararodney.com
ssl_cert: "{{ ssl_cert_tiararodney }}"
ssl_key: "{{ ssl_key_tiararodney }}"
backend_host: "10.0.0.2"
backend_port: 9000
websocket: true
restricted_locations:
-
path: "/if/admin/"
allowed_ips: ["10.0.0.0/24"]
-
hosts: idp
become: yes
tags: [authentik]
tasks:
-
include_role:
name: host
tasks_from: setup-zram
apply: { tags: [host, swap, zram] }
tags: [host, swap, zram]
-
include_role: { name: authentik }
vars:
version: "2026.2.1"
domain: "accounts.tiararodney.com"
pg_password: "{{ vault_pg_password }}"
secret_key: "{{ vault_secret_key }}"
bind_address: "10.0.0.2"
smtp_host: "{{ vault_authentik_smtp_hostname }}"
smtp_username: "{{ vault_authentik_smtp_username }}"
smtp_password: "{{ vault_authentik_smtp_password }}"
oauth_applications:
-
name: Chat
slug: chat
client_type: public
client_id: "{{ vault_xmpp_oauth_client_id }}"
redirect_uris:
- "https://chat.tiararodney.com/"
-
name: Chat XMPP
slug: chat-xmpp
client_id: "{{ vault_xmpp_ropc_client_id }}"
client_secret: "{{ vault_xmpp_ropc_client_secret }}"
redirect_uris:
- "https://chat.tiararodney.com/"
-
name: Comments
slug: comments
client_id: "{{ vault_comentario_oauth_client_id }}"
client_secret: "{{ vault_comentario_oauth_client_secret }}"
redirect_uris:
- "https://comments.tiararodney.com/api/oauth/oidc/callback/authentik"
-
name: Bugs
slug: bugs
client_id: "{{ vault_bugzilla_oauth_client_id }}"
client_secret: "{{ vault_bugzilla_oauth_client_secret }}"
redirect_uris:
- "https://bugs.code.tiararodney.com/oidc-callback"
social_login_sources:
-
name: Google Account
slug: google
provider_type: google
client_id: "{{ vault_social_google_client_id }}"
client_secret: "{{ vault_social_google_client_secret }}"
-
name: Microsoft Account
slug: microsoft
provider_type: entraid
client_id: "{{ vault_social_microsoft_client_id }}"
client_secret: "{{ vault_social_microsoft_client_secret }}"
-
name: Apple ID
slug: apple
provider_type: apple
client_id: "{{ vault_social_apple_client_id }}"
client_secret: "{{ vault_social_apple_client_secret }}"
-
name: Facebook Account
slug: facebook
provider_type: facebook
client_id: "{{ vault_social_facebook_client_id }}"
client_secret: "{{ vault_social_facebook_client_secret }}"
-
name: X (formerly Twitter) Account
slug: twitter
provider_type: twitter
client_id: "{{ vault_social_twitter_client_id }}"
client_secret: "{{ vault_social_twitter_client_secret }}"
-
hosts: proxy
become: yes
tasks:
-
name: Trigger registry backups
tags: [registry-backup, never]
command: "{{ item }}"
loop:
- /etc/restic/pre-backup.d/docker-registry.sh
- /etc/restic/pre-backup.d/docker-registry-ghcr.sh

4
ansible/requirements.yml Normal file
View file

@ -0,0 +1,4 @@
collections:
- name: community.docker
- name: ansible.posix
- name: community.general

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') }}

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