Compare commits
72 commits
feature/ne
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b631232a8 | ||
|
|
9921b3f96c | ||
|
|
9a38486322 | ||
|
|
12d9fc06e5 | ||
|
|
39c57d583a | ||
|
|
60513601d4 | ||
|
|
6af727f665 | ||
|
|
fb90d77dbc | ||
|
|
7e91e0e9de | ||
|
|
c56ba5d567 | ||
|
|
14256bcbce | ||
|
|
27d59e4cd3 | ||
|
|
e092931cb7 | ||
|
|
b6c9fa666d | ||
|
|
825ed29b25 | ||
|
|
52d8e40348 | ||
|
|
9dda882f63 | ||
|
|
c8793bb910 | ||
|
|
55fd2be9e5 | ||
|
|
79635eeece | ||
|
|
13685eb454 | ||
|
|
f40acee0a3 | ||
|
|
ecc09127ef | ||
|
|
2a107cbf14 | ||
|
|
7e2ade2d98 | ||
|
|
4906b13482 | ||
|
|
3e934f98a0 | ||
|
|
9a3afa325b | ||
|
|
5fc95d7127 | ||
|
|
e04efa1cb1 | ||
|
|
8a88096619 | ||
|
|
f795920f24 | ||
|
|
df3a98714c | ||
|
|
62977285ad | ||
|
|
9eb6f2028a | ||
|
|
0c4d536246 | ||
|
|
bf4659f662 | ||
|
|
ac4187d041 | ||
|
|
071ed083f7 | ||
|
|
e15fe78488 | ||
|
|
dc14b12688 | ||
|
|
6cd6d7cc79 | ||
|
|
fcc5b7bca2 | ||
|
|
918a43e820 | ||
|
|
847b2ad052 | ||
|
|
af2799170c | ||
|
|
508825ca5a | ||
|
|
22e526d56b | ||
|
|
90a92fca5a | ||
|
|
2d94df6a8a | ||
|
|
9571782382 | ||
|
|
669d70f98e | ||
|
|
325c9e6cf8 | ||
|
|
64e76036b2 | ||
|
|
2ccdf4aebf | ||
|
|
fb945c8737 | ||
|
|
45a41e3752 | ||
|
|
b300503e14 | ||
|
|
301394df14 | ||
|
|
5b38c4b5b4 | ||
|
|
8c3553d89f | ||
|
|
c1c690c565 | ||
|
|
30b3b394a6 | ||
|
|
671ebc985b | ||
|
|
d95862f522 | ||
|
|
bb41dbbbe3 | ||
|
|
797d5b4e36 | ||
|
|
7029de5bc9 | ||
|
|
5e46cf9cc4 | ||
|
|
b231ebec21 | ||
|
|
a5fe631717 | ||
|
|
20856f7f18 |
124 changed files with 9442 additions and 1131 deletions
|
|
@ -37,7 +37,7 @@ High-level guardian of the infrastructure architecture, ensuring consistency, ma
|
||||||
| Secrets | SOPS + Age | Simple, no server needed |
|
| Secrets | SOPS + Age | Simple, no server needed |
|
||||||
| Hosting | Hetzner | German, family-owned, GDPR |
|
| Hosting | Hetzner | German, family-owned, GDPR |
|
||||||
| DNS | Hetzner DNS | Single provider simplicity |
|
| DNS | Hetzner DNS | Single provider simplicity |
|
||||||
| Identity | Zitadel | Swiss company, AGPL |
|
| Identity | Authentik | German project lead |
|
||||||
| File Sync | Nextcloud | German company, AGPL |
|
| File Sync | Nextcloud | German company, AGPL |
|
||||||
| Reverse Proxy | Traefik | French company, MIT |
|
| Reverse Proxy | Traefik | French company, MIT |
|
||||||
| Backup | Restic → Hetzner Storage Box | Open source, EU storage |
|
| 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
|
### Does NOT Handle
|
||||||
- Writing OpenTofu configurations (→ Infrastructure Agent)
|
- Writing OpenTofu configurations (→ Infrastructure Agent)
|
||||||
- Writing Ansible playbooks or roles (→ Infrastructure Agent)
|
- Writing Ansible playbooks or roles (→ Infrastructure Agent)
|
||||||
- Zitadel-specific configuration (→ Zitadel Agent)
|
- Authentik-specific configuration (→ Authentik Agent)
|
||||||
- Nextcloud-specific configuration (→ Nextcloud Agent)
|
- Nextcloud-specific configuration (→ Nextcloud Agent)
|
||||||
- Debugging application issues (→ respective App Agent)
|
- Debugging application issues (→ respective App Agent)
|
||||||
|
|
||||||
### Defers To
|
### Defers To
|
||||||
- **Infrastructure Agent**: All IaC implementation questions
|
- **Infrastructure Agent**: All IaC implementation questions
|
||||||
- **Zitadel Agent**: Identity, SSO, OIDC specifics
|
- **Authentik Agent**: Identity, SSO, OIDC specifics
|
||||||
- **Nextcloud Agent**: Nextcloud features, `occ` commands
|
- **Nextcloud Agent**: Nextcloud features, `occ` commands
|
||||||
|
|
||||||
### Escalates When
|
### Escalates When
|
||||||
|
|
@ -138,6 +138,3 @@ When reviewing proposed changes, verify:
|
||||||
|
|
||||||
**Good prompt:** "Review this PR that adds a new Ansible role"
|
**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.
|
**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."
|
|
||||||
194
.claude/agents/authentik.md
Normal file
194
.claude/agents/authentik.md
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
# 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
|
||||||
|
|
@ -46,7 +46,7 @@ Implements and maintains all Infrastructure as Code, including OpenTofu configur
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
### Does NOT Handle
|
### Does NOT Handle
|
||||||
- Zitadel application configuration (→ Zitadel Agent)
|
- Authentik application configuration (→ Authentik Agent)
|
||||||
- Nextcloud application configuration (→ Nextcloud Agent)
|
- Nextcloud application configuration (→ Nextcloud Agent)
|
||||||
- Architecture decisions (→ Architect Agent)
|
- Architecture decisions (→ Architect Agent)
|
||||||
- Application-specific Docker compose sections (→ respective App 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
|
### Defers To
|
||||||
- **Architect Agent**: Technology choices, principle questions
|
- **Architect Agent**: Technology choices, principle questions
|
||||||
- **Zitadel Agent**: Zitadel container config, bootstrap logic
|
- **Authentik Agent**: Authentik container config, bootstrap logic
|
||||||
- **Nextcloud Agent**: Nextcloud container config, `occ` commands
|
- **Nextcloud Agent**: Nextcloud container config, `occ` commands
|
||||||
|
|
||||||
## Key Files (Owns)
|
## Key Files (Owns)
|
||||||
|
|
@ -170,8 +170,8 @@ output "client_ips" {
|
||||||
- role: common
|
- role: common
|
||||||
- role: docker
|
- role: docker
|
||||||
- role: traefik
|
- role: traefik
|
||||||
- role: zitadel
|
- role: authentik
|
||||||
when: "'zitadel' in apps"
|
when: "'authentik' in apps"
|
||||||
- role: nextcloud
|
- role: nextcloud
|
||||||
when: "'nextcloud' in apps"
|
when: "'nextcloud' in apps"
|
||||||
- role: backup
|
- role: backup
|
||||||
|
|
@ -290,7 +290,4 @@ backup_retention_daily: 7
|
||||||
**Response approach:** Create modular .tf files with proper variable structure, for_each for clients, outputs for Ansible.
|
**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"
|
**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
16
.gitignore
vendored
|
|
@ -3,7 +3,9 @@ secrets/**/*.yaml
|
||||||
secrets/**/*.yml
|
secrets/**/*.yml
|
||||||
!secrets/**/*.sops.yaml
|
!secrets/**/*.sops.yaml
|
||||||
!secrets/.sops.yaml
|
!secrets/.sops.yaml
|
||||||
|
secrets/clients/*.sops.yaml
|
||||||
keys/age-key.txt
|
keys/age-key.txt
|
||||||
|
keys/ssh/
|
||||||
*.key
|
*.key
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
|
@ -12,12 +14,16 @@ tofu/.terraform/
|
||||||
tofu/.terraform.lock.hcl
|
tofu/.terraform.lock.hcl
|
||||||
tofu/terraform.tfstate
|
tofu/terraform.tfstate
|
||||||
tofu/terraform.tfstate.backup
|
tofu/terraform.tfstate.backup
|
||||||
|
tofu/terraform.tfstate.*.backup
|
||||||
tofu/*.tfvars
|
tofu/*.tfvars
|
||||||
!tofu/terraform.tfvars.example
|
!tofu/terraform.tfvars.example
|
||||||
|
tofu/*.tfplan
|
||||||
|
tofu/tfplan
|
||||||
|
|
||||||
# Ansible
|
# Ansible
|
||||||
ansible/*.retry
|
ansible/*.retry
|
||||||
ansible/.vault_pass
|
ansible/.vault_pass
|
||||||
|
ansible/host_vars/
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
@ -56,3 +62,13 @@ venv/
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.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
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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.
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
# 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
112
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Post-X Society Multi-Tenant Infrastructure
|
# De Vrije Cloud: Post-Tyranny Tech Multi-Tenant Infrastructure
|
||||||
|
|
||||||
Infrastructure as Code for a scalable multi-tenant VPS platform running Nextcloud (file sync/share) on Hetzner Cloud.
|
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.
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ Infrastructure as Code for a scalable multi-tenant VPS platform running Nextclou
|
||||||
- **Configuration**: Ansible with dynamic inventory
|
- **Configuration**: Ansible with dynamic inventory
|
||||||
- **Secrets**: SOPS + Age encryption
|
- **Secrets**: SOPS + Age encryption
|
||||||
- **Hosting**: Hetzner Cloud (EU-based, GDPR-compliant)
|
- **Hosting**: Hetzner Cloud (EU-based, GDPR-compliant)
|
||||||
|
- **Identity**: Authentik (OAuth2/OIDC SSO, MIT license)
|
||||||
- **Storage**: Nextcloud (German company, AGPL 3.0)
|
- **Storage**: Nextcloud (German company, AGPL 3.0)
|
||||||
|
|
||||||
## 📁 Repository Structure
|
## 📁 Repository Structure
|
||||||
|
|
@ -32,7 +33,62 @@ infrastructure/
|
||||||
- [SOPS](https://github.com/getsops/sops) + [Age](https://github.com/FiloSottile/age)
|
- [SOPS](https://github.com/getsops/sops) + [Age](https://github.com/FiloSottile/age)
|
||||||
- [Hetzner Cloud account](https://www.hetzner.com/cloud)
|
- [Hetzner Cloud account](https://www.hetzner.com/cloud)
|
||||||
|
|
||||||
### Initial Setup
|
### 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>
|
||||||
|
|
||||||
1. **Clone repository**:
|
1. **Clone repository**:
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -52,20 +108,32 @@ infrastructure/
|
||||||
# Edit with your Hetzner API token and configuration
|
# Edit with your Hetzner API token and configuration
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Provision infrastructure**:
|
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**:
|
||||||
```bash
|
```bash
|
||||||
cd tofu
|
cd tofu
|
||||||
tofu init
|
tofu init
|
||||||
tofu plan
|
|
||||||
tofu apply
|
tofu apply
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Deploy applications**:
|
6. **Deploy applications**:
|
||||||
```bash
|
```bash
|
||||||
cd ../ansible
|
cd ../ansible
|
||||||
ansible-playbook playbooks/setup.yml
|
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>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## 🎯 Project Principles
|
## 🎯 Project Principles
|
||||||
|
|
||||||
1. **EU/GDPR-first**: European vendors and data residency
|
1. **EU/GDPR-first**: European vendors and data residency
|
||||||
|
|
@ -74,35 +142,11 @@ infrastructure/
|
||||||
4. **Infrastructure as Code**: All changes via version control
|
4. **Infrastructure as Code**: All changes via version control
|
||||||
5. **Security by default**: Encryption, hardening, least privilege
|
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
|
## 📝 License
|
||||||
|
|
||||||
TBD
|
MIT License - see [LICENSE](LICENSE) for details
|
||||||
|
|
||||||
## 🙋 Support
|
## 🙋 Support
|
||||||
|
|
||||||
For issues or questions, please create a GitHub issue with the appropriate label:
|
For issues or questions, please create am issue or contact us on [vrijecloud@postxsociety.org](mailto:vrijecloud@postxsociety.org)
|
||||||
- `agent:architect` - Architecture/design questions
|
|
||||||
- `agent:infrastructure` - IaC implementation
|
|
||||||
- `agent:nextcloud` - File sync/share
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ ansible/
|
||||||
│ ├── common/ # Base system hardening
|
│ ├── common/ # Base system hardening
|
||||||
│ ├── docker/ # Docker + Docker Compose
|
│ ├── docker/ # Docker + Docker Compose
|
||||||
│ ├── traefik/ # Reverse proxy
|
│ ├── traefik/ # Reverse proxy
|
||||||
│ ├── zitadel/ # Identity provider
|
│ ├── authentik/ # Identity provider (OAuth2/OIDC SSO)
|
||||||
│ ├── nextcloud/ # File sync/share
|
│ ├── nextcloud/ # File sync/share
|
||||||
│ └── backup/ # Restic backup
|
│ └── backup/ # Restic backup
|
||||||
└── group_vars/ # Group variables
|
└── group_vars/ # Group variables
|
||||||
|
|
@ -120,8 +120,8 @@ Reverse proxy with automatic SSL:
|
||||||
- HTTP to HTTPS redirection
|
- HTTP to HTTPS redirection
|
||||||
- Dashboard (optional)
|
- Dashboard (optional)
|
||||||
|
|
||||||
### zitadel
|
### authentik
|
||||||
Identity provider deployment (see Zitadel Agent for details)
|
Identity provider deployment (OAuth2/OIDC SSO)
|
||||||
|
|
||||||
### nextcloud
|
### nextcloud
|
||||||
File sync/share deployment (see Nextcloud Agent for details)
|
File sync/share deployment (see Nextcloud Agent for details)
|
||||||
|
|
@ -273,10 +273,9 @@ ansible-playbook playbooks/setup.yml -vvv # Very verbose
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
After initial setup:
|
After initial setup:
|
||||||
1. Deploy Zitadel: Follow Zitadel Agent instructions
|
1. Deploy applications: Run `playbooks/deploy.yml` to deploy Authentik and Nextcloud
|
||||||
2. Deploy Nextcloud: Follow Nextcloud Agent instructions
|
2. Configure backups: Use `backup` role
|
||||||
3. Configure backups: Use `backup` role
|
3. Set up monitoring: Configure Uptime Kuma
|
||||||
4. Set up monitoring: Configure Uptime Kuma
|
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[defaults]
|
[defaults]
|
||||||
# Inventory configuration
|
# Inventory configuration
|
||||||
inventory = hcloud.yml
|
# inventory = hcloud.yml # Disabled - use -i flag instead
|
||||||
host_key_checking = False
|
host_key_checking = False
|
||||||
interpreter_python = auto_silent
|
interpreter_python = auto_silent
|
||||||
|
|
||||||
|
|
@ -26,8 +26,8 @@ timeout = 30
|
||||||
roles_path = ./roles
|
roles_path = ./roles
|
||||||
|
|
||||||
[inventory]
|
[inventory]
|
||||||
# Enable Hetzner Cloud dynamic inventory plugin
|
# Enable inventory plugins
|
||||||
enable_plugins = hetzner.hcloud.hcloud
|
enable_plugins = hetzner.hcloud.hcloud, ini, yaml, auto
|
||||||
|
|
||||||
[privilege_escalation]
|
[privilege_escalation]
|
||||||
become = True
|
become = True
|
||||||
|
|
@ -37,4 +37,4 @@ become_ask_pass = False
|
||||||
|
|
||||||
[ssh_connection]
|
[ssh_connection]
|
||||||
pipelining = True
|
pipelining = True
|
||||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no
|
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o IdentitiesOnly=yes
|
||||||
|
|
|
||||||
66
ansible/configure-oidc.yml
Normal file
66
ansible/configure-oidc.yml
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
---
|
||||||
|
- 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)
|
||||||
4
ansible/inventory-temp.ini
Normal file
4
ansible/inventory-temp.ini
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[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
|
||||||
8
ansible/inventory-temp.yml
Normal file
8
ansible/inventory-temp.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
all:
|
||||||
|
children:
|
||||||
|
clients:
|
||||||
|
hosts:
|
||||||
|
valk:
|
||||||
|
ansible_host: 78.47.191.38
|
||||||
|
ansible_user: root
|
||||||
|
ansible_ssh_private_key_file: ../keys/ssh/valk
|
||||||
124
ansible/playbooks/260123-configure-diun-webhook.yml
Normal file
124
ansible/playbooks/260123-configure-diun-webhook.yml
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
123
ansible/playbooks/260123-upgrade-nextcloud-stage-v2.yml
Normal file
123
ansible/playbooks/260123-upgrade-nextcloud-stage-v2.yml
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
---
|
||||||
|
# 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 }}
|
||||||
|
============================================================
|
||||||
378
ansible/playbooks/260123-upgrade-nextcloud-v2.yml
Normal file
378
ansible/playbooks/260123-upgrade-nextcloud-v2.yml
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
|
============================================================
|
||||||
156
ansible/playbooks/260124-configure-diun-watchrepo.yml
Normal file
156
ansible/playbooks/260124-configure-diun-watchrepo.yml
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
151
ansible/playbooks/260124-nextcloud-maintenance.yml
Normal file
151
ansible/playbooks/260124-nextcloud-maintenance.yml
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
|
|
||||||
|
============================================================
|
||||||
40
ansible/playbooks/cleanup.yml
Normal file
40
ansible/playbooks/cleanup.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
# 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 }}"]'
|
||||||
|
============================================================
|
||||||
53
ansible/playbooks/configure-email.yml
Normal file
53
ansible/playbooks/configure-email.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
|
========================================
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
# Deploy applications to client servers
|
# Deploy applications to client servers
|
||||||
# This playbook deploys Nextcloud and other applications
|
# This playbook deploys Authentik, Nextcloud, and other applications
|
||||||
|
|
||||||
- name: Deploy applications to client servers
|
- name: Deploy applications to client servers
|
||||||
hosts: all
|
hosts: all
|
||||||
|
|
@ -14,25 +14,93 @@
|
||||||
set_fact:
|
set_fact:
|
||||||
client_name: "{{ inventory_hostname }}"
|
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
|
- name: Load client secrets
|
||||||
community.sops.load_vars:
|
community.sops.load_vars:
|
||||||
file: "{{ playbook_dir }}/../../secrets/clients/{{ client_name }}.sops.yaml"
|
file: "{{ playbook_dir }}/../../secrets/clients/{{ client_name }}.sops.yaml"
|
||||||
name: client_secrets
|
name: client_secrets
|
||||||
age_key: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||||
no_log: true
|
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
|
- name: Set client domain from secrets
|
||||||
set_fact:
|
set_fact:
|
||||||
client_domain: "{{ client_secrets.client_domain }}"
|
client_domain: "{{ client_secrets.client_domain }}"
|
||||||
when: client_secrets.client_domain is defined
|
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:
|
roles:
|
||||||
|
- role: mailgun
|
||||||
|
- role: authentik
|
||||||
- role: nextcloud
|
- role: nextcloud
|
||||||
|
- role: diun
|
||||||
|
tags: diun
|
||||||
|
- role: kuma
|
||||||
|
tags: kuma
|
||||||
|
|
||||||
post_tasks:
|
post_tasks:
|
||||||
- name: Display deployment summary
|
- name: Display deployment summary
|
||||||
debug:
|
debug:
|
||||||
msg: |
|
msg: |
|
||||||
Deployment complete for client: {{ client_name }}
|
============================================================
|
||||||
|
🎉 Deployment complete for client: {{ client_name }}
|
||||||
|
============================================================
|
||||||
|
|
||||||
Nextcloud: https://nextcloud.{{ client_domain }}
|
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.
|
||||||
|
============================================================
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@
|
||||||
- name: Gather facts
|
- name: Gather facts
|
||||||
setup:
|
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:
|
roles:
|
||||||
- role: common
|
- role: common
|
||||||
tags: ['common', 'security']
|
tags: ['common', 'security']
|
||||||
|
|
|
||||||
311
ansible/playbooks/update-containers.yml
Normal file
311
ansible/playbooks/update-containers.yml
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
---
|
||||||
|
# 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 }}"
|
||||||
61
ansible/playbooks/update-enrollment-flow.yml
Normal file
61
ansible/playbooks/update-enrollment-flow.yml
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
# 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/
|
||||||
25
ansible/roles/authentik/defaults/main.yml
Normal file
25
ansible/roles/authentik/defaults/main.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
303
ansible/roles/authentik/files/authentik_api.py
Executable file
303
ansible/roles/authentik/files/authentik_api.py
Executable file
|
|
@ -0,0 +1,303 @@
|
||||||
|
#!/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()
|
||||||
92
ansible/roles/authentik/files/configure_2fa_enforcement.py
Normal file
92
ansible/roles/authentik/files/configure_2fa_enforcement.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
#!/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()
|
||||||
115
ansible/roles/authentik/files/configure_invitation_flow.py
Normal file
115
ansible/roles/authentik/files/configure_invitation_flow.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
#!/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()
|
||||||
522
ansible/roles/authentik/files/create_recovery_flow.py
Normal file
522
ansible/roles/authentik/files/create_recovery_flow.py
Normal file
|
|
@ -0,0 +1,522 @@
|
||||||
|
#!/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()
|
||||||
40
ansible/roles/authentik/files/custom-flows.yaml
Normal file
40
ansible/roles/authentik/files/custom-flows.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
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]]
|
||||||
153
ansible/roles/authentik/files/enrollment-flow.yaml
Normal file
153
ansible/roles/authentik/files/enrollment-flow.yaml
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
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>"}
|
||||||
25
ansible/roles/authentik/files/invitation-flow.yaml
Normal file
25
ansible/roles/authentik/files/invitation-flow.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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
|
||||||
7
ansible/roles/authentik/handlers/main.yml
Normal file
7
ansible/roles/authentik/handlers/main.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
# Handlers for Authentik role
|
||||||
|
|
||||||
|
- name: Restart Authentik
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ authentik_config_dir }}"
|
||||||
|
state: restarted
|
||||||
25
ansible/roles/authentik/tasks/bootstrap.yml
Normal file
25
ansible/roles/authentik/tasks/bootstrap.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
43
ansible/roles/authentik/tasks/docker.yml
Normal file
43
ansible/roles/authentik/tasks/docker.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
22
ansible/roles/authentik/tasks/email.yml
Normal file
22
ansible/roles/authentik/tasks/email.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
|
========================================
|
||||||
126
ansible/roles/authentik/tasks/flows.yml
Normal file
126
ansible/roles/authentik/tasks/flows.yml
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
---
|
||||||
|
# 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.
|
||||||
|
========================================
|
||||||
112
ansible/roles/authentik/tasks/invitation.yml
Normal file
112
ansible/roles/authentik/tasks/invitation.yml
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
38
ansible/roles/authentik/tasks/main.yml
Normal file
38
ansible/roles/authentik/tasks/main.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
# 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']
|
||||||
97
ansible/roles/authentik/tasks/mfa.yml
Normal file
97
ansible/roles/authentik/tasks/mfa.yml
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
---
|
||||||
|
# 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.
|
||||||
|
========================================
|
||||||
76
ansible/roles/authentik/tasks/providers.yml
Normal file
76
ansible/roles/authentik/tasks/providers.yml
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
85
ansible/roles/authentik/tasks/recovery.yml
Normal file
85
ansible/roles/authentik/tasks/recovery.yml
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
---
|
||||||
|
# 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) }}"
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
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
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
---
|
---
|
||||||
# Handlers for common role
|
# Handlers for common role
|
||||||
|
|
||||||
|
- name: Restart systemd-resolved
|
||||||
|
service:
|
||||||
|
name: systemd-resolved
|
||||||
|
state: restarted
|
||||||
|
|
||||||
- name: Restart SSH
|
- name: Restart SSH
|
||||||
service:
|
service:
|
||||||
name: ssh
|
name: ssh
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,28 @@
|
||||||
---
|
---
|
||||||
# Main tasks for common role - base system setup and hardening
|
# 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
|
- name: Update apt cache
|
||||||
apt:
|
apt:
|
||||||
update_cache: yes
|
update_cache: yes
|
||||||
|
|
|
||||||
39
ansible/roles/diun/defaults/main.yml
Normal file
39
ansible/roles/diun/defaults/main.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
5
ansible/roles/diun/handlers/main.yml
Normal file
5
ansible/roles/diun/handlers/main.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
- name: Restart Diun
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: /opt/docker/diun
|
||||||
|
state: restarted
|
||||||
57
ansible/roles/diun/tasks/main.yml
Normal file
57
ansible/roles/diun/tasks/main.yml
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
---
|
||||||
|
- 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 }}"
|
||||||
77
ansible/roles/diun/templates/diun.yml.j2
Normal file
77
ansible/roles/diun/templates/diun.yml.j2
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
---
|
||||||
|
# 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 %}
|
||||||
24
ansible/roles/diun/templates/docker-compose.yml.j2
Normal file
24
ansible/roles/diun/templates/docker-compose.yml.j2
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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
|
||||||
|
|
@ -66,3 +66,13 @@
|
||||||
path: /opt/docker
|
path: /opt/docker
|
||||||
state: directory
|
state: directory
|
||||||
mode: '0755'
|
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]
|
||||||
|
|
|
||||||
38
ansible/roles/kuma/defaults/main.yml
Normal file
38
ansible/roles/kuma/defaults/main.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
# 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"
|
||||||
49
ansible/roles/kuma/tasks/main.yml
Normal file
49
ansible/roles/kuma/tasks/main.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
128
ansible/roles/kuma/templates/register_monitors.py.j2
Normal file
128
ansible/roles/kuma/templates/register_monitors.py.j2
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
#!/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()
|
||||||
64
ansible/roles/mailgun/tasks/delete.yml
Normal file
64
ansible/roles/mailgun/tasks/delete.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
103
ansible/roles/mailgun/tasks/main.yml
Normal file
103
ansible/roles/mailgun/tasks/main.yml
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# Default variables for nextcloud role
|
# Default variables for nextcloud role
|
||||||
|
|
||||||
# Nextcloud version
|
# Nextcloud version
|
||||||
nextcloud_version: "30" # Latest stable version (uses major version tag)
|
nextcloud_version: "latest" # Always use latest stable version
|
||||||
|
|
||||||
# Database configuration
|
# Database configuration
|
||||||
nextcloud_db_type: "pgsql"
|
nextcloud_db_type: "pgsql"
|
||||||
|
|
@ -22,10 +22,12 @@ nextcloud_redis_host: "nextcloud-redis"
|
||||||
nextcloud_redis_port: "6379"
|
nextcloud_redis_port: "6379"
|
||||||
|
|
||||||
# OIDC configuration
|
# 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_enabled: true
|
||||||
nextcloud_oidc_provider_url: "https://{{ zitadel_domain }}"
|
nextcloud_oidc_provider_url: "https://{{ authentik_domain }}"
|
||||||
nextcloud_oidc_client_id: "" # Will be set after creating app in Zitadel
|
nextcloud_oidc_client_id: "" # Set dynamically from Authentik
|
||||||
nextcloud_oidc_client_secret: "" # Will be set after creating app in Zitadel
|
nextcloud_oidc_client_secret: "" # Set dynamically from Authentik
|
||||||
|
|
||||||
# Trusted domains (for Nextcloud config)
|
# Trusted domains (for Nextcloud config)
|
||||||
nextcloud_trusted_domains:
|
nextcloud_trusted_domains:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
shell: docker exec -u www-data nextcloud php occ app:install richdocuments
|
shell: docker exec -u www-data nextcloud php occ app:install richdocuments
|
||||||
register: collabora_install
|
register: collabora_install
|
||||||
changed_when: "'richdocuments installed' in collabora_install.stdout"
|
changed_when: "'richdocuments installed' in collabora_install.stdout"
|
||||||
failed_when: collabora_install.rc != 0 and 'richdocuments already installed' not in collabora_install.stderr
|
failed_when: collabora_install.rc != 0 and 'already installed' not in collabora_install.stdout
|
||||||
when: collabora_enabled | default(true)
|
when: collabora_enabled | default(true)
|
||||||
|
|
||||||
- name: Enable Collabora Office app
|
- name: Enable Collabora Office app
|
||||||
|
|
@ -20,7 +20,8 @@
|
||||||
changed_when: true
|
changed_when: true
|
||||||
|
|
||||||
- name: Get Nextcloud internal network info
|
- name: Get Nextcloud internal network info
|
||||||
shell: docker inspect nextcloud-internal -f '{{{{ .IPAM.Config }}}}'
|
shell: |
|
||||||
|
docker inspect nextcloud-internal -f {% raw %}'{{ .IPAM.Config }}'{% endraw %}
|
||||||
register: nextcloud_network
|
register: nextcloud_network
|
||||||
changed_when: false
|
changed_when: false
|
||||||
when: collabora_enabled | default(true)
|
when: collabora_enabled | default(true)
|
||||||
|
|
@ -39,7 +40,7 @@
|
||||||
- twofactor_backupcodes
|
- twofactor_backupcodes
|
||||||
register: twofactor_install
|
register: twofactor_install
|
||||||
changed_when: "'installed' in twofactor_install.stdout"
|
changed_when: "'installed' in twofactor_install.stdout"
|
||||||
failed_when: twofactor_install.rc != 0 and 'already installed' not in twofactor_install.stderr
|
failed_when: twofactor_install.rc != 0 and 'already installed' not in twofactor_install.stdout
|
||||||
|
|
||||||
- name: Enable two-factor authentication apps
|
- name: Enable two-factor authentication apps
|
||||||
shell: docker exec -u www-data nextcloud php occ app:enable {{ item }}
|
shell: docker exec -u www-data nextcloud php occ app:enable {{ item }}
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@
|
||||||
state: present
|
state: present
|
||||||
register: nextcloud_deploy
|
register: nextcloud_deploy
|
||||||
|
|
||||||
- name: Wait for Nextcloud to be ready
|
- name: Wait for Nextcloud container to be ready
|
||||||
wait_for:
|
shell: docker exec nextcloud sh -c 'until curl -f http://localhost:80 >/dev/null 2>&1; do sleep 2; done'
|
||||||
host: localhost
|
args:
|
||||||
port: 80
|
executable: /bin/bash
|
||||||
delay: 10
|
register: nextcloud_ready
|
||||||
timeout: 120
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
timeout: 300
|
||||||
when: nextcloud_deploy.changed
|
when: nextcloud_deploy.changed
|
||||||
|
|
|
||||||
46
ansible/roles/nextcloud/tasks/email.yml
Normal file
46
ansible/roles/nextcloud/tasks/email.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
# 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
|
||||||
|
========================================
|
||||||
|
|
@ -26,6 +26,11 @@
|
||||||
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 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 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 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
|
- name: Configure Redis for caching
|
||||||
shell: |
|
shell: |
|
||||||
|
|
@ -41,3 +46,25 @@
|
||||||
- name: Run background jobs via cron
|
- name: Run background jobs via cron
|
||||||
shell: |
|
shell: |
|
||||||
docker exec -u www-data nextcloud php occ background:cron
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
---
|
---
|
||||||
# Main tasks for Nextcloud deployment
|
# Main tasks for Nextcloud deployment
|
||||||
|
|
||||||
|
- name: Include volume mounting tasks
|
||||||
|
include_tasks: mount-volume.yml
|
||||||
|
tags:
|
||||||
|
- nextcloud
|
||||||
|
- volume
|
||||||
|
|
||||||
- name: Include Docker deployment tasks
|
- name: Include Docker deployment tasks
|
||||||
include_tasks: docker.yml
|
include_tasks: docker.yml
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -25,3 +31,10 @@
|
||||||
tags:
|
tags:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- apps
|
- 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
|
||||||
|
|
|
||||||
74
ansible/roles/nextcloud/tasks/mount-volume.yml
Normal file
74
ansible/roles/nextcloud/tasks/mount-volume.yml
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
---
|
||||||
|
# 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"
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
# OIDC/SSO integration tasks for Nextcloud with Zitadel
|
# OIDC/SSO integration tasks for Nextcloud with Authentik
|
||||||
|
|
||||||
- name: Check if user_oidc app is installed
|
- name: Check if user_oidc app is installed
|
||||||
shell: docker exec -u www-data nextcloud php occ app:list --output=json
|
shell: docker exec -u www-data nextcloud php occ app:list --output=json
|
||||||
|
|
@ -20,33 +20,71 @@
|
||||||
shell: docker exec -u www-data nextcloud php occ app:enable user_oidc
|
shell: docker exec -u www-data nextcloud php occ app:enable user_oidc
|
||||||
when: not user_oidc_installed
|
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
|
- name: Check if OIDC provider is already configured
|
||||||
shell: docker exec -u www-data nextcloud php occ user_oidc:provider
|
shell: docker exec -u www-data nextcloud php occ user_oidc:provider
|
||||||
register: oidc_providers
|
register: oidc_providers
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
- name: Configure OIDC provider if credentials are available
|
- name: Configure Authentik OIDC provider
|
||||||
shell: |
|
shell: |
|
||||||
docker exec -u www-data nextcloud php occ user_oidc:provider:add \
|
docker exec -u www-data nextcloud php occ user_oidc:provider \
|
||||||
--clientid="{{ nextcloud_oidc_client_id }}" \
|
--clientid="{{ authentik_oidc.client_id }}" \
|
||||||
--clientsecret="{{ nextcloud_oidc_client_secret }}" \
|
--clientsecret="{{ authentik_oidc.client_secret }}" \
|
||||||
--discoveryuri="https://{{ zitadel_domain }}/.well-known/openid-configuration" \
|
--discoveryuri="{{ authentik_oidc.discovery_uri }}" \
|
||||||
"Zitadel"
|
"Authentik"
|
||||||
when:
|
when:
|
||||||
- nextcloud_oidc_client_id is defined
|
- authentik_oidc is defined
|
||||||
- nextcloud_oidc_client_secret is defined
|
- authentik_oidc.success | default(false)
|
||||||
- "'Zitadel' not in oidc_providers.stdout"
|
- "'Authentik' not in oidc_providers.stdout"
|
||||||
register: oidc_config
|
register: oidc_config
|
||||||
changed_when: "'Provider Zitadel has been created' in oidc_config.stdout"
|
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
|
||||||
|
|
||||||
- name: Display OIDC status
|
- name: Display OIDC status
|
||||||
debug:
|
debug:
|
||||||
msg: |
|
msg: |
|
||||||
{% if nextcloud_oidc_client_id is defined %}
|
{% if authentik_oidc is defined and authentik_oidc.success | default(false) %}
|
||||||
OIDC SSO fully configured!
|
✓ OIDC SSO fully configured!
|
||||||
Users can login with Zitadel credentials at: https://{{ nextcloud_domain }}
|
Users can login with Authentik credentials at: https://{{ nextcloud_domain }}
|
||||||
|
|
||||||
|
"Login with Authentik" button should be visible on the login page.
|
||||||
{% else %}
|
{% else %}
|
||||||
OIDC app installed but not yet configured.
|
⚠ OIDC app installed but not yet configured.
|
||||||
OIDC credentials will be configured automatically by Zitadel role.
|
|
||||||
|
To complete setup:
|
||||||
|
1. Ensure Authentik API token is in secrets (authentik_api_token)
|
||||||
|
2. Re-run deployment with: --tags authentik,oidc
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,17 @@ services:
|
||||||
POSTGRES_DB: {{ nextcloud_db_name }}
|
POSTGRES_DB: {{ nextcloud_db_name }}
|
||||||
POSTGRES_USER: {{ nextcloud_db_user }}
|
POSTGRES_USER: {{ nextcloud_db_user }}
|
||||||
POSTGRES_PASSWORD: {{ client_secrets.nextcloud_db_password }}
|
POSTGRES_PASSWORD: {{ client_secrets.nextcloud_db_password }}
|
||||||
# Grant full privileges to the user
|
|
||||||
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256"
|
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:
|
networks:
|
||||||
- nextcloud-internal
|
- nextcloud-internal
|
||||||
|
|
||||||
|
|
@ -35,7 +44,8 @@ services:
|
||||||
- nextcloud-db
|
- nextcloud-db
|
||||||
- nextcloud-redis
|
- nextcloud-redis
|
||||||
volumes:
|
volumes:
|
||||||
- nextcloud-data:/var/www/html
|
- nextcloud-app:/var/www/html
|
||||||
|
- /mnt/nextcloud-data/data:/var/www/html/data # User data on Hetzner Volume
|
||||||
entrypoint: /cron.sh
|
entrypoint: /cron.sh
|
||||||
networks:
|
networks:
|
||||||
- nextcloud-internal
|
- nextcloud-internal
|
||||||
|
|
@ -49,7 +59,8 @@ services:
|
||||||
- nextcloud-db
|
- nextcloud-db
|
||||||
- nextcloud-redis
|
- nextcloud-redis
|
||||||
volumes:
|
volumes:
|
||||||
- nextcloud-data:/var/www/html
|
- nextcloud-app:/var/www/html
|
||||||
|
- /mnt/nextcloud-data/data:/var/www/html/data # User data on Hetzner Volume
|
||||||
environment:
|
environment:
|
||||||
# Database configuration
|
# Database configuration
|
||||||
POSTGRES_HOST: {{ nextcloud_db_host }}
|
POSTGRES_HOST: {{ nextcloud_db_host }}
|
||||||
|
|
@ -115,11 +126,18 @@ services:
|
||||||
image: collabora/code:latest
|
image: collabora/code:latest
|
||||||
container_name: collabora
|
container_name: collabora
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Required capabilities for optimal performance (bind-mount instead of copy)
|
||||||
|
cap_add:
|
||||||
|
- MKNOD
|
||||||
|
- SYS_CHROOT
|
||||||
environment:
|
environment:
|
||||||
- domain={{ nextcloud_domain | regex_replace('\.', '\\.') }}
|
- domain={{ nextcloud_domain | regex_replace('\.', '\\.') }}
|
||||||
- username={{ collabora_admin_user }}
|
- username={{ collabora_admin_user }}
|
||||||
- password={{ client_secrets.collabora_admin_password }}
|
- password={{ client_secrets.collabora_admin_password }}
|
||||||
- extra_params=--o:ssl.enable=false --o:ssl.termination=true
|
# 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
|
||||||
- MEMPROPORTION=60.0
|
- MEMPROPORTION=60.0
|
||||||
- MAX_DOCUMENTS=10
|
- MAX_DOCUMENTS=10
|
||||||
- MAX_CONNECTIONS=20
|
- MAX_CONNECTIONS=20
|
||||||
|
|
@ -158,5 +176,6 @@ volumes:
|
||||||
name: nextcloud-db-data
|
name: nextcloud-db-data
|
||||||
nextcloud-redis-data:
|
nextcloud-redis-data:
|
||||||
name: nextcloud-redis-data
|
name: nextcloud-redis-data
|
||||||
nextcloud-data:
|
nextcloud-app:
|
||||||
name: nextcloud-data
|
name: nextcloud-app
|
||||||
|
# Note: nextcloud-data volume removed - user data now stored on Hetzner Volume at /mnt/nextcloud-data
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
# Default variables for traefik role
|
# Default variables for traefik role
|
||||||
|
|
||||||
# Traefik version (v3.2+ fixes Docker API compatibility)
|
# Traefik version (v3.6.1+ fixes Docker API 1.44 compatibility with Docker 29+)
|
||||||
traefik_version: "v3.2"
|
traefik_version: "v3.6"
|
||||||
|
|
||||||
# Let's Encrypt configuration
|
# Let's Encrypt configuration
|
||||||
traefik_acme_email: "admin@example.com" # Override this!
|
traefik_acme_email: "admin@example.com" # Override this!
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ services:
|
||||||
image: traefik:{{ traefik_version }}
|
image: traefik:{{ traefik_version }}
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
|
||||||
# Fix Docker API version compatibility - use 1.44 for modern Docker
|
|
||||||
- DOCKER_API_VERSION=1.44
|
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,8 @@
|
||||||
# Traefik dynamic configuration
|
# Traefik dynamic configuration
|
||||||
# Managed by Ansible - do not edit manually
|
# Managed by Ansible - Client-specific routes come from Docker labels
|
||||||
|
|
||||||
http:
|
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:
|
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
|
||||||
security-headers:
|
security-headers:
|
||||||
headers:
|
headers:
|
||||||
|
|
|
||||||
83
clients/README.md
Normal file
83
clients/README.md
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# 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`)
|
||||||
89
clients/registry.yml
Normal file
89
clients/registry.yml
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# 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: ""
|
||||||
|
|
@ -1,808 +0,0 @@
|
||||||
# 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
Normal file
7
keys/ssh/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# NEVER commit SSH private keys
|
||||||
|
*
|
||||||
|
|
||||||
|
# Allow README and public keys only
|
||||||
|
!.gitignore
|
||||||
|
!README.md
|
||||||
|
!*.pub
|
||||||
196
keys/ssh/README.md
Normal file
196
keys/ssh/README.md
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
# 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
|
||||||
1
keys/ssh/bever.pub
Normal file
1
keys/ssh/bever.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILKuSYRVVWCYqjNvJ5pHZTErkmVbEb1g3ac8olXUcXy7 client-bever-deploy-key
|
||||||
1
keys/ssh/das.pub
Normal file
1
keys/ssh/das.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGsGfzhrcVtYEn2YHzxVGibBDXPd571unltfOaVo5JlR client-das-deploy-key
|
||||||
1
keys/ssh/egel.pub
Normal file
1
keys/ssh/egel.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE75mnMfKHTIeq5Hp8LKaKYHGbzdFke1a9N7e0UEMNBu client-egel-deploy-key
|
||||||
1
keys/ssh/haas.pub
Normal file
1
keys/ssh/haas.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAa4QHMVKnTSS/q5kptQYzas7ln2MbgE5Db47GM2DjRI client-haas-deploy-key
|
||||||
1
keys/ssh/kikker.pub
Normal file
1
keys/ssh/kikker.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICtZzQTzNWLcFi4NNqg6l53kqPVDsgau1O7GWWKwZh9l client-kikker-deploy-key
|
||||||
1
keys/ssh/kraai.pub
Normal file
1
keys/ssh/kraai.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPXF5COMplFqwxCRymXN7y4b+RWiBbVQpIMmFoK10qgh client-kraai-deploy-key
|
||||||
1
keys/ssh/mees.pub
Normal file
1
keys/ssh/mees.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDGPPukFDhM4eIolsowRsD6jYrNYoM3/B9yLi2KNqmPi client-mees-deploy-key
|
||||||
1
keys/ssh/mol.pub
Normal file
1
keys/ssh/mol.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHAsLbdkl0peC15KnxhSsCI45Z2FwQu2Hy1LArzHoXu5 client-mol-deploy-key
|
||||||
1
keys/ssh/mus.pub
Normal file
1
keys/ssh/mus.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINAoeg3LDX5zRuw5Yt5WwbYNRXo70H7e5OYE3oMbJRyL client-mus-deploy-key
|
||||||
1
keys/ssh/otter.pub
Normal file
1
keys/ssh/otter.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG3edQhsIBD9Ers7wuFWSww8r3ROkKNJF8YcxgRtQdov client-otter-deploy-key
|
||||||
1
keys/ssh/ree.pub
Normal file
1
keys/ssh/ree.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB4QOkx75M28l7JAkQPl8bLjGuV/kKDFQINkUGRVRgIk client-ree-deploy-key
|
||||||
1
keys/ssh/specht.pub
Normal file
1
keys/ssh/specht.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAXFskaLenHy4FJHUZL2gpehFUAYaUdNfwP0BTMqp4La client-specht-deploy-key
|
||||||
1
keys/ssh/uil.pub
Normal file
1
keys/ssh/uil.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEhDcLx3ZaBXSHbhOoAgb5sI5xUVJwZEXl2HYq5+eRID client-uil-deploy-key
|
||||||
1
keys/ssh/valk.pub
Normal file
1
keys/ssh/valk.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILLDJCSNj3OZDDwGgoWSxy17K8DmJ8eqUXQ4Wmu/vRtG client-valk-deploy-key
|
||||||
1
keys/ssh/vos.pub
Normal file
1
keys/ssh/vos.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDg8F6LIVfdBdhD/CiNavs+xfFSiu9jxMmZcyigskuIQ client-vos-deploy-key
|
||||||
1
keys/ssh/wolf.pub
Normal file
1
keys/ssh/wolf.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKUcrgfG+JWtieySkcSZNyBehf/rB0YEQ35IQ93L+HHP client-wolf-deploy-key
|
||||||
1
keys/ssh/zwaan.pub
Normal file
1
keys/ssh/zwaan.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG76TbSdY1o5T7PlzGkbfu0HNGOKsiW5vtbAKLDz0BGv client-zwaan-deploy-key
|
||||||
267
scripts/README.md
Normal file
267
scripts/README.md
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
# 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
|
||||||
87
scripts/add-client-to-monitoring.sh
Executable file
87
scripts/add-client-to-monitoring.sh
Executable file
|
|
@ -0,0 +1,87 @@
|
||||||
|
#!/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 ""
|
||||||
250
scripts/add-client-to-terraform.sh
Executable file
250
scripts/add-client-to-terraform.sh
Executable file
|
|
@ -0,0 +1,250 @@
|
||||||
|
#!/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 ""
|
||||||
251
scripts/check-client-versions.sh
Executable file
251
scripts/check-client-versions.sh
Executable file
|
|
@ -0,0 +1,251 @@
|
||||||
|
#!/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
|
||||||
237
scripts/client-status.sh
Executable file
237
scripts/client-status.sh
Executable file
|
|
@ -0,0 +1,237 @@
|
||||||
|
#!/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}"
|
||||||
130
scripts/collect-client-versions.sh
Executable file
130
scripts/collect-client-versions.sh
Executable file
|
|
@ -0,0 +1,130 @@
|
||||||
|
#!/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"
|
||||||
170
scripts/configure-diun-all-servers.sh
Executable file
170
scripts/configure-diun-all-servers.sh
Executable file
|
|
@ -0,0 +1,170 @@
|
||||||
|
#!/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
|
||||||
156
scripts/configure-oidc.sh
Executable file
156
scripts/configure-oidc.sh
Executable file
|
|
@ -0,0 +1,156 @@
|
||||||
|
#!/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}"
|
||||||
328
scripts/deploy-client.sh
Executable file
328
scripts/deploy-client.sh
Executable file
|
|
@ -0,0 +1,328 @@
|
||||||
|
#!/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 ""
|
||||||
273
scripts/destroy-client.sh
Executable file
273
scripts/destroy-client.sh
Executable file
|
|
@ -0,0 +1,273 @@
|
||||||
|
#!/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 ""
|
||||||
228
scripts/detect-version-drift.sh
Executable file
228
scripts/detect-version-drift.sh
Executable file
|
|
@ -0,0 +1,228 @@
|
||||||
|
#!/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
Loading…
Add table
Reference in a new issue