Compare commits

..

No commits in common. "main" and "feature/nextcloud-deployment" have entirely different histories.

124 changed files with 1131 additions and 9442 deletions

View file

@ -37,7 +37,7 @@ High-level guardian of the infrastructure architecture, ensuring consistency, ma
| Secrets | SOPS + Age | Simple, no server needed |
| Hosting | Hetzner | German, family-owned, GDPR |
| DNS | Hetzner DNS | Single provider simplicity |
| Identity | Authentik | German project lead |
| Identity | Zitadel | Swiss company, AGPL |
| File Sync | Nextcloud | German company, AGPL |
| Reverse Proxy | Traefik | French company, MIT |
| Backup | Restic → Hetzner Storage Box | Open source, EU storage |
@ -48,13 +48,13 @@ High-level guardian of the infrastructure architecture, ensuring consistency, ma
### Does NOT Handle
- Writing OpenTofu configurations (→ Infrastructure Agent)
- Writing Ansible playbooks or roles (→ Infrastructure Agent)
- Authentik-specific configuration (→ Authentik Agent)
- Zitadel-specific configuration (→ Zitadel Agent)
- Nextcloud-specific configuration (→ Nextcloud Agent)
- Debugging application issues (→ respective App Agent)
### Defers To
- **Infrastructure Agent**: All IaC implementation questions
- **Authentik Agent**: Identity, SSO, OIDC specifics
- **Zitadel Agent**: Identity, SSO, OIDC specifics
- **Nextcloud Agent**: Nextcloud features, `occ` commands
### Escalates When
@ -138,3 +138,6 @@ When reviewing proposed changes, verify:
**Good prompt:** "Review this PR that adds a new Ansible role"
**Response approach:** Check role follows conventions, doesn't violate isolation, uses SOPS for secrets, aligns with existing patterns.
**Redirect prompt:** "How do I configure Zitadel OIDC scopes?"
**Response:** "This is a Zitadel-specific question. Please ask the Zitadel Agent. I can help if you need to understand how it fits into the overall architecture."

View file

@ -1,194 +0,0 @@
# Authentik Agent
You are a specialized AI agent responsible for Authentik identity provider configuration and integration.
## Your Responsibilities
### Primary Tasks
1. **Authentik Deployment**: Configure and deploy Authentik using Docker Compose
2. **OIDC/OAuth2 Configuration**: Set up OAuth2 providers for applications
3. **User Management**: Configure user sources, groups, and permissions
4. **Flow Configuration**: Design and implement authentication/authorization flows
5. **Integration**: Connect Authentik with applications (Nextcloud, etc.)
6. **API Automation**: Automate provider creation and configuration via Authentik API
### Expertise Areas
- Authentik architecture (server + worker model)
- OAuth2/OIDC protocol implementation
- SAML, LDAP, RADIUS configuration
- PostgreSQL backend configuration
- API-based automation for OIDC provider creation
- Nextcloud OIDC integration
## Key Information
### Authentik Version
- Current: **2025.10.3**
- License: MIT (truly open source)
- Image: `ghcr.io/goauthentik/server:2025.10.3`
### Architecture
```yaml
services:
authentik-server: # Web UI and API
authentik-worker: # Background tasks
authentik-db: # PostgreSQL 16
```
### No Redis Needed
As of v2025.10, Redis is no longer required. All caching, tasks, and WebSocket connections use PostgreSQL.
### Initial Setup Flow
- URL: `https://<domain>/if/flow/initial-setup/`
- Default admin: `akadmin`
- Creates first admin account and organization
### API Authentication
Authentik uses token-based authentication:
```bash
# Get token after login
TOKEN="your_token_here"
# API calls
curl -H "Authorization: Bearer $TOKEN" \
https://auth.example.com/api/v3/...
```
## Common Operations
### 1. Create OAuth2/OIDC Provider
```python
# Using Authentik API
POST /api/v3/providers/oauth2/
{
"name": "Nextcloud",
"authorization_flow": "<flow_uuid>",
"client_type": "confidential",
"redirect_uris": "https://nextcloud.example.com/apps/user_oidc/code",
"signing_key": "<cert_uuid>"
}
```
### 2. Create Application
```python
POST /api/v3/core/applications/
{
"name": "Nextcloud",
"slug": "nextcloud",
"provider": "<provider_id>",
"meta_launch_url": "https://nextcloud.example.com"
}
```
### 3. Nextcloud Integration
```bash
# In Nextcloud
occ user_oidc:provider Authentik \
--clientid="<client_id>" \
--clientsecret="<client_secret>" \
--discoveryuri="https://auth.example.com/application/o/nextcloud/.well-known/openid-configuration"
```
## Automation Goals
### Fully Automated SSO Setup
The goal is to automate the complete SSO integration:
1. **Authentik deploys** → wait for healthy
2. **Bootstrap initial admin** → via API or initial setup
3. **Create OAuth2 provider for Nextcloud** → via API
4. **Get client_id and client_secret** → from API response
5. **Configure Nextcloud** → use OIDC app to register provider
6. **Verify SSO** → "Login with Authentik" button appears
### Key Challenge: Initial Admin Token
The main automation challenge is obtaining the first API token:
- Option 1: Complete initial setup manually once, create service account
- Option 2: Use bootstrap tokens if supported
- Option 3: Automate initial setup flow with HTTP requests
## File Locations
### Ansible Role
- `roles/authentik/defaults/main.yml` - Default configuration
- `roles/authentik/templates/docker-compose.authentik.yml.j2` - Docker Compose template
- `roles/authentik/tasks/docker.yml` - Deployment tasks
- `roles/authentik/tasks/bootstrap.yml` - Initial setup tasks
### Automation Scripts
- `roles/authentik/files/authentik_api.py` - Python API client (to be created)
- `roles/authentik/files/create_oidc_provider.py` - OIDC provider automation
- `roles/authentik/tasks/providers.yml` - Provider creation tasks
## Integration with Other Agents
### Collaboration
- **Infrastructure Agent**: Coordinate Ansible role structure and deployment
- **Nextcloud Agent**: Work together on OIDC integration configuration
- **Architect Agent**: Consult on identity/authorization architecture decisions
### Handoff Points
- After Authentik deployment → inform about API endpoint availability
- After OIDC provider creation → provide credentials to Nextcloud agent
- Configuration changes → update architecture documentation
## Best Practices
### Security
- Always use HTTPS (via Traefik)
- Store secrets in SOPS-encrypted files
- Use strong random keys for `AUTHENTIK_SECRET_KEY`
- Implement proper RBAC with Authentik's permission system
### Deployment
- Wait for database health check before starting server
- Use health checks in deployment automation
- Keep media and templates in persistent volumes
- Monitor worker logs for background task errors
### Configuration
- Use flows to customize authentication behavior
- Create separate providers per application
- Use groups for role-based access control
- Document custom flows and policies
## Troubleshooting
### Common Issues
1. **502 Bad Gateway**: Check if database is healthy
2. **Worker not processing**: Check worker container logs
3. **OAuth2 errors**: Verify redirect URIs match exactly
4. **Certificate issues**: Ensure Traefik SSL is working
### Debug Commands
```bash
# Check container health
docker ps | grep authentik
# View server logs
docker logs authentik-server
# View worker logs
docker logs authentik-worker
# Check database
docker exec authentik-db psql -U authentik -d authentik -c '\dt'
```
## Documentation References
- Official Docs: https://docs.goauthentik.io
- API Documentation: https://docs.goauthentik.io/developer-docs/api
- Docker Install: https://docs.goauthentik.io/docs/install-config/install/docker-compose
- OAuth2 Provider: https://docs.goauthentik.io/docs/providers/oauth2
- Flow Configuration: https://docs.goauthentik.io/docs/flow
## Success Criteria
Your work is successful when:
- [ ] Authentik deploys successfully via Ansible
- [ ] Initial admin account can be created
- [ ] OAuth2 provider for Nextcloud is automatically created
- [ ] Nextcloud shows "Login with Authentik" button
- [ ] Users can log in to Nextcloud with Authentik credentials
- [ ] Everything works on fresh server deployment with zero manual steps

View file

@ -46,7 +46,7 @@ Implements and maintains all Infrastructure as Code, including OpenTofu configur
## Boundaries
### Does NOT Handle
- Authentik application configuration (→ Authentik Agent)
- Zitadel application configuration (→ Zitadel Agent)
- Nextcloud application configuration (→ Nextcloud Agent)
- Architecture decisions (→ Architect Agent)
- Application-specific Docker compose sections (→ respective App Agent)
@ -58,7 +58,7 @@ Implements and maintains all Infrastructure as Code, including OpenTofu configur
### Defers To
- **Architect Agent**: Technology choices, principle questions
- **Authentik Agent**: Authentik container config, bootstrap logic
- **Zitadel Agent**: Zitadel container config, bootstrap logic
- **Nextcloud Agent**: Nextcloud container config, `occ` commands
## Key Files (Owns)
@ -170,8 +170,8 @@ output "client_ips" {
- role: common
- role: docker
- role: traefik
- role: authentik
when: "'authentik' in apps"
- role: zitadel
when: "'zitadel' in apps"
- role: nextcloud
when: "'nextcloud' in apps"
- role: backup
@ -290,4 +290,7 @@ backup_retention_daily: 7
**Response approach:** Create modular .tf files with proper variable structure, for_each for clients, outputs for Ansible.
**Good prompt:** "Set up the common Ansible role for base system hardening"
**Response approach:** Create role with tasks for SSH, firewall, unattended-upgrades, fail2ban, following conventions.
**Response approach:** Create role with tasks for SSH, firewall, unattended-upgrades, fail2ban, following conventions.
**Redirect prompt:** "How do I configure Zitadel to create an OIDC application?"
**Response:** "Zitadel configuration is handled by the Zitadel Agent. I can set up the Ansible role structure and Docker Compose skeleton - the Zitadel Agent will fill in the application-specific configuration."

16
.gitignore vendored
View file

@ -3,9 +3,7 @@ secrets/**/*.yaml
secrets/**/*.yml
!secrets/**/*.sops.yaml
!secrets/.sops.yaml
secrets/clients/*.sops.yaml
keys/age-key.txt
keys/ssh/
*.key
*.pem
@ -14,16 +12,12 @@ tofu/.terraform/
tofu/.terraform.lock.hcl
tofu/terraform.tfstate
tofu/terraform.tfstate.backup
tofu/terraform.tfstate.*.backup
tofu/*.tfvars
!tofu/terraform.tfvars.example
tofu/*.tfplan
tofu/tfplan
# Ansible
ansible/*.retry
ansible/.vault_pass
ansible/host_vars/
# OS files
.DS_Store
@ -62,13 +56,3 @@ venv/
tmp/
temp/
*.tmp
# Test/debug scripts with secrets
scripts/*-test*.py
scripts/test-*.py
**/test-oidc-provider.py
# Documentation/reports (except README.md)
*.md
!README.md
docs/

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Post-X Society
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

106
PROJECT_REFERENCE.md Normal file
View file

@ -0,0 +1,106 @@
# Project Reference
Quick reference for essential project information and common operations.
## Project Structure
```
infrastructure/
├── ansible/ # Ansible playbooks and roles
│ ├── hcloud.yml # Dynamic inventory (Hetzner Cloud)
│ ├── playbooks/ # Main playbooks
│ │ ├── deploy.yml # Deploy applications to clients
│ │ └── setup.yml # Setup base server infrastructure
│ └── roles/ # Ansible roles (traefik, nextcloud, etc.)
├── keys/
│ └── age-key.txt # SOPS encryption key (gitignored)
├── secrets/
│ ├── clients/ # Per-client encrypted secrets
│ │ └── test.sops.yaml
│ └── shared.sops.yaml # Shared secrets
└── terraform/ # Infrastructure as Code (Hetzner)
```
## Essential Configuration
### SOPS Age Key
**Location**: `infrastructure/keys/age-key.txt`
**Usage**: Always set before running Ansible:
```bash
export SOPS_AGE_KEY_FILE="../keys/age-key.txt"
```
### Hetzner Cloud Token
**Usage**: Required for dynamic inventory:
```bash
export HCLOUD_TOKEN="MlURmliUzLcGyzCWXWWsZt3DeWxKcQH9ZMGiaaNrFM3VcgnASlEWKhhxLHdWAl0J"
```
### Ansible Paths
**Working Directory**: `infrastructure/ansible/`
**Inventory**: `hcloud.yml` (dynamic, pulls from Hetzner Cloud API)
**Python**: `~/.local/bin/ansible-playbook` (user-local installation)
## Current Deployment
### Client: test
- **Hostname**: test (from Hetzner Cloud)
- **Nextcloud**: https://nextcloud.test.vrije.cloud
- **Secrets**: `secrets/clients/test.sops.yaml`
## Common Operations
### Deploy Applications
```bash
cd infrastructure/ansible
export HCLOUD_TOKEN="MlURmliUzLcGyzCWXWWsZt3DeWxKcQH9ZMGiaaNrFM3VcgnASlEWKhhxLHdWAl0J"
export SOPS_AGE_KEY_FILE="../keys/age-key.txt"
# Deploy everything to test client
~/.local/bin/ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit test
```
### Check Service Status
```bash
# List inventory hosts
export HCLOUD_TOKEN="..."
~/.local/bin/ansible-inventory -i hcloud.yml --list
# Run ad-hoc commands
~/.local/bin/ansible test -i hcloud.yml -m shell -a "docker ps"
~/.local/bin/ansible test -i hcloud.yml -m shell -a "docker logs nextcloud 2>&1 | tail -50"
```
### Edit Secrets
```bash
cd infrastructure
export SOPS_AGE_KEY_FILE="keys/age-key.txt"
# Edit client secrets
sops secrets/clients/test.sops.yaml
# View decrypted secrets
sops --decrypt secrets/clients/test.sops.yaml
```
## Architecture Notes
### Service Stack
- **Traefik**: Reverse proxy with automatic Let's Encrypt certificates
- **Nextcloud 30.0.17**: File sync and collaboration
- **Redis**: Caching for Nextcloud
- **MariaDB**: Database for Nextcloud
### Docker Networks
- `traefik`: External network for all web-accessible services
- `nextcloud-internal`: Internal network for Nextcloud ↔ Redis/DB
### Volumes
- `nextcloud_nextcloud-data`: Nextcloud files and database
## Service Credentials
### Nextcloud Admin
- **URL**: https://nextcloud.test.vrije.cloud
- **Username**: admin
- **Password**: In `secrets/clients/test.sops.yaml``nextcloud_admin_password`

112
README.md
View file

@ -1,6 +1,6 @@
# De Vrije Cloud: Post-Tyranny Tech Multi-Tenant Infrastructure
# Post-X Society Multi-Tenant Infrastructure
Infrastructure as Code for our "[Vrije Cloud](https://www.vrije.cloud)" a scalable multi-tenant VPS platform running Nextcloud (file sync/share) on Hetzner Cloud.
Infrastructure as Code for a scalable multi-tenant VPS platform running Nextcloud (file sync/share) on Hetzner Cloud.
## 🏗️ Architecture
@ -8,7 +8,6 @@ Infrastructure as Code for our "[Vrije Cloud](https://www.vrije.cloud)" a scalab
- **Configuration**: Ansible with dynamic inventory
- **Secrets**: SOPS + Age encryption
- **Hosting**: Hetzner Cloud (EU-based, GDPR-compliant)
- **Identity**: Authentik (OAuth2/OIDC SSO, MIT license)
- **Storage**: Nextcloud (German company, AGPL 3.0)
## 📁 Repository Structure
@ -33,62 +32,7 @@ infrastructure/
- [SOPS](https://github.com/getsops/sops) + [Age](https://github.com/FiloSottile/age)
- [Hetzner Cloud account](https://www.hetzner.com/cloud)
### Automated Deployment (Recommended)
**The fastest way to deploy a client:**
```bash
# 1. Ensure SOPS Age key is available (if not set)
export SOPS_AGE_KEY_FILE="./keys/age-key.txt"
# 2. Add client to terraform.tfvars
# clients = {
# newclient = {
# server_type = "cx22"
# location = "fsn1"
# subdomain = "newclient"
# apps = ["authentik", "nextcloud"]
# }
# }
# 3. Deploy client (fully automated, ~10-15 minutes)
# The script automatically loads the Hetzner API token from SOPS
./scripts/deploy-client.sh newclient
```
**Note**: The Hetzner API token is now stored encrypted in `secrets/shared.sops.yaml` and loaded automatically by all scripts. No need to manually set `HCLOUD_TOKEN`.
The script will automatically:
- ✅ Generate unique SSH key pair (if missing)
- ✅ Create secrets file from template (if missing, opens in editor)
- ✅ Provision VPS on Hetzner Cloud
- ✅ Deploy Authentik (SSO/identity provider)
- ✅ Deploy Nextcloud (file storage)
- ✅ Configure OAuth2/OIDC integration
- ✅ Set up SSL certificates
- ✅ Create admin accounts
**Result**: Fully functional system, ready to use immediately!
### Management Scripts
```bash
# Deploy a fresh client
./scripts/deploy-client.sh <client_name>
# Rebuild existing client (destroy + redeploy)
./scripts/rebuild-client.sh <client_name>
# Destroy client infrastructure
./scripts/destroy-client.sh <client_name>
```
See [scripts/README.md](scripts/README.md) for detailed documentation.
### Manual Setup (Advanced)
<details>
<summary>Click to expand manual setup instructions</summary>
### Initial Setup
1. **Clone repository**:
```bash
@ -108,32 +52,20 @@ See [scripts/README.md](scripts/README.md) for detailed documentation.
# Edit with your Hetzner API token and configuration
```
4. **Create client secrets**:
```bash
cp secrets/clients/test.sops.yaml secrets/clients/<client>.sops.yaml
sops secrets/clients/<client>.sops.yaml
# Update client_name, domains, regenerate all passwords
```
5. **Provision infrastructure**:
4. **Provision infrastructure**:
```bash
cd tofu
tofu init
tofu plan
tofu apply
```
6. **Deploy applications**:
5. **Deploy applications**:
```bash
cd ../ansible
export HCLOUD_TOKEN="your-token"
export SOPS_AGE_KEY_FILE="../keys/age-key.txt"
ansible-playbook -i hcloud.yml playbooks/setup.yml --limit <client>
ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit <client>
ansible-playbook playbooks/setup.yml
```
</details>
## 🎯 Project Principles
1. **EU/GDPR-first**: European vendors and data residency
@ -142,11 +74,35 @@ See [scripts/README.md](scripts/README.md) for detailed documentation.
4. **Infrastructure as Code**: All changes via version control
5. **Security by default**: Encryption, hardening, least privilege
## 📖 Documentation
- **[PROJECT_REFERENCE.md](PROJECT_REFERENCE.md)** - Essential information and common operations
- [Architecture Decision Record](docs/architecture-decisions.md) - Complete design rationale
- [Agent Definitions](.claude/agents/) - Specialized AI agent instructions
## 🤝 Contributing
This project uses specialized AI agents for development:
- **Architect**: High-level design decisions
- **Infrastructure**: OpenTofu + Ansible implementation
- **Nextcloud**: File sync/share configuration
See individual agent files in `.claude/agents/` for responsibilities.
## 🔒 Security
- Secrets are encrypted with SOPS + Age before committing
- Age private keys are **NEVER** stored in this repository
- See `.gitignore` for protected files
## 📝 License
MIT License - see [LICENSE](LICENSE) for details
TBD
## 🙋 Support
For issues or questions, please create am issue or contact us on [vrijecloud@postxsociety.org](mailto:vrijecloud@postxsociety.org)
For issues or questions, please create a GitHub issue with the appropriate label:
- `agent:architect` - Architecture/design questions
- `agent:infrastructure` - IaC implementation
- `agent:nextcloud` - File sync/share

View file

@ -84,7 +84,7 @@ ansible/
│ ├── common/ # Base system hardening
│ ├── docker/ # Docker + Docker Compose
│ ├── traefik/ # Reverse proxy
│ ├── authentik/ # Identity provider (OAuth2/OIDC SSO)
│ ├── zitadel/ # Identity provider
│ ├── nextcloud/ # File sync/share
│ └── backup/ # Restic backup
└── group_vars/ # Group variables
@ -120,8 +120,8 @@ Reverse proxy with automatic SSL:
- HTTP to HTTPS redirection
- Dashboard (optional)
### authentik
Identity provider deployment (OAuth2/OIDC SSO)
### zitadel
Identity provider deployment (see Zitadel Agent for details)
### nextcloud
File sync/share deployment (see Nextcloud Agent for details)
@ -273,9 +273,10 @@ ansible-playbook playbooks/setup.yml -vvv # Very verbose
## Next Steps
After initial setup:
1. Deploy applications: Run `playbooks/deploy.yml` to deploy Authentik and Nextcloud
2. Configure backups: Use `backup` role
3. Set up monitoring: Configure Uptime Kuma
1. Deploy Zitadel: Follow Zitadel Agent instructions
2. Deploy Nextcloud: Follow Nextcloud Agent instructions
3. Configure backups: Use `backup` role
4. Set up monitoring: Configure Uptime Kuma
## Resources

View file

@ -1,6 +1,6 @@
[defaults]
# Inventory configuration
# inventory = hcloud.yml # Disabled - use -i flag instead
inventory = hcloud.yml
host_key_checking = False
interpreter_python = auto_silent
@ -26,8 +26,8 @@ timeout = 30
roles_path = ./roles
[inventory]
# Enable inventory plugins
enable_plugins = hetzner.hcloud.hcloud, ini, yaml, auto
# Enable Hetzner Cloud dynamic inventory plugin
enable_plugins = hetzner.hcloud.hcloud
[privilege_escalation]
become = True
@ -37,4 +37,4 @@ become_ask_pass = False
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o IdentitiesOnly=yes
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no

View file

@ -1,66 +0,0 @@
---
- name: Configure OIDC
hosts: test
gather_facts: no
vars:
nextcloud_domain: "nextcloud.test.vrije.cloud"
tasks:
- name: Check if Authentik OIDC credentials are available
stat:
path: /tmp/authentik_oidc_credentials.json
register: oidc_creds_file
- name: Load OIDC credentials from Authentik
slurp:
path: /tmp/authentik_oidc_credentials.json
register: oidc_creds_content
when: oidc_creds_file.stat.exists
- name: Parse OIDC credentials
set_fact:
authentik_oidc: "{{ oidc_creds_content.content | b64decode | from_json }}"
when: oidc_creds_file.stat.exists
- name: Check if user_oidc app is installed
shell: docker exec -u www-data nextcloud php occ app:list --output=json
register: nextcloud_apps
changed_when: false
- name: Parse installed apps
set_fact:
user_oidc_installed: "{{ 'user_oidc' in (nextcloud_apps.stdout | from_json).enabled }}"
- name: Enable user_oidc app
shell: docker exec -u www-data nextcloud php occ app:enable user_oidc
when: not user_oidc_installed
- name: Check if OIDC provider is already configured
shell: docker exec -u www-data nextcloud php occ user_oidc:provider
register: oidc_providers
changed_when: false
failed_when: false
- name: Configure Authentik OIDC provider
shell: |
docker exec -u www-data nextcloud php occ user_oidc:provider \
--clientid="{{ authentik_oidc.client_id }}" \
--clientsecret="{{ authentik_oidc.client_secret }}" \
--discoveryuri="{{ authentik_oidc.discovery_uri }}" \
"Authentik"
when:
- authentik_oidc is defined
- authentik_oidc.success | default(false)
- "'Authentik' not in oidc_providers.stdout"
register: oidc_config
changed_when: oidc_config.rc == 0
- name: Display OIDC status
debug:
msg: |
✓ OIDC SSO fully configured!
Users can login with Authentik credentials at: https://{{ nextcloud_domain }}
"Login with Authentik" button should be visible on the login page.
when:
- authentik_oidc is defined
- authentik_oidc.success | default(false)

View file

@ -1,4 +0,0 @@
[clients]
valk ansible_host=78.47.191.38 ansible_user=root ansible_ssh_private_key_file=../keys/ssh/valk
kikker ansible_host=23.88.124.67 ansible_user=root ansible_ssh_private_key_file=../keys/ssh/kikker
das ansible_host=49.13.49.246 ansible_user=root ansible_ssh_private_key_file=../keys/ssh/das

View file

@ -1,8 +0,0 @@
all:
children:
clients:
hosts:
valk:
ansible_host: 78.47.191.38
ansible_user: root
ansible_ssh_private_key_file: ../keys/ssh/valk

View file

@ -1,124 +0,0 @@
---
# Configure Diun to use webhook notifications instead of email
# This playbook updates all servers to send container update notifications
# to a Matrix room via webhook instead of individual emails per server
#
# Usage:
# ansible-playbook -i hcloud.yml playbooks/260123-configure-diun-webhook.yml
#
# Or for specific servers:
# ansible-playbook -i hcloud.yml playbooks/260123-configure-diun-webhook.yml --limit das,uil,vos
- name: Configure Diun webhook notifications on all servers
hosts: all
become: yes
vars:
# Diun base configuration (from role defaults)
diun_version: "latest"
diun_log_level: "info"
diun_watch_workers: 10
diun_watch_all: true
diun_exclude_containers: []
diun_first_check_notif: false
# Schedule: Daily at 6am UTC
diun_schedule: "0 6 * * *"
# Webhook configuration - sends to Matrix via custom webhook
diun_notif_enabled: true
diun_notif_type: webhook
diun_webhook_endpoint: "https://diun-webhook.postxsociety.cloud"
diun_webhook_method: POST
diun_webhook_headers:
Content-Type: application/json
# Disable email notifications
diun_email_enabled: false
# SMTP defaults (not used when email disabled, but needed for template)
diun_smtp_host: "smtp.eu.mailgun.org"
diun_smtp_port: 587
diun_smtp_from: "{{ client_name }}@mg.vrije.cloud"
diun_smtp_to: "pieter@postxsociety.org"
# Optional notification defaults (unused but needed for template)
diun_slack_webhook_url: ""
diun_matrix_enabled: false
diun_matrix_homeserver_url: ""
diun_matrix_user: ""
diun_matrix_password: ""
diun_matrix_room_id: ""
pre_tasks:
- name: Gather facts
setup:
- name: Determine client name from hostname
set_fact:
client_name: "{{ inventory_hostname }}"
- name: Load client secrets
community.sops.load_vars:
file: "{{ playbook_dir }}/../../secrets/clients/{{ client_name }}.sops.yaml"
name: client_secrets
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
no_log: true
- name: Load shared secrets
community.sops.load_vars:
file: "{{ playbook_dir }}/../../secrets/shared.sops.yaml"
name: shared_secrets
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
no_log: true
- name: Merge shared secrets into client_secrets
set_fact:
client_secrets: "{{ client_secrets | combine(shared_secrets) }}"
no_log: true
tasks:
- name: Set SMTP credentials (required by template even if unused)
set_fact:
diun_smtp_username_final: "{{ client_secrets.mailgun_smtp_user | default('') }}"
diun_smtp_password_final: ""
no_log: true
- name: Display configuration summary
debug:
msg: |
Configuring Diun on {{ inventory_hostname }}:
- Webhook endpoint: {{ diun_webhook_endpoint }}
- Email notifications: {{ 'enabled' if diun_email_enabled else 'disabled' }}
- Schedule: {{ diun_schedule }} (Daily at 6am UTC)
- name: Deploy Diun configuration with webhook
template:
src: "{{ playbook_dir }}/../roles/diun/templates/diun.yml.j2"
dest: /opt/docker/diun/diun.yml
mode: '0644'
notify: Restart Diun
- name: Restart Diun to apply new configuration
community.docker.docker_compose_v2:
project_src: /opt/docker/diun
state: restarted
- name: Wait for Diun to start
pause:
seconds: 5
- name: Check Diun status
shell: docker ps --filter name=diun --format "{{ '{{' }}.Status{{ '}}' }}"
register: diun_status
changed_when: false
- name: Display Diun status
debug:
msg: "Diun status on {{ inventory_hostname }}: {{ diun_status.stdout }}"
handlers:
- name: Restart Diun
community.docker.docker_compose_v2:
project_src: /opt/docker/diun
state: restarted

View file

@ -1,123 +0,0 @@
---
# Nextcloud Upgrade Stage Task File (Fixed Version)
# This file is included by 260123-upgrade-nextcloud-v2.yml for each upgrade stage
# Do not run directly
#
# Improvements:
# - Better version detection (actual running version)
# - Proper error handling
# - Clearer status messages
# - Maintenance mode handling
- name: "Stage {{ stage.stage }}: Starting v{{ stage.from }} → v{{ stage.to }}"
debug:
msg: |
============================================================
Stage {{ stage.stage }}: Upgrading v{{ stage.from }} → v{{ stage.to }}
============================================================
- name: "Stage {{ stage.stage }}: Get current running version"
shell: docker exec -u www-data nextcloud php occ status --output=json
register: stage_version_check
changed_when: false
- name: "Stage {{ stage.stage }}: Parse current version"
set_fact:
stage_current: "{{ (stage_version_check.stdout | from_json).versionstring }}"
- name: "Stage {{ stage.stage }}: Display current version"
debug:
msg: "Currently running: v{{ stage_current }}"
- name: "Stage {{ stage.stage }}: Check if already on target version"
debug:
msg: "✓ Already on v{{ stage_current }} - skipping this stage"
when: stage_current is version(stage.to, '>=')
- name: "Stage {{ stage.stage }}: Skip if already upgraded"
meta: end_play
when: stage_current is version(stage.to, '>=')
- name: "Stage {{ stage.stage }}: Verify version is compatible"
fail:
msg: "Cannot upgrade from v{{ stage_current }} (expected v{{ stage.from }}.x)"
when: stage_current is version(stage.from, '<') or (stage_current is version(stage.to, '>='))
- name: "Stage {{ stage.stage }}: Update docker-compose.yml to v{{ stage.to }}"
replace:
path: "{{ nextcloud_base_dir }}/docker-compose.yml"
regexp: 'image:\s*nextcloud:{{ stage.from }}'
replace: 'image: nextcloud:{{ stage.to }}'
- name: "Stage {{ stage.stage }}: Verify docker-compose.yml was updated"
shell: grep "image{{ ':' }} nextcloud{{ ':' }}{{ stage.to }}" {{ nextcloud_base_dir }}/docker-compose.yml
register: compose_verify
changed_when: false
failed_when: compose_verify.rc != 0
- name: "Stage {{ stage.stage }}: Pull Nextcloud v{{ stage.to }} image"
shell: docker pull nextcloud:{{ stage.to }}
register: image_pull
changed_when: "'Downloaded' in image_pull.stdout or 'Pulling' in image_pull.stdout or 'Downloaded newer' in image_pull.stderr"
- name: "Stage {{ stage.stage }}: Stop containers before upgrade"
community.docker.docker_compose_v2:
project_src: "{{ nextcloud_base_dir }}"
state: stopped
- name: "Stage {{ stage.stage }}: Start containers with new version"
community.docker.docker_compose_v2:
project_src: "{{ nextcloud_base_dir }}"
state: present
- name: "Stage {{ stage.stage }}: Wait for Nextcloud container to be ready"
shell: |
count=0
max_attempts=60
while [ $count -lt $max_attempts ]; do
if docker exec nextcloud curl -f http://localhost:80/status.php 2>/dev/null; then
echo "Container ready after $count attempts"
exit 0
fi
sleep 5
count=$((count + 1))
done
echo "Timeout waiting for container after $max_attempts attempts"
exit 1
register: container_ready
changed_when: false
- name: "Stage {{ stage.stage }}: Run occ upgrade"
shell: docker exec -u www-data nextcloud php occ upgrade --no-interaction
register: occ_upgrade
changed_when: "'Update successful' in occ_upgrade.stdout or 'upgraded' in occ_upgrade.stdout"
failed_when:
- occ_upgrade.rc != 0
- "'already latest version' not in occ_upgrade.stdout"
- "'No upgrade required' not in occ_upgrade.stdout"
- name: "Stage {{ stage.stage }}: Display upgrade output"
debug:
msg: "{{ occ_upgrade.stdout_lines }}"
- name: "Stage {{ stage.stage }}: Verify upgrade succeeded"
shell: docker exec -u www-data nextcloud php occ status --output=json
register: stage_verify
changed_when: false
- name: "Stage {{ stage.stage }}: Parse upgraded version"
set_fact:
stage_upgraded: "{{ (stage_verify.stdout | from_json).versionstring }}"
- name: "Stage {{ stage.stage }}: Check upgrade was successful"
fail:
msg: "Upgrade to v{{ stage.to }} failed - still on v{{ stage_upgraded }}"
when: stage_upgraded is version(stage.to, '<')
- name: "Stage {{ stage.stage }}: Success"
debug:
msg: |
============================================================
✓ Stage {{ stage.stage }} completed successfully
Upgraded from v{{ stage_current }} to v{{ stage_upgraded }}
============================================================

View file

@ -1,378 +0,0 @@
---
# Nextcloud Major Version Upgrade Playbook (Fixed Version)
# Created: 2026-01-23
# Purpose: Safely upgrade Nextcloud from v30 to v32 via v31 (staged upgrade)
#
# Usage:
# cd ansible/
# HCLOUD_TOKEN="..." ansible-playbook -i hcloud.yml \
# playbooks/260123-upgrade-nextcloud-v2.yml --limit <server> \
# --private-key "../keys/ssh/<server>"
#
# Requirements:
# - HCLOUD_TOKEN environment variable set
# - SSH access to target server
# - Sufficient disk space for backups
#
# Improvements over v1:
# - Idempotent: can be re-run safely after failures
# - Better version state tracking (reads actual running version)
# - Proper maintenance mode handling
# - Stage skipping if already on target version
# - Better error messages and rollback instructions
- name: Upgrade Nextcloud from v30 to v32 (staged)
hosts: all
become: true
gather_facts: true
vars:
nextcloud_base_dir: "/opt/nextcloud"
backup_dir: "/root/nextcloud-backup-{{ ansible_date_time.iso8601_basic_short }}"
target_version: "32"
tasks:
# ============================================================
# PRE-UPGRADE CHECKS
# ============================================================
- name: Display upgrade plan
debug:
msg: |
============================================================
Nextcloud Upgrade Plan - {{ inventory_hostname }}
============================================================
Target: Nextcloud v{{ target_version }}
Backup: {{ backup_dir }}
This playbook will:
1. Detect current version
2. Create backup if needed
3. Upgrade through required stages (v30→v31→v32)
4. Skip stages already completed
5. Re-enable apps and disable maintenance mode
Estimated time: 10-20 minutes
============================================================
- name: Check if Nextcloud is installed
shell: docker ps --filter "name=^nextcloud$" --format "{{ '{{' }}.Names{{ '}}' }}"
register: nextcloud_running
changed_when: false
failed_when: false
- name: Fail if Nextcloud is not running
fail:
msg: "Nextcloud container is not running on {{ inventory_hostname }}"
when: "'nextcloud' not in nextcloud_running.stdout"
- name: Get current Nextcloud version
shell: docker exec -u www-data nextcloud php occ status --output=json
register: nextcloud_status
changed_when: false
failed_when: false
- name: Parse Nextcloud status
set_fact:
nc_status: "{{ nextcloud_status.stdout | from_json }}"
when: nextcloud_status.rc == 0
- name: Handle Nextcloud in maintenance mode
block:
- name: Display maintenance mode warning
debug:
msg: "⚠ Nextcloud is in maintenance mode. Attempting to disable it..."
- name: Disable maintenance mode if enabled
shell: docker exec -u www-data nextcloud php occ maintenance:mode --off
register: maint_off
changed_when: "'disabled' in maint_off.stdout"
- name: Wait a moment for mode change
pause:
seconds: 2
- name: Re-check status after disabling maintenance mode
shell: docker exec -u www-data nextcloud php occ status --output=json
register: nextcloud_status_retry
changed_when: false
- name: Update status
set_fact:
nc_status: "{{ nextcloud_status_retry.stdout | from_json }}"
when: nextcloud_status.rc != 0 or (nc_status is defined and nc_status.maintenance | bool)
- name: Display current version
debug:
msg: |
Current: v{{ nc_status.versionstring }}
Target: v{{ target_version }}
Maintenance mode: {{ nc_status.maintenance }}
- name: Check if already on target version
debug:
msg: "✓ Nextcloud is already on v{{ nc_status.versionstring }} - nothing to do"
when: nc_status.versionstring is version(target_version, '>=')
- name: End play if already upgraded
meta: end_host
when: nc_status.versionstring is version(target_version, '>=')
- name: Check disk space
shell: df -BG {{ nextcloud_base_dir }} | tail -1 | awk '{print $4}' | sed 's/G//'
register: disk_space_gb
changed_when: false
- name: Verify sufficient disk space
fail:
msg: "Insufficient disk space: {{ disk_space_gb.stdout }}GB available, need at least 5GB"
when: disk_space_gb.stdout | int < 5
- name: Display available disk space
debug:
msg: "Available disk space: {{ disk_space_gb.stdout }}GB"
# ============================================================
# BACKUP PHASE (only if not already backed up)
# ============================================================
- name: Check if backup already exists
stat:
path: "{{ backup_dir }}"
register: backup_exists
- name: Skip backup if already exists
debug:
msg: "✓ Backup already exists at {{ backup_dir }} - skipping backup phase"
when: backup_exists.stat.exists
- name: Create backup
block:
- name: Create backup directory
file:
path: "{{ backup_dir }}"
state: directory
mode: '0700'
- name: Enable maintenance mode for backup
shell: docker exec -u www-data nextcloud php occ maintenance:mode --on
register: maintenance_on
changed_when: "'enabled' in maintenance_on.stdout"
- name: Backup Nextcloud database
shell: |
docker exec nextcloud-db pg_dump -U nextcloud nextcloud | gzip > {{ backup_dir }}/database.sql.gz
args:
creates: "{{ backup_dir }}/database.sql.gz"
- name: Get database backup size
stat:
path: "{{ backup_dir }}/database.sql.gz"
register: db_backup
- name: Display database backup info
debug:
msg: "Database backup: {{ (db_backup.stat.size / 1024 / 1024) | round(2) }} MB"
- name: Stop Nextcloud containers for volume backup
community.docker.docker_compose_v2:
project_src: "{{ nextcloud_base_dir }}"
state: stopped
- name: Backup Nextcloud app volume
shell: |
tar -czf {{ backup_dir }}/nextcloud-app-volume.tar.gz -C /var/lib/docker/volumes/nextcloud-app/_data .
args:
creates: "{{ backup_dir }}/nextcloud-app-volume.tar.gz"
- name: Backup Nextcloud database volume
shell: |
tar -czf {{ backup_dir }}/nextcloud-db-volume.tar.gz -C /var/lib/docker/volumes/nextcloud-db-data/_data .
args:
creates: "{{ backup_dir }}/nextcloud-db-volume.tar.gz"
- name: Copy current docker-compose.yml to backup
copy:
src: "{{ nextcloud_base_dir }}/docker-compose.yml"
dest: "{{ backup_dir }}/docker-compose.yml.backup"
remote_src: true
- name: Display backup summary
debug:
msg: |
============================================================
✓ Backup completed: {{ backup_dir }}
============================================================
To restore from backup if needed:
1. cd {{ nextcloud_base_dir }} && docker compose down
2. tar -xzf {{ backup_dir }}/nextcloud-app-volume.tar.gz -C /var/lib/docker/volumes/nextcloud-app/_data
3. tar -xzf {{ backup_dir }}/nextcloud-db-volume.tar.gz -C /var/lib/docker/volumes/nextcloud-db-data/_data
4. cp {{ backup_dir }}/docker-compose.yml.backup {{ nextcloud_base_dir }}/docker-compose.yml
5. cd {{ nextcloud_base_dir }} && docker compose up -d
============================================================
- name: Restart containers after backup
community.docker.docker_compose_v2:
project_src: "{{ nextcloud_base_dir }}"
state: present
- name: Wait for Nextcloud to be ready
shell: |
count=0
max_attempts=24
while [ $count -lt $max_attempts ]; do
if docker exec nextcloud curl -f http://localhost:80/status.php 2>/dev/null; then
echo "Ready after $count attempts"
exit 0
fi
sleep 5
count=$((count + 1))
done
echo "Timeout after $max_attempts attempts"
exit 1
register: nextcloud_ready
changed_when: false
- name: Disable maintenance mode after backup
shell: docker exec -u www-data nextcloud php occ maintenance:mode --off
register: maint_off_backup
changed_when: "'disabled' in maint_off_backup.stdout"
when: not backup_exists.stat.exists
# ============================================================
# DETERMINE UPGRADE PATH
# ============================================================
- name: Initialize stage counter
set_fact:
stage_number: 0
# ============================================================
# STAGED UPGRADE LOOP - Dynamic version checking
# ============================================================
- name: Stage 1 - Upgrade v30→v31 if needed
block:
- name: Get current version
shell: docker exec -u www-data nextcloud php occ status --output=json
register: version_check
changed_when: false
- name: Parse version
set_fact:
current_version: "{{ (version_check.stdout | from_json).versionstring }}"
- name: Check if v30→v31 upgrade needed
set_fact:
needs_v31_upgrade: "{{ current_version is version('30', '>=') and current_version is version('31', '<') }}"
- name: Perform v30→v31 upgrade
include_tasks: "{{ playbook_dir }}/260123-upgrade-nextcloud-stage-v2.yml"
vars:
stage:
from: "30"
to: "31"
stage: 1
when: needs_v31_upgrade
- name: Stage 2 - Upgrade v31→v32 if needed
block:
- name: Get current version
shell: docker exec -u www-data nextcloud php occ status --output=json
register: version_check
changed_when: false
- name: Parse version
set_fact:
current_version: "{{ (version_check.stdout | from_json).versionstring }}"
- name: Check if v31→v32 upgrade needed
set_fact:
needs_v32_upgrade: "{{ current_version is version('31', '>=') and current_version is version('32', '<') }}"
- name: Perform v31→v32 upgrade
include_tasks: "{{ playbook_dir }}/260123-upgrade-nextcloud-stage-v2.yml"
vars:
stage:
from: "31"
to: "32"
stage: 2
when: needs_v32_upgrade
# ============================================================
# POST-UPGRADE
# ============================================================
- name: Get final version
shell: docker exec -u www-data nextcloud php occ status --output=json
register: final_status
changed_when: false
- name: Parse final version
set_fact:
final_version: "{{ (final_status.stdout | from_json).versionstring }}"
- name: Verify upgrade to target version
fail:
msg: "Upgrade incomplete - on v{{ final_version }}, expected v{{ target_version }}.x"
when: final_version is version(target_version, '<')
- name: Run database optimizations
shell: docker exec -u www-data nextcloud php occ db:add-missing-indices
register: db_indices
changed_when: false
failed_when: false
- name: Run bigint conversion
shell: docker exec -u www-data nextcloud php occ db:convert-filecache-bigint --no-interaction
register: db_bigint
changed_when: false
failed_when: false
timeout: 600
- name: Re-enable critical apps
shell: |
docker exec -u www-data nextcloud php occ app:enable user_oidc || true
docker exec -u www-data nextcloud php occ app:enable richdocuments || true
register: apps_enabled
changed_when: false
- name: Ensure maintenance mode is disabled
shell: docker exec -u www-data nextcloud php occ maintenance:mode --off
register: final_maint_off
changed_when: "'disabled' in final_maint_off.stdout"
failed_when: false
- name: Update docker-compose.yml to use latest tag
replace:
path: "{{ nextcloud_base_dir }}/docker-compose.yml"
regexp: 'image:\s*nextcloud:\d+'
replace: 'image: nextcloud:latest'
- name: Display success message
debug:
msg: |
============================================================
✓ UPGRADE SUCCESSFUL!
============================================================
Server: {{ inventory_hostname }}
From: v30.x
To: v{{ final_version }}
Backup: {{ backup_dir }}
Next steps:
1. Test login: https://nextcloud.{{ client_domain }}
2. Test OIDC: Click "Login with Authentik"
3. Test file operations
4. Test Collabora Office
If all tests pass, remove backup:
rm -rf {{ backup_dir }}
docker-compose.yml now uses 'nextcloud:latest' tag
============================================================

View file

@ -1,156 +0,0 @@
---
# Configure Diun to disable watchRepo and add Docker Hub authentication
# This playbook updates all servers to:
# - Only watch specific image tags (not entire repositories) to reduce API calls
# - Add Docker Hub authentication for higher rate limits
#
# Background:
# - watchRepo: true checks ALL tags in a repository (hundreds of API calls)
# - watchRepo: false only checks the specific tag being used (1-2 API calls)
# - Docker Hub auth increases rate limit from 100 to 5000 pulls per 6 hours
#
# Usage:
# cd ansible/
# SOPS_AGE_KEY_FILE="../keys/age-key.txt" HCLOUD_TOKEN="..." \
# ansible-playbook -i hcloud.yml playbooks/260124-configure-diun-watchrepo.yml
#
# Or for specific servers:
# SOPS_AGE_KEY_FILE="../keys/age-key.txt" HCLOUD_TOKEN="..." \
# ansible-playbook -i hcloud.yml playbooks/260124-configure-diun-watchrepo.yml \
# --limit das,uil,vos --private-key "../keys/ssh/das"
- name: Configure Diun watchRepo and Docker Hub authentication
hosts: all
become: yes
vars:
# Diun base configuration
diun_version: "latest"
diun_log_level: "info"
diun_watch_workers: 10
diun_watch_all: true
diun_exclude_containers: []
diun_first_check_notif: false
# Schedule: Weekly on Monday at 6am UTC (to reduce API calls)
diun_schedule: "0 6 * * 1"
# Disable watchRepo - only check the specific tags we're using
diun_watch_repo: false
# Webhook configuration - sends to Matrix via custom webhook
diun_notif_enabled: true
diun_notif_type: webhook
diun_webhook_endpoint: "https://diun-webhook.postxsociety.cloud"
diun_webhook_method: POST
diun_webhook_headers:
Content-Type: application/json
# Disable email notifications
diun_email_enabled: false
# SMTP defaults (not used when email disabled, but needed for template)
diun_smtp_host: "smtp.eu.mailgun.org"
diun_smtp_port: 587
diun_smtp_from: "{{ client_name }}@mg.vrije.cloud"
diun_smtp_to: "pieter@postxsociety.org"
# Optional notification defaults (unused but needed for template)
diun_slack_webhook_url: ""
diun_matrix_enabled: false
diun_matrix_homeserver_url: ""
diun_matrix_user: ""
diun_matrix_password: ""
diun_matrix_room_id: ""
pre_tasks:
- name: Gather facts
setup:
- name: Determine client name from hostname
set_fact:
client_name: "{{ inventory_hostname }}"
- name: Load client secrets
community.sops.load_vars:
file: "{{ playbook_dir }}/../../secrets/clients/{{ client_name }}.sops.yaml"
name: client_secrets
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
no_log: true
- name: Load shared secrets
community.sops.load_vars:
file: "{{ playbook_dir }}/../../secrets/shared.sops.yaml"
name: shared_secrets
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
no_log: true
- name: Merge shared secrets into client_secrets
set_fact:
client_secrets: "{{ client_secrets | combine(shared_secrets) }}"
no_log: true
tasks:
- name: Set SMTP credentials (required by template even if unused)
set_fact:
diun_smtp_username_final: "{{ client_secrets.mailgun_smtp_user | default('') }}"
diun_smtp_password_final: ""
no_log: true
- name: Set Docker Hub credentials for higher rate limits
set_fact:
diun_docker_hub_username: "{{ client_secrets.docker_hub_username }}"
diun_docker_hub_password: "{{ client_secrets.docker_hub_password }}"
no_log: true
- name: Display configuration summary
debug:
msg: |
Configuring Diun on {{ inventory_hostname }}:
- Webhook endpoint: {{ diun_webhook_endpoint }}
- Email notifications: {{ 'enabled' if diun_email_enabled else 'disabled' }}
- Schedule: {{ diun_schedule }} (Weekly on Monday at 6am UTC)
- Watch entire repositories: {{ 'yes' if diun_watch_repo else 'no (only specific tags)' }}
- Docker Hub auth: {{ 'enabled' if diun_docker_hub_username else 'disabled' }}
- name: Deploy Diun configuration with watchRepo disabled and Docker Hub auth
template:
src: "{{ playbook_dir }}/../roles/diun/templates/diun.yml.j2"
dest: /opt/docker/diun/diun.yml
mode: '0644'
notify: Restart Diun
- name: Restart Diun to apply new configuration
community.docker.docker_compose_v2:
project_src: /opt/docker/diun
state: restarted
- name: Wait for Diun to start
pause:
seconds: 5
- name: Check Diun status
shell: docker ps --filter name=diun --format "{{ '{{' }}.Status{{ '}}' }}"
register: diun_status
changed_when: false
- name: Display Diun status
debug:
msg: "Diun status on {{ inventory_hostname }}: {{ diun_status.stdout }}"
- name: Verify Diun configuration
shell: docker exec diun cat /diun.yml | grep -E "(watchRepo|regopts)" || echo "Config deployed"
register: diun_config_check
changed_when: false
- name: Display configuration verification
debug:
msg: |
Configuration applied on {{ inventory_hostname }}:
{{ diun_config_check.stdout }}
handlers:
- name: Restart Diun
community.docker.docker_compose_v2:
project_src: /opt/docker/diun
state: restarted

View file

@ -1,151 +0,0 @@
---
# Nextcloud Maintenance Playbook
# Created: 2026-01-24
# Purpose: Run database and file maintenance tasks on Nextcloud instances
#
# This playbook performs:
# 1. Add missing database indices (improves query performance)
# 2. Update mimetypes database (ensures proper file type handling)
#
# Usage:
# cd ansible/
# HCLOUD_TOKEN="..." ansible-playbook -i hcloud.yml \
# playbooks/nextcloud-maintenance.yml --limit <server> \
# --private-key "../keys/ssh/<server>"
#
# To run on all servers:
# HCLOUD_TOKEN="..." ansible-playbook -i hcloud.yml \
# playbooks/nextcloud-maintenance.yml \
# --private-key "../keys/ssh/<server>"
#
# Requirements:
# - HCLOUD_TOKEN environment variable set
# - SSH access to target server(s)
# - Nextcloud container must be running
- name: Nextcloud Maintenance Tasks
hosts: all
become: true
gather_facts: true
vars:
nextcloud_container: "nextcloud"
tasks:
# ============================================================
# PRE-CHECK
# ============================================================
- name: Display maintenance plan
debug:
msg: |
============================================================
Nextcloud Maintenance - {{ inventory_hostname }}
============================================================
This playbook will:
1. Add missing database indices
2. Update mimetypes database
3. Display results
Estimated time: 1-3 minutes per server
============================================================
- name: Check if Nextcloud container is running
shell: docker ps --filter "name=^{{ nextcloud_container }}$" --format "{{ '{{' }}.Names{{ '}}' }}"
register: nextcloud_running
changed_when: false
failed_when: false
- name: Fail if Nextcloud is not running
fail:
msg: "Nextcloud container is not running on {{ inventory_hostname }}"
when: "'nextcloud' not in nextcloud_running.stdout"
- name: Get current Nextcloud version
shell: docker exec -u www-data {{ nextcloud_container }} php occ --version
register: nextcloud_version
changed_when: false
- name: Display Nextcloud version
debug:
msg: "{{ nextcloud_version.stdout }}"
# ============================================================
# TASK 1: ADD MISSING DATABASE INDICES
# ============================================================
- name: Check for missing database indices
shell: docker exec -u www-data {{ nextcloud_container }} php occ db:add-missing-indices
register: db_indices_result
changed_when: "'updated successfully' in db_indices_result.stdout"
failed_when: db_indices_result.rc != 0
- name: Display database indices results
debug:
msg: |
============================================================
Database Indices Results
============================================================
{{ db_indices_result.stdout }}
============================================================
# ============================================================
# TASK 2: UPDATE MIMETYPES DATABASE
# ============================================================
- name: Update mimetypes database
shell: docker exec -u www-data {{ nextcloud_container }} php occ maintenance:mimetype:update-db
register: mimetype_result
changed_when: "'Added' in mimetype_result.stdout"
failed_when: mimetype_result.rc != 0
- name: Parse mimetype results
set_fact:
mimetypes_added: "{{ mimetype_result.stdout | regex_search('Added (\\d+) new mimetypes', '\\1') | default(['0'], true) | first }}"
- name: Display mimetype results
debug:
msg: |
============================================================
Mimetype Update Results
============================================================
Mimetypes added: {{ mimetypes_added }}
{% if mimetypes_added | int > 0 %}
✓ Mimetype database updated successfully
{% else %}
✓ All mimetypes already up to date
{% endif %}
============================================================
# ============================================================
# SUMMARY
# ============================================================
- name: Display maintenance summary
debug:
msg: |
============================================================
✓ MAINTENANCE COMPLETED - {{ inventory_hostname }}
============================================================
Server: {{ inventory_hostname }}
Version: {{ nextcloud_version.stdout }}
Tasks completed:
{% if db_indices_result.changed %}
✓ Database indices: Updated
{% else %}
✓ Database indices: Already optimized
{% endif %}
{% if mimetype_result.changed %}
✓ Mimetypes: Added {{ mimetypes_added }} new types
{% else %}
✓ Mimetypes: Already up to date
{% endif %}
Next steps:
- Check admin interface for any remaining warnings
- Warnings may take a few minutes to clear from cache
============================================================

View file

@ -1,40 +0,0 @@
---
# Cleanup playbook - run before destroying servers
# Removes SMTP credentials and other cloud resources
- name: Cleanup server resources before destruction
hosts: all
become: no
gather_facts: no
pre_tasks:
- name: Determine client name from hostname
set_fact:
client_name: "{{ inventory_hostname }}"
- name: Load client secrets
community.sops.load_vars:
file: "{{ playbook_dir }}/../../secrets/clients/{{ client_name }}.sops.yaml"
name: client_secrets
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
no_log: true
tasks:
- name: Delete Mailgun SMTP credentials
include_role:
name: mailgun
tasks_from: delete
- name: Display cleanup summary
debug:
msg: |
============================================================
Cleanup complete for: {{ client_name }}
============================================================
Removed:
✓ Mailgun SMTP credential ({{ inventory_hostname }}@mg.vrije.cloud)
You can now safely destroy the server with:
cd ../tofu && tofu destroy -target='hcloud_server.client["{{ client_name }}"]'
============================================================

View file

@ -1,53 +0,0 @@
---
# Configure email for a single server
- hosts: all
gather_facts: yes
tasks:
- name: Load client secrets
community.sops.load_vars:
file: "{{ playbook_dir }}/../../secrets/clients/{{ inventory_hostname }}.sops.yaml"
name: client_secrets
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
no_log: true
- name: Load shared secrets
community.sops.load_vars:
file: "{{ playbook_dir }}/../../secrets/shared.sops.yaml"
name: shared_secrets
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
no_log: true
- name: Merge secrets
set_fact:
client_secrets: "{{ client_secrets | combine(shared_secrets) }}"
no_log: true
- name: Include mailgun role
include_role:
name: mailgun
- name: Configure Nextcloud email if credentials available
shell: |
docker exec -u www-data nextcloud php occ config:system:set mail_smtpmode --value="smtp"
docker exec -u www-data nextcloud php occ config:system:set mail_smtpsecure --value="tls"
docker exec -u www-data nextcloud php occ config:system:set mail_smtphost --value="smtp.eu.mailgun.org"
docker exec -u www-data nextcloud php occ config:system:set mail_smtpport --value="587"
docker exec -u www-data nextcloud php occ config:system:set mail_smtpauth --value="1"
docker exec -u www-data nextcloud php occ config:system:set mail_smtpname --value="{{ mailgun_smtp_user }}"
docker exec -u www-data nextcloud php occ config:system:set mail_smtppassword --value="{{ mailgun_smtp_password }}"
docker exec -u www-data nextcloud php occ config:system:set mail_from_address --value="{{ inventory_hostname }}"
docker exec -u www-data nextcloud php occ config:system:set mail_domain --value="mg.vrije.cloud"
when: mailgun_smtp_user is defined
no_log: true
register: email_config
- name: Display email configuration status
debug:
msg: |
========================================
Email Configuration
========================================
Status: {{ 'Configured' if email_config.changed | default(false) else 'Skipped (credentials not available)' }}
SMTP: smtp.eu.mailgun.org:587 (TLS)
From: {{ inventory_hostname }}@mg.vrije.cloud
========================================

View file

@ -1,6 +1,6 @@
---
# Deploy applications to client servers
# This playbook deploys Authentik, Nextcloud, and other applications
# This playbook deploys Nextcloud and other applications
- name: Deploy applications to client servers
hosts: all
@ -14,93 +14,25 @@
set_fact:
client_name: "{{ inventory_hostname }}"
- name: Check if base infrastructure is installed
stat:
path: /opt/docker/traefik/docker-compose.yml
register: traefik_compose
- name: Fail if base infrastructure is not installed
fail:
msg: |
❌ ERROR: Base infrastructure not installed!
Traefik reverse proxy is required but not found.
You must run the setup playbook BEFORE deploying applications:
ansible-playbook -i hcloud.yml playbooks/setup.yml --limit {{ client_name }}
Or use the rebuild script which handles the correct order automatically:
./scripts/rebuild-client.sh {{ client_name }}
when: not traefik_compose.stat.exists
- name: Load client secrets
community.sops.load_vars:
file: "{{ playbook_dir }}/../../secrets/clients/{{ client_name }}.sops.yaml"
name: client_secrets
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
age_key: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
no_log: true
tags: always
- name: Load shared secrets (Mailgun API key, etc.)
community.sops.load_vars:
file: "{{ playbook_dir }}/../../secrets/shared.sops.yaml"
name: shared_secrets
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
no_log: true
tags: always
- name: Merge shared secrets into client_secrets
set_fact:
client_secrets: "{{ client_secrets | combine(shared_secrets) }}"
no_log: true
tags: always
- name: Set client domain from secrets
set_fact:
client_domain: "{{ client_secrets.client_domain }}"
when: client_secrets.client_domain is defined
- name: Set Authentik domain from secrets
set_fact:
authentik_domain: "{{ client_secrets.authentik_domain }}"
when: client_secrets.authentik_domain is defined
roles:
- role: mailgun
- role: authentik
- role: nextcloud
- role: diun
tags: diun
- role: kuma
tags: kuma
post_tasks:
- name: Display deployment summary
debug:
msg: |
============================================================
🎉 Deployment complete for client: {{ client_name }}
============================================================
Deployment complete for client: {{ client_name }}
Services deployed and configured:
✓ Authentik SSO: https://{{ authentik_domain }}
✓ Nextcloud: https://nextcloud.{{ client_domain }}
✓ SSO Integration: Fully automated (OAuth2/OIDC)
Authentik Admin Access:
- Username: akadmin
- Password: {{ client_secrets.authentik_bootstrap_password }}
- API Token: Configured automatically
Nextcloud Admin Access:
- Username: {{ client_secrets.nextcloud_admin_user }}
- Password: {{ client_secrets.nextcloud_admin_password }}
End User Access:
1. Create users in Authentik: https://{{ authentik_domain }}
2. Users login to Nextcloud via "Login with Authentik" button
3. First login creates linked Nextcloud account automatically
============================================================
Ready to use! No manual configuration required.
============================================================
Nextcloud: https://nextcloud.{{ client_domain }}

View file

@ -18,13 +18,6 @@
- name: Gather facts
setup:
- name: Load shared secrets (Docker Hub, etc.)
community.sops.load_vars:
file: "{{ playbook_dir }}/../../secrets/shared.sops.yaml"
name: shared_secrets
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
no_log: true
roles:
- role: common
tags: ['common', 'security']

View file

@ -1,311 +0,0 @@
---
# Playbook: Update Docker containers across clients
# Usage:
# # Update single client
# ansible-playbook -i hcloud.yml playbooks/update-containers.yml --limit black
#
# # Update specific service only
# ansible-playbook -i hcloud.yml playbooks/update-containers.yml --limit black --tags authentik
#
# # Dry run (check mode)
# ansible-playbook -i hcloud.yml playbooks/update-containers.yml --limit black --check
#
# # Update multiple clients in sequence
# ansible-playbook -i hcloud.yml playbooks/update-containers.yml --limit "dev,test"
- name: Update Docker containers
hosts: all
become: yes
serial: 1 # Process one host at a time for safety
vars:
# Services to update (override with -e "services_to_update=['authentik']")
services_to_update:
- traefik
- authentik
- nextcloud
- diun
# Backup before update
create_backup: true
# Wait time between service updates (seconds)
update_delay: 30
pre_tasks:
- name: Display update plan
debug:
msg: |
Updating {{ inventory_hostname }}
Services: {{ services_to_update | join(', ') }}
Backup enabled: {{ create_backup }}
tags: always
- name: Check if host is reachable
ping:
tags: always
- name: Get current container status (before)
shell: docker ps --format 'table {{{{.Names}}}}\t{{{{.Status}}}}\t{{{{.Image}}}}'
register: containers_before
changed_when: false
tags: always
- name: Display current containers
debug:
msg: "{{ containers_before.stdout_lines }}"
tags: always
tasks:
# ==========================================
# Traefik Updates
# ==========================================
- name: Update Traefik
block:
- name: Create Traefik backup
shell: |
cd /opt/docker/traefik
tar -czf /tmp/traefik-backup-$(date +%Y%m%d-%H%M%S).tar.gz \
acme.json docker-compose.yml traefik.yml 2>/dev/null || true
when: create_backup
- name: Pull latest Traefik image
docker_image:
name: traefik:latest
source: pull
force_source: yes
- name: Restart Traefik
docker_compose:
project_src: /opt/docker/traefik
restarted: yes
pull: yes
- name: Wait for Traefik to be healthy
shell: docker inspect --format='{{{{.State.Status}}}}' traefik
register: traefik_status
until: traefik_status.stdout == "running"
retries: 10
delay: 5
changed_when: false
- name: Verify Traefik SSL certificates
shell: docker exec traefik ls -la /acme.json
register: traefik_certs
changed_when: false
failed_when: traefik_certs.rc != 0
- name: Delay between services
pause:
seconds: "{{ update_delay }}"
when: "'traefik' in services_to_update"
tags: traefik
# ==========================================
# Authentik Updates
# ==========================================
- name: Update Authentik
block:
- name: Create Authentik database backup
shell: |
docker exec authentik-db pg_dump -U authentik authentik | \
gzip > /tmp/authentik-backup-$(date +%Y%m%d-%H%M%S).sql.gz
when: create_backup
- name: Pull latest Authentik images
docker_image:
name: "{{ item }}"
source: pull
force_source: yes
loop:
- ghcr.io/goauthentik/server:latest
- postgres:16-alpine
- redis:alpine
- name: Restart Authentik services
docker_compose:
project_src: /opt/docker/authentik
restarted: yes
pull: yes
- name: Wait for Authentik server to be healthy
shell: docker inspect --format='{{{{.State.Health.Status}}}}' authentik-server
register: authentik_status
until: authentik_status.stdout == "healthy"
retries: 20
delay: 10
changed_when: false
- name: Wait for Authentik worker to be healthy
shell: docker inspect --format='{{{{.State.Health.Status}}}}' authentik-worker
register: authentik_worker_status
until: authentik_worker_status.stdout == "healthy"
retries: 20
delay: 10
changed_when: false
- name: Verify Authentik web interface
uri:
url: "https://auth.{{ client_name }}.vrije.cloud/if/flow/default-authentication-flow/"
validate_certs: yes
status_code: 200
register: authentik_health
retries: 5
delay: 10
- name: Delay between services
pause:
seconds: "{{ update_delay }}"
when: "'authentik' in services_to_update"
tags: authentik
# ==========================================
# Nextcloud Updates
# ==========================================
- name: Update Nextcloud
block:
- name: Create Nextcloud database backup
shell: |
docker exec nextcloud-db mysqldump -u nextcloud -p$(docker exec nextcloud-db cat /run/secrets/db_password 2>/dev/null || echo 'password') nextcloud | \
gzip > /tmp/nextcloud-backup-$(date +%Y%m%d-%H%M%S).sql.gz
when: create_backup
ignore_errors: yes
- name: Enable Nextcloud maintenance mode
shell: docker exec -u www-data nextcloud php occ maintenance:mode --on
register: maintenance_mode
changed_when: "'Maintenance mode enabled' in maintenance_mode.stdout"
- name: Pull latest Nextcloud images
docker_image:
name: "{{ item }}"
source: pull
force_source: yes
loop:
- nextcloud:latest
- mariadb:11
- redis:alpine
- collabora/code:latest
- name: Restart Nextcloud services
docker_compose:
project_src: /opt/docker/nextcloud
restarted: yes
pull: yes
- name: Wait for Nextcloud to be ready
shell: docker exec nextcloud-db mysqladmin ping -h localhost -u root --silent
register: nc_db_status
until: nc_db_status.rc == 0
retries: 20
delay: 5
changed_when: false
- name: Run Nextcloud upgrade (if needed)
shell: docker exec -u www-data nextcloud php occ upgrade
register: nc_upgrade
changed_when: "'Updated database' in nc_upgrade.stdout"
failed_when: nc_upgrade.rc != 0 and 'already latest version' not in nc_upgrade.stdout
- name: Disable Nextcloud maintenance mode
shell: docker exec -u www-data nextcloud php occ maintenance:mode --off
register: maintenance_off
changed_when: "'Maintenance mode disabled' in maintenance_off.stdout"
- name: Verify Nextcloud web interface
uri:
url: "https://nextcloud.{{ client_name }}.vrije.cloud/status.php"
validate_certs: yes
status_code: 200
register: nc_health
retries: 10
delay: 10
- name: Verify Nextcloud installed status
uri:
url: "https://nextcloud.{{ client_name }}.vrije.cloud/status.php"
validate_certs: yes
return_content: yes
register: nc_status_check
failed_when: "'\"installed\":true' not in nc_status_check.content"
- name: Delay between services
pause:
seconds: "{{ update_delay }}"
when: "'nextcloud' in services_to_update"
tags: nextcloud
# ==========================================
# Diun Updates
# ==========================================
- name: Update Diun
block:
- name: Pull latest Diun image
docker_image:
name: crazymax/diun:latest
source: pull
force_source: yes
- name: Restart Diun
docker_compose:
project_src: /opt/docker/diun
restarted: yes
pull: yes
- name: Wait for Diun to be running
shell: docker inspect --format='{{{{.State.Status}}}}' diun
register: diun_status
until: diun_status.stdout == "running"
retries: 5
delay: 3
changed_when: false
when: "'diun' in services_to_update"
tags: diun
post_tasks:
- name: Get final container status
shell: docker ps --format 'table {{{{.Names}}}}\t{{{{.Status}}}}\t{{{{.Image}}}}'
register: containers_after
changed_when: false
tags: always
- name: Display final container status
debug:
msg: "{{ containers_after.stdout_lines }}"
tags: always
- name: Verify all expected containers are running
shell: docker ps --filter "status=running" --format '{{{{.Names}}}}' | wc -l
register: running_count
changed_when: false
tags: always
- name: Check for unhealthy containers
shell: docker ps --filter "health=unhealthy" --format '{{{{.Names}}}}'
register: unhealthy_containers
changed_when: false
failed_when: unhealthy_containers.stdout != ""
tags: always
- name: Update summary
debug:
msg: |
========================================
Update Summary for {{ inventory_hostname }}
========================================
Running containers: {{ running_count.stdout }}
Unhealthy containers: {{ unhealthy_containers.stdout or 'None' }}
Services updated: {{ services_to_update | join(', ') }}
Status: SUCCESS
tags: always
- name: Post-update validation
hosts: all
become: yes
gather_facts: no
tasks:
- name: Final health check
debug:
msg: "All updates completed successfully on {{ inventory_hostname }}"

View file

@ -1,61 +0,0 @@
---
# Update enrollment flow blueprint on running Authentik instance
- name: Update enrollment flow blueprint
hosts: all
gather_facts: no
become: yes
vars:
authentik_api_token: "ak_DtA2LG1Z9shl-tw9r0cs34B1G9l8Lpz76GxLf-4OBiUWbiHbAVJ04GYLcZ30"
client_domain: "dev.vrije.cloud"
tasks:
- name: Create blueprints directory
file:
path: /opt/config/authentik/blueprints
state: directory
mode: '0755'
- name: Copy enrollment flow blueprint
copy:
src: ../roles/authentik/files/enrollment-flow.yaml
dest: /opt/config/authentik/blueprints/enrollment-flow.yaml
mode: '0644'
register: blueprint_copied
- name: Copy blueprint into authentik-worker container
shell: |
docker cp /opt/config/authentik/blueprints/enrollment-flow.yaml authentik-worker:/blueprints/enrollment-flow.yaml
when: blueprint_copied.changed
- name: Copy blueprint into authentik-server container
shell: |
docker cp /opt/config/authentik/blueprints/enrollment-flow.yaml authentik-server:/blueprints/enrollment-flow.yaml
when: blueprint_copied.changed
- name: Restart authentik-worker to force blueprint discovery
shell: docker restart authentik-worker
when: blueprint_copied.changed
- name: Wait for blueprint to be applied
shell: |
sleep 30
docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
'http://localhost:9000/api/v3/flows/instances/?slug=default-enrollment-flow'
register: flow_check
retries: 6
delay: 10
until: flow_check.rc == 0
no_log: true
- name: Display success message
debug:
msg: |
✓ Enrollment flow blueprint updated successfully!
The invitation-only enrollment flow is now set as the default.
When you create invitations in Authentik, they will automatically
use the correct flow.
Flow URL: https://auth.{{ client_domain }}/if/flow/default-enrollment-flow/

View file

@ -1,25 +0,0 @@
---
# Defaults for Authentik role
# Authentik version
authentik_version: "2025.10.3"
authentik_image: "ghcr.io/goauthentik/server"
# PostgreSQL configuration
authentik_db_user: "authentik"
authentik_db_name: "authentik"
# Ports (internal to Docker network, exposed via Traefik)
authentik_http_port: 9000
authentik_https_port: 9443
# Docker configuration
authentik_config_dir: "/opt/docker/authentik"
authentik_network: "authentik-internal"
authentik_traefik_network: "traefik"
# Domain (set per client)
# authentik_domain: "auth.example.com"
# Bootstrap settings
authentik_bootstrap: true

View file

@ -1,303 +0,0 @@
#!/usr/bin/env python3
"""
Authentik API client for automated OIDC provider configuration.
This script handles the complete automation of Authentik SSO setup:
1. Bootstrap initial admin user (if needed)
2. Create OAuth2/OIDC provider for Nextcloud
3. Return client credentials for Nextcloud configuration
Usage:
python3 authentik_api.py --domain https://auth.example.com \
--app-name Nextcloud \
--redirect-uri https://nextcloud.example.com/apps/user_oidc/code \
--bootstrap-password <admin_password>
"""
import argparse
import json
import sys
import time
import urllib.request
import urllib.error
from typing import Dict, Optional, Tuple
class AuthentikAPI:
"""Client for Authentik API with bootstrapping support."""
def __init__(self, base_url: str, token: Optional[str] = None):
self.base_url = base_url.rstrip('/')
self.token = token
self.session_cookie = None
def _request(self, method: str, path: str, data: Optional[Dict] = None,
headers: Optional[Dict] = None) -> Tuple[int, Dict]:
"""Make HTTP request to Authentik API."""
import ssl
url = f"{self.base_url}{path}"
req_headers = headers or {}
# Add authentication
if self.token:
req_headers['Authorization'] = f'Bearer {self.token}'
elif self.session_cookie:
req_headers['Cookie'] = self.session_cookie
req_headers['Content-Type'] = 'application/json'
body = json.dumps(data).encode('utf-8') if data else None
request = urllib.request.Request(url, data=body, headers=req_headers, method=method)
# Create SSL context (don't verify for internal services)
ctx = ssl.create_default_context()
# For production, you'd want to verify certificates properly
# But for automated deployments, we trust the internal network
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(request, timeout=30, context=ctx) as response:
response_data = json.loads(response.read().decode('utf-8'))
# Capture session cookie if present
cookie = response.headers.get('Set-Cookie')
if cookie and not self.session_cookie:
self.session_cookie = cookie.split(';')[0]
return response.status, response_data
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8')
try:
error_data = json.loads(error_body)
except json.JSONDecodeError:
error_data = {'error': error_body}
return e.code, error_data
except urllib.error.URLError as e:
return 0, {'error': str(e)}
def wait_for_ready(self, timeout: int = 300) -> bool:
"""Wait for Authentik to be ready and responding."""
print(f"Waiting for Authentik at {self.base_url} to be ready...", file=sys.stderr)
start_time = time.time()
while time.time() - start_time < timeout:
try:
status, _ = self._request('GET', '/')
if status in [200, 302]:
print("Authentik is ready!", file=sys.stderr)
return True
except Exception:
pass
time.sleep(5)
print(f"Timeout waiting for Authentik after {timeout}s", file=sys.stderr)
return False
def check_bootstrap_needed(self) -> bool:
"""Check if initial setup is needed."""
status, data = self._request('GET', '/if/flow/initial-setup/')
# 200 = setup needed, 302/404 = already configured
return status == 200
def bootstrap_admin(self, username: str, password: str, email: str) -> bool:
"""Bootstrap initial admin account via initial setup flow."""
print(f"Bootstrapping admin user: {username}", file=sys.stderr)
# This is a simplified approach - real implementation would need to:
# 1. Get CSRF token from initial setup page
# 2. Submit form with proper flow context
# 3. Handle multi-step flow if needed
# For now, we'll document that manual setup is required
print("WARNING: Automatic bootstrap not yet implemented", file=sys.stderr)
print(f"Please complete initial setup at: {self.base_url}/if/flow/initial-setup/",
file=sys.stderr)
return False
def create_service_account_token(self, username: str, password: str) -> Optional[str]:
"""Login and create service account token."""
print("Creating service account token...", file=sys.stderr)
# Try to authenticate
status, data = self._request('POST', '/api/v3/core/tokens/', {
'identifier': username,
'password': password,
'intent': 'app_password',
'description': 'Ansible automation token'
})
if status == 201:
token = data.get('key')
print("Service account token created successfully", file=sys.stderr)
return token
else:
print(f"Failed to create token: {data}", file=sys.stderr)
return None
def get_default_authorization_flow(self) -> Optional[str]:
"""Get the default authorization flow UUID."""
status, data = self._request('GET', '/api/v3/flows/instances/')
if status == 200:
for flow in data.get('results', []):
if flow.get('slug') == 'default-authorization-flow':
return flow['pk']
# Fallback: get any authorization flow
for flow in data.get('results', []):
if flow.get('designation') == 'authorization':
return flow['pk']
print("ERROR: No authorization flow found", file=sys.stderr)
return None
def get_default_signing_key(self) -> Optional[str]:
"""Get the default signing key UUID."""
status, data = self._request('GET', '/api/v3/crypto/certificatekeypairs/')
if status == 200:
results = data.get('results', [])
if results:
# Return first available key
return results[0]['pk']
print("ERROR: No signing key found", file=sys.stderr)
return None
def create_oidc_provider(self, name: str, redirect_uris: str,
flow_uuid: str, key_uuid: str) -> Optional[Dict]:
"""Create OAuth2/OIDC provider."""
print(f"Creating OIDC provider for {name}...", file=sys.stderr)
provider_data = {
'name': name,
'authorization_flow': flow_uuid,
'client_type': 'confidential',
'redirect_uris': redirect_uris,
'signing_key': key_uuid,
'sub_mode': 'hashed_user_id',
'include_claims_in_id_token': True,
}
status, data = self._request('POST', '/api/v3/providers/oauth2/', provider_data)
if status == 201:
print(f"OIDC provider created: {data['pk']}", file=sys.stderr)
return data
else:
print(f"ERROR: Failed to create OIDC provider: {data}", file=sys.stderr)
return None
def create_application(self, name: str, slug: str, provider_id: int,
launch_url: str) -> Optional[Dict]:
"""Create application linked to OIDC provider."""
print(f"Creating application {name}...", file=sys.stderr)
app_data = {
'name': name,
'slug': slug,
'provider': provider_id,
'meta_launch_url': launch_url,
}
status, data = self._request('POST', '/api/v3/core/applications/', app_data)
if status == 201:
print(f"Application created: {data['pk']}", file=sys.stderr)
return data
else:
print(f"ERROR: Failed to create application: {data}", file=sys.stderr)
return None
def main():
parser = argparse.ArgumentParser(description='Automate Authentik OIDC provider setup')
parser.add_argument('--domain', required=True, help='Authentik domain (https://auth.example.com)')
parser.add_argument('--app-name', required=True, help='Application name (e.g., Nextcloud)')
parser.add_argument('--app-slug', help='Application slug (defaults to lowercase app-name)')
parser.add_argument('--redirect-uri', required=True, help='OAuth2 redirect URI')
parser.add_argument('--launch-url', help='Application launch URL (defaults to redirect-uri base)')
parser.add_argument('--token', help='Authentik API token (if already bootstrapped)')
parser.add_argument('--bootstrap-user', default='akadmin', help='Bootstrap admin username')
parser.add_argument('--bootstrap-password', help='Bootstrap admin password')
parser.add_argument('--bootstrap-email', default='admin@localhost', help='Bootstrap admin email')
parser.add_argument('--wait-timeout', type=int, default=300, help='Timeout for waiting (seconds)')
args = parser.parse_args()
# Derive defaults
app_slug = args.app_slug or args.app_name.lower()
launch_url = args.launch_url or args.redirect_uri.rsplit('/', 2)[0]
# Initialize API client
api = AuthentikAPI(args.domain, args.token)
# Wait for Authentik to be ready
if not api.wait_for_ready(args.wait_timeout):
print(json.dumps({'error': 'Authentik not ready'}))
sys.exit(1)
# Check if bootstrap is needed
if not args.token:
if api.check_bootstrap_needed():
if not args.bootstrap_password:
print(json.dumps({
'error': 'Bootstrap needed but no password provided',
'action_required': f'Visit {args.domain}/if/flow/initial-setup/ to complete setup',
'next_step': 'Create service account and provide --token'
}))
sys.exit(1)
# Try to bootstrap (not yet implemented)
if not api.bootstrap_admin(args.bootstrap_user, args.bootstrap_password,
args.bootstrap_email):
print(json.dumps({
'error': 'Bootstrap not yet automated',
'action_required': f'Visit {args.domain}/if/flow/initial-setup/ manually',
'instructions': [
f'1. Create admin user: {args.bootstrap_user}',
'2. Create API token in admin UI',
'3. Re-run with --token <token>'
]
}))
sys.exit(1)
print("ERROR: No API token provided and bootstrap needed", file=sys.stderr)
sys.exit(1)
# Get required UUIDs
flow_uuid = api.get_default_authorization_flow()
key_uuid = api.get_default_signing_key()
if not flow_uuid or not key_uuid:
print(json.dumps({'error': 'Failed to get required Authentik configuration'}))
sys.exit(1)
# Create OIDC provider
provider = api.create_oidc_provider(args.app_name, args.redirect_uri, flow_uuid, key_uuid)
if not provider:
print(json.dumps({'error': 'Failed to create OIDC provider'}))
sys.exit(1)
# Create application
application = api.create_application(args.app_name, app_slug, provider['pk'], launch_url)
if not application:
print(json.dumps({'error': 'Failed to create application'}))
sys.exit(1)
# Output credentials
result = {
'success': True,
'provider_id': provider['pk'],
'application_id': application['pk'],
'client_id': provider['client_id'],
'client_secret': provider['client_secret'],
'discovery_uri': f"{args.domain}/application/o/{app_slug}/.well-known/openid-configuration",
'issuer': f"{args.domain}/application/o/{app_slug}/",
}
print(json.dumps(result, indent=2))
if __name__ == '__main__':
main()

View file

@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""
Configure 2FA enforcement in Authentik.
Modifies the default-authentication-mfa-validation stage to force users to configure MFA.
"""
import sys
import json
import urllib.request
import urllib.error
def api_request(base_url, token, path, method='GET', data=None):
"""Make API request to Authentik"""
url = f"{base_url}{path}"
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
request_data = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=request_data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
error_body = e.read().decode()
try:
error_data = json.loads(error_body)
except:
error_data = {'error': error_body}
return e.code, error_data
def main():
if len(sys.argv) != 3:
print(json.dumps({'error': 'Usage: configure_2fa_enforcement.py <base_url> <api_token>'}), file=sys.stderr)
sys.exit(1)
base_url = sys.argv[1]
token = sys.argv[2]
# Step 1: Find the default MFA validation stage
status, stages_response = api_request(base_url, token, '/api/v3/stages/authenticator/validate/')
if status != 200:
print(json.dumps({'error': 'Failed to list authenticator validate stages', 'details': stages_response}), file=sys.stderr)
sys.exit(1)
mfa_stage = next((s for s in stages_response.get('results', [])
if 'default-authentication-mfa-validation' in s.get('name', '').lower()), None)
if not mfa_stage:
print(json.dumps({'error': 'default-authentication-mfa-validation stage not found'}), file=sys.stderr)
sys.exit(1)
stage_pk = mfa_stage['pk']
# Step 2: Find the default TOTP setup stage to use as configuration stage
status, totp_stages_response = api_request(base_url, token, '/api/v3/stages/authenticator/totp/')
if status != 200:
print(json.dumps({'error': 'Failed to list TOTP setup stages', 'details': totp_stages_response}), file=sys.stderr)
sys.exit(1)
totp_setup_stage = next((s for s in totp_stages_response.get('results', [])
if 'setup' in s.get('name', '').lower()), None)
if not totp_setup_stage:
print(json.dumps({'error': 'TOTP setup stage not found'}), file=sys.stderr)
sys.exit(1)
totp_setup_pk = totp_setup_stage['pk']
# Step 3: Update the MFA validation stage to force configuration
update_data = {
'name': mfa_stage['name'],
'not_configured_action': 'configure', # Force user to configure
'configuration_stages': [totp_setup_pk] # Use TOTP setup stage
}
status, updated_stage = api_request(base_url, token, f'/api/v3/stages/authenticator/validate/{stage_pk}/', 'PATCH', update_data)
if status not in [200, 201]:
print(json.dumps({'error': 'Failed to update MFA validation stage', 'details': updated_stage}), file=sys.stderr)
sys.exit(1)
print(json.dumps({
'success': True,
'message': '2FA enforcement configured',
'stage_name': mfa_stage['name'],
'stage_pk': stage_pk,
'note': 'Users will be forced to configure TOTP on login'
}))
if __name__ == '__main__':
main()

View file

@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""
Configure Authentik invitation flow.
Creates an invitation stage and binds it to the default enrollment flow.
"""
import sys
import json
import urllib.request
import urllib.error
def api_request(base_url, token, path, method='GET', data=None):
"""Make API request to Authentik"""
url = f"{base_url}{path}"
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
request_data = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=request_data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
error_body = e.read().decode()
try:
error_data = json.loads(error_body)
except:
error_data = {'error': error_body}
return e.code, error_data
def main():
if len(sys.argv) != 3:
print(json.dumps({'error': 'Usage: configure_invitation_flow.py <base_url> <api_token>'}), file=sys.stderr)
sys.exit(1)
base_url = sys.argv[1]
token = sys.argv[2]
# Step 1: Get the default enrollment flow
status, flows_response = api_request(base_url, token, '/api/v3/flows/instances/')
if status != 200:
print(json.dumps({'error': 'Failed to list flows', 'details': flows_response}), file=sys.stderr)
sys.exit(1)
enrollment_flow = next((f for f in flows_response.get('results', [])
if f.get('designation') == 'enrollment'), None)
if not enrollment_flow:
print(json.dumps({'error': 'No enrollment flow found'}), file=sys.stderr)
sys.exit(1)
flow_slug = enrollment_flow['slug']
flow_pk = enrollment_flow['pk']
# Step 2: Check if invitation stage already exists
status, stages_response = api_request(base_url, token, '/api/v3/stages/invitation/')
if status != 200:
print(json.dumps({'error': 'Failed to list invitation stages', 'details': stages_response}), file=sys.stderr)
sys.exit(1)
invitation_stage = next((s for s in stages_response.get('results', [])
if s.get('name') == 'default-enrollment-invitation'), None)
# Step 3: Create invitation stage if it doesn't exist
if not invitation_stage:
stage_data = {
'name': 'default-enrollment-invitation',
'continue_flow_without_invitation': True
}
status, invitation_stage = api_request(base_url, token, '/api/v3/stages/invitation/', 'POST', stage_data)
if status not in [200, 201]:
print(json.dumps({'error': 'Failed to create invitation stage', 'details': invitation_stage}), file=sys.stderr)
sys.exit(1)
stage_pk = invitation_stage['pk']
# Step 4: Check if the stage is already bound to the enrollment flow
status, bindings_response = api_request(base_url, token, f'/api/v3/flows/bindings/?target={flow_pk}')
if status != 200:
print(json.dumps({'error': 'Failed to list flow bindings', 'details': bindings_response}), file=sys.stderr)
sys.exit(1)
# Check if invitation stage is already bound
invitation_binding = next((b for b in bindings_response.get('results', [])
if b.get('stage') == stage_pk), None)
# Step 5: Bind the invitation stage to the enrollment flow if not already bound
if not invitation_binding:
# Find the highest order number to insert at the beginning
max_order = max([b.get('order', 0) for b in bindings_response.get('results', [])], default=0)
binding_data = {
'target': flow_pk,
'stage': stage_pk,
'order': 0, # Put invitation stage first
'evaluate_on_plan': True,
're_evaluate_policies': False
}
status, binding = api_request(base_url, token, '/api/v3/flows/bindings/', 'POST', binding_data)
if status not in [200, 201]:
print(json.dumps({'error': 'Failed to bind invitation stage to flow', 'details': binding}), file=sys.stderr)
sys.exit(1)
print(json.dumps({
'success': True,
'message': 'Invitation flow configured',
'flow_slug': flow_slug,
'stage_pk': stage_pk,
'note': 'Invitation stage bound to enrollment flow'
}))
if __name__ == '__main__':
main()

View file

@ -1,522 +0,0 @@
#!/usr/bin/env python3
"""
Authentik Recovery Flow Automation Script
This script creates a complete password recovery flow in Authentik with:
- Password complexity policy (12 chars, mixed case, digit, symbol)
- Recovery identification stage (username/email)
- Recovery email stage (sends recovery token)
- Password change stages (with validation)
- Integration with default authentication flow
- Brand default recovery flow configuration
Usage:
python3 create_recovery_flow.py <api_token> <authentik_domain>
"""
import sys
import json
import urllib.request
import urllib.error
def api_request(base_url, token, path, method='GET', data=None):
"""Make an API request to Authentik"""
url = f"{base_url}{path}"
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
request_data = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=request_data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode()
if body:
return resp.status, json.loads(body)
return resp.status, {}
except urllib.error.HTTPError as e:
error_body = e.read().decode()
try:
error_data = json.loads(error_body) if error_body else {'error': 'Empty error response'}
except:
error_data = {'error': error_body or 'Unknown error'}
return e.code, error_data
except Exception as e:
return 0, {'error': str(e)}
def get_or_create_password_policy(base_url, token):
"""Create password complexity policy"""
print("Checking for password complexity policy...")
policy_data = {
"name": "password-complexity",
"password_field": "password",
"amount_digits": 1,
"amount_uppercase": 1,
"amount_lowercase": 1,
"amount_symbols": 1,
"length_min": 12,
"symbol_charset": "!\\\"#$%&'()*+,-./:;<=>?@[]^_`{|}~",
"error_message": "Enter a minimum of 12 characters, with at least 1 lowercase, uppercase, digit and symbol",
"check_static_rules": True,
"check_have_i_been_pwned": True,
"check_zxcvbn": True,
"hibp_allowed_count": 0,
"zxcvbn_score_threshold": 2
}
# Check if policy already exists
status, policies = api_request(base_url, token, '/api/v3/policies/password/')
print(f" Initial check status: {status}")
if status == 200:
results = policies.get('results', [])
print(f" Found {len(results)} existing policies")
for policy in results:
policy_name = policy.get('name')
print(f" - {policy_name}")
if policy_name == 'password-complexity':
print(f" ✓ Password policy already exists: {policy['pk']}")
return policy['pk']
else:
print(f" Initial check failed: {policies}")
# Create new policy
status, policy = api_request(base_url, token, '/api/v3/policies/password/', 'POST', policy_data)
if status == 201:
print(f" ✓ Created password policy: {policy['pk']}")
return policy['pk']
elif status == 400 and 'name' in policy:
# Policy with same name already exists, search for it again
print(f" ! Policy name already exists, retrieving existing policy...")
status, policies = api_request(base_url, token, '/api/v3/policies/password/')
if status == 200:
for existing_policy in policies.get('results', []):
if existing_policy.get('name') == 'password-complexity':
print(f" ✓ Found existing password policy: {existing_policy['pk']}")
return existing_policy['pk']
print(f" ✗ Failed to find existing policy after creation conflict")
return None
else:
print(f" ✗ Failed to create password policy: {policy}")
return None
def get_or_create_recovery_identification_stage(base_url, token):
"""Create recovery identification stage"""
print("Creating recovery identification stage...")
stage_data = {
"name": "recovery-authentication-identification",
"user_fields": ["username", "email"],
"password_stage": None,
"case_insensitive_matching": True,
"show_matched_user": True,
"pretend_user_exists": True,
"enable_remember_me": False
}
# Check if stage already exists
status, stages = api_request(base_url, token, '/api/v3/stages/identification/')
if status == 200:
for stage in stages.get('results', []):
if stage.get('name') == 'recovery-authentication-identification':
print(f" ✓ Recovery identification stage already exists: {stage['pk']}")
return stage['pk']
# Create new stage
status, stage = api_request(base_url, token, '/api/v3/stages/identification/', 'POST', stage_data)
if status == 201:
print(f" ✓ Created recovery identification stage: {stage['pk']}")
return stage['pk']
elif status == 400 and 'name' in stage:
# Stage with same name already exists
print(f" ! Stage name already exists, retrieving existing stage...")
status, stages = api_request(base_url, token, '/api/v3/stages/identification/')
if status == 200:
for existing_stage in stages.get('results', []):
if existing_stage.get('name') == 'recovery-authentication-identification':
print(f" ✓ Found existing recovery identification stage: {existing_stage['pk']}")
return existing_stage['pk']
print(f" ✗ Failed to find existing stage after creation conflict")
return None
else:
print(f" ✗ Failed to create recovery identification stage: {stage}")
return None
def get_or_create_recovery_email_stage(base_url, token):
"""Create recovery email stage"""
print("Creating recovery email stage...")
stage_data = {
"name": "recovery-email",
"use_global_settings": True,
"token_expiry": "minutes=30",
"subject": "Password recovery",
"template": "email/password_reset.html",
"activate_user_on_success": True,
"recovery_max_attempts": 5,
"recovery_cache_timeout": "minutes=5"
}
# Check if stage already exists
status, stages = api_request(base_url, token, '/api/v3/stages/email/')
if status == 200:
for stage in stages.get('results', []):
if stage.get('name') == 'recovery-email':
print(f" ✓ Recovery email stage already exists: {stage['pk']}")
return stage['pk']
# Create new stage
status, stage = api_request(base_url, token, '/api/v3/stages/email/', 'POST', stage_data)
if status == 201:
print(f" ✓ Created recovery email stage: {stage['pk']}")
return stage['pk']
elif status == 400 and 'name' in stage:
# Stage with same name already exists
print(f" ! Stage name already exists, retrieving existing stage...")
status, stages = api_request(base_url, token, '/api/v3/stages/email/')
if status == 200:
for existing_stage in stages.get('results', []):
if existing_stage.get('name') == 'recovery-email':
print(f" ✓ Found existing recovery email stage: {existing_stage['pk']}")
return existing_stage['pk']
print(f" ✗ Failed to find existing stage after creation conflict")
return None
else:
print(f" ✗ Failed to create recovery email stage: {stage}")
return None
def get_existing_stage_uuid(base_url, token, stage_name, stage_type):
"""Get UUID of an existing stage"""
status, stages = api_request(base_url, token, f'/api/v3/stages/{stage_type}/')
if status == 200:
for stage in stages.get('results', []):
if stage.get('name') == stage_name:
return stage['pk']
return None
def get_or_create_recovery_flow(base_url, token, stage_ids):
"""Create recovery flow with stage bindings"""
print("Creating recovery flow...")
flow_data = {
"name": "recovery",
"slug": "recovery",
"title": "Recovery",
"designation": "recovery",
"policy_engine_mode": "any",
"compatibility_mode": False,
"layout": "stacked",
"denied_action": "message_continue"
}
# Check if flow already exists
status, flows = api_request(base_url, token, '/api/v3/flows/instances/')
if status == 200:
for flow in flows.get('results', []):
if flow.get('slug') == 'recovery':
print(f" ✓ Recovery flow already exists: {flow['pk']}")
return flow['pk']
# Create new flow
status, flow = api_request(base_url, token, '/api/v3/flows/instances/', 'POST', flow_data)
if status != 201:
print(f" ✗ Failed to create recovery flow: {flow}")
return None
flow_uuid = flow['pk']
print(f" ✓ Created recovery flow: {flow_uuid}")
# Create stage bindings
bindings = [
{"stage": stage_ids['recovery_identification'], "order": 0},
{"stage": stage_ids['recovery_email'], "order": 10},
{"stage": stage_ids['password_change_prompt'], "order": 20},
{"stage": stage_ids['password_change_write'], "order": 30},
]
for binding in bindings:
binding_data = {
"target": flow_uuid,
"stage": binding['stage'],
"order": binding['order'],
"evaluate_on_plan": False,
"re_evaluate_policies": True,
"policy_engine_mode": "any",
"invalid_response_action": "retry"
}
status, result = api_request(base_url, token, '/api/v3/flows/bindings/', 'POST', binding_data)
if status == 201:
print(f" ✓ Bound stage {binding['stage']} at order {binding['order']}")
else:
print(f" ✗ Failed to bind stage: {result}")
return flow_uuid
def update_password_change_prompt_stage(base_url, token, stage_uuid, password_complexity_uuid):
"""Add password complexity policy to password change prompt stage"""
print("Updating password change prompt stage...")
# Get current stage configuration
status, stage = api_request(base_url, token, f'/api/v3/stages/prompt/stages/{stage_uuid}/')
if status != 200:
print(f" ✗ Failed to get stage: {stage}")
return False
# Add password complexity to validation policies
validation_policies = stage.get('validation_policies', [])
if password_complexity_uuid not in validation_policies:
validation_policies.append(password_complexity_uuid)
update_data = {
"validation_policies": validation_policies
}
status, result = api_request(base_url, token, f'/api/v3/stages/prompt/stages/{stage_uuid}/', 'PATCH', update_data)
if status == 200:
print(f" ✓ Added password complexity policy to validation")
return True
else:
print(f" ✗ Failed to update stage: {result}")
return False
else:
print(f" ✓ Password complexity policy already in validation")
return True
def remove_separate_password_stage_from_auth_flow(base_url, token, auth_flow_uuid, password_stage_uuid):
"""Remove separate password stage from authentication flow if it exists"""
print("Checking for separate password stage in authentication flow...")
# Get all flow bindings
status, bindings_data = api_request(base_url, token, '/api/v3/flows/bindings/')
if status != 200:
print(f" ✗ Failed to get flow bindings: {bindings_data}")
return False
# Find password stage binding in auth flow
password_binding = None
for binding in bindings_data.get('results', []):
if binding.get('target') == auth_flow_uuid and binding.get('stage') == password_stage_uuid:
password_binding = binding
break
if not password_binding:
print(f" ✓ No separate password stage found (already removed)")
return True
# Delete the password stage binding
binding_uuid = password_binding.get('pk')
status, result = api_request(base_url, token, f'/api/v3/flows/bindings/{binding_uuid}/', 'DELETE')
if status == 204 or status == 200:
print(f" ✓ Removed separate password stage from authentication flow")
return True
else:
print(f" ✗ Failed to remove password stage: {result}")
return False
def update_authentication_identification_stage(base_url, token, stage_uuid, password_stage_uuid, recovery_flow_uuid):
"""Update authentication identification stage with password field and recovery flow"""
print("Updating authentication identification stage...")
# First get the current stage configuration
status, current_stage = api_request(base_url, token, f'/api/v3/stages/identification/{stage_uuid}/')
if status != 200:
print(f" ✗ Failed to get current stage: {current_stage}")
return False
# Check if already configured
if current_stage.get('password_stage') == password_stage_uuid and current_stage.get('recovery_flow') == recovery_flow_uuid:
print(f" ✓ Authentication identification stage already configured")
return True
# Update with new values while preserving existing configuration
update_data = {
"name": current_stage.get('name'),
"user_fields": current_stage.get('user_fields', ["username", "email"]),
"password_stage": password_stage_uuid,
"recovery_flow": recovery_flow_uuid,
"case_insensitive_matching": current_stage.get('case_insensitive_matching', True),
"show_matched_user": current_stage.get('show_matched_user', True),
"pretend_user_exists": current_stage.get('pretend_user_exists', True)
}
status, result = api_request(base_url, token, f'/api/v3/stages/identification/{stage_uuid}/', 'PATCH', update_data)
if status == 200:
print(f" ✓ Updated authentication identification stage")
print(f" - Added password field on same page")
print(f" - Added recovery flow link")
return True
else:
print(f" ✗ Failed to update stage: {result}")
return False
def update_brand_recovery_flow(base_url, token, recovery_flow_uuid):
"""Update the default brand to use the recovery flow"""
print("Updating brand default recovery flow...")
# Get the default brand (authentik has one brand by default)
status, brands = api_request(base_url, token, '/api/v3/core/brands/')
if status != 200:
print(f" ✗ Failed to get brands: {brands}")
return False
results = brands.get('results', [])
if not results:
print(f" ✗ No brands found")
return False
# Use the first/default brand
brand = results[0]
brand_uuid = brand.get('brand_uuid')
# Check if already configured
if brand.get('flow_recovery') == recovery_flow_uuid:
print(f" ✓ Brand recovery flow already configured")
return True
# Update the brand with recovery flow
update_data = {
"domain": brand.get('domain'),
"flow_recovery": recovery_flow_uuid
}
status, result = api_request(base_url, token, f'/api/v3/core/brands/{brand_uuid}/', 'PATCH', update_data)
if status == 200:
print(f" ✓ Updated brand default recovery flow")
return True
else:
print(f" ✗ Failed to update brand: {result}")
return False
def main():
if len(sys.argv) < 3:
print("Usage: python3 create_recovery_flow.py <api_token> <authentik_domain>")
sys.exit(1)
token = sys.argv[1]
authentik_domain = sys.argv[2]
# Use internal localhost URL when running inside Authentik container
# This avoids SSL/DNS issues
base_url = "http://localhost:9000"
print(f"Using internal API endpoint: {base_url}")
print(f"External domain: https://{authentik_domain}\n")
print("=" * 80)
print("Authentik Recovery Flow Automation")
print("=" * 80)
print(f"Target: {base_url}\n")
# Step 1: Create password complexity policy
password_complexity_uuid = get_or_create_password_policy(base_url, token)
if not password_complexity_uuid:
print("\n✗ Failed to create password complexity policy")
sys.exit(1)
# Step 2: Create recovery identification stage
recovery_identification_uuid = get_or_create_recovery_identification_stage(base_url, token)
if not recovery_identification_uuid:
print("\n✗ Failed to create recovery identification stage")
sys.exit(1)
# Step 3: Create recovery email stage
recovery_email_uuid = get_or_create_recovery_email_stage(base_url, token)
if not recovery_email_uuid:
print("\n✗ Failed to create recovery email stage")
sys.exit(1)
# Step 4: Get existing stage and flow UUIDs
print("\nGetting existing stage and flow UUIDs...")
password_change_prompt_uuid = get_existing_stage_uuid(base_url, token, 'default-password-change-prompt', 'prompt/stages')
password_change_write_uuid = get_existing_stage_uuid(base_url, token, 'default-password-change-write', 'user_write')
auth_identification_uuid = get_existing_stage_uuid(base_url, token, 'default-authentication-identification', 'identification')
auth_password_uuid = get_existing_stage_uuid(base_url, token, 'default-authentication-password', 'password')
# Get default authentication flow UUID
status, flows = api_request(base_url, token, '/api/v3/flows/instances/')
auth_flow_uuid = None
if status == 200:
for flow in flows.get('results', []):
if flow.get('slug') == 'default-authentication-flow':
auth_flow_uuid = flow.get('pk')
break
if not all([password_change_prompt_uuid, password_change_write_uuid, auth_identification_uuid, auth_password_uuid, auth_flow_uuid]):
print(" ✗ Failed to find all required existing stages and flows")
sys.exit(1)
print(f" ✓ Found all existing stages and flows")
# Step 5: Create recovery flow
stage_ids = {
'recovery_identification': recovery_identification_uuid,
'recovery_email': recovery_email_uuid,
'password_change_prompt': password_change_prompt_uuid,
'password_change_write': password_change_write_uuid
}
recovery_flow_uuid = get_or_create_recovery_flow(base_url, token, stage_ids)
if not recovery_flow_uuid:
print("\n✗ Failed to create recovery flow")
sys.exit(1)
# Step 6: Update password change prompt stage
if not update_password_change_prompt_stage(base_url, token, password_change_prompt_uuid, password_complexity_uuid):
print("\n⚠ Warning: Failed to update password change prompt stage")
# Step 7: Update authentication identification stage
if not update_authentication_identification_stage(base_url, token, auth_identification_uuid, auth_password_uuid, recovery_flow_uuid):
print("\n⚠ Warning: Failed to update authentication identification stage")
# Step 8: Remove separate password stage from authentication flow
if not remove_separate_password_stage_from_auth_flow(base_url, token, auth_flow_uuid, auth_password_uuid):
print("\n⚠ Warning: Failed to remove separate password stage (may not exist)")
# Step 9: Update brand default recovery flow
if not update_brand_recovery_flow(base_url, token, recovery_flow_uuid):
print("\n⚠ Warning: Failed to update brand recovery flow (non-critical)")
# Success!
print("\n" + "=" * 80)
print("✓ Recovery Flow Configuration Complete!")
print("=" * 80)
print(f"\nRecovery Flow UUID: {recovery_flow_uuid}")
print(f"Recovery URL: https://{authentik_domain}/if/flow/recovery/")
print(f"\nFeatures enabled:")
print(" ✓ Password complexity policy (12 chars, mixed case, digit, symbol)")
print(" ✓ Recovery email with 30-minute token")
print(" ✓ Password + username on same login page")
print("'Forgot password?' link on login page")
print(" ✓ Brand default recovery flow configured")
print("\nTest the recovery flow:")
print(f" 1. Visit: https://{authentik_domain}/if/flow/default-authentication-flow/")
print(" 2. Click 'Forgot password?' link")
print(" 3. Enter username or email")
print(" 4. Check email for recovery link")
print("=" * 80)
# Output JSON for Ansible
result = {
"success": True,
"recovery_flow_uuid": recovery_flow_uuid,
"password_complexity_uuid": password_complexity_uuid,
"recovery_url": f"https://{authentik_domain}/if/flow/recovery/"
}
print("\n" + json.dumps(result))
if __name__ == "__main__":
main()

View file

@ -1,40 +0,0 @@
version: 1
metadata:
name: custom-flow-configuration
labels:
blueprints.goauthentik.io/description: "Configure invitation and 2FA enforcement"
blueprints.goauthentik.io/instantiate: "true"
entries:
# 1. CREATE INVITATION STAGE
# This stage allows enrollment flows to work with or without invitation tokens
- model: authentik_stages_invitation.invitationstage
identifiers:
name: default-enrollment-invitation
id: invitation-stage
attrs:
continue_flow_without_invitation: true
# 2. BIND INVITATION STAGE TO ENROLLMENT FLOW
# Adds the invitation stage as the first stage in the enrollment flow
- model: authentik_flows.flowstagebinding
identifiers:
target: !Find [authentik_flows.flow, [slug, default-enrollment-flow]]
stage: !KeyOf invitation-stage
order: 0
attrs:
evaluate_on_plan: true
re_evaluate_policies: false
# 3. ENFORCE 2FA CONFIGURATION
# Updates MFA validation stage to force users to configure TOTP
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
identifiers:
name: default-authentication-mfa-validation
attrs:
not_configured_action: configure
device_classes:
- totp
- webauthn
configuration_stages:
- !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]]

View file

@ -1,153 +0,0 @@
version: 1
metadata:
name: invitation-enrollment-flow
labels:
blueprints.goauthentik.io/description: "Invitation-only enrollment flow"
blueprints.goauthentik.io/instantiate: "true"
entries:
# 1. CREATE ENROLLMENT FLOW
- attrs:
designation: enrollment
name: Default enrollment Flow
title: Welcome to authentik!
authentication: none
denied_action: message_continue
identifiers:
slug: default-enrollment-flow
model: authentik_flows.flow
id: flow
# 2. CREATE INVITATION STAGE
- attrs:
continue_flow_without_invitation: false
identifiers:
name: default-enrollment-invitation
id: invitation-stage
model: authentik_stages_invitation.invitationstage
# 3. CREATE PROMPT FIELDS
- attrs:
order: 0
placeholder: Username
placeholder_expression: false
required: true
type: username
field_key: username
label: Username
identifiers:
name: default-enrollment-field-username
id: prompt-field-username
model: authentik_stages_prompt.prompt
- attrs:
order: 1
placeholder: Name
placeholder_expression: false
required: true
type: text
field_key: name
label: Name
identifiers:
name: default-enrollment-field-name
id: prompt-field-name
model: authentik_stages_prompt.prompt
- attrs:
order: 2
placeholder: Email
placeholder_expression: false
required: true
type: email
field_key: email
label: Email
identifiers:
name: default-enrollment-field-email
id: prompt-field-email
model: authentik_stages_prompt.prompt
- attrs:
order: 3
placeholder: Password
placeholder_expression: false
required: true
type: password
field_key: password
label: Password
identifiers:
name: default-enrollment-field-password
id: prompt-field-password
model: authentik_stages_prompt.prompt
- attrs:
order: 4
placeholder: Password (repeat)
placeholder_expression: false
required: true
type: password
field_key: password_repeat
label: Password (repeat)
identifiers:
name: default-enrollment-field-password-repeat
id: prompt-field-password-repeat
model: authentik_stages_prompt.prompt
# 4. CREATE PROMPT STAGE
- attrs:
fields:
- !KeyOf prompt-field-username
- !KeyOf prompt-field-name
- !KeyOf prompt-field-email
- !KeyOf prompt-field-password
- !KeyOf prompt-field-password-repeat
validation_policies: []
identifiers:
name: default-enrollment-prompt
id: prompt-stage
model: authentik_stages_prompt.promptstage
# 5. CREATE USER WRITE STAGE
- attrs:
user_creation_mode: always_create
create_users_as_inactive: false
create_users_group: null
user_path_template: ""
identifiers:
name: default-enrollment-user-write
id: user-write-stage
model: authentik_stages_user_write.userwritestage
# 6. BIND INVITATION STAGE TO FLOW (order 0)
- attrs:
evaluate_on_plan: true
re_evaluate_policies: false
identifiers:
order: 0
stage: !KeyOf invitation-stage
target: !KeyOf flow
model: authentik_flows.flowstagebinding
# 8. BIND PROMPT STAGE TO FLOW (order 10)
- attrs:
evaluate_on_plan: true
re_evaluate_policies: false
identifiers:
order: 10
stage: !KeyOf prompt-stage
target: !KeyOf flow
model: authentik_flows.flowstagebinding
# 9. BIND USER WRITE STAGE TO FLOW (order 20)
- attrs:
evaluate_on_plan: true
re_evaluate_policies: false
identifiers:
order: 20
stage: !KeyOf user-write-stage
target: !KeyOf flow
model: authentik_flows.flowstagebinding
# Note: Brand enrollment flow configuration must be done via API
# The tenant model is restricted in blueprints
# Use: PATCH /api/v3/core/tenants/{tenant_uuid}/
# Body: {"flow_enrollment": "<flow_uuid>"}

View file

@ -1,25 +0,0 @@
version: 1
metadata:
name: invitation-flow-configuration
labels:
blueprints.goauthentik.io/description: "Configure invitation stage for enrollment"
blueprints.goauthentik.io/instantiate: "true"
entries:
# 1. CREATE INVITATION STAGE
- model: authentik_stages_invitation.invitationstage
identifiers:
name: default-enrollment-invitation
id: invitation-stage
attrs:
continue_flow_without_invitation: true
# 2. BIND INVITATION STAGE TO ENROLLMENT FLOW
- model: authentik_flows.flowstagebinding
identifiers:
target: !Find [authentik_flows.flow, [designation, enrollment]]
stage: !KeyOf invitation-stage
order: 0
attrs:
evaluate_on_plan: true
re_evaluate_policies: false

View file

@ -1,7 +0,0 @@
---
# Handlers for Authentik role
- name: Restart Authentik
community.docker.docker_compose_v2:
project_src: "{{ authentik_config_dir }}"
state: restarted

View file

@ -1,25 +0,0 @@
---
# Bootstrap tasks for initial Authentik configuration
- name: Display bootstrap status
debug:
msg: |
========================================
Authentik is running!
========================================
URL: https://{{ authentik_domain }}
Bootstrap Configuration:
✓ Admin user 'akadmin' automatically created
✓ Password: (stored in secrets file)
✓ API token: (stored in secrets file)
The admin account and API token are automatically configured
via AUTHENTIK_BOOTSTRAP_* environment variables.
Note: HTTPS access requires DNS propagation and SSL certificate
issuance. This typically takes 1-5 minutes after deployment.
Authentik is accessible internally and the deployment will continue.
Documentation: https://docs.goauthentik.io

View file

@ -1,43 +0,0 @@
---
# Docker Compose setup for Authentik
- name: Create Authentik configuration directory
file:
path: "{{ authentik_config_dir }}"
state: directory
mode: '0755'
- name: Create Authentik internal network
community.docker.docker_network:
name: "{{ authentik_network }}"
driver: bridge
internal: yes
- name: Deploy Authentik Docker Compose configuration
template:
src: docker-compose.authentik.yml.j2
dest: "{{ authentik_config_dir }}/docker-compose.yml"
mode: '0644'
notify: Restart Authentik
- name: Start Authentik services
community.docker.docker_compose_v2:
project_src: "{{ authentik_config_dir }}"
state: present
- name: Wait for Authentik database to be ready
community.docker.docker_container_info:
name: authentik-db
register: db_container
until: db_container.container.State.Health.Status == "healthy"
retries: 30
delay: 5
changed_when: false
- name: Wait for Authentik server to be healthy (via docker)
shell: "docker exec authentik-server curl -s -o /dev/null -w '%{http_code}' http://localhost:9000/"
register: authentik_health
until: authentik_health.stdout in ['200', '302']
retries: 30
delay: 10
changed_when: false

View file

@ -1,22 +0,0 @@
---
# Display Authentik email configuration status
# Email settings are configured via docker-compose environment variables
- name: Display Authentik email configuration status
debug:
msg: |
========================================
Authentik Email Configuration
========================================
Email is configured via Docker Compose environment variables:
AUTHENTIK_EMAIL__HOST: smtp.eu.mailgun.org
AUTHENTIK_EMAIL__FROM: {{ inventory_hostname }}@mg.vrije.cloud
Status: ✓ Configured
Authentik can now send:
- Password reset emails
- User invitation emails
- Notification emails
========================================

View file

@ -1,126 +0,0 @@
---
# Configure Authentik flows (invitation, 2FA) via Blueprints
- name: Use bootstrap token for API access
set_fact:
authentik_api_token: "{{ client_secrets.authentik_bootstrap_token }}"
- name: Wait for Authentik API to be ready
shell: |
i=1
while [ $i -le 30 ]; do
if docker exec authentik-server curl -sf -H "Authorization: Bearer {{ authentik_api_token }}" http://localhost:9000/api/v3/flows/instances/ > /dev/null 2>&1; then
echo "Authentik API is ready"
exit 0
fi
echo "Waiting for Authentik API... attempt $i/30"
sleep 5
i=$((i+1))
done
exit 1
register: api_wait
changed_when: false
- name: Create blueprints directory on server
file:
path: "{{ authentik_config_dir }}/blueprints"
state: directory
mode: '0755'
- name: Copy flow blueprints to server
copy:
src: "{{ item }}"
dest: "{{ authentik_config_dir }}/blueprints/{{ item }}"
mode: '0644'
loop:
- custom-flows.yaml
- enrollment-flow.yaml
register: blueprints_copied
- name: Copy blueprints into authentik-worker container
shell: |
docker cp "{{ authentik_config_dir }}/blueprints/{{ item }}" authentik-worker:/blueprints/{{ item }}
loop:
- custom-flows.yaml
- enrollment-flow.yaml
when: blueprints_copied.changed
- name: Copy blueprints into authentik-server container
shell: |
docker cp "{{ authentik_config_dir }}/blueprints/{{ item }}" authentik-server:/blueprints/{{ item }}
loop:
- custom-flows.yaml
- enrollment-flow.yaml
when: blueprints_copied.changed
- name: Wait for blueprint to be discovered and applied
shell: |
echo "Waiting for blueprint to be discovered and applied..."
sleep 10
# Check if blueprint instance was created
i=1
while [ $i -le 24 ]; do
result=$(docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
'http://localhost:9000/api/v3/managed/blueprints/' 2>/dev/null || echo '')
if echo "$result" | grep -q 'custom-flow-configuration'; then
echo "Blueprint instance found"
# Check if it has been applied successfully
if echo "$result" | grep -A 10 'custom-flow-configuration' | grep -q 'successful'; then
echo "Blueprint applied successfully"
exit 0
else
echo "Blueprint found but not yet applied, waiting..."
fi
else
echo "Waiting for blueprint discovery... attempt $i/24"
fi
sleep 5
i=$((i+1))
done
echo "Blueprint may still be applying, continuing..."
exit 0
register: blueprint_wait
changed_when: false
- name: Verify invitation stage was created
shell: |
docker exec authentik-server curl -sf -H "Authorization: Bearer {{ authentik_api_token }}" \
"http://localhost:9000/api/v3/stages/all/" | \
python3 -c "import sys, json; data = json.load(sys.stdin); stages = [s for s in data['results'] if 'invitation' in s.get('name', '').lower()]; print(json.dumps({'found': len(stages) > 0, 'count': len(stages)}))"
register: invitation_check
changed_when: false
failed_when: false
- name: Display flows configuration status
debug:
msg: |
========================================
Authentik Flows Configuration
========================================
Configuration Method: YAML Blueprints
Blueprints Deployed:
- /blueprints/custom-flows.yaml (2FA enforcement)
- /blueprints/enrollment-flow.yaml (invitation-only registration)
✓ Blueprints Deployed: {{ blueprints_copied.changed }}
✓ Blueprints Applied: {{ 'Yes' if 'successfully' in blueprint_wait.stdout else 'In Progress' }}
Verification:
{{ invitation_check.stdout | default('Invitation stage: Checking...') }}
Note: Authentik applies blueprints asynchronously.
Changes should be visible within 1-2 minutes.
Recovery flows must be configured manually in Authentik admin UI.
Flow URLs:
- Enrollment: https://{{ authentik_domain }}/if/flow/default-enrollment-flow/
Email configuration is active - emails sent via Mailgun SMTP.
========================================

View file

@ -1,112 +0,0 @@
---
# Configure invitation stage for enrollment flow
- name: Use bootstrap token for API access
set_fact:
authentik_api_token: "{{ client_secrets.authentik_bootstrap_token }}"
- name: Wait for Authentik API to be ready
uri:
url: "https://{{ authentik_domain }}/api/v3/root/config/"
method: GET
validate_certs: no
status_code: 200
register: api_result
until: api_result.status == 200
retries: 12
delay: 5
ignore_errors: yes
failed_when: false
- name: Create blueprints directory on server
file:
path: /opt/config/authentik/blueprints
state: directory
mode: '0755'
when: api_result.status is defined and api_result.status == 200
- name: Copy public enrollment flow blueprint to server
copy:
src: enrollment-flow.yaml
dest: /opt/config/authentik/blueprints/enrollment-flow.yaml
mode: '0644'
register: enrollment_blueprint_copied
when: api_result.status is defined and api_result.status == 200
- name: Copy enrollment blueprint into authentik-worker container
shell: |
docker cp /opt/config/authentik/blueprints/enrollment-flow.yaml authentik-worker:/blueprints/enrollment-flow.yaml
when: api_result.status is defined and api_result.status == 200
- name: Copy enrollment blueprint into authentik-server container
shell: |
docker cp /opt/config/authentik/blueprints/enrollment-flow.yaml authentik-server:/blueprints/enrollment-flow.yaml
when: api_result.status is defined and api_result.status == 200
- name: Wait for enrollment blueprint to be discovered and applied
shell: |
echo "Waiting for public enrollment blueprint to be discovered and applied..."
sleep 10
# Check if blueprint instance was created
i=1
while [ $i -le 24 ]; do
result=$(docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
'http://localhost:9000/api/v3/managed/blueprints/' 2>/dev/null || echo '')
if echo "$result" | grep -q 'public-enrollment-flow'; then
echo "Blueprint instance found"
if echo "$result" | grep -A 10 'public-enrollment-flow' | grep -q 'successful'; then
echo "Blueprint applied successfully"
exit 0
fi
fi
sleep 5
i=$((i+1))
done
echo "Blueprint deployment in progress (may take 1-2 minutes)"
register: enrollment_blueprint_result
changed_when: false
when: api_result.status is defined and api_result.status == 200
- name: Verify enrollment flow was created
shell: |
docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
'http://localhost:9000/api/v3/flows/instances/?slug=default-enrollment-flow' | \
python3 -c "import sys, json; d = json.load(sys.stdin); print(json.dumps({'found': len(d.get('results', [])) > 0, 'count': len(d.get('results', []))}))"
register: enrollment_flow_check
changed_when: false
failed_when: false
when: api_result.status is defined and api_result.status == 200
- name: Display public enrollment flow configuration status
debug:
msg: |
========================================
Authentik Public Enrollment Flow
========================================
Configuration Method: YAML Blueprints
Blueprint File: /blueprints/enrollment-flow.yaml
✓ Blueprint Deployed: {{ enrollment_blueprint_copied.changed | default(false) }}
✓ Blueprint Applied: {{ 'In Progress' if (enrollment_blueprint_result is defined and enrollment_blueprint_result.rc is defined and enrollment_blueprint_result.rc != 0) else 'Complete' }}
Verification: {{ enrollment_flow_check.stdout | default('{}') }}
Features:
- Invitation-only enrollment (requires valid invitation token)
- User prompts: username, name, email, password
- Automatic user creation and login
Note: Brand enrollment flow is NOT auto-configured (API restriction).
Flow is accessible via direct URL even without brand configuration.
To use enrollment:
1. Create invitation: Directory > Invitations > Create Invitation
2. Share invitation link: https://{{ authentik_domain }}/if/flow/default-enrollment-flow/?itoken=TOKEN
To verify:
- Login to https://{{ authentik_domain }}
- Check Admin > Flows for "default-enrollment-flow"
- Test enrollment URL: https://{{ authentik_domain }}/if/flow/default-enrollment-flow/
========================================
when: api_result.status is defined and api_result.status == 200

View file

@ -1,38 +0,0 @@
---
# Main tasks file for Authentik role
- name: Include Docker Compose setup
include_tasks: docker.yml
- name: Include bootstrap setup
include_tasks: bootstrap.yml
when: authentik_bootstrap | default(true)
- name: Include OIDC provider configuration
include_tasks: providers.yml
tags: ['authentik', 'oidc']
- name: Include email configuration
include_tasks: email.yml
when: mailgun_smtp_user is defined or (client_secrets.mailgun_smtp_user is defined and client_secrets.mailgun_smtp_user != "" and "PLACEHOLDER" not in client_secrets.mailgun_smtp_user)
tags: ['authentik', 'email']
- name: Include flows configuration (invitation, 2FA)
include_tasks: flows.yml
when: authentik_bootstrap | default(true)
tags: ['authentik', 'flows']
- name: Include MFA/2FA enforcement configuration
include_tasks: mfa.yml
when: authentik_bootstrap | default(true)
tags: ['authentik', 'mfa', '2fa']
- name: Include invitation stage configuration
include_tasks: invitation.yml
when: authentik_bootstrap | default(true)
tags: ['authentik', 'invitation']
- name: Include password recovery flow configuration
include_tasks: recovery.yml
when: authentik_bootstrap | default(true)
tags: ['authentik', 'recovery']

View file

@ -1,97 +0,0 @@
---
# Configure 2FA/MFA enforcement in Authentik
- name: Use bootstrap token for API access
set_fact:
authentik_api_token: "{{ client_secrets.authentik_bootstrap_token }}"
- name: Get TOTP setup stage UUID
shell: |
docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
'http://localhost:9000/api/v3/stages/authenticator/totp/?name=default-authenticator-totp-setup'
register: totp_stage_result
changed_when: false
- name: Parse TOTP stage UUID
set_fact:
totp_stage_pk: "{{ (totp_stage_result.stdout | from_json).results[0].pk }}"
- name: Get current MFA validation stage configuration
shell: |
docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
'http://localhost:9000/api/v3/stages/authenticator/validate/?name=default-authentication-mfa-validation'
register: mfa_stage_result
changed_when: false
- name: Parse MFA validation stage
set_fact:
mfa_stage: "{{ (mfa_stage_result.stdout | from_json).results[0] }}"
- name: Check if MFA enforcement needs configuration
set_fact:
mfa_needs_update: "{{ mfa_stage.not_configured_action != 'configure' or totp_stage_pk not in (mfa_stage.configuration_stages | default([])) }}"
- name: Create Python script for MFA enforcement
copy:
content: |
import sys, json, urllib.request
base_url = "http://localhost:9000"
token = "{{ authentik_api_token }}"
stage_pk = "{{ mfa_stage.pk }}"
totp_stage_pk = "{{ totp_stage_pk }}"
# Prepare the update payload
payload = {
"name": "{{ mfa_stage.name }}",
"not_configured_action": "configure",
"device_classes": ["totp", "webauthn", "static"],
"configuration_stages": [totp_stage_pk]
}
# Make PATCH request to update the stage
url = f"{base_url}/api/v3/stages/authenticator/validate/{stage_pk}/"
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, method='PATCH')
req.add_header('Authorization', f'Bearer {token}')
req.add_header('Content-Type', 'application/json')
try:
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read())
print(json.dumps({"success": True, "message": "MFA enforcement configured", "not_configured_action": result.get("not_configured_action")}))
except urllib.error.HTTPError as e:
error_data = e.read().decode()
print(json.dumps({"success": False, "error": error_data}), file=sys.stderr)
sys.exit(1)
dest: /tmp/configure_mfa.py
mode: '0755'
when: mfa_needs_update
- name: Configure MFA enforcement via API
shell: docker exec -i authentik-server python3 < /tmp/configure_mfa.py
register: mfa_config_result
when: mfa_needs_update
- name: Cleanup MFA script
file:
path: /tmp/configure_mfa.py
state: absent
when: mfa_needs_update
- name: Display MFA configuration status
debug:
msg: |
========================================
Authentik 2FA/MFA Enforcement
========================================
Status: {% if mfa_needs_update %}✓ Configured{% else %}✓ Already configured{% endif %}
Configuration:
- Not configured action: Force user to configure authenticator
- Supported methods: TOTP, WebAuthn, Static backup codes
- Configuration stage: default-authenticator-totp-setup
Users will be required to set up 2FA on their next login.
========================================

View file

@ -1,76 +0,0 @@
---
# Create OIDC providers in Authentik for application integration
- name: Use bootstrap token for API access
set_fact:
authentik_api_token: "{{ client_secrets.authentik_bootstrap_token }}"
- name: Create Python script for OIDC provider setup
copy:
content: |
import sys, json, urllib.request
base_url, token = "http://localhost:9000", "{{ authentik_api_token }}"
def req(p, m='GET', d=None):
r = urllib.request.Request(f"{base_url}{p}", json.dumps(d).encode() if d else None, {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, method=m)
try:
with urllib.request.urlopen(r, timeout=30) as resp: return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e: return e.code, json.loads(e.read()) if e.headers.get('Content-Type', '').startswith('application/json') else {'error': e.read().decode()}
s, d = req('/api/v3/flows/instances/')
auth_flow = next((f['pk'] for f in d.get('results', []) if f.get('slug') == 'default-authorization-flow' or f.get('designation') == 'authorization'), None)
inval_flow = next((f['pk'] for f in d.get('results', []) if f.get('slug') == 'default-invalidation-flow' or f.get('designation') == 'invalidation'), None)
s, d = req('/api/v3/crypto/certificatekeypairs/')
key = d.get('results', [{}])[0].get('pk') if d.get('results') else None
if not auth_flow or not key: print(json.dumps({'error': 'Config missing'}), file=sys.stderr); sys.exit(1)
s, prov = req('/api/v3/providers/oauth2/', 'POST', {'name': 'Nextcloud', 'authorization_flow': auth_flow, 'invalidation_flow': inval_flow, 'client_type': 'confidential', 'redirect_uris': [{'matching_mode': 'strict', 'url': 'https://{{ nextcloud_domain }}/apps/user_oidc/code'}], 'signing_key': key, 'sub_mode': 'hashed_user_id', 'include_claims_in_id_token': True})
if s != 201: print(json.dumps({'error': 'Provider failed', 'details': prov}), file=sys.stderr); sys.exit(1)
s, app = req('/api/v3/core/applications/', 'POST', {'name': 'Nextcloud', 'slug': 'nextcloud', 'provider': prov['pk'], 'meta_launch_url': 'https://nextcloud.{{ client_domain }}'})
if s != 201: print(json.dumps({'error': 'App failed', 'details': app}), file=sys.stderr); sys.exit(1)
print(json.dumps({'success': True, 'provider_id': prov['pk'], 'application_id': app['pk'], 'client_id': prov['client_id'], 'client_secret': prov['client_secret'], 'discovery_uri': f"https://{{ authentik_domain }}/application/o/nextcloud/.well-known/openid-configuration", 'issuer': f"https://{{ authentik_domain }}/application/o/nextcloud/"}))
dest: /tmp/create_oidc.py
mode: '0755'
- name: Create Nextcloud OIDC provider in Authentik
shell: docker exec -i authentik-server python3 < /tmp/create_oidc.py
register: oidc_provider_result
failed_when: false
- name: Cleanup OIDC script
file:
path: /tmp/create_oidc.py
state: absent
- name: Parse OIDC provider credentials
set_fact:
oidc_credentials: "{{ oidc_provider_result.stdout | from_json }}"
when: oidc_provider_result.rc == 0
- name: Display OIDC provider creation result
debug:
msg: |
OIDC Provider Created Successfully!
Client ID: {{ oidc_credentials.client_id }}
Discovery URI: {{ oidc_credentials.discovery_uri }}
These credentials will be automatically configured in Nextcloud.
when:
- oidc_credentials is defined
- oidc_credentials.success | default(false)
- name: Save OIDC credentials to temporary file for Nextcloud configuration
copy:
content: "{{ oidc_credentials | to_json }}"
dest: "/tmp/authentik_oidc_credentials.json"
mode: '0600'
when:
- oidc_credentials is defined
- oidc_credentials.success | default(false)
- name: Display error if OIDC provider creation failed
debug:
msg: |
ERROR: Failed to create OIDC provider
{{ oidc_provider_result.stdout | default('No output') }}
{{ oidc_provider_result.stderr | default('') }}
when: oidc_provider_result.rc != 0

View file

@ -1,85 +0,0 @@
---
# Configure Authentik password recovery flow
# This creates a complete recovery flow with email verification and password complexity validation
- name: Use bootstrap token for API access
set_fact:
authentik_api_token: "{{ client_secrets.authentik_bootstrap_token }}"
- name: Copy recovery flow creation script to server
copy:
src: create_recovery_flow.py
dest: /tmp/create_recovery_flow.py
mode: '0755'
- name: Copy recovery flow script into Authentik container
shell: docker cp /tmp/create_recovery_flow.py authentik-server:/tmp/create_recovery_flow.py
changed_when: false
- name: Create recovery flow via Authentik API
shell: |
docker exec authentik-server python3 /tmp/create_recovery_flow.py "{{ authentik_api_token }}" "{{ authentik_domain }}"
register: recovery_flow_result
failed_when: false
changed_when: "'Recovery Flow Configuration Complete' in recovery_flow_result.stdout"
- name: Cleanup recovery flow script from server
file:
path: /tmp/create_recovery_flow.py
state: absent
- name: Cleanup recovery flow script from container
shell: docker exec authentik-server rm -f /tmp/create_recovery_flow.py
changed_when: false
failed_when: false
- name: Parse recovery flow result
set_fact:
recovery_flow: "{{ recovery_flow_result.stdout | regex_search('\\{.*\\}', multiline=True) | from_json }}"
when: recovery_flow_result.rc == 0
failed_when: false
- name: Display recovery flow configuration result
debug:
msg: |
========================================
Authentik Password Recovery Flow
========================================
{% if recovery_flow is defined and recovery_flow.success | default(false) %}
Status: ✓ Configured Successfully
Recovery Flow UUID: {{ recovery_flow.recovery_flow_uuid }}
Password Policy UUID: {{ recovery_flow.password_complexity_uuid }}
Features:
- Password complexity: 12+ chars, mixed case, digit, symbol
- Recovery email with 30-minute expiry token
- Username + password on same login page
- "Forgot password?" link on login page
Test Recovery Flow:
1. Go to: https://{{ authentik_domain }}/if/flow/default-authentication-flow/
2. Click "Forgot password?" link
3. Enter username or email
4. Check email for recovery link (sent via Mailgun)
5. Set new password (must meet complexity requirements)
========================================
{% else %}
Status: ⚠ Configuration incomplete or failed
This is non-critical - recovery flow can be configured manually.
To configure manually:
1. Login to https://{{ authentik_domain }}
2. Go to Admin > Flows & Stages
3. Create recovery flow with email verification
Details: {{ recovery_flow_result.stdout | default('No output') }}
========================================
{% endif %}
- name: Set recovery flow status fact
set_fact:
recovery_flow_configured: "{{ recovery_flow is defined and recovery_flow.success | default(false) }}"

View file

@ -1,158 +0,0 @@
services:
authentik-db:
image: postgres:16-alpine
container_name: authentik-db
restart: unless-stopped
environment:
POSTGRES_DB: "{{ authentik_db_name }}"
POSTGRES_USER: "{{ authentik_db_user }}"
POSTGRES_PASSWORD: "{{ client_secrets.authentik_db_password }}"
volumes:
- authentik-db-data:/var/lib/postgresql/data
networks:
- {{ authentik_network }}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d {{ authentik_db_name }} -U {{ authentik_db_user }}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
deploy:
resources:
limits:
memory: 512M
cpus: "0.5"
authentik-server:
image: {{ authentik_image }}:{{ authentik_version }}
container_name: authentik-server
restart: unless-stopped
command: server
environment:
# PostgreSQL connection
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__NAME: "{{ authentik_db_name }}"
AUTHENTIK_POSTGRESQL__USER: "{{ authentik_db_user }}"
AUTHENTIK_POSTGRESQL__PASSWORD: "{{ client_secrets.authentik_db_password }}"
# Secret key for encryption
AUTHENTIK_SECRET_KEY: "{{ client_secrets.authentik_secret_key }}"
# Error reporting (optional)
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
# Branding
AUTHENTIK_BRANDING__TITLE: "{{ client_name | title }} SSO"
# Email configuration
{% if mailgun_smtp_user is defined or (client_secrets.mailgun_smtp_user is defined and client_secrets.mailgun_smtp_user != "" and "PLACEHOLDER" not in client_secrets.mailgun_smtp_user) %}
AUTHENTIK_EMAIL__HOST: "smtp.eu.mailgun.org"
AUTHENTIK_EMAIL__PORT: "587"
AUTHENTIK_EMAIL__USERNAME: "{{ mailgun_smtp_user | default(client_secrets.mailgun_smtp_user) }}"
AUTHENTIK_EMAIL__PASSWORD: "{{ mailgun_smtp_password | default(client_secrets.mailgun_smtp_password) }}"
AUTHENTIK_EMAIL__USE_TLS: "true"
AUTHENTIK_EMAIL__FROM: "Vrije Cloud <{{ inventory_hostname }}@mg.vrije.cloud>"
{% else %}
# Email not configured - set mailgun_smtp_user/password in secrets
{% endif %}
volumes:
- authentik-media:/media
- authentik-templates:/templates
networks:
- {{ authentik_traefik_network }}
- {{ authentik_network }}
depends_on:
authentik-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.authentik.rule=Host(`{{ authentik_domain }}`)"
- "traefik.http.routers.authentik.tls=true"
- "traefik.http.routers.authentik.tls.certresolver=letsencrypt"
- "traefik.http.routers.authentik.entrypoints=websecure"
- "traefik.http.services.authentik.loadbalancer.server.port={{ authentik_http_port }}"
# Security headers
- "traefik.http.routers.authentik.middlewares=authentik-headers"
- "traefik.http.middlewares.authentik-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.authentik-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.authentik-headers.headers.stsPreload=true"
deploy:
resources:
limits:
memory: 1G
cpus: "1.0"
authentik-worker:
image: {{ authentik_image }}:{{ authentik_version }}
container_name: authentik-worker
restart: unless-stopped
command: worker
environment:
# PostgreSQL connection
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__NAME: "{{ authentik_db_name }}"
AUTHENTIK_POSTGRESQL__USER: "{{ authentik_db_user }}"
AUTHENTIK_POSTGRESQL__PASSWORD: "{{ client_secrets.authentik_db_password }}"
# Secret key for encryption (must match server)
AUTHENTIK_SECRET_KEY: "{{ client_secrets.authentik_secret_key }}"
# Error reporting (optional)
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
# Bootstrap configuration (only used on first startup)
AUTHENTIK_BOOTSTRAP_PASSWORD: "{{ client_secrets.authentik_bootstrap_password }}"
AUTHENTIK_BOOTSTRAP_TOKEN: "{{ client_secrets.authentik_bootstrap_token }}"
AUTHENTIK_BOOTSTRAP_EMAIL: "{{ client_secrets.authentik_bootstrap_email | default('admin@' + client_domain) }}"
# Email configuration (must match server)
{% if mailgun_smtp_user is defined or (client_secrets.mailgun_smtp_user is defined and client_secrets.mailgun_smtp_user != "" and "PLACEHOLDER" not in client_secrets.mailgun_smtp_user) %}
AUTHENTIK_EMAIL__HOST: "smtp.eu.mailgun.org"
AUTHENTIK_EMAIL__PORT: "587"
AUTHENTIK_EMAIL__USERNAME: "{{ mailgun_smtp_user | default(client_secrets.mailgun_smtp_user) }}"
AUTHENTIK_EMAIL__PASSWORD: "{{ mailgun_smtp_password | default(client_secrets.mailgun_smtp_password) }}"
AUTHENTIK_EMAIL__USE_TLS: "true"
AUTHENTIK_EMAIL__FROM: "Vrije Cloud <{{ inventory_hostname }}@mg.vrije.cloud>"
{% endif %}
volumes:
- authentik-media:/media
- authentik-templates:/templates
networks:
- {{ authentik_traefik_network }}
- {{ authentik_network }}
depends_on:
authentik-db:
condition: service_healthy
deploy:
resources:
limits:
memory: 512M
cpus: "0.5"
volumes:
authentik-db-data:
driver: local
authentik-media:
driver: local
authentik-templates:
driver: local
networks:
{{ authentik_traefik_network }}:
external: true
{{ authentik_network }}:
driver: bridge
internal: true

View file

@ -1,11 +1,6 @@
---
# Handlers for common role
- name: Restart systemd-resolved
service:
name: systemd-resolved
state: restarted
- name: Restart SSH
service:
name: ssh

View file

@ -1,28 +1,6 @@
---
# Main tasks for common role - base system setup and hardening
- name: Ensure systemd-resolved config directory exists
file:
path: /etc/systemd/resolved.conf.d
state: directory
mode: '0755'
tags: [dns]
- name: Configure DNS (systemd-resolved)
copy:
dest: /etc/systemd/resolved.conf.d/dns_servers.conf
content: |
[Resolve]
DNS=8.8.8.8 8.8.4.4
FallbackDNS=1.1.1.1 1.0.0.1
mode: '0644'
notify: Restart systemd-resolved
tags: [dns]
- name: Flush handlers (apply DNS config immediately)
meta: flush_handlers
tags: [dns]
- name: Update apt cache
apt:
update_cache: yes

View file

@ -1,39 +0,0 @@
---
# Diun default configuration
diun_version: "latest"
diun_schedule: "0 6 * * *" # Daily at 6am UTC
diun_log_level: "info"
diun_watch_workers: 10
# Notification configuration
diun_notif_enabled: true
diun_notif_type: "webhook" # Options: webhook, slack, discord, email, gotify
diun_webhook_endpoint: "" # Set per environment or via secrets
diun_webhook_method: "POST"
diun_webhook_headers: {}
# Optional: Slack notification
diun_slack_webhook_url: ""
# Optional: Email notification (Mailgun)
# Note: Uses per-client SMTP credentials from mailgun role
diun_email_enabled: true
diun_smtp_host: "smtp.eu.mailgun.org"
diun_smtp_port: 587
diun_smtp_from: "{{ client_name }}@mg.vrije.cloud"
diun_smtp_to: "pieter@postxsociety.org"
# Which containers to watch
diun_watch_all: true
diun_exclude_containers: []
# Don't send notifications on first check (prevents spam on initial run)
diun_first_check_notif: false
# Optional: Matrix notification
diun_matrix_enabled: false
diun_matrix_homeserver_url: "" # e.g., https://matrix.postxsociety.cloud
diun_matrix_user: "" # e.g., @diun:matrix.postxsociety.cloud
diun_matrix_password: "" # Bot user password (if using password auth)
diun_matrix_access_token: "" # Bot access token (preferred over password)
diun_matrix_room_id: "" # e.g., !abc123:matrix.postxsociety.cloud

View file

@ -1,5 +0,0 @@
---
- name: Restart Diun
community.docker.docker_compose_v2:
project_src: /opt/docker/diun
state: restarted

View file

@ -1,57 +0,0 @@
---
- name: Set SMTP credentials from mailgun role facts or client_secrets
set_fact:
diun_smtp_username_final: "{{ mailgun_smtp_user | default(client_secrets.mailgun_smtp_user | default(client_name ~ '@mg.vrije.cloud')) }}"
diun_smtp_password_final: "{{ mailgun_smtp_password | default(client_secrets.mailgun_smtp_password | default('')) }}"
when: mailgun_smtp_user is defined or client_secrets.mailgun_smtp_user is defined or client_name is defined
no_log: true
- name: Create monitoring Docker network
community.docker.docker_network:
name: monitoring
state: present
- name: Create Diun directory
file:
path: /opt/docker/diun
state: directory
mode: '0755'
- name: Create Diun data directory
file:
path: /opt/docker/diun/data
state: directory
mode: '0755'
- name: Deploy Diun configuration
template:
src: diun.yml.j2
dest: /opt/docker/diun/diun.yml
mode: '0644'
notify: Restart Diun
- name: Deploy Diun docker-compose.yml
template:
src: docker-compose.yml.j2
dest: /opt/docker/diun/docker-compose.yml
mode: '0644'
notify: Restart Diun
- name: Start Diun container
community.docker.docker_compose_v2:
project_src: /opt/docker/diun
state: present
pull: always
register: diun_deploy
- name: Wait for Diun to be healthy
shell: docker inspect --format='{{"{{"}} .State.Status {{"}}"}}' diun
register: diun_status
until: diun_status.stdout == "running"
retries: 5
delay: 3
changed_when: false
- name: Display Diun status
debug:
msg: "Diun is {{ diun_status.stdout }} on {{ inventory_hostname }}"

View file

@ -1,77 +0,0 @@
---
# Diun configuration for {{ inventory_hostname }}
# Documentation: https://crazymax.dev/diun/
db:
path: /data/diun.db
watch:
workers: {{ diun_watch_workers }}
schedule: "{{ diun_schedule }}"
firstCheckNotif: {{ diun_first_check_notif | lower }}
defaults:
watchRepo: {{ diun_watch_repo | default(true) | lower }}
notifyOn:
- new
- update
{% if diun_docker_hub_username is defined and diun_docker_hub_password is defined %}
regopts:
- selector: image
username: {{ diun_docker_hub_username }}
password: {{ diun_docker_hub_password }}
{% endif %}
providers:
docker:
watchByDefault: {{ diun_watch_all | lower }}
{% if diun_exclude_containers | length > 0 %}
excludeContainers:
{% for container in diun_exclude_containers %}
- {{ container }}
{% endfor %}
{% endif %}
notif:
{% if diun_notif_enabled and diun_notif_type == 'webhook' and diun_webhook_endpoint %}
webhook:
endpoint: {{ diun_webhook_endpoint }}
method: {{ diun_webhook_method }}
timeout: 10s
{% if diun_webhook_headers | length > 0 %}
headers:
{% for key, value in diun_webhook_headers.items() %}
{{ key }}: {{ value }}
{% endfor %}
{% endif %}
{% endif %}
{% if diun_slack_webhook_url %}
slack:
webhookURL: {{ diun_slack_webhook_url }}
{% endif %}
{% if diun_email_enabled and diun_smtp_username_final is defined and diun_smtp_password_final is defined and diun_smtp_password_final != '' %}
mail:
host: {{ diun_smtp_host }}
port: {{ diun_smtp_port }}
ssl: false
insecureSkipVerify: false
username: {{ diun_smtp_username_final }}
password: {{ diun_smtp_password_final }}
from: {{ diun_smtp_from }}
to: {{ diun_smtp_to }}
{% endif %}
{% if diun_matrix_enabled and diun_matrix_homeserver_url and diun_matrix_user and diun_matrix_room_id %}
matrix:
homeserverURL: {{ diun_matrix_homeserver_url }}
user: "{{ diun_matrix_user }}"
{% if diun_matrix_access_token %}
accessToken: {{ diun_matrix_access_token }}
{% elif diun_matrix_password %}
password: "{{ diun_matrix_password }}"
{% endif %}
roomID: "{{ diun_matrix_room_id }}"
{% endif %}

View file

@ -1,24 +0,0 @@
version: '3.8'
services:
diun:
image: crazymax/diun:{{ diun_version }}
container_name: diun
restart: unless-stopped
command: serve
volumes:
- "./data:/data"
- "./diun.yml:/diun.yml:ro"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
environment:
- TZ=UTC
- LOG_LEVEL={{ diun_log_level }}
labels:
- "diun.enable=true"
networks:
- monitoring
networks:
monitoring:
name: monitoring
external: true

View file

@ -66,13 +66,3 @@
path: /opt/docker
state: directory
mode: '0755'
- name: Login to Docker Hub (if credentials provided)
community.docker.docker_login:
username: "{{ shared_secrets.docker_hub_username }}"
password: "{{ shared_secrets.docker_hub_password }}"
state: present
when:
- shared_secrets.docker_hub_username is defined
- shared_secrets.docker_hub_password is defined
tags: [docker, docker-login]

View file

@ -1,38 +0,0 @@
---
# Uptime Kuma monitoring registration
kuma_enabled: true
kuma_url: "https://status.vrije.cloud"
# Authentication - credentials loaded from shared_secrets in tasks/main.yml
# Uses username/password (required for Socket.io API used by Python library)
kuma_username: "" # Loaded from shared_secrets.kuma_username
kuma_password: "" # Loaded from shared_secrets.kuma_password
# Monitors to create for each client
kuma_monitors:
- name: "{{ client_name }} - Authentik SSO"
type: "http"
url: "https://auth.{{ client_domain }}"
method: "GET"
interval: 60
maxretries: 3
retry_interval: 60
expected_status: "200,302"
- name: "{{ client_name }} - Nextcloud"
type: "http"
url: "https://nextcloud.{{ client_domain }}"
method: "GET"
interval: 60
maxretries: 3
retry_interval: 60
expected_status: "200,302"
- name: "{{ client_name }} - Collabora Office"
type: "http"
url: "https://office.{{ client_domain }}"
method: "GET"
interval: 60
maxretries: 3
retry_interval: 60
expected_status: "200"

View file

@ -1,49 +0,0 @@
---
# Register client services with Uptime Kuma monitoring
# Uses uptime-kuma-api Python library with Socket.io
- name: Set Kuma credentials from shared secrets
set_fact:
kuma_username: "{{ shared_secrets.kuma_username | default('') }}"
kuma_password: "{{ shared_secrets.kuma_password | default('') }}"
when: shared_secrets is defined
- name: Check if Kuma monitoring is enabled
set_fact:
kuma_registration_enabled: "{{ (kuma_enabled | bool) and (kuma_url | length > 0) and (kuma_username | length > 0) and (kuma_password | length > 0) }}"
- name: Kuma registration block
when: kuma_registration_enabled
delegate_to: localhost
become: false
block:
- name: Ensure uptime-kuma-api Python package is installed
pip:
name: uptime-kuma-api
state: present
- name: Create Kuma registration script
template:
src: register_monitors.py.j2
dest: /tmp/kuma_register_{{ client_name }}.py
mode: '0700'
- name: Register monitors with Uptime Kuma
command: "{{ ansible_playbook_python }} /tmp/kuma_register_{{ client_name }}.py"
register: kuma_result
changed_when: "'Added' in kuma_result.stdout or 'Updated' in kuma_result.stdout"
failed_when: kuma_result.rc != 0
- name: Display Kuma registration result
debug:
msg: "{{ kuma_result.stdout_lines }}"
- name: Cleanup registration script
file:
path: /tmp/kuma_register_{{ client_name }}.py
state: absent
- name: Skip Kuma registration message
debug:
msg: "Kuma monitoring registration skipped (not enabled or missing credentials)"
when: not kuma_registration_enabled

View file

@ -1,128 +0,0 @@
#!/usr/bin/env python3
"""
Uptime Kuma Monitor Registration Script
Auto-generated for client: {{ client_name }}
"""
import sys
from uptime_kuma_api import UptimeKumaApi, MonitorType
# Configuration
KUMA_URL = "{{ kuma_url }}"
KUMA_USERNAME = "{{ kuma_username | default('') }}"
KUMA_PASSWORD = "{{ kuma_password | default('') }}"
CLIENT_NAME = "{{ client_name }}"
CLIENT_DOMAIN = "{{ client_domain }}"
# Monitor definitions
MONITORS = {{ kuma_monitors | to_json }}
# Monitor type mapping
TYPE_MAP = {
"http": MonitorType.HTTP,
"https": MonitorType.HTTP,
"ping": MonitorType.PING,
"tcp": MonitorType.PORT,
"dns": MonitorType.DNS,
}
def main():
"""Register monitors with Uptime Kuma"""
# Check if credentials are provided
if not KUMA_USERNAME or not KUMA_PASSWORD:
print("⚠️ Kuma registration skipped: No credentials provided")
print("")
print("To enable automated monitor registration, add to your secrets:")
print(" kuma_username: your_username")
print(" kuma_password: your_password")
print("")
print("Note: API keys (uk1_*) are only for REST endpoints, not monitor management")
print("Manual registration required at: https://status.vrije.cloud")
sys.exit(0) # Exit with success (not a failure, just skipped)
try:
# Connect to Uptime Kuma (Socket.io connection)
print(f"🔌 Connecting to Uptime Kuma at {KUMA_URL}...")
api = UptimeKumaApi(KUMA_URL)
# Login with username/password
print(f"🔐 Authenticating as {KUMA_USERNAME}...")
api.login(KUMA_USERNAME, KUMA_PASSWORD)
# Get existing monitors
print("📋 Fetching existing monitors...")
existing_monitors = api.get_monitors()
existing_names = {m['name']: m['id'] for m in existing_monitors}
# Register each monitor
added_count = 0
updated_count = 0
skipped_count = 0
for monitor_config in MONITORS:
monitor_name = monitor_config['name']
monitor_type_str = monitor_config.get('type', 'http').lower()
monitor_type = TYPE_MAP.get(monitor_type_str, MonitorType.HTTP)
# Build monitor parameters
params = {
'type': monitor_type,
'name': monitor_name,
'interval': monitor_config.get('interval', 60),
'maxretries': monitor_config.get('maxretries', 3),
'retryInterval': monitor_config.get('retry_interval', 60),
}
# Add type-specific parameters
if monitor_type == MonitorType.HTTP:
params['url'] = monitor_config['url']
params['method'] = monitor_config.get('method', 'GET')
if 'expected_status' in monitor_config:
params['accepted_statuscodes'] = monitor_config['expected_status'].split(',')
elif monitor_type == MonitorType.PING:
params['hostname'] = monitor_config.get('hostname', monitor_config.get('url', ''))
# Check if monitor already exists
if monitor_name in existing_names:
print(f"⚠️ Monitor '{monitor_name}' already exists (ID: {existing_monitors[monitor_name]})")
print(f" Skipping (update not implemented)")
skipped_count += 1
else:
print(f" Adding monitor: {monitor_name}")
try:
result = api.add_monitor(**params)
print(f" ✓ Added (ID: {result.get('monitorID', 'unknown')})")
added_count += 1
except Exception as e:
print(f" ✗ Failed: {e}")
# Disconnect
api.disconnect()
# Summary
print("")
print("=" * 60)
print(f"📊 Registration Summary for {CLIENT_NAME}:")
print(f" Added: {added_count}")
print(f" Skipped (already exist): {skipped_count}")
print(f" Total monitors: {len(MONITORS)}")
print("=" * 60)
if added_count > 0:
print(f"✅ Successfully registered {added_count} new monitor(s)")
except Exception as e:
print(f"❌ ERROR: Failed to register monitors: {e}")
print("")
print("Troubleshooting:")
print(f" 1. Verify Kuma is accessible: {KUMA_URL}")
print(" 2. Check username/password are correct")
print(" 3. Ensure uptime-kuma-api Python package is installed")
print(" 4. Check network connectivity from deployment machine")
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -1,64 +0,0 @@
---
# Delete Mailgun SMTP credential for a server
- name: Check if Mailgun API key is configured
set_fact:
mailgun_api_configured: "{{ client_secrets.mailgun_api_key is defined and client_secrets.mailgun_api_key != '' and 'PLACEHOLDER' not in client_secrets.mailgun_api_key }}"
- name: Delete SMTP credential for this server
block:
- name: Create Python script for Mailgun API credential deletion
copy:
content: |
import sys, json, urllib.request, urllib.parse
domain = "mg.vrije.cloud"
login = "{{ inventory_hostname }}@mg.vrije.cloud"
api_key = "{{ client_secrets.mailgun_api_key }}"
# Delete SMTP credential via Mailgun API (EU region)
url = f"https://api.eu.mailgun.net/v3/{domain}/credentials/{urllib.parse.quote(login)}"
req = urllib.request.Request(url, method='DELETE')
req.add_header('Authorization', f'Basic {__import__("base64").b64encode(f"api:{api_key}".encode()).decode()}')
try:
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read())
print(json.dumps({"success": True, "message": f"Deleted credential for {login}"}))
except urllib.error.HTTPError as e:
if e.code == 404:
print(json.dumps({"success": True, "message": f"Credential {login} does not exist (already deleted)"}))
else:
error_data = e.read().decode()
print(json.dumps({"success": False, "error": error_data}), file=sys.stderr)
sys.exit(1)
dest: /tmp/mailgun_delete_credential.py
mode: '0700'
delegate_to: localhost
- name: Execute Mailgun credential deletion
command: python3 /tmp/mailgun_delete_credential.py
register: mailgun_delete_result
changed_when: true
delegate_to: localhost
failed_when: false
- name: Cleanup deletion script
file:
path: /tmp/mailgun_delete_credential.py
state: absent
delegate_to: localhost
- name: Display deletion result
debug:
msg: |
========================================
Mailgun SMTP Credential Deleted
========================================
Server: {{ inventory_hostname }}
Email: {{ inventory_hostname }}@mg.vrije.cloud
Status: {{ (mailgun_delete_result.stdout | from_json).message }}
========================================
when: mailgun_api_configured

View file

@ -1,103 +0,0 @@
---
# Mailgun SMTP credential management via API
- name: Check if Mailgun API key is configured
set_fact:
mailgun_api_configured: "{{ client_secrets.mailgun_api_key is defined and client_secrets.mailgun_api_key != '' and 'PLACEHOLDER' not in client_secrets.mailgun_api_key }}"
smtp_credentials_exist: "{{ client_secrets.mailgun_smtp_user is defined and client_secrets.mailgun_smtp_user != '' and 'PLACEHOLDER' not in client_secrets.mailgun_smtp_user and client_secrets.mailgun_smtp_password is defined and client_secrets.mailgun_smtp_password != '' }}"
- name: Use existing SMTP credentials from secrets (skip API creation)
set_fact:
mailgun_smtp_user: "{{ client_secrets.mailgun_smtp_user }}"
mailgun_smtp_password: "{{ client_secrets.mailgun_smtp_password }}"
when: smtp_credentials_exist
no_log: true
- name: Create unique SMTP credential via Mailgun API
when: mailgun_api_configured and not smtp_credentials_exist
block:
- name: Generate secure SMTP password
shell: python3 -c "import secrets, string; print(''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32)))"
register: generated_password
changed_when: false
delegate_to: localhost
become: false
- name: Create Python script for Mailgun API credential creation
copy:
content: |
import sys, json, urllib.request
domain = "{{ 'mg.vrije.cloud' }}"
login = "{{ inventory_hostname }}@mg.vrije.cloud"
password = "{{ generated_password.stdout }}"
api_key = "{{ client_secrets.mailgun_api_key }}"
# Create SMTP credential via Mailgun API (EU region)
url = f"https://api.eu.mailgun.net/v3/domains/{domain}/credentials"
data = urllib.parse.urlencode({'login': login, 'password': password}).encode()
req = urllib.request.Request(url, data=data, method='POST')
req.add_header('Authorization', f'Basic {__import__("base64").b64encode(f"api:{api_key}".encode()).decode()}')
try:
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read())
print(json.dumps({"success": True, "login": login, "password": password, "message": result.get("message", "Created")}))
except urllib.error.HTTPError as e:
error_data = e.read().decode()
if "already exists" in error_data or e.code == 409:
# Credential already exists - update password instead
update_url = f"https://api.eu.mailgun.net/v3/domains/{domain}/credentials/{urllib.parse.quote(login)}"
update_data = urllib.parse.urlencode({'password': password}).encode()
update_req = urllib.request.Request(update_url, data=update_data, method='PUT')
update_req.add_header('Authorization', f'Basic {__import__("base64").b64encode(f"api:{api_key}".encode()).decode()}')
with urllib.request.urlopen(update_req, timeout=30) as resp:
result = json.loads(resp.read())
print(json.dumps({"success": True, "login": login, "password": password, "message": "Updated existing credential"}))
else:
print(json.dumps({"success": False, "error": error_data}), file=sys.stderr)
sys.exit(1)
dest: /tmp/mailgun_create_credential.py
mode: '0700'
delegate_to: localhost
become: false
- name: Execute Mailgun credential creation
command: python3 /tmp/mailgun_create_credential.py
register: mailgun_result
changed_when: true
delegate_to: localhost
become: false
no_log: false
- name: Parse Mailgun API result
set_fact:
mailgun_credential: "{{ mailgun_result.stdout | from_json }}"
no_log: true
- name: Cleanup credential creation script
file:
path: /tmp/mailgun_create_credential.py
state: absent
delegate_to: localhost
become: false
- name: Display credential creation result
debug:
msg: |
========================================
Mailgun SMTP Credential Created
========================================
Server: {{ inventory_hostname }}
Email: {{ mailgun_credential.login }}
Status: {{ mailgun_credential.message }}
This credential is unique to this server for security isolation.
========================================
- name: Store credentials in fact for email configuration
set_fact:
mailgun_smtp_user: "{{ mailgun_credential.login }}"
mailgun_smtp_password: "{{ mailgun_credential.password }}"
no_log: true

View file

@ -2,7 +2,7 @@
# Default variables for nextcloud role
# Nextcloud version
nextcloud_version: "latest" # Always use latest stable version
nextcloud_version: "30" # Latest stable version (uses major version tag)
# Database configuration
nextcloud_db_type: "pgsql"
@ -22,12 +22,10 @@ nextcloud_redis_host: "nextcloud-redis"
nextcloud_redis_port: "6379"
# OIDC configuration
# Note: OIDC credentials are provided dynamically by the Authentik role
# via /tmp/authentik_oidc_credentials.json during deployment
nextcloud_oidc_enabled: true
nextcloud_oidc_provider_url: "https://{{ authentik_domain }}"
nextcloud_oidc_client_id: "" # Set dynamically from Authentik
nextcloud_oidc_client_secret: "" # Set dynamically from Authentik
nextcloud_oidc_provider_url: "https://{{ zitadel_domain }}"
nextcloud_oidc_client_id: "" # Will be set after creating app in Zitadel
nextcloud_oidc_client_secret: "" # Will be set after creating app in Zitadel
# Trusted domains (for Nextcloud config)
nextcloud_trusted_domains:

View file

@ -5,7 +5,7 @@
shell: docker exec -u www-data nextcloud php occ app:install richdocuments
register: collabora_install
changed_when: "'richdocuments installed' in collabora_install.stdout"
failed_when: collabora_install.rc != 0 and 'already installed' not in collabora_install.stdout
failed_when: collabora_install.rc != 0 and 'richdocuments already installed' not in collabora_install.stderr
when: collabora_enabled | default(true)
- name: Enable Collabora Office app
@ -20,8 +20,7 @@
changed_when: true
- name: Get Nextcloud internal network info
shell: |
docker inspect nextcloud-internal -f {% raw %}'{{ .IPAM.Config }}'{% endraw %}
shell: docker inspect nextcloud-internal -f '{{{{ .IPAM.Config }}}}'
register: nextcloud_network
changed_when: false
when: collabora_enabled | default(true)
@ -40,7 +39,7 @@
- twofactor_backupcodes
register: twofactor_install
changed_when: "'installed' in twofactor_install.stdout"
failed_when: twofactor_install.rc != 0 and 'already installed' not in twofactor_install.stdout
failed_when: twofactor_install.rc != 0 and 'already installed' not in twofactor_install.stderr
- name: Enable two-factor authentication apps
shell: docker exec -u www-data nextcloud php occ app:enable {{ item }}

View file

@ -20,12 +20,10 @@
state: present
register: nextcloud_deploy
- name: Wait for Nextcloud container to be ready
shell: docker exec nextcloud sh -c 'until curl -f http://localhost:80 >/dev/null 2>&1; do sleep 2; done'
args:
executable: /bin/bash
register: nextcloud_ready
changed_when: false
failed_when: false
timeout: 300
- name: Wait for Nextcloud to be ready
wait_for:
host: localhost
port: 80
delay: 10
timeout: 120
when: nextcloud_deploy.changed

View file

@ -1,46 +0,0 @@
---
# Configure Nextcloud email settings via Mailgun SMTP
- name: Determine SMTP credentials source
set_fact:
smtp_user: "{{ mailgun_smtp_user | default(client_secrets.mailgun_smtp_user) }}"
smtp_password: "{{ mailgun_smtp_password | default(client_secrets.mailgun_smtp_password) }}"
no_log: true
- name: Configure SMTP email settings
shell: |
docker exec -u www-data nextcloud php occ config:system:set mail_smtpmode --value="smtp"
docker exec -u www-data nextcloud php occ config:system:set mail_smtpsecure --value="tls"
docker exec -u www-data nextcloud php occ config:system:set mail_smtphost --value="smtp.eu.mailgun.org"
docker exec -u www-data nextcloud php occ config:system:set mail_smtpport --value="587"
docker exec -u www-data nextcloud php occ config:system:set mail_smtpauth --value="1"
docker exec -u www-data nextcloud php occ config:system:set mail_smtpname --value="{{ smtp_user }}"
docker exec -u www-data nextcloud php occ config:system:set mail_smtppassword --value="{{ smtp_password }}"
docker exec -u www-data nextcloud php occ config:system:set mail_from_address --value="{{ inventory_hostname }}"
docker exec -u www-data nextcloud php occ config:system:set mail_domain --value="mg.vrije.cloud"
no_log: true
register: email_config
changed_when: true
- name: Configure admin user email address
shell: |
docker exec -u www-data nextcloud php occ user:setting {{ client_secrets.nextcloud_admin_user }} settings email "{{ inventory_hostname }}@mg.vrije.cloud"
register: admin_email_set
changed_when: true
- name: Display email configuration status
debug:
msg: |
========================================
Nextcloud Email Configuration
========================================
SMTP Host: smtp.eu.mailgun.org
SMTP Port: 587 (TLS)
From Address: {{ inventory_hostname }}@mg.vrije.cloud
Admin Email: {{ inventory_hostname }}@mg.vrije.cloud
Status: ✓ Configured
Test: Settings → Basic settings → Send email
========================================

View file

@ -26,11 +26,6 @@
docker exec -u www-data nextcloud php occ config:system:set overwriteprotocol --value="https"
docker exec -u www-data nextcloud php occ config:system:set overwritehost --value="{{ nextcloud_domain }}"
docker exec -u www-data nextcloud php occ config:system:set overwrite.cli.url --value="https://{{ nextcloud_domain }}"
docker exec -u www-data nextcloud php occ config:system:set trusted_proxies 0 --value="172.18.0.0/16"
docker exec -u www-data nextcloud php occ config:system:set trusted_proxies 1 --value="172.19.0.0/16"
docker exec -u www-data nextcloud php occ config:system:set trusted_proxies 2 --value="172.20.0.0/16"
docker exec -u www-data nextcloud php occ config:system:set trusted_proxies 3 --value="172.21.0.0/16"
docker exec -u www-data nextcloud php occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
- name: Configure Redis for caching
shell: |
@ -46,25 +41,3 @@
- name: Run background jobs via cron
shell: |
docker exec -u www-data nextcloud php occ background:cron
- name: Add missing database indices
shell: |
docker exec -u www-data nextcloud php occ db:add-missing-indices
register: add_indices
changed_when: "'Adding' in add_indices.stdout"
failed_when: false
- name: Run expensive maintenance repairs (mimetype migrations)
shell: |
docker exec -u www-data nextcloud php occ maintenance:repair --include-expensive
register: maintenance_repair
changed_when: "'mimetype' in maintenance_repair.stdout"
failed_when: false
async: 600
poll: 10
- name: Clear any initial background job errors from log
shell: |
docker exec nextcloud truncate -s 0 /var/www/html/data/nextcloud.log
when: maintenance_repair.changed
changed_when: false

View file

@ -1,12 +1,6 @@
---
# Main tasks for Nextcloud deployment
- name: Include volume mounting tasks
include_tasks: mount-volume.yml
tags:
- nextcloud
- volume
- name: Include Docker deployment tasks
include_tasks: docker.yml
tags:
@ -31,10 +25,3 @@
tags:
- nextcloud
- apps
- name: Include email configuration
include_tasks: email.yml
when: mailgun_smtp_user is defined or (client_secrets.mailgun_smtp_user is defined and client_secrets.mailgun_smtp_user != "" and "PLACEHOLDER" not in client_secrets.mailgun_smtp_user)
tags:
- nextcloud
- email

View file

@ -1,74 +0,0 @@
---
# Mount Hetzner Volume for Nextcloud Data Storage
#
# This task file handles mounting the Hetzner Volume that stores Nextcloud user data.
# The volume is created and attached by OpenTofu, we just mount it here.
- name: Wait for volume device to appear
wait_for:
path: /dev/disk/by-id/
timeout: 30
register: disk_ready
- name: Find Nextcloud volume device
shell: |
ls -1 /dev/disk/by-id/scsi-0HC_Volume_* 2>/dev/null | head -1
register: volume_device_result
changed_when: false
failed_when: false
- name: Set volume device fact
set_fact:
volume_device: "{{ volume_device_result.stdout }}"
- name: Display found volume device
debug:
msg: "Found Nextcloud volume at: {{ volume_device }}"
- name: Check if volume is already formatted
shell: |
blkid {{ volume_device }} | grep -q 'TYPE="ext4"'
register: volume_formatted
changed_when: false
failed_when: false
- name: Format volume as ext4 if not formatted
filesystem:
fstype: ext4
dev: "{{ volume_device }}"
when: volume_formatted.rc != 0
- name: Create mount point directory
file:
path: /mnt/nextcloud-data
state: directory
mode: '0755'
- name: Mount Nextcloud data volume
mount:
path: /mnt/nextcloud-data
src: "{{ volume_device }}"
fstype: ext4
state: mounted
opts: defaults,discard
register: mount_result
- name: Ensure mount persists across reboots
mount:
path: /mnt/nextcloud-data
src: "{{ volume_device }}"
fstype: ext4
state: present
opts: defaults,discard
- name: Create Nextcloud data directory on volume
file:
path: /mnt/nextcloud-data/data
state: directory
owner: www-data
group: www-data
mode: '0750'
- name: Display mount success
debug:
msg: "Nextcloud volume successfully mounted at /mnt/nextcloud-data"

View file

@ -1,5 +1,5 @@
---
# OIDC/SSO integration tasks for Nextcloud with Authentik
# OIDC/SSO integration tasks for Nextcloud with Zitadel
- name: Check if user_oidc app is installed
shell: docker exec -u www-data nextcloud php occ app:list --output=json
@ -20,71 +20,33 @@
shell: docker exec -u www-data nextcloud php occ app:enable user_oidc
when: not user_oidc_installed
- name: Check if Authentik OIDC credentials are available
stat:
path: /tmp/authentik_oidc_credentials.json
register: oidc_creds_file
- name: Load OIDC credentials from Authentik
slurp:
path: /tmp/authentik_oidc_credentials.json
register: oidc_creds_content
when: oidc_creds_file.stat.exists
- name: Parse OIDC credentials
set_fact:
authentik_oidc: "{{ oidc_creds_content.content | b64decode | from_json }}"
when: oidc_creds_file.stat.exists
- name: Check if OIDC provider is already configured
shell: docker exec -u www-data nextcloud php occ user_oidc:provider
register: oidc_providers
changed_when: false
failed_when: false
- name: Configure Authentik OIDC provider
- name: Configure OIDC provider if credentials are available
shell: |
docker exec -u www-data nextcloud php occ user_oidc:provider \
--clientid="{{ authentik_oidc.client_id }}" \
--clientsecret="{{ authentik_oidc.client_secret }}" \
--discoveryuri="{{ authentik_oidc.discovery_uri }}" \
"Authentik"
docker exec -u www-data nextcloud php occ user_oidc:provider:add \
--clientid="{{ nextcloud_oidc_client_id }}" \
--clientsecret="{{ nextcloud_oidc_client_secret }}" \
--discoveryuri="https://{{ zitadel_domain }}/.well-known/openid-configuration" \
"Zitadel"
when:
- authentik_oidc is defined
- authentik_oidc.success | default(false)
- "'Authentik' not in oidc_providers.stdout"
- nextcloud_oidc_client_id is defined
- nextcloud_oidc_client_secret is defined
- "'Zitadel' not in oidc_providers.stdout"
register: oidc_config
changed_when: oidc_config.rc == 0
- name: Configure OIDC settings (allow native login + OIDC)
shell: |
docker exec -u www-data nextcloud php occ config:app:set user_oidc allow_multiple_user_backends --value=1
docker exec -u www-data nextcloud php occ config:app:set user_oidc auto_provision --value=1
docker exec -u www-data nextcloud php occ config:app:set user_oidc single_logout --value=0
when:
- authentik_oidc is defined
- authentik_oidc.success | default(false)
register: oidc_settings
changed_when: oidc_settings.rc == 0
- name: Cleanup OIDC credentials file
file:
path: /tmp/authentik_oidc_credentials.json
state: absent
when: oidc_creds_file.stat.exists
changed_when: "'Provider Zitadel has been created' in oidc_config.stdout"
- name: Display OIDC status
debug:
msg: |
{% if authentik_oidc is defined and authentik_oidc.success | default(false) %}
✓ OIDC SSO fully configured!
Users can login with Authentik credentials at: https://{{ nextcloud_domain }}
"Login with Authentik" button should be visible on the login page.
{% if nextcloud_oidc_client_id is defined %}
OIDC SSO fully configured!
Users can login with Zitadel credentials at: https://{{ nextcloud_domain }}
{% else %}
⚠ OIDC app installed but not yet configured.
To complete setup:
1. Ensure Authentik API token is in secrets (authentik_api_token)
2. Re-run deployment with: --tags authentik,oidc
OIDC app installed but not yet configured.
OIDC credentials will be configured automatically by Zitadel role.
{% endif %}

View file

@ -10,17 +10,8 @@ services:
POSTGRES_DB: {{ nextcloud_db_name }}
POSTGRES_USER: {{ nextcloud_db_user }}
POSTGRES_PASSWORD: {{ client_secrets.nextcloud_db_password }}
# Grant full privileges to the user
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256"
command: >
postgres
-c shared_buffers=256MB
-c max_connections=200
-c shared_preload_libraries=''
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ nextcloud_db_user }} -d {{ nextcloud_db_name }}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- nextcloud-internal
@ -44,8 +35,7 @@ services:
- nextcloud-db
- nextcloud-redis
volumes:
- nextcloud-app:/var/www/html
- /mnt/nextcloud-data/data:/var/www/html/data # User data on Hetzner Volume
- nextcloud-data:/var/www/html
entrypoint: /cron.sh
networks:
- nextcloud-internal
@ -59,8 +49,7 @@ services:
- nextcloud-db
- nextcloud-redis
volumes:
- nextcloud-app:/var/www/html
- /mnt/nextcloud-data/data:/var/www/html/data # User data on Hetzner Volume
- nextcloud-data:/var/www/html
environment:
# Database configuration
POSTGRES_HOST: {{ nextcloud_db_host }}
@ -126,18 +115,11 @@ services:
image: collabora/code:latest
container_name: collabora
restart: unless-stopped
# Required capabilities for optimal performance (bind-mount instead of copy)
cap_add:
- MKNOD
- SYS_CHROOT
environment:
- domain={{ nextcloud_domain | regex_replace('\.', '\\.') }}
- username={{ collabora_admin_user }}
- password={{ client_secrets.collabora_admin_password }}
# Performance tuning based on available CPU cores
# num_prespawn_children: Number of child processes to keep started (default: 1)
# per_document.max_concurrency: Max threads per document (should be <= CPU cores)
- extra_params=--o:ssl.enable=false --o:ssl.termination=true --o:num_prespawn_children=1 --o:per_document.max_concurrency=2
- extra_params=--o:ssl.enable=false --o:ssl.termination=true
- MEMPROPORTION=60.0
- MAX_DOCUMENTS=10
- MAX_CONNECTIONS=20
@ -176,6 +158,5 @@ volumes:
name: nextcloud-db-data
nextcloud-redis-data:
name: nextcloud-redis-data
nextcloud-app:
name: nextcloud-app
# Note: nextcloud-data volume removed - user data now stored on Hetzner Volume at /mnt/nextcloud-data
nextcloud-data:
name: nextcloud-data

View file

@ -1,8 +1,8 @@
---
# Default variables for traefik role
# Traefik version (v3.6.1+ fixes Docker API 1.44 compatibility with Docker 29+)
traefik_version: "v3.6"
# Traefik version (v3.2+ fixes Docker API compatibility)
traefik_version: "v3.2"
# Let's Encrypt configuration
traefik_acme_email: "admin@example.com" # Override this!

View file

@ -6,6 +6,9 @@ services:
image: traefik:{{ traefik_version }}
container_name: traefik
restart: unless-stopped
environment:
# Fix Docker API version compatibility - use 1.44 for modern Docker
- DOCKER_API_VERSION=1.44
security_opt:
- no-new-privileges:true
ports:

View file

@ -1,8 +1,66 @@
# Traefik dynamic configuration
# Managed by Ansible - Client-specific routes come from Docker labels
# Managed by Ansible - do not edit manually
http:
routers:
# Zitadel identity provider
zitadel:
rule: "Host(`zitadel.test.vrije.cloud`)"
service: zitadel
entryPoints:
- websecure
tls:
certResolver: letsencrypt
middlewares:
- zitadel-headers
# Nextcloud file sync/share
nextcloud:
rule: "Host(`nextcloud.test.vrije.cloud`)"
service: nextcloud
entryPoints:
- websecure
tls:
certResolver: letsencrypt
middlewares:
- nextcloud-headers
- nextcloud-redirectregex
services:
# Zitadel service
zitadel:
loadBalancer:
servers:
- url: "h2c://zitadel:8080"
# Nextcloud service
nextcloud:
loadBalancer:
servers:
- url: "http://nextcloud:80"
middlewares:
# Zitadel-specific headers
zitadel-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
# Nextcloud-specific headers
nextcloud-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
# CalDAV/CardDAV redirect for Nextcloud
nextcloud-redirectregex:
redirectRegex:
permanent: true
regex: "https://(.*)/.well-known/(card|cal)dav"
replacement: "https://$1/remote.php/dav/"
# Security headers
security-headers:
headers:

View file

@ -1,83 +0,0 @@
# Client Registry
This directory contains the client registry system for tracking all deployed infrastructure.
## Files
- **[registry.yml](registry.yml)** - Single source of truth for all clients
- Deployment status and lifecycle
- Server specifications
- Application versions
- Maintenance history
- Access URLs
## Management Scripts
All scripts are located in [`../scripts/`](../scripts/):
### View Clients
View the registry directly:
```bash
# View full registry
cat registry.yml
# View specific client (requires yq)
yq eval '.clients.dev' registry.yml
```
### View Client Details
```bash
# Show detailed status with live health checks
../scripts/client-status.sh <client_name>
```
### Update Registry
The registry is **automatically updated** by deployment scripts:
- `deploy-client.sh` - Creates/updates entry on deployment
- `rebuild-client.sh` - Updates entry on rebuild
- `destroy-client.sh` - Marks as destroyed
For manual updates, edit `registry.yml` directly.
## Registry Structure
Each client entry tracks:
- **Status**: `pending``deployed``maintenance``offboarding``destroyed`
- **Role**: `canary` (testing) or `production` (live)
- **Server**: Type, location, IP, Hetzner ID
- **Apps**: Installed applications
- **Versions**: Application and OS versions
- **Maintenance**: Update and backup history
- **URLs**: Access endpoints
- **Notes**: Operational documentation
## Canary Deployment
The `dev` client has role `canary` and is used for testing:
```bash
# 1. Test on canary first
../scripts/deploy-client.sh dev
# 2. Verify it works
../scripts/client-status.sh dev
# 3. Roll out to production clients manually
# Review registry.yml for production clients, then rebuild each one
```
## Registry Structure Details
The `registry.yml` file uses YAML format with the following structure:
- Complete registry structure reference in the file itself
- Client lifecycle states and metadata
- Server specifications and IP addresses
- Deployment timestamps and version tracking
## Requirements
- **yq**: YAML processor (`brew install yq`)
- **jq**: JSON processor (`brew install jq`)

View file

@ -1,89 +0,0 @@
# Client Registry
#
# Single source of truth for all clients in the infrastructure.
# This file tracks client lifecycle, deployment state, and versions.
#
# Status values:
# - pending: Client configuration created, not yet deployed
# - deployed: Client is live and operational
# - maintenance: Under maintenance, may be temporarily unavailable
# - offboarding: Being decommissioned
# - destroyed: Infrastructure removed, secrets archived
#
# Role values:
# - canary: Used for testing updates before production rollout
# - production: Live client serving real users
clients:
dev:
status: deployed
role: canary
deployed_date: 2026-01-17
destroyed_date: null
server:
type: cpx22 # 3 vCPU, 4 GB RAM, 80 GB SSD
location: fsn1 # Falkenstein, Germany
ip: 78.47.191.38
id: "117714358" # Hetzner server ID
apps:
- authentik
- nextcloud
versions:
authentik: "2025.10.3"
nextcloud: "30.0.17"
traefik: "v3.0"
ubuntu: "24.04"
maintenance:
last_full_update: 2026-01-17
last_security_patch: 2026-01-17
last_os_update: 2026-01-17
last_backup_verified: null
urls:
authentik: "https://auth.dev.vrije.cloud"
nextcloud: "https://nextcloud.dev.vrije.cloud"
notes: |
Canary/test server. Used for testing updates before production rollout.
Server was recreated on 2026-01-17 for per-client SSH key implementation.
# Add new clients here as they are deployed
# Template:
#
# clientname:
# status: deployed
# role: production
# deployed_date: YYYY-MM-DD
# destroyed_date: null
#
# server:
# type: cx22
# location: nbg1
# ip: 1.2.3.4
# id: "12345678"
#
# apps:
# - authentik
# - nextcloud
#
# versions:
# authentik: "2025.10.3"
# nextcloud: "30.0.17"
# traefik: "v3.0"
# ubuntu: "24.04"
#
# maintenance:
# last_full_update: YYYY-MM-DD
# last_security_patch: YYYY-MM-DD
# last_os_update: YYYY-MM-DD
# last_backup_verified: null
#
# urls:
# authentik: "https://auth.clientname.vrije.cloud"
# nextcloud: "https://nextcloud.clientname.vrije.cloud"
#
# notes: ""

View file

@ -0,0 +1,808 @@
# Infrastructure Architecture Decision Record
## Post-X Society Multi-Tenant VPS Platform
**Document Status:** Living document
**Created:** December 2024
**Last Updated:** December 2025
---
## Executive Summary
This document captures architectural decisions for a scalable, multi-tenant infrastructure platform starting with 10 identical VPS instances running Keycloak and Nextcloud, with plans to expand both server count and application offerings.
**Key Technology Choices:**
- **OpenTofu** over Terraform (truly open source, MPL 2.0)
- **SOPS + Age** over HashiCorp Vault (simple, no server, European-friendly)
- **Hetzner** for all infrastructure (GDPR-compliant, EU-based)
---
## 1. Infrastructure Provisioning
### Decision: OpenTofu + Ansible with Dynamic Inventory
**Choice:** Infrastructure as Code using OpenTofu for resource provisioning and Ansible for configuration management.
**Why OpenTofu over Terraform:**
- Truly open source (MPL 2.0) vs HashiCorp's BSL 1.1
- Drop-in replacement - same syntax, same providers
- Linux Foundation governance - no single company can close the license
- Active community after HashiCorp's 2023 license change
- No risk of future license restrictions
**Approach:**
- **OpenTofu** manages Hetzner resources (VPS instances, networks, firewalls, DNS)
- **Ansible** configures servers using the `hcloud` dynamic inventory plugin
- No static inventory files - Ansible queries Hetzner API at runtime
**Rationale:**
- 10+ identical servers makes manual management unsustainable
- Version-controlled infrastructure in Git
- Dynamic inventory eliminates sync issues between OpenTofu and Ansible
- Skills transfer to other providers if needed
**Implementation:**
```yaml
# ansible.cfg
[inventory]
enable_plugins = hetzner.hcloud.hcloud
# hcloud.yml (inventory config)
plugin: hetzner.hcloud.hcloud
locations:
- fsn1
keyed_groups:
- key: labels.role
prefix: role
- key: labels.client
prefix: client
```
---
## 2. Application Deployment
### Decision: Modular Ansible Roles with Feature Flags
**Choice:** Each application is a separate Ansible role, enabled per-server via inventory variables.
**Rationale:**
- Allows heterogeneous deployments (client A wants Pretix, client B doesn't)
- Test new applications on single server before fleet rollout
- Clear separation of concerns
- Minimal refactoring when adding new applications
**Structure:**
```
ansible/
├── roles/
│ ├── common/ # Base setup, hardening, Docker
│ ├── traefik/ # Reverse proxy, SSL
│ ├── nextcloud/ # File sync and collaboration
│ ├── pretix/ # Future: Event ticketing
│ ├── listmonk/ # Future: Newsletter/mailing
│ ├── backup/ # Restic configuration
│ └── monitoring/ # Node exporter, promtail
```
**Inventory Example:**
```yaml
all:
children:
clients:
hosts:
client-alpha:
client_name: alpha
domain: alpha.platform.nl
apps:
- nextcloud
client-beta:
client_name: beta
domain: beta.platform.nl
apps:
- nextcloud
- pretix
```
---
## 3. DNS Management
### Decision: Hetzner DNS via OpenTofu
**Choice:** Manage all DNS records through Hetzner DNS using OpenTofu.
**Rationale:**
- Single provider for infrastructure and DNS simplifies management
- OpenTofu provider available and well-maintained (same as Terraform provider)
- Cost-effective (included with Hetzner)
- GDPR-compliant (EU-based)
**Domain Strategy:**
- Start with subdomains: `{client}.platform.nl`
- Support custom domains later via variable override
- Wildcard approach not used - explicit records per service
**Implementation:**
```hcl
resource "hcloud_server" "client" {
for_each = var.clients
name = each.key
server_type = each.value.server_type
# ...
}
resource "hetznerdns_record" "client_a" {
for_each = var.clients
zone_id = data.hetznerdns_zone.main.id
name = each.value.subdomain
type = "A"
value = hcloud_server.client[each.key].ipv4_address
}
```
**SSL Certificates:** Handled by Traefik with Let's Encrypt, automatic per-domain.
---
## 4. Identity Provider
### Decision: Removed (previously Zitadel)
**Status:** Identity provider removed from architecture.
**Reason for Removal:**
- Zitadel v2.63.7 has critical bugs with FirstInstance initialization
- ALL `ZITADEL_FIRSTINSTANCE_*` environment variables cause database migration errors
- Requires manual web UI setup for each instance (not scalable for multi-tenant deployment)
- See: https://github.com/zitadel/zitadel/issues/8791
**Future Consideration:**
- May revisit with Authentik or other identity providers when needed
- Currently focusing on Nextcloud as standalone solution
---
## 4. Backup Strategy
### Decision: Dual Backup Approach
**Choice:** Hetzner automated snapshots + Restic application-level backups to Hetzner Storage Box.
#### Layer 1: Hetzner Snapshots
**Purpose:** Disaster recovery (complete server loss)
| Aspect | Configuration |
|--------|---------------|
| Frequency | Daily (Hetzner automated) |
| Retention | 7 snapshots |
| Cost | 20% of VPS price |
| Restoration | Full server restore via Hetzner console/API |
**Limitations:**
- Crash-consistent only (may catch database mid-write)
- Same datacenter (not true off-site)
- Coarse granularity (all or nothing)
#### Layer 2: Restic to Hetzner Storage Box
**Purpose:** Granular application recovery, off-server storage
**Backend Choice:** Hetzner Storage Box
**Rationale:**
- GDPR-compliant (German/EU data residency)
- Same Hetzner network = fast transfers, no egress costs
- Cost-effective (~€3.81/month for BX10 with 1TB)
- Supports SFTP, CIFS/Samba, rsync, Restic-native
- Can be accessed from all VPSs simultaneously
**Storage Hierarchy:**
```
Storage Box (BX10 or larger)
└── /backups/
├── /client-alpha/
│ ├── /restic-repo/ # Encrypted Restic repository
│ └── /manual/ # Ad-hoc exports if needed
├── /client-beta/
│ └── /restic-repo/
└── /client-gamma/
└── /restic-repo/
```
**Connection Method:**
- Primary: SFTP (native Restic support, encrypted in transit)
- Optional: CIFS mount for manual file access
- Each client VPS gets Storage Box sub-account or uses main credentials with path restrictions
| Aspect | Configuration |
|--------|---------------|
| Frequency | Nightly (after DB dumps) |
| Time | 03:00 local time |
| Retention | 7 daily, 4 weekly, 6 monthly |
| Encryption | Restic default (AES-256) |
| Repo passwords | Stored in SOPS-encrypted files |
**What Gets Backed Up:**
```
/opt/docker/
├── nextcloud/
│ └── data/ # ✓ User files
├── pretix/
│ └── data/ # ✓ When applicable
└── configs/ # ✓ docker-compose files, env
```
**Backup Ansible Role Tasks:**
1. Install Restic
2. Initialize repo (if not exists)
3. Configure SFTP connection to Storage Box
4. Create pre-backup script (database dumps)
5. Create backup script
6. Create systemd timer
7. Configure backup monitoring (alert on failure)
**Sizing Guidance:**
- Start with BX10 (1TB) for 10 clients
- Monitor usage monthly
- Scale to BX20 (2TB) when approaching 70% capacity
**Verification:**
- Weekly `restic check` via cron
- Monthly test restore to staging environment
- Alerts on backup job failures
---
## 5. Secrets Management
### Decision: SOPS + Age Encryption
**Choice:** File-based secrets encryption using SOPS with Age encryption, stored in Git.
**Why SOPS + Age over HashiCorp Vault:**
- No additional server to maintain
- Truly open source (MPL 2.0 for SOPS, Apache 2.0 for Age)
- Secrets versioned alongside infrastructure code
- Simple to understand and debug
- Age developed with European privacy values (FiloSottile)
- Perfect for 10-50 server scale
- No vendor lock-in concerns
**How It Works:**
1. Secrets stored in YAML files, encrypted with Age
2. Only the values are encrypted, keys remain readable
3. Decryption happens at Ansible runtime
4. One Age key per environment (or shared across all)
**Example Encrypted File:**
```yaml
# secrets/client-alpha.sops.yaml
db_password: ENC[AES256_GCM,data:kH3x9...,iv:abc...,tag:def...,type:str]
keycloak_admin: ENC[AES256_GCM,data:mN4y2...,iv:ghi...,tag:jkl...,type:str]
nextcloud_admin: ENC[AES256_GCM,data:pQ5z7...,iv:mno...,tag:pqr...,type:str]
restic_repo_password: ENC[AES256_GCM,data:rS6a1...,iv:stu...,tag:vwx...,type:str]
```
**Key Management:**
```
keys/
├── age-key.txt # Master key (NEVER in Git, backed up securely)
└── .sops.yaml # SOPS configuration (in Git)
```
**.sops.yaml Configuration:**
```yaml
creation_rules:
- path_regex: secrets/.*\.sops\.yaml$
age: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
**Secret Structure:**
```
secrets/
├── .sops.yaml # SOPS config
├── shared.sops.yaml # Shared secrets (Storage Box, API tokens)
└── clients/
├── alpha.sops.yaml # Client-specific secrets
├── beta.sops.yaml
└── gamma.sops.yaml
```
**Ansible Integration:**
```yaml
# Using community.sops collection
- name: Load client secrets
community.sops.load_vars:
file: "secrets/clients/{{ client_name }}.sops.yaml"
name: client_secrets
- name: Use decrypted secret
ansible.builtin.template:
src: docker-compose.yml.j2
dest: /opt/docker/docker-compose.yml
vars:
db_password: "{{ client_secrets.db_password }}"
```
**Daily Operations:**
```bash
# Encrypt a new file
sops --encrypt --age $(cat keys/age-key.pub) secrets/clients/new.yaml > secrets/clients/new.sops.yaml
# Edit existing secrets (decrypts, opens editor, re-encrypts)
SOPS_AGE_KEY_FILE=keys/age-key.txt sops secrets/clients/alpha.sops.yaml
# View decrypted content
SOPS_AGE_KEY_FILE=keys/age-key.txt sops --decrypt secrets/clients/alpha.sops.yaml
```
**Key Backup Strategy:**
- Age private key stored in password manager (Bitwarden/1Password)
- Printed paper backup in secure location
- Key never stored in Git repository
- Consider key escrow for bus factor
**Advantages for Your Setup:**
| Aspect | Benefit |
|--------|---------|
| Simplicity | No Vault server to maintain, secure, update |
| Auditability | Git history shows who changed what secrets when |
| Portability | Works offline, no network dependency |
| Reliability | No secrets server = no secrets server downtime |
| Cost | Zero infrastructure cost |
---
## 6. Monitoring
### Decision: Centralized Uptime Kuma
**Choice:** Uptime Kuma on dedicated monitoring server.
**Rationale:**
- Simple to deploy and maintain
- Beautiful UI for status overview
- Flexible alerting (email, Slack, webhook)
- Self-hosted (data stays in-house)
- Sufficient for "is it up?" monitoring at current scale
**Deployment:**
- Dedicated VPS or container on monitoring server
- Monitors all client servers and services
- Public status page optional per client
**Monitors per Client:**
- HTTPS endpoint (Nextcloud)
- TCP port checks (database, if exposed)
- Docker container health (via API or agent)
**Alerting:**
- Primary: Email
- Secondary: Slack/Mattermost webhook
- Escalation: SMS for extended downtime (future)
**Future Expansion Path:**
When deeper metrics needed:
1. Add Prometheus + Node Exporter
2. Add Grafana dashboards
3. Add Loki for log aggregation
4. Uptime Kuma remains for synthetic monitoring
---
## 7. Client Isolation
### Decision: Full Isolation
**Choice:** Maximum isolation between clients at all levels.
**Implementation:**
| Layer | Isolation Method |
|-------|------------------|
| Compute | Separate VPS per client |
| Network | Hetzner firewall rules, no inter-VPS traffic |
| Database | Separate PostgreSQL container per client |
| Storage | Separate Docker volumes |
| Backups | Separate Restic repositories |
| Secrets | Separate SOPS files per client |
| DNS | Separate records/domains |
**Network Rules:**
- Each VPS accepts traffic only on 80, 443, 22 (management IP only)
- No private network between client VPSs
- Monitoring server can reach all clients (outbound checks)
**Rationale:**
- Security: Compromise of one client cannot spread
- Compliance: Data separation demonstrable
- Operations: Can maintain/upgrade clients independently
- Billing: Clear resource attribution
---
## 8. Deployment Strategy
### Decision: Canary Deployments with Version Pinning
**Choice:** Staged rollouts with explicit version control.
#### Version Pinning
All container images use explicit tags:
```yaml
# docker-compose.yml
services:
nextcloud:
image: nextcloud:28.0.1 # Never use :latest
keycloak:
image: quay.io/keycloak/keycloak:23.0.1
postgres:
image: postgres:16.1
```
Version updates require explicit change and commit.
#### Canary Process
**Inventory Groups:**
```yaml
all:
children:
canary:
hosts:
client-alpha: # Designated test client (internal or willing partner)
production:
hosts:
client-beta:
client-gamma:
# ... remaining clients
```
**Deployment Script:**
```bash
#!/bin/bash
set -e
echo "=== Deploying to canary ==="
ansible-playbook deploy.yml --limit canary
echo "=== Waiting for verification ==="
read -p "Canary OK? Proceed to production? [y/N] " confirm
if [[ $confirm != "y" ]]; then
echo "Deployment aborted"
exit 1
fi
echo "=== Deploying to production ==="
ansible-playbook deploy.yml --limit production
```
#### Rollback Procedures
**Scenario 1: Bad container version**
```bash
# Revert version in docker-compose
git revert HEAD
# Redeploy
ansible-playbook deploy.yml --limit affected_hosts
```
**Scenario 2: Database migration issue**
```bash
# Restore from pre-upgrade Restic backup
restic -r sftp:user@backup-server:/client-x/restic-repo restore latest --target /tmp/restore
# Restore database dump
psql < /tmp/restore/db-dumps/keycloak.sql
# Revert and redeploy application
```
**Scenario 3: Complete server failure**
```bash
# Restore Hetzner snapshot via API
hcloud server rebuild <server-id> --image <snapshot-id>
# Or via OpenTofu
tofu apply -replace="hcloud_server.client[\"affected\"]"
```
---
## 9. Security Baseline
### Decision: Comprehensive Hardening
All servers receive the `common` Ansible role with:
#### SSH Hardening
```yaml
# /etc/ssh/sshd_config (managed by Ansible)
PermitRootLogin: no
PasswordAuthentication: no
PubkeyAuthentication: yes
AllowUsers: deploy
```
#### Firewall (UFW)
```yaml
- 22/tcp: Management IPs only
- 80/tcp: Any (redirects to 443)
- 443/tcp: Any
- All other: Deny
```
#### Automatic Updates
```yaml
# unattended-upgrades configuration
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::Automatic-Reboot "false"; # Manual reboot control
```
#### Fail2ban
```yaml
# Jails enabled
- sshd
- traefik-auth (custom, for repeated 401s)
```
#### Container Security
```yaml
# Trivy scanning in CI/CD
- Scan images before deployment
- Block critical vulnerabilities
- Weekly scheduled scans of running containers
```
#### Additional Measures
- No password authentication anywhere
- Secrets encrypted with SOPS + Age, never plaintext in Git
- Regular dependency updates via Dependabot/Renovate
- SSH keys rotated annually
---
## 10. Onboarding Procedure
### New Client Checklist
```markdown
## Client Onboarding: {CLIENT_NAME}
### Prerequisites
- [ ] Client agreement signed
- [ ] Domain/subdomain confirmed: _______________
- [ ] Contact email: _______________
- [ ] Desired applications: [ ] Keycloak [ ] Nextcloud [ ] Pretix [ ] Listmonk
### Infrastructure
- [ ] Add client to `tofu/variables.tf`
- [ ] Add client to `ansible/inventory/clients.yml`
- [ ] Create secrets file: `sops secrets/clients/{name}.sops.yaml`
- [ ] Create Storage Box subdirectory for backups
- [ ] Run: `tofu apply`
- [ ] Run: `ansible-playbook playbooks/setup.yml --limit {client}`
### Verification
- [ ] HTTPS accessible
- [ ] Nextcloud admin login works
- [ ] Backup job runs successfully
- [ ] Monitoring checks green
### Handover
- [ ] Send credentials securely (1Password link, Signal, etc.)
- [ ] Schedule onboarding call if needed
- [ ] Add to status page (if applicable)
- [ ] Document any custom configuration
### Estimated Time: 30-45 minutes
```
---
## 11. Offboarding Procedure
### Client Removal Checklist
```markdown
## Client Offboarding: {CLIENT_NAME}
### Pre-Offboarding
- [ ] Confirm termination date: _______________
- [ ] Data export requested? [ ] Yes [ ] No
- [ ] Final invoice sent
### Data Export (if requested)
- [ ] Export Nextcloud data
- [ ] Confirm receipt
### Infrastructure Removal
- [ ] Disable monitoring checks (set maintenance mode first)
- [ ] Create final backup (retain per policy)
- [ ] Remove from Ansible inventory
- [ ] Remove from OpenTofu config
- [ ] Run: `tofu apply` (destroys VPS)
- [ ] Remove DNS records (automatic via OpenTofu)
- [ ] Remove/archive SOPS secrets file
### Backup Retention
- [ ] Move Restic repo to archive path
- [ ] Set deletion date: _______ (default: 90 days post-termination)
- [ ] Schedule deletion job
### Cleanup
- [ ] Remove from status page
- [ ] Update client count in documentation
- [ ] Archive client folder in documentation
### Verification
- [ ] DNS no longer resolves
- [ ] IP returns nothing
- [ ] Monitoring shows no alerts (host removed)
- [ ] Billing stopped
### Estimated Time: 15-30 minutes
```
### Data Retention Policy
| Data Type | Retention Post-Offboarding |
|-----------|---------------------------|
| Application data (Restic) | 90 days |
| Hetzner snapshots | Deleted immediately (with VPS) |
| SOPS secrets files | Archived 90 days, then deleted |
| Logs | 30 days |
| Invoices/contracts | 7 years (legal requirement) |
---
## 12. Repository Structure
```
infrastructure/
├── README.md
├── docs/
│ ├── architecture-decisions.md # This document
│ ├── runbook.md # Operational procedures
│ └── clients/ # Per-client notes
│ ├── alpha.md
│ └── beta.md
├── tofu/ # OpenTofu configuration
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── dns.tf
│ ├── firewall.tf
│ └── versions.tf
├── ansible/
│ ├── ansible.cfg
│ ├── hcloud.yml # Dynamic inventory config
│ ├── playbooks/
│ │ ├── setup.yml # Initial server setup
│ │ ├── deploy.yml # Deploy/update applications
│ │ ├── upgrade.yml # System updates
│ │ └── backup-restore.yml # Manual backup/restore
│ ├── roles/
│ │ ├── common/
│ │ ├── docker/
│ │ ├── traefik/
│ │ ├── nextcloud/
│ │ ├── backup/
│ │ └── monitoring-agent/
│ └── group_vars/
│ └── all.yml
├── secrets/ # SOPS-encrypted secrets
│ ├── .sops.yaml # SOPS configuration
│ ├── shared.sops.yaml # Shared secrets
│ └── clients/
│ ├── alpha.sops.yaml
│ └── beta.sops.yaml
├── docker/
│ ├── docker-compose.base.yml # Common services
│ └── docker-compose.apps.yml # Application services
└── scripts/
├── deploy.sh # Canary deployment wrapper
├── onboard-client.sh
└── offboard-client.sh
```
**Note:** The Age private key (`age-key.txt`) is NOT stored in this repository. It must be:
- Stored in a password manager
- Backed up securely offline
- Available on deployment machine only
---
## 13. Open Decisions / Future Considerations
### To Decide Later
- [ ] Identity provider (Authentik or other) - if SSO needed
- [ ] Prometheus metrics - when/if needed
- [ ] Custom domain SSL workflow
- [ ] Client self-service portal
### Scaling Triggers
- **20+ servers:** Consider Kubernetes or Nomad
- **Multi-region:** Add OpenTofu workspaces per region
- **Team growth:** Consider moving from SOPS to Infisical for better access control
- **Complex secret rotation:** May need dedicated secrets server
---
## 14. Technology Choices Rationale
### Why We Chose Open Source / European-Friendly Tools
| Tool | Chosen | Avoided | Reason |
|------|--------|---------|--------|
| IaC | OpenTofu | Terraform | BSL license concerns, HashiCorp trust issues |
| Secrets | SOPS + Age | HashiCorp Vault | Simplicity, no US vendor dependency, truly open source |
| Identity | (Removed) | Keycloak/Zitadel | Removed due to complexity; may add Authentik in future |
| Hosting | Hetzner | AWS/GCP/Azure | EU-based, cost-effective, GDPR-compliant |
| Backup | Restic + Hetzner Storage Box | Cloud backup services | Open source, EU data residency |
**Guiding Principles:**
1. Prefer truly open source (OSI-approved) over source-available
2. Prefer EU-based services for GDPR simplicity
3. Avoid vendor lock-in where practical
4. Choose simplicity appropriate to scale (10-50 servers)
---
## 15. Development Environment and Tooling
### Decision: Isolated Python Environments with pipx
**Choice:** Use `pipx` for installing Python CLI tools (Ansible) in isolated virtual environments.
**Why pipx:**
- Prevents dependency conflicts between tools
- Each tool has its own Python environment
- No interference with system Python packages
- Easy to upgrade/rollback individual tools
- Modern best practice for Python CLI tools
**Implementation:**
```bash
# Install pipx
brew install pipx
pipx ensurepath
# Install Ansible in isolation
pipx install --include-deps ansible
# Inject additional dependencies as needed
pipx inject ansible requests python-dateutil
```
**Benefits:**
| Aspect | Benefit |
|--------|---------|
| Isolation | No conflicts with other Python tools |
| Reproducibility | Each team member gets same isolated environment |
| Maintainability | Easy to upgrade Ansible without breaking other tools |
| Clean system | No pollution of system Python packages |
**Alternatives Considered:**
- **Homebrew Ansible** - Rejected: Can conflict with system Python, harder to manage dependencies
- **System pip install** - Rejected: Pollutes global Python environment
- **Manual venv** - Rejected: More manual work, pipx automates this
---
## Changelog
| Date | Change | Author |
|------|--------|--------|
| 2024-12 | Initial architecture decisions | Pieter / Claude |
| 2024-12 | Added Hetzner Storage Box as Restic backend | Pieter / Claude |
| 2024-12 | Switched from Terraform to OpenTofu (licensing concerns) | Pieter / Claude |
| 2024-12 | Switched from HashiCorp Vault to SOPS + Age (simplicity, open source) | Pieter / Claude |
| 2024-12 | Switched from Keycloak to Zitadel (Swiss company, GDPR jurisdiction) | Pieter / Claude |
| 2026-01 | Removed Zitadel due to FirstInstance bugs; may add Authentik in future | Pieter / Claude |
```

7
keys/ssh/.gitignore vendored
View file

@ -1,7 +0,0 @@
# NEVER commit SSH private keys
*
# Allow README and public keys only
!.gitignore
!README.md
!*.pub

View file

@ -1,196 +0,0 @@
# SSH Keys Directory
This directory contains **per-client SSH key pairs** for server access.
## Purpose
Each client gets a dedicated SSH key pair to ensure:
- **Isolation**: Compromise of one client ≠ access to others
- **Granular control**: Rotate or revoke keys per-client
- **Security**: Defense in depth, minimize blast radius
## Files
```
keys/ssh/
├── .gitignore # Protects private keys from git
├── README.md # This file
├── dev # Private key for dev server (gitignored)
├── dev.pub # Public key for dev server (committed)
├── client1 # Private key for client1 (gitignored)
└── client1.pub # Public key for client1 (committed)
```
## Generating Keys
Use the helper script:
```bash
./scripts/generate-client-keys.sh <client_name>
```
Or manually:
```bash
ssh-keygen -t ed25519 -f keys/ssh/<client_name> -C "client-<client_name>-deploy-key" -N ""
```
## Security
### What Gets Committed
- ✅ **Public keys** (`*.pub`) - Safe to commit
- ✅ **README.md** - Documentation
- ✅ **`.gitignore`** - Protection rules
### What NEVER Gets Committed
- ❌ **Private keys** (no `.pub` extension) - Gitignored
- ❌ **Temporary files** - Gitignored
- ❌ **Backup keys** - Gitignored
The `.gitignore` file in this directory ensures private keys are never committed:
```gitignore
# NEVER commit SSH private keys
*
# Allow README and public keys only
!.gitignore
!README.md
!*.pub
```
## Backup Strategy
**⚠️ IMPORTANT: Backup private keys securely!**
Private keys must be backed up to prevent lockout:
1. **Password Manager** (Recommended):
- Store in 1Password, Bitwarden, etc.
- Tag with client name and server IP
2. **Encrypted Archive**:
```bash
tar czf - keys/ssh/ | gpg -c > ssh-keys-backup.tar.gz.gpg
```
3. **Team Vault**:
- Share securely with team members who need access
- Document key ownership
## Usage
### SSH Connection
```bash
# Connect to client server
ssh -i keys/ssh/dev root@<server_ip>
# Run command
ssh -i keys/ssh/dev root@<server_ip> "docker ps"
```
### Ansible
Ansible automatically uses the correct key (via dynamic inventory and OpenTofu):
```bash
ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit dev
```
### SSH Config
Add to `~/.ssh/config` for convenience:
```
Host dev.vrije.cloud
User root
IdentityFile ~/path/to/infrastructure/keys/ssh/dev
```
Then: `ssh dev.vrije.cloud`
## Key Rotation
Rotate keys annually or on security events:
```bash
# Generate new key (backs up old automatically)
./scripts/generate-client-keys.sh dev
# Apply to server (recreates server with new key)
cd tofu && tofu apply
# Test new key
ssh -i keys/ssh/dev root@<new_ip> hostname
```
## Verification
### Check Key Fingerprint
```bash
# Show fingerprint of private key
ssh-keygen -lf keys/ssh/dev
# Show fingerprint of public key
ssh-keygen -lf keys/ssh/dev.pub
# Should match!
```
### Check What's in Git
```bash
# Verify no private keys committed
git ls-files keys/ssh/
# Should only show:
# keys/ssh/.gitignore
# keys/ssh/README.md
# keys/ssh/*.pub
```
### Check Permissions
```bash
# Private keys must be 600
ls -la keys/ssh/dev
# Should show: -rw------- (600)
# Fix if needed:
chmod 600 keys/ssh/*
chmod 644 keys/ssh/*.pub
```
## Troubleshooting
### "Permission denied (publickey)"
1. Check you're using the correct private key for the client
2. Verify public key is on server (check OpenTofu state)
3. Ensure private key has correct permissions (600)
### "No such file or directory"
Generate the key first:
```bash
./scripts/generate-client-keys.sh <client_name>
```
### "Bad permissions"
Fix key permissions:
```bash
chmod 600 keys/ssh/<client_name>
chmod 644 keys/ssh/<client_name>.pub
```
## See Also
- [../docs/ssh-key-management.md](../../docs/ssh-key-management.md) - Complete SSH key management guide
- [../../scripts/generate-client-keys.sh](../../scripts/generate-client-keys.sh) - Key generation script
- [../../tofu/main.tf](../../tofu/main.tf) - OpenTofu SSH key resources

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILKuSYRVVWCYqjNvJ5pHZTErkmVbEb1g3ac8olXUcXy7 client-bever-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGsGfzhrcVtYEn2YHzxVGibBDXPd571unltfOaVo5JlR client-das-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE75mnMfKHTIeq5Hp8LKaKYHGbzdFke1a9N7e0UEMNBu client-egel-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAa4QHMVKnTSS/q5kptQYzas7ln2MbgE5Db47GM2DjRI client-haas-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICtZzQTzNWLcFi4NNqg6l53kqPVDsgau1O7GWWKwZh9l client-kikker-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPXF5COMplFqwxCRymXN7y4b+RWiBbVQpIMmFoK10qgh client-kraai-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDGPPukFDhM4eIolsowRsD6jYrNYoM3/B9yLi2KNqmPi client-mees-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHAsLbdkl0peC15KnxhSsCI45Z2FwQu2Hy1LArzHoXu5 client-mol-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINAoeg3LDX5zRuw5Yt5WwbYNRXo70H7e5OYE3oMbJRyL client-mus-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG3edQhsIBD9Ers7wuFWSww8r3ROkKNJF8YcxgRtQdov client-otter-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB4QOkx75M28l7JAkQPl8bLjGuV/kKDFQINkUGRVRgIk client-ree-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAXFskaLenHy4FJHUZL2gpehFUAYaUdNfwP0BTMqp4La client-specht-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEhDcLx3ZaBXSHbhOoAgb5sI5xUVJwZEXl2HYq5+eRID client-uil-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILLDJCSNj3OZDDwGgoWSxy17K8DmJ8eqUXQ4Wmu/vRtG client-valk-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDg8F6LIVfdBdhD/CiNavs+xfFSiu9jxMmZcyigskuIQ client-vos-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKUcrgfG+JWtieySkcSZNyBehf/rB0YEQ35IQ93L+HHP client-wolf-deploy-key

View file

@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG76TbSdY1o5T7PlzGkbfu0HNGOKsiW5vtbAKLDz0BGv client-zwaan-deploy-key

View file

@ -1,267 +0,0 @@
# Management Scripts
Automated scripts for managing client infrastructure.
## Prerequisites
Set SOPS Age key location (optional, scripts use default):
```bash
export SOPS_AGE_KEY_FILE="./keys/age-key.txt"
```
**Note**: The Hetzner API token is now automatically loaded from SOPS-encrypted `secrets/shared.sops.yaml`. No need to manually set `HCLOUD_TOKEN`.
## Scripts
### 1. Deploy Fresh Client
**Purpose**: Deploy a brand new client from scratch
**Usage**:
```bash
./scripts/deploy-client.sh <client_name>
```
**What it does** (automatically):
1. **Generates SSH key** (if missing) - Unique per-client key pair
2. **Creates secrets file** (if missing) - From template, opens in editor
3. Provisions VPS server (if not exists)
4. Sets up base system (Docker, Traefik)
5. Deploys Authentik + Nextcloud
6. Configures SSO integration automatically
**Time**: ~10-15 minutes
**Example**:
```bash
# Just run the script - it handles everything!
./scripts/deploy-client.sh newclient
# Script will:
# 1. Generate keys/ssh/newclient + keys/ssh/newclient.pub
# 2. Copy secrets/clients/template.sops.yaml → secrets/clients/newclient.sops.yaml
# 3. Open SOPS editor for you to customize secrets
# 4. Continue with deployment
```
**Requirements**:
- Client must be defined in `tofu/terraform.tfvars`
- SOPS Age key available at `keys/age-key.txt` (or set `SOPS_AGE_KEY_FILE`)
---
### 2. Rebuild Client
**Purpose**: Destroy and recreate a client's infrastructure from scratch
**Usage**:
```bash
./scripts/rebuild-client.sh <client_name>
```
**What it does**:
1. Destroys existing infrastructure (asks for confirmation)
2. Provisions new VPS server
3. Sets up base system
4. Deploys applications
5. Configures SSO
**Time**: ~10-15 minutes
**Example**:
```bash
./scripts/rebuild-client.sh test
```
**Warning**: This is **destructive** - all data on the server will be lost!
---
### 3. Destroy Client
**Purpose**: Completely remove a client's infrastructure
**Usage**:
```bash
./scripts/destroy-client.sh <client_name>
```
**What it does**:
1. Stops and removes all Docker containers
2. Removes all Docker volumes
3. Destroys VPS server via OpenTofu
4. Removes DNS records
**Time**: ~2-3 minutes
**Example**:
```bash
./scripts/destroy-client.sh test
```
**Warning**: This is **destructive and irreversible**! All data will be lost.
**Note**: Secrets file is preserved after destruction.
---
## Workflow Examples
### Deploy a New Client (Fully Automated)
```bash
# 1. Add to terraform.tfvars
vim tofu/terraform.tfvars
# Add:
# newclient = {
# server_type = "cx22"
# location = "fsn1"
# subdomain = "newclient"
# apps = ["authentik", "nextcloud"]
# }
# 2. Deploy (script handles SSH key + secrets automatically)
./scripts/deploy-client.sh newclient
# That's it! Script will:
# - Generate SSH key if missing
# - Create secrets file from template if missing (opens editor)
# - Deploy everything
```
### Test Changes (Rebuild)
```bash
# Make changes to Ansible roles/playbooks
# Test by rebuilding
./scripts/rebuild-client.sh test
# Verify changes worked
```
### Clean Up
```bash
# Remove test infrastructure
./scripts/destroy-client.sh test
```
## Script Output
All scripts provide:
- ✓ Colored output (green = success, yellow = warning, red = error)
- Progress indicators for each step
- Total time taken
- Service URLs and credentials
- Next steps guidance
## Error Handling
Scripts will exit if:
- Required environment variables not set
- Secrets file doesn't exist
- Confirmation not provided (for destructive operations)
- Any command fails (set -e)
## Safety Features
### Destroy Script
- Requires typing client name to confirm
- Shows what will be deleted
- Preserves secrets file
### Rebuild Script
- Asks for confirmation before destroying
- 10-second delay after destroy before rebuilding
- Shows existing infrastructure before proceeding
### Deploy Script
- Checks for existing infrastructure
- Skips provisioning if server exists
- Validates secrets file exists
## Integration with CI/CD
These scripts can be used in automation:
```bash
# Non-interactive deployment
export SOPS_AGE_KEY_FILE="..."
./scripts/deploy-client.sh production
```
For rebuild (skip confirmation):
```bash
# Modify rebuild-client.sh to accept --yes flag
./scripts/rebuild-client.sh production --yes
```
## Troubleshooting
### Script fails with "HCLOUD_TOKEN not set"
The token should be automatically loaded from SOPS. If this fails:
1. Ensure SOPS Age key is available:
```bash
export SOPS_AGE_KEY_FILE="./keys/age-key.txt"
ls -la keys/age-key.txt
```
2. Verify token is in shared secrets:
```bash
sops -d secrets/shared.sops.yaml | grep hcloud_token
```
3. Manually load secrets:
```bash
source scripts/load-secrets-env.sh
```
### Script fails with "Secrets file not found"
Create the secrets file:
```bash
cp secrets/clients/test.sops.yaml secrets/clients/<client>.sops.yaml
sops secrets/clients/<client>.sops.yaml
```
### Server not reachable during destroy
This is normal if server is already destroyed. The script will skip Docker cleanup and proceed to OpenTofu destroy.
### OpenTofu state conflicts
If multiple people are managing infrastructure:
```bash
cd tofu
tofu state pull
tofu state push
```
Consider using remote state (S3, Terraform Cloud, etc.)
## Performance
Typical timings:
| Operation | Time |
|-----------|------|
| Deploy fresh | 10-15 min |
| Rebuild | 10-15 min |
| Destroy | 2-3 min |
Breakdown:
- Infrastructure provisioning: 2 min
- Server initialization: 1 min
- Base system setup: 3 min
- Application deployment: 5-7 min
## See Also
- [AUTOMATION_STATUS.md](../docs/AUTOMATION_STATUS.md) - Full automation details
- [sso-automation.md](../docs/sso-automation.md) - SSO integration workflow
- [architecture-decisions.md](../docs/architecture-decisions.md) - Design decisions

View file

@ -1,87 +0,0 @@
#!/usr/bin/env bash
#
# Add client monitors to Uptime Kuma
#
# Usage: ./scripts/add-client-to-monitoring.sh <client_name>
#
# This script creates HTTP(S) and SSL monitors for a client's services
# Currently uses manual instructions - future: use Uptime Kuma API
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Check arguments
if [ $# -ne 1 ]; then
echo -e "${RED}Error: Client name required${NC}"
echo "Usage: $0 <client_name>"
exit 1
fi
CLIENT_NAME="$1"
BASE_DOMAIN="vrije.cloud"
# Calculate URLs
AUTH_URL="https://auth.${CLIENT_NAME}.${BASE_DOMAIN}"
NEXTCLOUD_URL="https://nextcloud.${CLIENT_NAME}.${BASE_DOMAIN}"
AUTH_DOMAIN="auth.${CLIENT_NAME}.${BASE_DOMAIN}"
NEXTCLOUD_DOMAIN="nextcloud.${CLIENT_NAME}.${BASE_DOMAIN}"
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}Add Client to Monitoring${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
echo -e "${YELLOW}Client: ${CLIENT_NAME}${NC}"
echo ""
# TODO: Implement automated monitor creation via Uptime Kuma API
# For now, provide manual instructions
echo -e "${YELLOW}Manual Setup Required:${NC}"
echo ""
echo "Please add the following monitors in Uptime Kuma:"
echo "🔗 Access: https://status.vrije.cloud"
echo ""
echo -e "${GREEN}HTTP(S) Monitors:${NC}"
echo ""
echo "1. ${CLIENT_NAME} - Authentik"
echo " Type: HTTP(S)"
echo " URL: ${AUTH_URL}"
echo " Interval: 300 seconds (5 min)"
echo " Retries: 3"
echo ""
echo "2. ${CLIENT_NAME} - Nextcloud"
echo " Type: HTTP(S)"
echo " URL: ${NEXTCLOUD_URL}"
echo " Interval: 300 seconds (5 min)"
echo " Retries: 3"
echo ""
echo -e "${GREEN}SSL Certificate Monitors:${NC}"
echo ""
echo "3. ${CLIENT_NAME} - Authentik SSL"
echo " Type: Certificate Expiry"
echo " Hostname: ${AUTH_DOMAIN}"
echo " Port: 443"
echo " Expiry Days: 30"
echo " Interval: 86400 seconds (1 day)"
echo ""
echo "4. ${CLIENT_NAME} - Nextcloud SSL"
echo " Type: Certificate Expiry"
echo " Hostname: ${NEXTCLOUD_DOMAIN}"
echo " Port: 443"
echo " Expiry Days: 30"
echo " Interval: 86400 seconds (1 day)"
echo ""
echo -e "${BLUE}========================================${NC}"
echo ""
echo -e "${YELLOW}Note: Automated monitor creation via API is planned for future enhancement.${NC}"
echo ""

View file

@ -1,250 +0,0 @@
#!/usr/bin/env bash
#
# Add a new client to OpenTofu configuration
#
# Usage: ./scripts/add-client-to-terraform.sh <client_name> [options]
#
# Options:
# --server-type=TYPE Server type (default: cpx22)
# --location=LOC Data center location (default: fsn1)
# --volume-size=SIZE Nextcloud volume size in GB (default: 100)
# --apps=APP1,APP2 Applications to deploy (default: zitadel,nextcloud)
# --non-interactive Don't prompt, use defaults
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
TFVARS_FILE="$PROJECT_ROOT/tofu/terraform.tfvars"
# Check arguments
if [ $# -lt 1 ]; then
echo -e "${RED}Error: Client name required${NC}"
echo "Usage: $0 <client_name> [options]"
echo ""
echo "Options:"
echo " --server-type=TYPE Server type (default: cpx22)"
echo " --location=LOC Data center (default: fsn1)"
echo " --volume-size=SIZE Nextcloud volume GB (default: 100)"
echo " --apps=APP1,APP2 Apps (default: zitadel,nextcloud)"
echo " --non-interactive Use defaults, don't prompt"
echo ""
echo "Example: $0 blue --server-type=cx22 --location=nbg1 --volume-size=50"
exit 1
fi
CLIENT_NAME="$1"
shift
# Default values
SERVER_TYPE="cpx22"
LOCATION="fsn1"
VOLUME_SIZE="100"
APPS="authentik,nextcloud"
PRIVATE_IP=""
PUBLIC_IP_ENABLED="false"
NON_INTERACTIVE=false
# Parse options
for arg in "$@"; do
case $arg in
--server-type=*)
SERVER_TYPE="${arg#*=}"
;;
--location=*)
LOCATION="${arg#*=}"
;;
--volume-size=*)
VOLUME_SIZE="${arg#*=}"
;;
--apps=*)
APPS="${arg#*=}"
;;
--private-ip=*)
PRIVATE_IP="${arg#*=}"
;;
--public-ip)
PUBLIC_IP_ENABLED="true"
;;
--non-interactive)
NON_INTERACTIVE=true
;;
*)
echo -e "${RED}Unknown option: $arg${NC}"
exit 1
;;
esac
done
# Auto-assign private IP if not provided
if [ -z "$PRIVATE_IP" ]; then
# Find the highest existing IP in terraform.tfvars and increment
LAST_IP=$(grep -oP 'private_ip\s*=\s*"10\.0\.0\.\K\d+' "$TFVARS_FILE" 2>/dev/null | sort -n | tail -1)
if [ -z "$LAST_IP" ]; then
NEXT_IP=40 # Start from 10.0.0.40 (edge is .2)
else
NEXT_IP=$((LAST_IP + 1))
fi
PRIVATE_IP="10.0.0.$NEXT_IP"
echo -e "${BLUE}Auto-assigned private IP: $PRIVATE_IP${NC}"
echo ""
fi
# Validate client name
if [[ ! "$CLIENT_NAME" =~ ^[a-z0-9-]+$ ]]; then
echo -e "${RED}Error: Client name must contain only lowercase letters, numbers, and hyphens${NC}"
exit 1
fi
# Check if tfvars file exists
if [ ! -f "$TFVARS_FILE" ]; then
echo -e "${RED}Error: terraform.tfvars not found at $TFVARS_FILE${NC}"
exit 1
fi
# Check if client already exists
if grep -q "^[[:space:]]*${CLIENT_NAME}[[:space:]]*=" "$TFVARS_FILE"; then
echo -e "${YELLOW}⚠ Client '${CLIENT_NAME}' already exists in terraform.tfvars${NC}"
echo ""
echo "Existing configuration:"
grep -A 7 "^[[:space:]]*${CLIENT_NAME}[[:space:]]*=" "$TFVARS_FILE" | head -8
echo ""
read -p "Update configuration? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo "Cancelled"
exit 0
fi
# Remove existing entry
# This is complex - for now just error and let user handle manually
echo -e "${RED}Error: Updating existing clients not yet implemented${NC}"
echo "Please manually edit $TFVARS_FILE"
exit 1
fi
# Interactive prompts (if not non-interactive)
if [ "$NON_INTERACTIVE" = false ]; then
echo -e "${BLUE}Adding client '${CLIENT_NAME}' to OpenTofu configuration${NC}"
echo ""
echo "Current defaults:"
echo " Server type: $SERVER_TYPE"
echo " Location: $LOCATION"
echo " Volume size: $VOLUME_SIZE GB"
echo " Apps: $APPS"
echo ""
read -p "Use these defaults? (yes/no): " use_defaults
if [ "$use_defaults" != "yes" ]; then
# Prompt for each value
echo ""
read -p "Server type [$SERVER_TYPE]: " input
SERVER_TYPE="${input:-$SERVER_TYPE}"
read -p "Location [$LOCATION]: " input
LOCATION="${input:-$LOCATION}"
read -p "Volume size GB [$VOLUME_SIZE]: " input
VOLUME_SIZE="${input:-$VOLUME_SIZE}"
read -p "Apps (comma-separated) [$APPS]: " input
APPS="${input:-$APPS}"
fi
fi
# Convert apps list to array format
APPS_ARRAY=$(echo "$APPS" | sed 's/,/", "/g' | sed 's/^/["/' | sed 's/$/"]/')
# Find the closing brace of the clients block
CLIENTS_CLOSE_LINE=$(grep -n "^}" "$TFVARS_FILE" | head -1 | cut -d: -f1)
if [ -z "$CLIENTS_CLOSE_LINE" ]; then
echo -e "${RED}Error: Could not find closing brace in terraform.tfvars${NC}"
exit 1
fi
# Create the new client configuration
NEW_CLIENT_CONFIG="
# ${CLIENT_NAME} server
${CLIENT_NAME} = {
server_type = \"${SERVER_TYPE}\"
location = \"${LOCATION}\"
subdomain = \"${CLIENT_NAME}\"
apps = ${APPS_ARRAY}
nextcloud_volume_size = ${VOLUME_SIZE}
private_ip = \"${PRIVATE_IP}\"
public_ip_enabled = ${PUBLIC_IP_ENABLED}
}"
# Create temporary file with new config inserted before closing brace
TMP_FILE=$(mktemp)
head -n $((CLIENTS_CLOSE_LINE - 1)) "$TFVARS_FILE" > "$TMP_FILE"
echo "$NEW_CLIENT_CONFIG" >> "$TMP_FILE"
tail -n +$CLIENTS_CLOSE_LINE "$TFVARS_FILE" >> "$TMP_FILE"
# Show the diff
echo ""
echo -e "${CYAN}Configuration to be added:${NC}"
echo "$NEW_CLIENT_CONFIG"
echo ""
# Confirm
if [ "$NON_INTERACTIVE" = false ]; then
read -p "Add this configuration to terraform.tfvars? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
rm "$TMP_FILE"
echo "Cancelled"
exit 0
fi
fi
# Apply changes
mv "$TMP_FILE" "$TFVARS_FILE"
echo ""
echo -e "${GREEN}✓ Client '${CLIENT_NAME}' added to terraform.tfvars${NC}"
echo ""
# Create Ansible host_vars file
HOST_VARS_FILE="$PROJECT_ROOT/ansible/host_vars/${CLIENT_NAME}.yml"
if [ ! -f "$HOST_VARS_FILE" ]; then
echo -e "${BLUE}Creating Ansible host_vars file...${NC}"
mkdir -p "$(dirname "$HOST_VARS_FILE")"
cat > "$HOST_VARS_FILE" << EOF
---
# ${CLIENT_NAME} server configuration
ansible_host: ${PRIVATE_IP}
# Client identification
client_name: ${CLIENT_NAME}
client_domain: ${CLIENT_NAME}.vrije.cloud
client_secrets_file: ${CLIENT_NAME}.sops.yaml
EOF
echo -e "${GREEN}✓ Created host_vars file: $HOST_VARS_FILE${NC}"
echo ""
fi
echo "Configuration added:"
echo " Server: $SERVER_TYPE in $LOCATION"
echo " Volume: $VOLUME_SIZE GB"
echo " Apps: $APPS"
echo " Private IP: $PRIVATE_IP"
echo ""
echo -e "${CYAN}Next steps:${NC}"
echo "1. Review changes: cat tofu/terraform.tfvars"
echo "2. Plan infrastructure: cd tofu && tofu plan"
echo "3. Apply infrastructure: cd tofu && tofu apply"
echo "4. Deploy services: ./scripts/deploy-client.sh $CLIENT_NAME"
echo ""

View file

@ -1,251 +0,0 @@
#!/usr/bin/env bash
#
# Report software versions across all clients
#
# Usage: ./scripts/check-client-versions.sh [options]
#
# Options:
# --format=table Show as colorized table (default)
# --format=csv Export as CSV
# --format=json Export as JSON
# --app=<name> Filter by application (authentik|nextcloud|traefik|ubuntu)
# --outdated Show only clients with outdated versions
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
REGISTRY_FILE="$PROJECT_ROOT/clients/registry.yml"
# Default options
FORMAT="table"
FILTER_APP=""
SHOW_OUTDATED=false
# Parse arguments
for arg in "$@"; do
case $arg in
--format=*)
FORMAT="${arg#*=}"
;;
--app=*)
FILTER_APP="${arg#*=}"
;;
--outdated)
SHOW_OUTDATED=true
;;
*)
echo "Unknown option: $arg"
echo "Usage: $0 [--format=table|csv|json] [--app=<name>] [--outdated]"
exit 1
;;
esac
done
# Check if yq is available
if ! command -v yq &> /dev/null; then
echo -e "${RED}Error: 'yq' not found. Install with: brew install yq${NC}"
exit 1
fi
# Check if registry exists
if [ ! -f "$REGISTRY_FILE" ]; then
echo -e "${RED}Error: Registry file not found: $REGISTRY_FILE${NC}"
exit 1
fi
# Get list of clients
CLIENTS=$(yq eval '.clients | keys | .[]' "$REGISTRY_FILE" 2>/dev/null)
if [ -z "$CLIENTS" ]; then
echo -e "${YELLOW}No clients found in registry${NC}"
exit 0
fi
# Determine latest versions (from canary/dev or most common)
declare -A LATEST_VERSIONS
LATEST_VERSIONS[authentik]=$(yq eval '.clients | to_entries | .[].value.versions.authentik' "$REGISTRY_FILE" | sort -V | tail -1)
LATEST_VERSIONS[nextcloud]=$(yq eval '.clients | to_entries | .[].value.versions.nextcloud' "$REGISTRY_FILE" | sort -V | tail -1)
LATEST_VERSIONS[traefik]=$(yq eval '.clients | to_entries | .[].value.versions.traefik' "$REGISTRY_FILE" | sort -V | tail -1)
LATEST_VERSIONS[ubuntu]=$(yq eval '.clients | to_entries | .[].value.versions.ubuntu' "$REGISTRY_FILE" | sort -V | tail -1)
# Function to check if version is outdated
is_outdated() {
local app=$1
local version=$2
local latest=${LATEST_VERSIONS[$app]}
if [ "$version" != "$latest" ] && [ "$version" != "null" ] && [ "$version" != "unknown" ]; then
return 0
else
return 1
fi
}
case $FORMAT in
table)
echo -e "${BLUE}═══════════════════════════════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} CLIENT VERSION REPORT${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════════════════════════════${NC}"
echo ""
# Header
printf "${CYAN}%-15s %-15s %-15s %-15s %-15s %-15s${NC}\n" \
"CLIENT" "STATUS" "AUTHENTIK" "NEXTCLOUD" "TRAEFIK" "UBUNTU"
echo -e "${CYAN}$(printf '─%.0s' {1..90})${NC}"
# Rows
for client in $CLIENTS; do
status=$(yq eval ".clients.\"$client\".status" "$REGISTRY_FILE")
authentik=$(yq eval ".clients.\"$client\".versions.authentik" "$REGISTRY_FILE")
nextcloud=$(yq eval ".clients.\"$client\".versions.nextcloud" "$REGISTRY_FILE")
traefik=$(yq eval ".clients.\"$client\".versions.traefik" "$REGISTRY_FILE")
ubuntu=$(yq eval ".clients.\"$client\".versions.ubuntu" "$REGISTRY_FILE")
# Skip if filtering by outdated and not outdated
if [ "$SHOW_OUTDATED" = true ]; then
has_outdated=false
is_outdated "authentik" "$authentik" && has_outdated=true
is_outdated "nextcloud" "$nextcloud" && has_outdated=true
is_outdated "traefik" "$traefik" && has_outdated=true
is_outdated "ubuntu" "$ubuntu" && has_outdated=true
if [ "$has_outdated" = false ]; then
continue
fi
fi
# Colorize versions (red if outdated)
authentik_color=$NC
is_outdated "authentik" "$authentik" && authentik_color=$RED
nextcloud_color=$NC
is_outdated "nextcloud" "$nextcloud" && nextcloud_color=$RED
traefik_color=$NC
is_outdated "traefik" "$traefik" && traefik_color=$RED
ubuntu_color=$NC
is_outdated "ubuntu" "$ubuntu" && ubuntu_color=$RED
# Status color
status_color=$GREEN
[ "$status" != "deployed" ] && status_color=$YELLOW
printf "%-15s ${status_color}%-15s${NC} ${authentik_color}%-15s${NC} ${nextcloud_color}%-15s${NC} ${traefik_color}%-15s${NC} ${ubuntu_color}%-15s${NC}\n" \
"$client" "$status" "$authentik" "$nextcloud" "$traefik" "$ubuntu"
done
echo ""
echo -e "${CYAN}Latest versions:${NC}"
echo " Authentik: ${LATEST_VERSIONS[authentik]}"
echo " Nextcloud: ${LATEST_VERSIONS[nextcloud]}"
echo " Traefik: ${LATEST_VERSIONS[traefik]}"
echo " Ubuntu: ${LATEST_VERSIONS[ubuntu]}"
echo ""
echo -e "${YELLOW}Note: ${RED}Red${NC} indicates outdated version${NC}"
echo ""
;;
csv)
# CSV header
echo "client,status,authentik,nextcloud,traefik,ubuntu,last_update,outdated"
# CSV rows
for client in $CLIENTS; do
status=$(yq eval ".clients.\"$client\".status" "$REGISTRY_FILE")
authentik=$(yq eval ".clients.\"$client\".versions.authentik" "$REGISTRY_FILE")
nextcloud=$(yq eval ".clients.\"$client\".versions.nextcloud" "$REGISTRY_FILE")
traefik=$(yq eval ".clients.\"$client\".versions.traefik" "$REGISTRY_FILE")
ubuntu=$(yq eval ".clients.\"$client\".versions.ubuntu" "$REGISTRY_FILE")
last_update=$(yq eval ".clients.\"$client\".maintenance.last_full_update" "$REGISTRY_FILE")
# Check if any version is outdated
outdated="no"
is_outdated "authentik" "$authentik" && outdated="yes"
is_outdated "nextcloud" "$nextcloud" && outdated="yes"
is_outdated "traefik" "$traefik" && outdated="yes"
is_outdated "ubuntu" "$ubuntu" && outdated="yes"
# Skip if filtering by outdated
if [ "$SHOW_OUTDATED" = true ] && [ "$outdated" = "no" ]; then
continue
fi
echo "$client,$status,$authentik,$nextcloud,$traefik,$ubuntu,$last_update,$outdated"
done
;;
json)
# Build JSON array
echo "{"
echo " \"latest_versions\": {"
echo " \"authentik\": \"${LATEST_VERSIONS[authentik]}\","
echo " \"nextcloud\": \"${LATEST_VERSIONS[nextcloud]}\","
echo " \"traefik\": \"${LATEST_VERSIONS[traefik]}\","
echo " \"ubuntu\": \"${LATEST_VERSIONS[ubuntu]}\""
echo " },"
echo " \"clients\": ["
first=true
for client in $CLIENTS; do
status=$(yq eval ".clients.\"$client\".status" "$REGISTRY_FILE")
authentik=$(yq eval ".clients.\"$client\".versions.authentik" "$REGISTRY_FILE")
nextcloud=$(yq eval ".clients.\"$client\".versions.nextcloud" "$REGISTRY_FILE")
traefik=$(yq eval ".clients.\"$client\".versions.traefik" "$REGISTRY_FILE")
ubuntu=$(yq eval ".clients.\"$client\".versions.ubuntu" "$REGISTRY_FILE")
last_update=$(yq eval ".clients.\"$client\".maintenance.last_full_update" "$REGISTRY_FILE")
# Check if any version is outdated
outdated=false
is_outdated "authentik" "$authentik" && outdated=true
is_outdated "nextcloud" "$nextcloud" && outdated=true
is_outdated "traefik" "$traefik" && outdated=true
is_outdated "ubuntu" "$ubuntu" && outdated=true
# Skip if filtering by outdated
if [ "$SHOW_OUTDATED" = true ] && [ "$outdated" = false ]; then
continue
fi
if [ "$first" = false ]; then
echo " ,"
fi
first=false
cat <<EOF
{
"name": "$client",
"status": "$status",
"versions": {
"authentik": "$authentik",
"nextcloud": "$nextcloud",
"traefik": "$traefik",
"ubuntu": "$ubuntu"
},
"last_update": "$last_update",
"outdated": $outdated
}
EOF
done
echo ""
echo " ]"
echo "}"
;;
*)
echo -e "${RED}Error: Unknown format '$FORMAT'${NC}"
echo "Valid formats: table, csv, json"
exit 1
;;
esac

View file

@ -1,237 +0,0 @@
#!/usr/bin/env bash
#
# Show detailed status for a specific client
#
# Usage: ./scripts/client-status.sh <client_name>
#
# Displays:
# - Deployment status and metadata
# - Server information
# - Application versions
# - Maintenance history
# - URLs and access information
# - Live health checks (optional)
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
REGISTRY_FILE="$PROJECT_ROOT/clients/registry.yml"
# Check arguments
if [ $# -ne 1 ]; then
echo -e "${RED}Error: Client name required${NC}"
echo "Usage: $0 <client_name>"
echo ""
echo "Example: $0 dev"
exit 1
fi
CLIENT_NAME="$1"
# Check if yq is available
if ! command -v yq &> /dev/null; then
echo -e "${RED}Error: 'yq' not found. Install with: brew install yq${NC}"
exit 1
fi
# Check if registry exists
if [ ! -f "$REGISTRY_FILE" ]; then
echo -e "${RED}Error: Registry file not found: $REGISTRY_FILE${NC}"
exit 1
fi
# Check if client exists
if yq eval ".clients.\"$CLIENT_NAME\"" "$REGISTRY_FILE" | grep -q "null"; then
echo -e "${RED}Error: Client '$CLIENT_NAME' not found in registry${NC}"
echo ""
echo "Available clients:"
yq eval '.clients | keys | .[]' "$REGISTRY_FILE"
exit 1
fi
# Extract client information
STATUS=$(yq eval ".clients.\"$CLIENT_NAME\".status" "$REGISTRY_FILE")
ROLE=$(yq eval ".clients.\"$CLIENT_NAME\".role" "$REGISTRY_FILE")
DEPLOYED_DATE=$(yq eval ".clients.\"$CLIENT_NAME\".deployed_date" "$REGISTRY_FILE")
DESTROYED_DATE=$(yq eval ".clients.\"$CLIENT_NAME\".destroyed_date" "$REGISTRY_FILE")
SERVER_TYPE=$(yq eval ".clients.\"$CLIENT_NAME\".server.type" "$REGISTRY_FILE")
SERVER_LOCATION=$(yq eval ".clients.\"$CLIENT_NAME\".server.location" "$REGISTRY_FILE")
SERVER_IP=$(yq eval ".clients.\"$CLIENT_NAME\".server.ip" "$REGISTRY_FILE")
SERVER_ID=$(yq eval ".clients.\"$CLIENT_NAME\".server.id" "$REGISTRY_FILE")
APPS=$(yq eval ".clients.\"$CLIENT_NAME\".apps | join(\", \")" "$REGISTRY_FILE")
AUTHENTIK_VERSION=$(yq eval ".clients.\"$CLIENT_NAME\".versions.authentik" "$REGISTRY_FILE")
NEXTCLOUD_VERSION=$(yq eval ".clients.\"$CLIENT_NAME\".versions.nextcloud" "$REGISTRY_FILE")
TRAEFIK_VERSION=$(yq eval ".clients.\"$CLIENT_NAME\".versions.traefik" "$REGISTRY_FILE")
UBUNTU_VERSION=$(yq eval ".clients.\"$CLIENT_NAME\".versions.ubuntu" "$REGISTRY_FILE")
LAST_FULL_UPDATE=$(yq eval ".clients.\"$CLIENT_NAME\".maintenance.last_full_update" "$REGISTRY_FILE")
LAST_SECURITY_PATCH=$(yq eval ".clients.\"$CLIENT_NAME\".maintenance.last_security_patch" "$REGISTRY_FILE")
LAST_OS_UPDATE=$(yq eval ".clients.\"$CLIENT_NAME\".maintenance.last_os_update" "$REGISTRY_FILE")
LAST_BACKUP_VERIFIED=$(yq eval ".clients.\"$CLIENT_NAME\".maintenance.last_backup_verified" "$REGISTRY_FILE")
AUTHENTIK_URL=$(yq eval ".clients.\"$CLIENT_NAME\".urls.authentik" "$REGISTRY_FILE")
NEXTCLOUD_URL=$(yq eval ".clients.\"$CLIENT_NAME\".urls.nextcloud" "$REGISTRY_FILE")
NOTES=$(yq eval ".clients.\"$CLIENT_NAME\".notes" "$REGISTRY_FILE")
# Display header
echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} CLIENT STATUS: $CLIENT_NAME${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}"
echo ""
# Status section
echo -e "${CYAN}━━━ Deployment Status ━━━${NC}"
echo ""
# Color status
STATUS_COLOR=$NC
case $STATUS in
deployed) STATUS_COLOR=$GREEN ;;
pending) STATUS_COLOR=$YELLOW ;;
maintenance) STATUS_COLOR=$CYAN ;;
offboarding) STATUS_COLOR=$RED ;;
destroyed) STATUS_COLOR=$RED ;;
esac
# Color role
ROLE_COLOR=$NC
case $ROLE in
canary) ROLE_COLOR=$YELLOW ;;
production) ROLE_COLOR=$GREEN ;;
esac
echo -e "Status: ${STATUS_COLOR}$STATUS${NC}"
echo -e "Role: ${ROLE_COLOR}$ROLE${NC}"
echo -e "Deployed: $DEPLOYED_DATE"
if [ "$DESTROYED_DATE" != "null" ]; then
echo -e "Destroyed: ${RED}$DESTROYED_DATE${NC}"
fi
echo ""
# Server section
echo -e "${CYAN}━━━ Server Information ━━━${NC}"
echo ""
echo -e "Server Type: $SERVER_TYPE"
echo -e "Location: $SERVER_LOCATION"
echo -e "IP Address: $SERVER_IP"
echo -e "Server ID: $SERVER_ID"
echo ""
# Applications section
echo -e "${CYAN}━━━ Applications ━━━${NC}"
echo ""
echo -e "Installed: $APPS"
echo ""
# Versions section
echo -e "${CYAN}━━━ Versions ━━━${NC}"
echo ""
echo -e "Authentik: $AUTHENTIK_VERSION"
echo -e "Nextcloud: $NEXTCLOUD_VERSION"
echo -e "Traefik: $TRAEFIK_VERSION"
echo -e "Ubuntu: $UBUNTU_VERSION"
echo ""
# Maintenance section
echo -e "${CYAN}━━━ Maintenance History ━━━${NC}"
echo ""
echo -e "Last Full Update: $LAST_FULL_UPDATE"
echo -e "Last Security Patch: $LAST_SECURITY_PATCH"
echo -e "Last OS Update: $LAST_OS_UPDATE"
if [ "$LAST_BACKUP_VERIFIED" != "null" ]; then
echo -e "Last Backup Verified: $LAST_BACKUP_VERIFIED"
else
echo -e "Last Backup Verified: ${YELLOW}Never${NC}"
fi
echo ""
# URLs section
echo -e "${CYAN}━━━ Access URLs ━━━${NC}"
echo ""
echo -e "Authentik: $AUTHENTIK_URL"
echo -e "Nextcloud: $NEXTCLOUD_URL"
echo ""
# Notes section
if [ "$NOTES" != "null" ] && [ -n "$NOTES" ]; then
echo -e "${CYAN}━━━ Notes ━━━${NC}"
echo ""
echo "$NOTES" | sed 's/^/ /'
echo ""
fi
# Live health check (if server is deployed and reachable)
if [ "$STATUS" = "deployed" ]; then
echo -e "${CYAN}━━━ Live Health Check ━━━${NC}"
echo ""
# Check if server is reachable via SSH (if Ansible is configured)
if command -v ansible &> /dev/null && [ -n "${HCLOUD_TOKEN:-}" ]; then
cd "$PROJECT_ROOT/ansible"
if timeout 10 ~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m ping -o &>/dev/null; then
echo -e "SSH Access: ${GREEN}✓ Reachable${NC}"
# Get Docker status
DOCKER_STATUS=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "docker ps --format '{{.Names}}' 2>/dev/null | wc -l" -o 2>/dev/null | tail -1 | awk '{print $NF}' || echo "0")
if [ "$DOCKER_STATUS" != "0" ]; then
echo -e "Docker: ${GREEN}✓ Running ($DOCKER_STATUS containers)${NC}"
else
echo -e "Docker: ${RED}✗ No containers running${NC}"
fi
else
echo -e "SSH Access: ${RED}✗ Not reachable${NC}"
fi
else
echo -e "${YELLOW}Note: Install Ansible and set HCLOUD_TOKEN for live health checks${NC}"
fi
echo ""
# Check HTTPS endpoints
echo -e "HTTPS Endpoints:"
# Check Authentik
if command -v curl &> /dev/null; then
if timeout 10 curl -sSf -o /dev/null "$AUTHENTIK_URL" 2>/dev/null; then
echo -e " Authentik: ${GREEN}✓ Responding${NC}"
else
echo -e " Authentik: ${RED}<EFBFBD><EFBFBD> Not responding${NC}"
fi
# Check Nextcloud
if timeout 10 curl -sSf -o /dev/null "$NEXTCLOUD_URL" 2>/dev/null; then
echo -e " Nextcloud: ${GREEN}✓ Responding${NC}"
else
echo -e " Nextcloud: ${RED}✗ Not responding${NC}"
fi
else
echo -e " ${YELLOW}Install curl for endpoint checks${NC}"
fi
echo ""
fi
# Management commands section
echo -e "${CYAN}━━━ Management Commands ━━━${NC}"
echo ""
echo -e "View secrets: ${BLUE}sops secrets/clients/${CLIENT_NAME}.sops.yaml${NC}"
echo -e "Rebuild server: ${BLUE}./scripts/rebuild-client.sh $CLIENT_NAME${NC}"
echo -e "Destroy server: ${BLUE}./scripts/destroy-client.sh $CLIENT_NAME${NC}"
echo -e "List all: ${BLUE}./scripts/list-clients.sh${NC}"
echo ""
echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}"

View file

@ -1,130 +0,0 @@
#!/usr/bin/env bash
#
# Collect deployed software versions from a client and update registry
#
# Usage: ./scripts/collect-client-versions.sh <client_name>
#
# Queries the deployed server for actual running versions:
# - Docker container image versions
# - Ubuntu OS version
# - Updates the client registry with collected versions
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
REGISTRY_FILE="$PROJECT_ROOT/clients/registry.yml"
# Check arguments
if [ $# -ne 1 ]; then
echo -e "${RED}Error: Client name required${NC}"
echo "Usage: $0 <client_name>"
echo ""
echo "Example: $0 dev"
exit 1
fi
CLIENT_NAME="$1"
# Check if yq is available
if ! command -v yq &> /dev/null; then
echo -e "${RED}Error: 'yq' not found. Install with: brew install yq${NC}"
exit 1
fi
# Load Hetzner API token from SOPS if not already set
if [ -z "${HCLOUD_TOKEN:-}" ]; then
# shellcheck source=scripts/load-secrets-env.sh
source "$SCRIPT_DIR/load-secrets-env.sh" > /dev/null 2>&1
fi
# Check if registry exists
if [ ! -f "$REGISTRY_FILE" ]; then
echo -e "${RED}Error: Registry file not found: $REGISTRY_FILE${NC}"
exit 1
fi
# Check if client exists in registry
if yq eval ".clients.\"$CLIENT_NAME\"" "$REGISTRY_FILE" | grep -q "null"; then
echo -e "${RED}Error: Client '$CLIENT_NAME' not found in registry${NC}"
exit 1
fi
echo -e "${BLUE}Collecting versions for client: $CLIENT_NAME${NC}"
echo ""
cd "$PROJECT_ROOT/ansible"
# Check if server is reachable
if ! timeout 10 ~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m ping -o &>/dev/null; then
echo -e "${RED}Error: Cannot reach server for client '$CLIENT_NAME'${NC}"
echo "Server may not be deployed or network is unreachable"
exit 1
fi
echo -e "${YELLOW}Querying deployed versions...${NC}"
echo ""
# Query Docker container versions
echo "Collecting Docker container versions..."
# Function to extract version from image tag
extract_version() {
local image=$1
# Extract version after the colon, or return "latest"
if [[ $image == *":"* ]]; then
echo "$image" | awk -F: '{print $2}'
else
echo "latest"
fi
}
# Collect Authentik version
AUTHENTIK_IMAGE=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "docker inspect authentik-server 2>/dev/null | jq -r '.[0].Config.Image' 2>/dev/null || echo 'unknown'" -o 2>/dev/null | tail -1 | awk '{print $NF}')
AUTHENTIK_VERSION=$(extract_version "$AUTHENTIK_IMAGE")
# Collect Nextcloud version
NEXTCLOUD_IMAGE=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "docker inspect nextcloud 2>/dev/null | jq -r '.[0].Config.Image' 2>/dev/null || echo 'unknown'" -o 2>/dev/null | tail -1 | awk '{print $NF}')
NEXTCLOUD_VERSION=$(extract_version "$NEXTCLOUD_IMAGE")
# Collect Traefik version
TRAEFIK_IMAGE=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "docker inspect traefik 2>/dev/null | jq -r '.[0].Config.Image' 2>/dev/null || echo 'unknown'" -o 2>/dev/null | tail -1 | awk '{print $NF}')
TRAEFIK_VERSION=$(extract_version "$TRAEFIK_IMAGE")
# Collect Ubuntu version
UBUNTU_VERSION=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "lsb_release -rs 2>/dev/null || echo 'unknown'" -o 2>/dev/null | tail -1 | awk '{print $NF}')
echo -e "${GREEN}✓ Versions collected${NC}"
echo ""
# Display collected versions
echo "Collected versions:"
echo " Authentik: $AUTHENTIK_VERSION"
echo " Nextcloud: $NEXTCLOUD_VERSION"
echo " Traefik: $TRAEFIK_VERSION"
echo " Ubuntu: $UBUNTU_VERSION"
echo ""
# Update registry
echo -e "${YELLOW}Updating registry...${NC}"
# Update versions in registry
yq eval -i ".clients.\"$CLIENT_NAME\".versions.authentik = \"$AUTHENTIK_VERSION\"" "$REGISTRY_FILE"
yq eval -i ".clients.\"$CLIENT_NAME\".versions.nextcloud = \"$NEXTCLOUD_VERSION\"" "$REGISTRY_FILE"
yq eval -i ".clients.\"$CLIENT_NAME\".versions.traefik = \"$TRAEFIK_VERSION\"" "$REGISTRY_FILE"
yq eval -i ".clients.\"$CLIENT_NAME\".versions.ubuntu = \"$UBUNTU_VERSION\"" "$REGISTRY_FILE"
echo -e "${GREEN}✓ Registry updated${NC}"
echo ""
echo "Updated: $REGISTRY_FILE"
echo ""
echo "To view registry:"
echo " ./scripts/client-status.sh $CLIENT_NAME"

View file

@ -1,170 +0,0 @@
#!/usr/bin/env bash
#
# Configure Diun on all servers (disable watchRepo, add Docker Hub auth)
# Created: 2026-01-24
#
# This script runs the diun configuration playbook on each server
# with its corresponding SSH key.
#
# Usage:
# cd infrastructure/
# SOPS_AGE_KEY_FILE="keys/age-key.txt" HCLOUD_TOKEN="..." ./scripts/configure-diun-all-servers.sh
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ANSIBLE_DIR="$PROJECT_ROOT/ansible"
KEYS_DIR="$PROJECT_ROOT/keys/ssh"
PLAYBOOK="playbooks/260124-configure-diun-watchrepo.yml"
# Check required environment variables
if [ -z "${HCLOUD_TOKEN:-}" ]; then
echo -e "${RED}Error: HCLOUD_TOKEN environment variable is required${NC}"
exit 1
fi
if [ -z "${SOPS_AGE_KEY_FILE:-}" ]; then
echo -e "${RED}Error: SOPS_AGE_KEY_FILE environment variable is required${NC}"
exit 1
fi
# Convert SOPS_AGE_KEY_FILE to absolute path if it's relative
if [[ ! "$SOPS_AGE_KEY_FILE" = /* ]]; then
export SOPS_AGE_KEY_FILE="$PROJECT_ROOT/$SOPS_AGE_KEY_FILE"
fi
# Change to ansible directory
cd "$ANSIBLE_DIR"
echo -e "${BLUE}============================================================${NC}"
echo -e "${BLUE}Diun Configuration - All Servers${NC}"
echo -e "${BLUE}============================================================${NC}"
echo ""
echo "Playbook: $PLAYBOOK"
echo "Ansible directory: $ANSIBLE_DIR"
echo ""
echo "Configuration changes:"
echo " - Disable watchRepo (only check specific tags, not entire repos)"
echo " - Add Docker Hub authentication (5000 pulls/6h limit)"
echo " - Schedule: Weekly on Monday at 6am UTC"
echo ""
# Get list of all servers with SSH keys
SERVERS=()
for keyfile in "$KEYS_DIR"/*.pub; do
if [ -f "$keyfile" ]; then
server=$(basename "$keyfile" .pub)
# Skip special servers
if [[ "$server" != "README" ]] && [[ "$server" != "edge" ]]; then
SERVERS+=("$server")
fi
fi
done
echo -e "${BLUE}Found ${#SERVERS[@]} servers:${NC}"
printf '%s\n' "${SERVERS[@]}" | sort
echo ""
# Counters
SUCCESS_COUNT=0
FAILED_COUNT=0
SKIPPED_COUNT=0
declare -a SUCCESS_SERVERS
declare -a FAILED_SERVERS
declare -a SKIPPED_SERVERS
echo -e "${BLUE}============================================================${NC}"
echo -e "${BLUE}Starting configuration run...${NC}"
echo -e "${BLUE}============================================================${NC}"
echo ""
# Run playbook for each server
for server in "${SERVERS[@]}"; do
echo -e "${YELLOW}-----------------------------------------------------------${NC}"
echo -e "${YELLOW}Processing: $server${NC}"
echo -e "${YELLOW}-----------------------------------------------------------${NC}"
SSH_KEY="$KEYS_DIR/$server"
if [ ! -f "$SSH_KEY" ]; then
echo -e "${RED}✗ SSH key not found: $SSH_KEY${NC}"
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
SKIPPED_SERVERS+=("$server")
echo ""
continue
fi
# Run the playbook (with SSH options to prevent agent key issues)
if env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
SOPS_AGE_KEY_FILE="$SOPS_AGE_KEY_FILE" \
ANSIBLE_SSH_ARGS="-o IdentitiesOnly=yes" \
~/.local/bin/ansible-playbook \
-i hcloud.yml \
"$PLAYBOOK" \
--limit "$server" \
--private-key "$SSH_KEY" 2>&1; then
echo -e "${GREEN}✓ Success: $server${NC}"
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
SUCCESS_SERVERS+=("$server")
else
echo -e "${RED}✗ Failed: $server${NC}"
FAILED_COUNT=$((FAILED_COUNT + 1))
FAILED_SERVERS+=("$server")
fi
echo ""
done
# Summary
echo -e "${BLUE}============================================================${NC}"
echo -e "${BLUE}CONFIGURATION RUN SUMMARY${NC}"
echo -e "${BLUE}============================================================${NC}"
echo ""
echo "Total servers: ${#SERVERS[@]}"
echo -e "${GREEN}Successful: $SUCCESS_COUNT${NC}"
echo -e "${RED}Failed: $FAILED_COUNT${NC}"
echo -e "${YELLOW}Skipped: $SKIPPED_COUNT${NC}"
echo ""
if [ $SUCCESS_COUNT -gt 0 ]; then
echo -e "${GREEN}Successful servers:${NC}"
printf ' %s\n' "${SUCCESS_SERVERS[@]}"
echo ""
fi
if [ $FAILED_COUNT -gt 0 ]; then
echo -e "${RED}Failed servers:${NC}"
printf ' %s\n' "${FAILED_SERVERS[@]}"
echo ""
fi
if [ $SKIPPED_COUNT -gt 0 ]; then
echo -e "${YELLOW}Skipped servers:${NC}"
printf ' %s\n' "${SKIPPED_SERVERS[@]}"
echo ""
fi
echo -e "${BLUE}============================================================${NC}"
echo ""
echo "Next steps:"
echo " 1. Wait for next Monday at 6am UTC for scheduled run"
echo " 2. Or manually trigger: docker exec diun diun once"
echo " 3. Check logs: docker logs diun"
echo ""
# Exit with error if any failures
if [ $FAILED_COUNT -gt 0 ]; then
exit 1
fi
exit 0

View file

@ -1,156 +0,0 @@
#!/usr/bin/env bash
#
# Configure OIDC for a single client
#
# Usage: ./scripts/configure-oidc.sh <client_name>
#
# This script:
# 1. Creates OIDC provider in Authentik
# 2. Installs user_oidc app in Nextcloud
# 3. Configures OIDC connection
# 4. Enables multiple user backends
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Check arguments
if [ $# -ne 1 ]; then
echo -e "${RED}Error: Client name required${NC}"
echo "Usage: $0 <client_name>"
exit 1
fi
CLIENT_NAME="$1"
# Check environment variables
if [ -z "${SOPS_AGE_KEY_FILE:-}" ]; then
export SOPS_AGE_KEY_FILE="$PROJECT_ROOT/keys/age-key.txt"
fi
if [ -z "${HCLOUD_TOKEN:-}" ]; then
echo -e "${RED}Error: HCLOUD_TOKEN not set${NC}"
exit 1
fi
echo -e "${BLUE}Configuring OIDC for ${CLIENT_NAME}...${NC}"
cd "$PROJECT_ROOT"
# Step 1: Get credentials from secrets
echo "Getting credentials from secrets..."
TOKEN=$(sops -d "secrets/clients/${CLIENT_NAME}.sops.yaml" | grep authentik_bootstrap_token | awk '{print $2}')
if [ -z "$TOKEN" ]; then
echo -e "${RED}Error: Could not get Authentik token${NC}"
exit 1
fi
# Step 2: Create OIDC provider in Authentik
echo "Creating OIDC provider in Authentik..."
# Create Python script
cat > /tmp/create_oidc_${CLIENT_NAME}.py << EOFPYTHON
import sys, json, urllib.request
base_url, token = "http://localhost:9000", "${TOKEN}"
def req(p, m='GET', d=None):
r = urllib.request.Request(f"{base_url}{p}", json.dumps(d).encode() if d else None, {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, method=m)
try:
with urllib.request.urlopen(r, timeout=30) as resp: return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e: return e.code, json.loads(e.read()) if e.headers.get('Content-Type', '').startswith('application/json') else {'error': e.read().decode()}
s, d = req('/api/v3/flows/instances/')
auth_flow = next((f['pk'] for f in d.get('results', []) if f.get('slug') == 'default-authorization-flow' or f.get('designation') == 'authorization'), None)
inval_flow = next((f['pk'] for f in d.get('results', []) if f.get('slug') == 'default-invalidation-flow' or f.get('designation') == 'invalidation'), None)
s, d = req('/api/v3/crypto/certificatekeypairs/')
key = d.get('results', [{}])[0].get('pk') if d.get('results') else None
if not auth_flow or not key: print(json.dumps({'error': 'Config missing', 'auth_flow': auth_flow, 'key': key}), file=sys.stderr); sys.exit(1)
s, prov = req('/api/v3/providers/oauth2/', 'POST', {'name': 'Nextcloud', 'authorization_flow': auth_flow, 'invalidation_flow': inval_flow, 'client_type': 'confidential', 'redirect_uris': [{'matching_mode': 'strict', 'url': 'https://nextcloud.${CLIENT_NAME}.vrije.cloud/apps/user_oidc/code'}], 'signing_key': key, 'sub_mode': 'hashed_user_id', 'include_claims_in_id_token': True})
if s != 201: print(json.dumps({'error': 'Provider failed', 'status': s, 'details': prov}), file=sys.stderr); sys.exit(1)
s, app = req('/api/v3/core/applications/', 'POST', {'name': 'Nextcloud', 'slug': 'nextcloud', 'provider': prov['pk'], 'meta_launch_url': 'https://nextcloud.${CLIENT_NAME}.vrije.cloud'})
if s != 201: print(json.dumps({'error': 'App failed', 'status': s, 'details': app}), file=sys.stderr); sys.exit(1)
print(json.dumps({'success': True, 'provider_id': prov['pk'], 'application_id': app['pk'], 'client_id': prov['client_id'], 'client_secret': prov['client_secret'], 'discovery_uri': f"https://auth.${CLIENT_NAME}.vrije.cloud/application/o/nextcloud/.well-known/openid-configuration", 'issuer': f"https://auth.${CLIENT_NAME}.vrije.cloud/application/o/nextcloud/"}))
EOFPYTHON
# Copy script to server and execute
cd ansible
env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
ansible "${CLIENT_NAME}" \
-i hcloud.yml \
-m copy \
-a "src=/tmp/create_oidc_${CLIENT_NAME}.py dest=/tmp/create_oidc.py mode=0755" \
--private-key "../keys/ssh/${CLIENT_NAME}" > /dev/null 2>&1
# Execute the script
OIDC_RESULT=$(env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
ansible "${CLIENT_NAME}" \
-i hcloud.yml \
-m shell \
-a "docker exec -i authentik-server python3 < /tmp/create_oidc.py" \
--private-key "../keys/ssh/${CLIENT_NAME}" 2>/dev/null | grep -A1 "CHANGED" | tail -1)
if [ -z "$OIDC_RESULT" ]; then
echo -e "${RED}Error: Failed to create OIDC provider${NC}"
exit 1
fi
# Parse credentials
CLIENT_ID=$(echo "$OIDC_RESULT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d['client_id'])")
CLIENT_SECRET=$(echo "$OIDC_RESULT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d['client_secret'])")
DISCOVERY_URI=$(echo "$OIDC_RESULT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d['discovery_uri'])")
if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ] || [ -z "$DISCOVERY_URI" ]; then
echo -e "${RED}Error: Failed to parse OIDC credentials${NC}"
exit 1
fi
echo -e "${GREEN}✓ OIDC provider created${NC}"
# Step 3: Install user_oidc app in Nextcloud
echo "Installing user_oidc app..."
env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
ansible "${CLIENT_NAME}" \
-i hcloud.yml \
-m shell \
-a "docker exec -u www-data nextcloud php occ app:install user_oidc" \
--private-key "../keys/ssh/${CLIENT_NAME}" > /dev/null 2>&1 || true
echo -e "${GREEN}✓ user_oidc app installed${NC}"
# Step 4: Configure OIDC provider in Nextcloud
echo "Configuring OIDC provider..."
env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
ansible "${CLIENT_NAME}" \
-i hcloud.yml \
-m shell \
-a "docker exec -u www-data nextcloud php occ user_oidc:provider --clientid=\"${CLIENT_ID}\" --clientsecret=\"${CLIENT_SECRET}\" --discoveryuri=\"${DISCOVERY_URI}\" \"Authentik\"" \
--private-key "../keys/ssh/${CLIENT_NAME}" > /dev/null 2>&1
echo -e "${GREEN}✓ OIDC provider configured${NC}"
# Step 5: Configure OIDC settings
echo "Configuring OIDC settings..."
env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
ansible "${CLIENT_NAME}" \
-i hcloud.yml \
-m shell \
-a "docker exec -u www-data nextcloud php occ config:app:set user_oidc allow_multiple_user_backends --value=1 && docker exec -u www-data nextcloud php occ config:app:set user_oidc auto_provision --value=1 && docker exec -u www-data nextcloud php occ config:app:set user_oidc single_logout --value=0" \
--private-key "../keys/ssh/${CLIENT_NAME}" > /dev/null 2>&1
echo -e "${GREEN}✓ OIDC settings configured${NC}"
# Cleanup
rm -f /tmp/create_oidc_${CLIENT_NAME}.py
echo -e "${GREEN}✓ OIDC configuration complete for ${CLIENT_NAME}${NC}"

View file

@ -1,328 +0,0 @@
#!/usr/bin/env bash
#
# Deploy a fresh client from scratch
#
# Usage: ./scripts/deploy-client.sh <client_name>
#
# This script will:
# 1. Provision new VPS server (if not exists)
# 2. Setup base system (Docker, Traefik)
# 3. Deploy applications (Authentik, Nextcloud)
# 4. Configure SSO integration
#
# Result: Fully functional Authentik + Nextcloud with automated SSO
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Check arguments
if [ $# -ne 1 ]; then
echo -e "${RED}Error: Client name required${NC}"
echo "Usage: $0 <client_name>"
echo ""
echo "Example: $0 test"
exit 1
fi
CLIENT_NAME="$1"
# Check if SSH key exists, generate if missing
SSH_KEY_FILE="$PROJECT_ROOT/keys/ssh/${CLIENT_NAME}"
if [ ! -f "$SSH_KEY_FILE" ]; then
echo -e "${YELLOW}SSH key not found for client: $CLIENT_NAME${NC}"
echo "Generating SSH key pair automatically..."
echo ""
# Generate SSH key
"$SCRIPT_DIR/generate-client-keys.sh" "$CLIENT_NAME"
echo ""
echo -e "${GREEN}✓ SSH key generated${NC}"
echo ""
fi
# Check if secrets file exists, create from template if missing
SECRETS_FILE="$PROJECT_ROOT/secrets/clients/${CLIENT_NAME}.sops.yaml"
TEMPLATE_FILE="$PROJECT_ROOT/secrets/clients/template.sops.yaml"
if [ ! -f "$SECRETS_FILE" ]; then
echo -e "${YELLOW}Secrets file not found for client: $CLIENT_NAME${NC}"
echo "Creating from template and opening for editing..."
echo ""
# Check if template exists
if [ ! -f "$TEMPLATE_FILE" ]; then
echo -e "${RED}Error: Template file not found: $TEMPLATE_FILE${NC}"
exit 1
fi
# Copy template and decrypt to temporary file
if [ -z "${SOPS_AGE_KEY_FILE:-}" ]; then
export SOPS_AGE_KEY_FILE="$PROJECT_ROOT/keys/age-key.txt"
fi
# Decrypt template to temp file
TEMP_PLAIN=$(mktemp)
sops -d "$TEMPLATE_FILE" > "$TEMP_PLAIN"
# Replace client name placeholders
sed -i '' "s/test/${CLIENT_NAME}/g" "$TEMP_PLAIN"
# Create unencrypted file in correct location (matching .sops.yaml regex)
# This is necessary because SOPS needs the file path to match creation rules
TEMP_SOPS="${SECRETS_FILE%.sops.yaml}-unenc.sops.yaml"
cat "$TEMP_PLAIN" > "$TEMP_SOPS"
# Encrypt in-place (SOPS finds creation rules because path matches regex)
sops --encrypt --in-place "$TEMP_SOPS"
# Rename to final name
mv "$TEMP_SOPS" "$SECRETS_FILE"
# Cleanup
rm "$TEMP_PLAIN"
echo -e "${GREEN}✓ Created secrets file with client-specific domains${NC}"
echo ""
# Automatically generate unique passwords
echo -e "${BLUE}Generating unique passwords for ${CLIENT_NAME}...${NC}"
echo ""
# Call the password generator script
"$SCRIPT_DIR/generate-passwords.sh" "$CLIENT_NAME"
echo ""
echo -e "${GREEN}✓ Secrets file configured with unique passwords${NC}"
echo ""
echo -e "${YELLOW}To view credentials:${NC}"
echo -e " ${BLUE}./scripts/get-passwords.sh ${CLIENT_NAME}${NC}"
echo ""
fi
# Load Hetzner API token from SOPS if not already set
if [ -z "${HCLOUD_TOKEN:-}" ]; then
echo -e "${BLUE}Loading Hetzner API token from SOPS...${NC}"
# shellcheck source=scripts/load-secrets-env.sh
source "$SCRIPT_DIR/load-secrets-env.sh"
echo ""
fi
if [ -z "${SOPS_AGE_KEY_FILE:-}" ]; then
echo -e "${YELLOW}Warning: SOPS_AGE_KEY_FILE not set, using default${NC}"
export SOPS_AGE_KEY_FILE="$PROJECT_ROOT/keys/age-key.txt"
fi
# Verify client is defined in terraform.tfvars
cd "$PROJECT_ROOT/tofu"
if ! grep -q "\"$CLIENT_NAME\"" terraform.tfvars 2>/dev/null; then
echo -e "${YELLOW}⚠ Client '$CLIENT_NAME' not found in terraform.tfvars${NC}"
echo ""
echo "Add the following to tofu/terraform.tfvars:"
echo ""
echo "clients = {"
echo " \"$CLIENT_NAME\" = {"
echo " server_type = \"cx22\" # 2 vCPU, 4GB RAM"
echo " location = \"nbg1\" # Nuremberg, Germany"
echo " }"
echo "}"
echo ""
read -p "Continue anyway? (yes/no): " continue_confirm
if [ "$continue_confirm" != "yes" ]; then
exit 1
fi
fi
# Check if client exists in terraform.tfvars
TFVARS_FILE="$PROJECT_ROOT/tofu/terraform.tfvars"
if ! grep -q "^[[:space:]]*${CLIENT_NAME}[[:space:]]*=" "$TFVARS_FILE"; then
echo -e "${YELLOW}⚠ Client '${CLIENT_NAME}' not found in terraform.tfvars${NC}"
echo ""
echo "The client must be added to OpenTofu configuration before deployment."
echo ""
read -p "Would you like to add it now? (yes/no): " add_confirm
if [ "$add_confirm" = "yes" ]; then
echo ""
"$SCRIPT_DIR/add-client-to-terraform.sh" "$CLIENT_NAME"
echo ""
else
echo -e "${RED}Error: Cannot deploy without OpenTofu configuration${NC}"
echo ""
echo "Add the client manually to tofu/terraform.tfvars, or run:"
echo " ./scripts/add-client-to-terraform.sh $CLIENT_NAME"
exit 1
fi
fi
# Start timer
START_TIME=$(date +%s)
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}Deploying fresh client: $CLIENT_NAME${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Step 1: Provision infrastructure
echo -e "${YELLOW}[1/5] Provisioning infrastructure with OpenTofu...${NC}"
cd "$PROJECT_ROOT/tofu"
# Export TF_VAR environment variables if HCLOUD_TOKEN is set
if [ -n "${HCLOUD_TOKEN:-}" ]; then
export TF_VAR_hcloud_token="$HCLOUD_TOKEN"
export TF_VAR_hetznerdns_token="$HCLOUD_TOKEN"
fi
# Check if already exists
if tofu state list 2>/dev/null | grep -q "hcloud_server.client\[\"$CLIENT_NAME\"\]"; then
echo -e "${YELLOW}⚠ Server already exists, applying any missing DNS records...${NC}"
tofu apply -auto-approve -var-file="terraform.tfvars"
else
# Apply full infrastructure (server + DNS)
tofu apply -auto-approve -var-file="terraform.tfvars"
echo ""
echo -e "${GREEN}✓ Infrastructure provisioned${NC}"
echo ""
# Wait for server to be ready
echo -e "${YELLOW}Waiting 60 seconds for server to initialize...${NC}"
sleep 60
fi
echo ""
# Step 2: Setup base system
echo -e "${YELLOW}[2/5] Setting up base system (Docker, Traefik)...${NC}"
cd "$PROJECT_ROOT/ansible"
~/.local/bin/ansible-playbook -i hcloud.yml playbooks/setup.yml --limit "$CLIENT_NAME" --private-key "../keys/ssh/$CLIENT_NAME"
echo ""
echo -e "${GREEN}✓ Base system configured${NC}"
echo ""
# Step 3: Deploy applications
echo -e "${YELLOW}[3/5] Deploying applications (Authentik, Nextcloud, SSO)...${NC}"
~/.local/bin/ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit "$CLIENT_NAME" --private-key "../keys/ssh/$CLIENT_NAME"
echo ""
echo -e "${GREEN}✓ Applications deployed${NC}"
echo ""
# Step 4: Update client registry
echo -e "${YELLOW}[4/5] Updating client registry...${NC}"
cd "$PROJECT_ROOT/tofu"
# Get server information from Terraform state
SERVER_IP=$(tofu output -json client_ips 2>/dev/null | jq -r ".\"$CLIENT_NAME\"" || echo "")
SERVER_ID=$(tofu state show "hcloud_server.client[\"$CLIENT_NAME\"]" 2>/dev/null | grep "^[[:space:]]*id[[:space:]]*=" | awk '{print $3}' | tr -d '"' || echo "")
SERVER_TYPE=$(tofu state show "hcloud_server.client[\"$CLIENT_NAME\"]" 2>/dev/null | grep "^[[:space:]]*server_type[[:space:]]*=" | awk '{print $3}' | tr -d '"' || echo "")
SERVER_LOCATION=$(tofu state show "hcloud_server.client[\"$CLIENT_NAME\"]" 2>/dev/null | grep "^[[:space:]]*location[[:space:]]*=" | awk '{print $3}' | tr -d '"' || echo "")
# Determine role (dev is canary, everything else is production by default)
ROLE="production"
if [ "$CLIENT_NAME" = "dev" ]; then
ROLE="canary"
fi
# Update registry
"$SCRIPT_DIR/update-registry.sh" "$CLIENT_NAME" deploy \
--role="$ROLE" \
--server-ip="$SERVER_IP" \
--server-id="$SERVER_ID" \
--server-type="$SERVER_TYPE" \
--server-location="$SERVER_LOCATION"
echo ""
echo -e "${GREEN}✓ Registry updated${NC}"
echo ""
# Collect deployed versions
echo -e "${YELLOW}Collecting deployed versions...${NC}"
"$SCRIPT_DIR/collect-client-versions.sh" "$CLIENT_NAME" 2>/dev/null || {
echo -e "${YELLOW}⚠ Could not collect versions automatically${NC}"
echo "Run manually later: ./scripts/collect-client-versions.sh $CLIENT_NAME"
}
echo ""
# Add to monitoring
echo -e "${YELLOW}[5/5] Adding client to monitoring...${NC}"
echo ""
if [ -f "$SCRIPT_DIR/add-client-to-monitoring.sh" ]; then
"$SCRIPT_DIR/add-client-to-monitoring.sh" "$CLIENT_NAME"
else
echo -e "${YELLOW}⚠ Monitoring script not found${NC}"
echo "Manually add monitors at: https://status.vrije.cloud"
fi
echo ""
# Calculate duration
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
MINUTES=$((DURATION / 60))
SECONDS=$((DURATION % 60))
# Success summary
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}✓ Deployment complete!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "${BLUE}Time taken: ${MINUTES}m ${SECONDS}s${NC}"
echo ""
echo "Services deployed:"
# Load client domains from secrets
CLIENT_DOMAIN=$(sops -d "$SECRETS_FILE" | grep "^client_domain:" | awk '{print $2}')
AUTHENTIK_DOMAIN=$(sops -d "$SECRETS_FILE" | grep "^authentik_domain:" | awk '{print $2}')
NEXTCLOUD_DOMAIN=$(sops -d "$SECRETS_FILE" | grep "^nextcloud_domain:" | awk '{print $2}')
BOOTSTRAP_PASSWORD=$(sops -d "$SECRETS_FILE" | grep "^authentik_bootstrap_password:" | awk '{print $2}')
NEXTCLOUD_PASSWORD=$(sops -d "$SECRETS_FILE" | grep "^nextcloud_admin_password:" | awk '{print $2}')
echo " ✓ Authentik SSO: https://$AUTHENTIK_DOMAIN"
echo " ✓ Nextcloud: https://$NEXTCLOUD_DOMAIN"
echo ""
echo "Admin credentials:"
echo " Authentik:"
echo " Username: akadmin"
echo " Password: $BOOTSTRAP_PASSWORD"
echo ""
echo " Nextcloud:"
echo " Username: admin"
echo " Password: $NEXTCLOUD_PASSWORD"
echo ""
echo -e "${GREEN}✓ SSO Integration: Fully automated and configured${NC}"
echo " Users can login to Nextcloud with Authentik credentials"
echo " 'Login with Authentik' button is already visible"
echo ""
echo -e "${GREEN}Ready to use! No manual configuration required.${NC}"
echo ""
echo "Next steps:"
echo " 1. Login to Authentik: https://$AUTHENTIK_DOMAIN"
echo " 2. Create user accounts in Authentik"
echo " 3. Users can login to Nextcloud with those credentials"
echo ""
echo "Management commands:"
echo " View secrets: sops $SECRETS_FILE"
echo " Rebuild server: ./scripts/rebuild-client.sh $CLIENT_NAME"
echo " Destroy server: ./scripts/destroy-client.sh $CLIENT_NAME"
echo ""

View file

@ -1,273 +0,0 @@
#!/usr/bin/env bash
#
# Destroy a client's infrastructure
#
# Usage: ./scripts/destroy-client.sh <client_name>
#
# This script will:
# 1. Remove all Docker containers and volumes on the server
# 2. Destroy the VPS server via OpenTofu
# 3. Remove DNS records
#
# WARNING: This is DESTRUCTIVE and IRREVERSIBLE!
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Check arguments
if [ $# -ne 1 ]; then
echo -e "${RED}Error: Client name required${NC}"
echo "Usage: $0 <client_name>"
echo ""
echo "Example: $0 test"
exit 1
fi
CLIENT_NAME="$1"
# Check if secrets file exists
SECRETS_FILE="$PROJECT_ROOT/secrets/clients/${CLIENT_NAME}.sops.yaml"
if [ ! -f "$SECRETS_FILE" ]; then
echo -e "${RED}Error: Secrets file not found: $SECRETS_FILE${NC}"
exit 1
fi
# Load Hetzner API token from SOPS if not already set
if [ -z "${HCLOUD_TOKEN:-}" ]; then
echo -e "${BLUE}Loading Hetzner API token from SOPS...${NC}"
# shellcheck source=scripts/load-secrets-env.sh
source "$SCRIPT_DIR/load-secrets-env.sh"
echo ""
fi
if [ -z "${SOPS_AGE_KEY_FILE:-}" ]; then
echo -e "${YELLOW}Warning: SOPS_AGE_KEY_FILE not set, using default${NC}"
export SOPS_AGE_KEY_FILE="$PROJECT_ROOT/keys/age-key.txt"
fi
# Confirmation prompt
echo -e "${RED}========================================${NC}"
echo -e "${RED}WARNING: DESTRUCTIVE OPERATION${NC}"
echo -e "${RED}========================================${NC}"
echo ""
echo -e "This will ${RED}PERMANENTLY DELETE${NC}:"
echo " - VPS server for client: $CLIENT_NAME"
echo " - All Docker containers and volumes"
echo " - All DNS records"
echo " - All data on the server"
echo ""
echo -e "${YELLOW}This operation CANNOT be undone!${NC}"
echo ""
read -p "Type the client name '$CLIENT_NAME' to confirm: " confirmation
if [ "$confirmation" != "$CLIENT_NAME" ]; then
echo -e "${RED}Confirmation failed. Aborting.${NC}"
exit 1
fi
echo ""
echo -e "${YELLOW}Starting destruction of client: $CLIENT_NAME${NC}"
echo ""
# Step 0: Remove from monitoring
echo -e "${YELLOW}[0/7] Removing client from monitoring...${NC}"
echo ""
if [ -f "$SCRIPT_DIR/remove-client-from-monitoring.sh" ]; then
"$SCRIPT_DIR/remove-client-from-monitoring.sh" "$CLIENT_NAME"
else
echo -e "${YELLOW}⚠ Monitoring script not found${NC}"
echo "Manually remove monitors at: https://status.vrije.cloud"
fi
echo ""
# Step 1: Delete Mailgun SMTP credentials
echo -e "${YELLOW}[1/7] Deleting Mailgun SMTP credentials...${NC}"
cd "$PROJECT_ROOT/ansible"
# Run cleanup playbook to delete SMTP credentials
~/.local/bin/ansible-playbook -i hcloud.yml playbooks/cleanup.yml --limit "$CLIENT_NAME" 2>/dev/null || echo -e "${YELLOW}⚠ Could not delete SMTP credentials (API key may not be configured)${NC}"
echo -e "${GREEN}✓ SMTP credentials cleanup attempted${NC}"
echo ""
# Step 2: Clean up Docker containers and volumes on the server (if reachable)
echo -e "${YELLOW}[2/7] Cleaning up Docker containers and volumes...${NC}"
# Try to use per-client SSH key if it exists
SSH_KEY_ARG=""
if [ -f "$PROJECT_ROOT/keys/ssh/${CLIENT_NAME}" ]; then
SSH_KEY_ARG="--private-key=$PROJECT_ROOT/keys/ssh/${CLIENT_NAME}"
fi
if ~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" $SSH_KEY_ARG -m ping -o &>/dev/null; then
echo "Server is reachable, cleaning up Docker resources..."
# Stop and remove all containers
~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" $SSH_KEY_ARG -m shell -a "docker ps -aq | xargs -r docker stop" -b 2>/dev/null || true
~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" $SSH_KEY_ARG -m shell -a "docker ps -aq | xargs -r docker rm -f" -b 2>/dev/null || true
# Remove all volumes
~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" $SSH_KEY_ARG -m shell -a "docker volume ls -q | xargs -r docker volume rm -f" -b 2>/dev/null || true
# Remove all networks (except defaults)
~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" $SSH_KEY_ARG -m shell -a "docker network ls --filter type=custom -q | xargs -r docker network rm" -b 2>/dev/null || true
echo -e "${GREEN}✓ Docker cleanup complete${NC}"
else
echo -e "${YELLOW}⚠ Server not reachable, skipping Docker cleanup${NC}"
fi
echo ""
# Step 3: Destroy infrastructure with OpenTofu
echo -e "${YELLOW}[3/7] Destroying infrastructure with OpenTofu...${NC}"
cd "$PROJECT_ROOT/tofu"
# Destroy all resources for this client (server, volume, SSH key, DNS)
echo "Checking current infrastructure..."
tofu plan -destroy -var-file="terraform.tfvars" \
-target="hcloud_server.client[\"$CLIENT_NAME\"]" \
-target="hcloud_volume.nextcloud_data[\"$CLIENT_NAME\"]" \
-target="hcloud_volume_attachment.nextcloud_data[\"$CLIENT_NAME\"]" \
-target="hcloud_ssh_key.client[\"$CLIENT_NAME\"]" \
-target="hcloud_zone_rrset.client_a[\"$CLIENT_NAME\"]" \
-target="hcloud_zone_rrset.client_wildcard[\"$CLIENT_NAME\"]" \
-out=destroy.tfplan
echo ""
echo "Verifying plan only targets $CLIENT_NAME resources..."
# Verify the plan only contains the client's resources
PLAN_OUTPUT=$(tofu show destroy.tfplan 2>&1)
if echo "$PLAN_OUTPUT" | grep -E "will be destroyed" | grep -v "\"$CLIENT_NAME\"" | grep -q .; then
echo -e "${RED}ERROR: Plan contains resources NOT belonging to $CLIENT_NAME!${NC}"
echo ""
echo "Resources in plan:"
echo "$PLAN_OUTPUT" | grep -E "# .* will be destroyed" | head -20
echo ""
echo "Aborting to prevent accidental destruction of other clients."
rm -f destroy.tfplan
exit 1
fi
echo -e "${GREEN}✓ Plan verified - only $CLIENT_NAME resources will be destroyed${NC}"
echo ""
echo "Applying destruction..."
tofu apply -auto-approve destroy.tfplan
# Cleanup plan file
rm -f destroy.tfplan
echo -e "${GREEN}✓ Infrastructure destroyed${NC}"
echo ""
# Step 4: Remove client from terraform.tfvars
echo -e "${YELLOW}[4/7] Removing client from terraform.tfvars...${NC}"
TFVARS_FILE="$PROJECT_ROOT/tofu/terraform.tfvars"
if grep -q "^[[:space:]]*${CLIENT_NAME}[[:space:]]*=" "$TFVARS_FILE"; then
# Create backup
cp "$TFVARS_FILE" "$TFVARS_FILE.bak"
# Remove the client block (from "client_name = {" to the closing "}")
# This uses awk to find and remove the entire block
awk -v client="$CLIENT_NAME" '
BEGIN { skip=0; in_block=0 }
/^[[:space:]]*#.*[Cc]lient/ { if (skip==0) print; next }
$0 ~ "^[[:space:]]*" client "[[:space:]]*=" { skip=1; in_block=1; brace_count=0; next }
skip==1 {
for(i=1; i<=length($0); i++) {
c=substr($0,i,1)
if(c=="{") brace_count++
if(c=="}") brace_count--
}
if(brace_count<0 || (brace_count==0 && $0 ~ /^[[:space:]]*}/)) {
skip=0
in_block=0
next
}
next
}
{ print }
' "$TFVARS_FILE" > "$TFVARS_FILE.tmp"
mv "$TFVARS_FILE.tmp" "$TFVARS_FILE"
echo -e "${GREEN}✓ Removed $CLIENT_NAME from terraform.tfvars${NC}"
else
echo -e "${YELLOW}⚠ Client not found in terraform.tfvars${NC}"
fi
echo ""
# Step 5: Remove SSH keys
echo -e "${YELLOW}[5/7] Removing SSH keys...${NC}"
SSH_PRIVATE="$PROJECT_ROOT/keys/ssh/${CLIENT_NAME}"
SSH_PUBLIC="$PROJECT_ROOT/keys/ssh/${CLIENT_NAME}.pub"
if [ -f "$SSH_PRIVATE" ]; then
rm -f "$SSH_PRIVATE"
echo -e "${GREEN}✓ Removed private key: $SSH_PRIVATE${NC}"
else
echo -e "${YELLOW}⚠ Private key not found${NC}"
fi
if [ -f "$SSH_PUBLIC" ]; then
rm -f "$SSH_PUBLIC"
echo -e "${GREEN}✓ Removed public key: $SSH_PUBLIC${NC}"
else
echo -e "${YELLOW}⚠ Public key not found${NC}"
fi
echo ""
# Step 6: Remove secrets file
echo -e "${YELLOW}[6/7] Removing secrets file...${NC}"
if [ -f "$SECRETS_FILE" ]; then
rm -f "$SECRETS_FILE"
echo -e "${GREEN}✓ Removed secrets file: $SECRETS_FILE${NC}"
else
echo -e "${YELLOW}⚠ Secrets file not found${NC}"
fi
echo ""
# Step 7: Update client registry
echo -e "${YELLOW}[7/7] Updating client registry...${NC}"
"$SCRIPT_DIR/update-registry.sh" "$CLIENT_NAME" destroy
echo ""
echo -e "${GREEN}✓ Registry updated${NC}"
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}✓ Client '$CLIENT_NAME' destroyed successfully${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo "The following have been removed:"
echo " ✓ Mailgun SMTP credentials"
echo " ✓ VPS server"
echo " ✓ Hetzner Volume"
echo " ✓ SSH keys (Hetzner + local)"
echo " ✓ DNS records"
echo " ✓ Firewall rules"
echo " ✓ Secrets file"
echo " ✓ terraform.tfvars entry"
echo " ✓ Registry entry"
echo ""
echo "The client has been completely removed from the infrastructure."
echo ""

View file

@ -1,228 +0,0 @@
#!/usr/bin/env bash
#
# Detect version drift between clients
#
# Usage: ./scripts/detect-version-drift.sh [options]
#
# Options:
# --threshold=<days> Only report clients not updated in X days (default: 30)
# --app=<name> Check specific app only (authentik|nextcloud|traefik|ubuntu)
# --format=table Show as table (default)
# --format=summary Show summary only
#
# Exit codes:
# 0 - No drift detected
# 1 - Drift detected
# 2 - Error
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
REGISTRY_FILE="$PROJECT_ROOT/clients/registry.yml"
# Default options
THRESHOLD_DAYS=30
FILTER_APP=""
FORMAT="table"
# Parse arguments
for arg in "$@"; do
case $arg in
--threshold=*)
THRESHOLD_DAYS="${arg#*=}"
;;
--app=*)
FILTER_APP="${arg#*=}"
;;
--format=*)
FORMAT="${arg#*=}"
;;
*)
echo "Unknown option: $arg"
echo "Usage: $0 [--threshold=<days>] [--app=<name>] [--format=table|summary]"
exit 2
;;
esac
done
# Check if yq is available
if ! command -v yq &> /dev/null; then
echo -e "${RED}Error: 'yq' not found. Install with: brew install yq${NC}"
exit 2
fi
# Check if registry exists
if [ ! -f "$REGISTRY_FILE" ]; then
echo -e "${RED}Error: Registry file not found: $REGISTRY_FILE${NC}"
exit 2
fi
# Get list of deployed clients only
CLIENTS=$(yq eval '.clients | to_entries | map(select(.value.status == "deployed")) | .[].key' "$REGISTRY_FILE" 2>/dev/null)
if [ -z "$CLIENTS" ]; then
echo -e "${YELLOW}No deployed clients found${NC}"
exit 0
fi
# Determine latest versions
declare -A LATEST_VERSIONS
LATEST_VERSIONS[authentik]=$(yq eval '.clients | to_entries | .[].value.versions.authentik' "$REGISTRY_FILE" | sort -V | tail -1)
LATEST_VERSIONS[nextcloud]=$(yq eval '.clients | to_entries | .[].value.versions.nextcloud' "$REGISTRY_FILE" | sort -V | tail -1)
LATEST_VERSIONS[traefik]=$(yq eval '.clients | to_entries | .[].value.versions.traefik' "$REGISTRY_FILE" | sort -V | tail -1)
LATEST_VERSIONS[ubuntu]=$(yq eval '.clients | to_entries | .[].value.versions.ubuntu' "$REGISTRY_FILE" | sort -V | tail -1)
# Calculate date threshold
if command -v gdate &> /dev/null; then
# macOS with GNU coreutils
THRESHOLD_DATE=$(gdate -d "$THRESHOLD_DAYS days ago" +%Y-%m-%d)
elif date --version &> /dev/null 2>&1; then
# GNU date (Linux)
THRESHOLD_DATE=$(date -d "$THRESHOLD_DAYS days ago" +%Y-%m-%d)
else
# BSD date (macOS default)
THRESHOLD_DATE=$(date -v-${THRESHOLD_DAYS}d +%Y-%m-%d)
fi
# Counters
DRIFT_FOUND=0
OUTDATED_COUNT=0
STALE_COUNT=0
# Arrays to store drift details
declare -a DRIFT_CLIENTS
declare -a DRIFT_DETAILS
# Analyze each client
for client in $CLIENTS; do
authentik=$(yq eval ".clients.\"$client\".versions.authentik" "$REGISTRY_FILE")
nextcloud=$(yq eval ".clients.\"$client\".versions.nextcloud" "$REGISTRY_FILE")
traefik=$(yq eval ".clients.\"$client\".versions.traefik" "$REGISTRY_FILE")
ubuntu=$(yq eval ".clients.\"$client\".versions.ubuntu" "$REGISTRY_FILE")
last_update=$(yq eval ".clients.\"$client\".maintenance.last_full_update" "$REGISTRY_FILE")
has_drift=false
drift_reasons=()
# Check version drift
if [ -z "$FILTER_APP" ] || [ "$FILTER_APP" = "authentik" ]; then
if [ "$authentik" != "${LATEST_VERSIONS[authentik]}" ] && [ "$authentik" != "null" ] && [ "$authentik" != "unknown" ]; then
has_drift=true
drift_reasons+=("Authentik: $authentik${LATEST_VERSIONS[authentik]}")
fi
fi
if [ -z "$FILTER_APP" ] || [ "$FILTER_APP" = "nextcloud" ]; then
if [ "$nextcloud" != "${LATEST_VERSIONS[nextcloud]}" ] && [ "$nextcloud" != "null" ] && [ "$nextcloud" != "unknown" ]; then
has_drift=true
drift_reasons+=("Nextcloud: $nextcloud${LATEST_VERSIONS[nextcloud]}")
fi
fi
if [ -z "$FILTER_APP" ] || [ "$FILTER_APP" = "traefik" ]; then
if [ "$traefik" != "${LATEST_VERSIONS[traefik]}" ] && [ "$traefik" != "null" ] && [ "$traefik" != "unknown" ]; then
has_drift=true
drift_reasons+=("Traefik: $traefik${LATEST_VERSIONS[traefik]}")
fi
fi
if [ -z "$FILTER_APP" ] || [ "$FILTER_APP" = "ubuntu" ]; then
if [ "$ubuntu" != "${LATEST_VERSIONS[ubuntu]}" ] && [ "$ubuntu" != "null" ] && [ "$ubuntu" != "unknown" ]; then
has_drift=true
drift_reasons+=("Ubuntu: $ubuntu${LATEST_VERSIONS[ubuntu]}")
fi
fi
# Check if update is stale (older than threshold)
is_stale=false
if [ "$last_update" != "null" ] && [ -n "$last_update" ]; then
if [[ "$last_update" < "$THRESHOLD_DATE" ]]; then
is_stale=true
drift_reasons+=("Last update: $last_update (>$THRESHOLD_DAYS days ago)")
fi
fi
# Record drift
if [ "$has_drift" = true ] || [ "$is_stale" = true ]; then
DRIFT_FOUND=1
DRIFT_CLIENTS+=("$client")
DRIFT_DETAILS+=("$(IFS='; '; echo "${drift_reasons[*]}")")
[ "$has_drift" = true ] && ((OUTDATED_COUNT++)) || true
[ "$is_stale" = true ] && ((STALE_COUNT++)) || true
fi
done
# Output results
case $FORMAT in
table)
if [ $DRIFT_FOUND -eq 0 ]; then
echo -e "${GREEN}✓ No version drift detected${NC}"
echo ""
echo "All deployed clients are running latest versions:"
echo " Authentik: ${LATEST_VERSIONS[authentik]}"
echo " Nextcloud: ${LATEST_VERSIONS[nextcloud]}"
echo " Traefik: ${LATEST_VERSIONS[traefik]}"
echo " Ubuntu: ${LATEST_VERSIONS[ubuntu]}"
echo ""
else
echo -e "${RED}⚠ VERSION DRIFT DETECTED${NC}"
echo ""
echo -e "${CYAN}Clients with outdated versions:${NC}"
echo ""
for i in "${!DRIFT_CLIENTS[@]}"; do
client="${DRIFT_CLIENTS[$i]}"
details="${DRIFT_DETAILS[$i]}"
echo -e "${YELLOW}$client${NC}"
IFS=';' read -ra REASONS <<< "$details"
for reason in "${REASONS[@]}"; do
echo " $reason"
done
echo ""
done
echo -e "${CYAN}Recommended actions:${NC}"
echo ""
echo "1. Test updates on canary server first:"
echo " ${BLUE}./scripts/rebuild-client.sh dev${NC}"
echo ""
echo "2. Verify canary health:"
echo " ${BLUE}./scripts/client-status.sh dev${NC}"
echo ""
echo "3. Update outdated clients:"
for client in "${DRIFT_CLIENTS[@]}"; do
echo " ${BLUE}./scripts/rebuild-client.sh $client${NC}"
done
echo ""
fi
;;
summary)
if [ $DRIFT_FOUND -eq 0 ]; then
echo "Status: OK"
echo "Drift: No"
echo "Clients checked: $(echo "$CLIENTS" | wc -l | xargs)"
else
echo "Status: DRIFT DETECTED"
echo "Drift: Yes"
echo "Clients checked: $(echo "$CLIENTS" | wc -l | xargs)"
echo "Clients with outdated versions: $OUTDATED_COUNT"
echo "Clients not updated in $THRESHOLD_DAYS days: $STALE_COUNT"
echo "Affected clients: ${DRIFT_CLIENTS[*]}"
fi
;;
esac
exit $DRIFT_FOUND

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