Compare commits
No commits in common. "main" and "feature/nextcloud-deployment" have entirely different histories.
main
...
feature/ne
124 changed files with 1131 additions and 9442 deletions
|
|
@ -37,7 +37,7 @@ High-level guardian of the infrastructure architecture, ensuring consistency, ma
|
|||
| Secrets | SOPS + Age | Simple, no server needed |
|
||||
| Hosting | Hetzner | German, family-owned, GDPR |
|
||||
| DNS | Hetzner DNS | Single provider simplicity |
|
||||
| Identity | Authentik | German project lead |
|
||||
| Identity | Zitadel | Swiss company, AGPL |
|
||||
| File Sync | Nextcloud | German company, AGPL |
|
||||
| Reverse Proxy | Traefik | French company, MIT |
|
||||
| Backup | Restic → Hetzner Storage Box | Open source, EU storage |
|
||||
|
|
@ -48,13 +48,13 @@ High-level guardian of the infrastructure architecture, ensuring consistency, ma
|
|||
### Does NOT Handle
|
||||
- Writing OpenTofu configurations (→ Infrastructure Agent)
|
||||
- Writing Ansible playbooks or roles (→ Infrastructure Agent)
|
||||
- Authentik-specific configuration (→ Authentik Agent)
|
||||
- Zitadel-specific configuration (→ Zitadel Agent)
|
||||
- Nextcloud-specific configuration (→ Nextcloud Agent)
|
||||
- Debugging application issues (→ respective App Agent)
|
||||
|
||||
### Defers To
|
||||
- **Infrastructure Agent**: All IaC implementation questions
|
||||
- **Authentik Agent**: Identity, SSO, OIDC specifics
|
||||
- **Zitadel Agent**: Identity, SSO, OIDC specifics
|
||||
- **Nextcloud Agent**: Nextcloud features, `occ` commands
|
||||
|
||||
### Escalates When
|
||||
|
|
@ -138,3 +138,6 @@ When reviewing proposed changes, verify:
|
|||
|
||||
**Good prompt:** "Review this PR that adds a new Ansible role"
|
||||
**Response approach:** Check role follows conventions, doesn't violate isolation, uses SOPS for secrets, aligns with existing patterns.
|
||||
|
||||
**Redirect prompt:** "How do I configure Zitadel OIDC scopes?"
|
||||
**Response:** "This is a Zitadel-specific question. Please ask the Zitadel Agent. I can help if you need to understand how it fits into the overall architecture."
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
# Authentik Agent
|
||||
|
||||
You are a specialized AI agent responsible for Authentik identity provider configuration and integration.
|
||||
|
||||
## Your Responsibilities
|
||||
|
||||
### Primary Tasks
|
||||
1. **Authentik Deployment**: Configure and deploy Authentik using Docker Compose
|
||||
2. **OIDC/OAuth2 Configuration**: Set up OAuth2 providers for applications
|
||||
3. **User Management**: Configure user sources, groups, and permissions
|
||||
4. **Flow Configuration**: Design and implement authentication/authorization flows
|
||||
5. **Integration**: Connect Authentik with applications (Nextcloud, etc.)
|
||||
6. **API Automation**: Automate provider creation and configuration via Authentik API
|
||||
|
||||
### Expertise Areas
|
||||
- Authentik architecture (server + worker model)
|
||||
- OAuth2/OIDC protocol implementation
|
||||
- SAML, LDAP, RADIUS configuration
|
||||
- PostgreSQL backend configuration
|
||||
- API-based automation for OIDC provider creation
|
||||
- Nextcloud OIDC integration
|
||||
|
||||
## Key Information
|
||||
|
||||
### Authentik Version
|
||||
- Current: **2025.10.3**
|
||||
- License: MIT (truly open source)
|
||||
- Image: `ghcr.io/goauthentik/server:2025.10.3`
|
||||
|
||||
### Architecture
|
||||
```yaml
|
||||
services:
|
||||
authentik-server: # Web UI and API
|
||||
authentik-worker: # Background tasks
|
||||
authentik-db: # PostgreSQL 16
|
||||
```
|
||||
|
||||
### No Redis Needed
|
||||
As of v2025.10, Redis is no longer required. All caching, tasks, and WebSocket connections use PostgreSQL.
|
||||
|
||||
### Initial Setup Flow
|
||||
- URL: `https://<domain>/if/flow/initial-setup/`
|
||||
- Default admin: `akadmin`
|
||||
- Creates first admin account and organization
|
||||
|
||||
### API Authentication
|
||||
Authentik uses token-based authentication:
|
||||
```bash
|
||||
# Get token after login
|
||||
TOKEN="your_token_here"
|
||||
|
||||
# API calls
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
https://auth.example.com/api/v3/...
|
||||
```
|
||||
|
||||
## Common Operations
|
||||
|
||||
### 1. Create OAuth2/OIDC Provider
|
||||
```python
|
||||
# Using Authentik API
|
||||
POST /api/v3/providers/oauth2/
|
||||
{
|
||||
"name": "Nextcloud",
|
||||
"authorization_flow": "<flow_uuid>",
|
||||
"client_type": "confidential",
|
||||
"redirect_uris": "https://nextcloud.example.com/apps/user_oidc/code",
|
||||
"signing_key": "<cert_uuid>"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create Application
|
||||
```python
|
||||
POST /api/v3/core/applications/
|
||||
{
|
||||
"name": "Nextcloud",
|
||||
"slug": "nextcloud",
|
||||
"provider": "<provider_id>",
|
||||
"meta_launch_url": "https://nextcloud.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Nextcloud Integration
|
||||
```bash
|
||||
# In Nextcloud
|
||||
occ user_oidc:provider Authentik \
|
||||
--clientid="<client_id>" \
|
||||
--clientsecret="<client_secret>" \
|
||||
--discoveryuri="https://auth.example.com/application/o/nextcloud/.well-known/openid-configuration"
|
||||
```
|
||||
|
||||
## Automation Goals
|
||||
|
||||
### Fully Automated SSO Setup
|
||||
The goal is to automate the complete SSO integration:
|
||||
|
||||
1. **Authentik deploys** → wait for healthy
|
||||
2. **Bootstrap initial admin** → via API or initial setup
|
||||
3. **Create OAuth2 provider for Nextcloud** → via API
|
||||
4. **Get client_id and client_secret** → from API response
|
||||
5. **Configure Nextcloud** → use OIDC app to register provider
|
||||
6. **Verify SSO** → "Login with Authentik" button appears
|
||||
|
||||
### Key Challenge: Initial Admin Token
|
||||
The main automation challenge is obtaining the first API token:
|
||||
- Option 1: Complete initial setup manually once, create service account
|
||||
- Option 2: Use bootstrap tokens if supported
|
||||
- Option 3: Automate initial setup flow with HTTP requests
|
||||
|
||||
## File Locations
|
||||
|
||||
### Ansible Role
|
||||
- `roles/authentik/defaults/main.yml` - Default configuration
|
||||
- `roles/authentik/templates/docker-compose.authentik.yml.j2` - Docker Compose template
|
||||
- `roles/authentik/tasks/docker.yml` - Deployment tasks
|
||||
- `roles/authentik/tasks/bootstrap.yml` - Initial setup tasks
|
||||
|
||||
### Automation Scripts
|
||||
- `roles/authentik/files/authentik_api.py` - Python API client (to be created)
|
||||
- `roles/authentik/files/create_oidc_provider.py` - OIDC provider automation
|
||||
- `roles/authentik/tasks/providers.yml` - Provider creation tasks
|
||||
|
||||
## Integration with Other Agents
|
||||
|
||||
### Collaboration
|
||||
- **Infrastructure Agent**: Coordinate Ansible role structure and deployment
|
||||
- **Nextcloud Agent**: Work together on OIDC integration configuration
|
||||
- **Architect Agent**: Consult on identity/authorization architecture decisions
|
||||
|
||||
### Handoff Points
|
||||
- After Authentik deployment → inform about API endpoint availability
|
||||
- After OIDC provider creation → provide credentials to Nextcloud agent
|
||||
- Configuration changes → update architecture documentation
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Security
|
||||
- Always use HTTPS (via Traefik)
|
||||
- Store secrets in SOPS-encrypted files
|
||||
- Use strong random keys for `AUTHENTIK_SECRET_KEY`
|
||||
- Implement proper RBAC with Authentik's permission system
|
||||
|
||||
### Deployment
|
||||
- Wait for database health check before starting server
|
||||
- Use health checks in deployment automation
|
||||
- Keep media and templates in persistent volumes
|
||||
- Monitor worker logs for background task errors
|
||||
|
||||
### Configuration
|
||||
- Use flows to customize authentication behavior
|
||||
- Create separate providers per application
|
||||
- Use groups for role-based access control
|
||||
- Document custom flows and policies
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. **502 Bad Gateway**: Check if database is healthy
|
||||
2. **Worker not processing**: Check worker container logs
|
||||
3. **OAuth2 errors**: Verify redirect URIs match exactly
|
||||
4. **Certificate issues**: Ensure Traefik SSL is working
|
||||
|
||||
### Debug Commands
|
||||
```bash
|
||||
# Check container health
|
||||
docker ps | grep authentik
|
||||
|
||||
# View server logs
|
||||
docker logs authentik-server
|
||||
|
||||
# View worker logs
|
||||
docker logs authentik-worker
|
||||
|
||||
# Check database
|
||||
docker exec authentik-db psql -U authentik -d authentik -c '\dt'
|
||||
```
|
||||
|
||||
## Documentation References
|
||||
|
||||
- Official Docs: https://docs.goauthentik.io
|
||||
- API Documentation: https://docs.goauthentik.io/developer-docs/api
|
||||
- Docker Install: https://docs.goauthentik.io/docs/install-config/install/docker-compose
|
||||
- OAuth2 Provider: https://docs.goauthentik.io/docs/providers/oauth2
|
||||
- Flow Configuration: https://docs.goauthentik.io/docs/flow
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Your work is successful when:
|
||||
- [ ] Authentik deploys successfully via Ansible
|
||||
- [ ] Initial admin account can be created
|
||||
- [ ] OAuth2 provider for Nextcloud is automatically created
|
||||
- [ ] Nextcloud shows "Login with Authentik" button
|
||||
- [ ] Users can log in to Nextcloud with Authentik credentials
|
||||
- [ ] Everything works on fresh server deployment with zero manual steps
|
||||
|
|
@ -46,7 +46,7 @@ Implements and maintains all Infrastructure as Code, including OpenTofu configur
|
|||
## Boundaries
|
||||
|
||||
### Does NOT Handle
|
||||
- Authentik application configuration (→ Authentik Agent)
|
||||
- Zitadel application configuration (→ Zitadel Agent)
|
||||
- Nextcloud application configuration (→ Nextcloud Agent)
|
||||
- Architecture decisions (→ Architect Agent)
|
||||
- Application-specific Docker compose sections (→ respective App Agent)
|
||||
|
|
@ -58,7 +58,7 @@ Implements and maintains all Infrastructure as Code, including OpenTofu configur
|
|||
|
||||
### Defers To
|
||||
- **Architect Agent**: Technology choices, principle questions
|
||||
- **Authentik Agent**: Authentik container config, bootstrap logic
|
||||
- **Zitadel Agent**: Zitadel container config, bootstrap logic
|
||||
- **Nextcloud Agent**: Nextcloud container config, `occ` commands
|
||||
|
||||
## Key Files (Owns)
|
||||
|
|
@ -170,8 +170,8 @@ output "client_ips" {
|
|||
- role: common
|
||||
- role: docker
|
||||
- role: traefik
|
||||
- role: authentik
|
||||
when: "'authentik' in apps"
|
||||
- role: zitadel
|
||||
when: "'zitadel' in apps"
|
||||
- role: nextcloud
|
||||
when: "'nextcloud' in apps"
|
||||
- role: backup
|
||||
|
|
@ -291,3 +291,6 @@ backup_retention_daily: 7
|
|||
|
||||
**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.
|
||||
|
||||
**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,9 +3,7 @@ secrets/**/*.yaml
|
|||
secrets/**/*.yml
|
||||
!secrets/**/*.sops.yaml
|
||||
!secrets/.sops.yaml
|
||||
secrets/clients/*.sops.yaml
|
||||
keys/age-key.txt
|
||||
keys/ssh/
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
|
|
@ -14,16 +12,12 @@ tofu/.terraform/
|
|||
tofu/.terraform.lock.hcl
|
||||
tofu/terraform.tfstate
|
||||
tofu/terraform.tfstate.backup
|
||||
tofu/terraform.tfstate.*.backup
|
||||
tofu/*.tfvars
|
||||
!tofu/terraform.tfvars.example
|
||||
tofu/*.tfplan
|
||||
tofu/tfplan
|
||||
|
||||
# Ansible
|
||||
ansible/*.retry
|
||||
ansible/.vault_pass
|
||||
ansible/host_vars/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
|
|
@ -62,13 +56,3 @@ venv/
|
|||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Test/debug scripts with secrets
|
||||
scripts/*-test*.py
|
||||
scripts/test-*.py
|
||||
**/test-oidc-provider.py
|
||||
|
||||
# Documentation/reports (except README.md)
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
|
|
|
|||
21
LICENSE
21
LICENSE
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Post-X Society
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
106
PROJECT_REFERENCE.md
Normal file
106
PROJECT_REFERENCE.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Project Reference
|
||||
|
||||
Quick reference for essential project information and common operations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
infrastructure/
|
||||
├── ansible/ # Ansible playbooks and roles
|
||||
│ ├── hcloud.yml # Dynamic inventory (Hetzner Cloud)
|
||||
│ ├── playbooks/ # Main playbooks
|
||||
│ │ ├── deploy.yml # Deploy applications to clients
|
||||
│ │ └── setup.yml # Setup base server infrastructure
|
||||
│ └── roles/ # Ansible roles (traefik, nextcloud, etc.)
|
||||
├── keys/
|
||||
│ └── age-key.txt # SOPS encryption key (gitignored)
|
||||
├── secrets/
|
||||
│ ├── clients/ # Per-client encrypted secrets
|
||||
│ │ └── test.sops.yaml
|
||||
│ └── shared.sops.yaml # Shared secrets
|
||||
└── terraform/ # Infrastructure as Code (Hetzner)
|
||||
```
|
||||
|
||||
## Essential Configuration
|
||||
|
||||
### SOPS Age Key
|
||||
**Location**: `infrastructure/keys/age-key.txt`
|
||||
**Usage**: Always set before running Ansible:
|
||||
```bash
|
||||
export SOPS_AGE_KEY_FILE="../keys/age-key.txt"
|
||||
```
|
||||
|
||||
### Hetzner Cloud Token
|
||||
**Usage**: Required for dynamic inventory:
|
||||
```bash
|
||||
export HCLOUD_TOKEN="MlURmliUzLcGyzCWXWWsZt3DeWxKcQH9ZMGiaaNrFM3VcgnASlEWKhhxLHdWAl0J"
|
||||
```
|
||||
|
||||
### Ansible Paths
|
||||
**Working Directory**: `infrastructure/ansible/`
|
||||
**Inventory**: `hcloud.yml` (dynamic, pulls from Hetzner Cloud API)
|
||||
**Python**: `~/.local/bin/ansible-playbook` (user-local installation)
|
||||
|
||||
## Current Deployment
|
||||
|
||||
### Client: test
|
||||
- **Hostname**: test (from Hetzner Cloud)
|
||||
- **Nextcloud**: https://nextcloud.test.vrije.cloud
|
||||
- **Secrets**: `secrets/clients/test.sops.yaml`
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Deploy Applications
|
||||
```bash
|
||||
cd infrastructure/ansible
|
||||
export HCLOUD_TOKEN="MlURmliUzLcGyzCWXWWsZt3DeWxKcQH9ZMGiaaNrFM3VcgnASlEWKhhxLHdWAl0J"
|
||||
export SOPS_AGE_KEY_FILE="../keys/age-key.txt"
|
||||
|
||||
# Deploy everything to test client
|
||||
~/.local/bin/ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit test
|
||||
```
|
||||
|
||||
### Check Service Status
|
||||
```bash
|
||||
# List inventory hosts
|
||||
export HCLOUD_TOKEN="..."
|
||||
~/.local/bin/ansible-inventory -i hcloud.yml --list
|
||||
|
||||
# Run ad-hoc commands
|
||||
~/.local/bin/ansible test -i hcloud.yml -m shell -a "docker ps"
|
||||
~/.local/bin/ansible test -i hcloud.yml -m shell -a "docker logs nextcloud 2>&1 | tail -50"
|
||||
```
|
||||
|
||||
### Edit Secrets
|
||||
```bash
|
||||
cd infrastructure
|
||||
export SOPS_AGE_KEY_FILE="keys/age-key.txt"
|
||||
|
||||
# Edit client secrets
|
||||
sops secrets/clients/test.sops.yaml
|
||||
|
||||
# View decrypted secrets
|
||||
sops --decrypt secrets/clients/test.sops.yaml
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Service Stack
|
||||
- **Traefik**: Reverse proxy with automatic Let's Encrypt certificates
|
||||
- **Nextcloud 30.0.17**: File sync and collaboration
|
||||
- **Redis**: Caching for Nextcloud
|
||||
- **MariaDB**: Database for Nextcloud
|
||||
|
||||
### Docker Networks
|
||||
- `traefik`: External network for all web-accessible services
|
||||
- `nextcloud-internal`: Internal network for Nextcloud ↔ Redis/DB
|
||||
|
||||
### Volumes
|
||||
- `nextcloud_nextcloud-data`: Nextcloud files and database
|
||||
|
||||
## Service Credentials
|
||||
|
||||
### Nextcloud Admin
|
||||
- **URL**: https://nextcloud.test.vrije.cloud
|
||||
- **Username**: admin
|
||||
- **Password**: In `secrets/clients/test.sops.yaml` → `nextcloud_admin_password`
|
||||
112
README.md
112
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# De Vrije Cloud: Post-Tyranny Tech Multi-Tenant Infrastructure
|
||||
# Post-X Society Multi-Tenant Infrastructure
|
||||
|
||||
Infrastructure as Code for our "[Vrije Cloud](https://www.vrije.cloud)" a scalable multi-tenant VPS platform running Nextcloud (file sync/share) on Hetzner Cloud.
|
||||
Infrastructure as Code for a scalable multi-tenant VPS platform running Nextcloud (file sync/share) on Hetzner Cloud.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
|
|
@ -8,7 +8,6 @@ Infrastructure as Code for our "[Vrije Cloud](https://www.vrije.cloud)" a scalab
|
|||
- **Configuration**: Ansible with dynamic inventory
|
||||
- **Secrets**: SOPS + Age encryption
|
||||
- **Hosting**: Hetzner Cloud (EU-based, GDPR-compliant)
|
||||
- **Identity**: Authentik (OAuth2/OIDC SSO, MIT license)
|
||||
- **Storage**: Nextcloud (German company, AGPL 3.0)
|
||||
|
||||
## 📁 Repository Structure
|
||||
|
|
@ -33,62 +32,7 @@ infrastructure/
|
|||
- [SOPS](https://github.com/getsops/sops) + [Age](https://github.com/FiloSottile/age)
|
||||
- [Hetzner Cloud account](https://www.hetzner.com/cloud)
|
||||
|
||||
### Automated Deployment (Recommended)
|
||||
|
||||
**The fastest way to deploy a client:**
|
||||
|
||||
```bash
|
||||
# 1. Ensure SOPS Age key is available (if not set)
|
||||
export SOPS_AGE_KEY_FILE="./keys/age-key.txt"
|
||||
|
||||
# 2. Add client to terraform.tfvars
|
||||
# clients = {
|
||||
# newclient = {
|
||||
# server_type = "cx22"
|
||||
# location = "fsn1"
|
||||
# subdomain = "newclient"
|
||||
# apps = ["authentik", "nextcloud"]
|
||||
# }
|
||||
# }
|
||||
|
||||
# 3. Deploy client (fully automated, ~10-15 minutes)
|
||||
# The script automatically loads the Hetzner API token from SOPS
|
||||
./scripts/deploy-client.sh newclient
|
||||
```
|
||||
|
||||
**Note**: The Hetzner API token is now stored encrypted in `secrets/shared.sops.yaml` and loaded automatically by all scripts. No need to manually set `HCLOUD_TOKEN`.
|
||||
|
||||
The script will automatically:
|
||||
- ✅ Generate unique SSH key pair (if missing)
|
||||
- ✅ Create secrets file from template (if missing, opens in editor)
|
||||
- ✅ Provision VPS on Hetzner Cloud
|
||||
- ✅ Deploy Authentik (SSO/identity provider)
|
||||
- ✅ Deploy Nextcloud (file storage)
|
||||
- ✅ Configure OAuth2/OIDC integration
|
||||
- ✅ Set up SSL certificates
|
||||
- ✅ Create admin accounts
|
||||
|
||||
**Result**: Fully functional system, ready to use immediately!
|
||||
|
||||
### Management Scripts
|
||||
|
||||
```bash
|
||||
# Deploy a fresh client
|
||||
./scripts/deploy-client.sh <client_name>
|
||||
|
||||
# Rebuild existing client (destroy + redeploy)
|
||||
./scripts/rebuild-client.sh <client_name>
|
||||
|
||||
# Destroy client infrastructure
|
||||
./scripts/destroy-client.sh <client_name>
|
||||
```
|
||||
|
||||
See [scripts/README.md](scripts/README.md) for detailed documentation.
|
||||
|
||||
### Manual Setup (Advanced)
|
||||
|
||||
<details>
|
||||
<summary>Click to expand manual setup instructions</summary>
|
||||
### Initial Setup
|
||||
|
||||
1. **Clone repository**:
|
||||
```bash
|
||||
|
|
@ -108,32 +52,20 @@ See [scripts/README.md](scripts/README.md) for detailed documentation.
|
|||
# Edit with your Hetzner API token and configuration
|
||||
```
|
||||
|
||||
4. **Create client secrets**:
|
||||
```bash
|
||||
cp secrets/clients/test.sops.yaml secrets/clients/<client>.sops.yaml
|
||||
sops secrets/clients/<client>.sops.yaml
|
||||
# Update client_name, domains, regenerate all passwords
|
||||
```
|
||||
|
||||
5. **Provision infrastructure**:
|
||||
4. **Provision infrastructure**:
|
||||
```bash
|
||||
cd tofu
|
||||
tofu init
|
||||
tofu plan
|
||||
tofu apply
|
||||
```
|
||||
|
||||
6. **Deploy applications**:
|
||||
5. **Deploy applications**:
|
||||
```bash
|
||||
cd ../ansible
|
||||
export HCLOUD_TOKEN="your-token"
|
||||
export SOPS_AGE_KEY_FILE="../keys/age-key.txt"
|
||||
|
||||
ansible-playbook -i hcloud.yml playbooks/setup.yml --limit <client>
|
||||
ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit <client>
|
||||
ansible-playbook playbooks/setup.yml
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🎯 Project Principles
|
||||
|
||||
1. **EU/GDPR-first**: European vendors and data residency
|
||||
|
|
@ -142,11 +74,35 @@ See [scripts/README.md](scripts/README.md) for detailed documentation.
|
|||
4. **Infrastructure as Code**: All changes via version control
|
||||
5. **Security by default**: Encryption, hardening, least privilege
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **[PROJECT_REFERENCE.md](PROJECT_REFERENCE.md)** - Essential information and common operations
|
||||
- [Architecture Decision Record](docs/architecture-decisions.md) - Complete design rationale
|
||||
- [Agent Definitions](.claude/agents/) - Specialized AI agent instructions
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
This project uses specialized AI agents for development:
|
||||
|
||||
- **Architect**: High-level design decisions
|
||||
- **Infrastructure**: OpenTofu + Ansible implementation
|
||||
- **Nextcloud**: File sync/share configuration
|
||||
|
||||
See individual agent files in `.claude/agents/` for responsibilities.
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
- Secrets are encrypted with SOPS + Age before committing
|
||||
- Age private keys are **NEVER** stored in this repository
|
||||
- See `.gitignore` for protected files
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details
|
||||
TBD
|
||||
|
||||
## 🙋 Support
|
||||
|
||||
For issues or questions, please create am issue or contact us on [vrijecloud@postxsociety.org](mailto:vrijecloud@postxsociety.org)
|
||||
|
||||
For issues or questions, please create a GitHub issue with the appropriate label:
|
||||
- `agent:architect` - Architecture/design questions
|
||||
- `agent:infrastructure` - IaC implementation
|
||||
- `agent:nextcloud` - File sync/share
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ ansible/
|
|||
│ ├── common/ # Base system hardening
|
||||
│ ├── docker/ # Docker + Docker Compose
|
||||
│ ├── traefik/ # Reverse proxy
|
||||
│ ├── authentik/ # Identity provider (OAuth2/OIDC SSO)
|
||||
│ ├── zitadel/ # Identity provider
|
||||
│ ├── nextcloud/ # File sync/share
|
||||
│ └── backup/ # Restic backup
|
||||
└── group_vars/ # Group variables
|
||||
|
|
@ -120,8 +120,8 @@ Reverse proxy with automatic SSL:
|
|||
- HTTP to HTTPS redirection
|
||||
- Dashboard (optional)
|
||||
|
||||
### authentik
|
||||
Identity provider deployment (OAuth2/OIDC SSO)
|
||||
### zitadel
|
||||
Identity provider deployment (see Zitadel Agent for details)
|
||||
|
||||
### nextcloud
|
||||
File sync/share deployment (see Nextcloud Agent for details)
|
||||
|
|
@ -273,9 +273,10 @@ ansible-playbook playbooks/setup.yml -vvv # Very verbose
|
|||
## Next Steps
|
||||
|
||||
After initial setup:
|
||||
1. Deploy applications: Run `playbooks/deploy.yml` to deploy Authentik and Nextcloud
|
||||
2. Configure backups: Use `backup` role
|
||||
3. Set up monitoring: Configure Uptime Kuma
|
||||
1. Deploy Zitadel: Follow Zitadel Agent instructions
|
||||
2. Deploy Nextcloud: Follow Nextcloud Agent instructions
|
||||
3. Configure backups: Use `backup` role
|
||||
4. Set up monitoring: Configure Uptime Kuma
|
||||
|
||||
## Resources
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[defaults]
|
||||
# Inventory configuration
|
||||
# inventory = hcloud.yml # Disabled - use -i flag instead
|
||||
inventory = hcloud.yml
|
||||
host_key_checking = False
|
||||
interpreter_python = auto_silent
|
||||
|
||||
|
|
@ -26,8 +26,8 @@ timeout = 30
|
|||
roles_path = ./roles
|
||||
|
||||
[inventory]
|
||||
# Enable inventory plugins
|
||||
enable_plugins = hetzner.hcloud.hcloud, ini, yaml, auto
|
||||
# Enable Hetzner Cloud dynamic inventory plugin
|
||||
enable_plugins = hetzner.hcloud.hcloud
|
||||
|
||||
[privilege_escalation]
|
||||
become = True
|
||||
|
|
@ -37,4 +37,4 @@ become_ask_pass = False
|
|||
|
||||
[ssh_connection]
|
||||
pipelining = True
|
||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o IdentitiesOnly=yes
|
||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
---
|
||||
- name: Configure OIDC
|
||||
hosts: test
|
||||
gather_facts: no
|
||||
vars:
|
||||
nextcloud_domain: "nextcloud.test.vrije.cloud"
|
||||
tasks:
|
||||
- name: Check if Authentik OIDC credentials are available
|
||||
stat:
|
||||
path: /tmp/authentik_oidc_credentials.json
|
||||
register: oidc_creds_file
|
||||
|
||||
- name: Load OIDC credentials from Authentik
|
||||
slurp:
|
||||
path: /tmp/authentik_oidc_credentials.json
|
||||
register: oidc_creds_content
|
||||
when: oidc_creds_file.stat.exists
|
||||
|
||||
- name: Parse OIDC credentials
|
||||
set_fact:
|
||||
authentik_oidc: "{{ oidc_creds_content.content | b64decode | from_json }}"
|
||||
when: oidc_creds_file.stat.exists
|
||||
|
||||
- name: Check if user_oidc app is installed
|
||||
shell: docker exec -u www-data nextcloud php occ app:list --output=json
|
||||
register: nextcloud_apps
|
||||
changed_when: false
|
||||
|
||||
- name: Parse installed apps
|
||||
set_fact:
|
||||
user_oidc_installed: "{{ 'user_oidc' in (nextcloud_apps.stdout | from_json).enabled }}"
|
||||
|
||||
- name: Enable user_oidc app
|
||||
shell: docker exec -u www-data nextcloud php occ app:enable user_oidc
|
||||
when: not user_oidc_installed
|
||||
|
||||
- name: Check if OIDC provider is already configured
|
||||
shell: docker exec -u www-data nextcloud php occ user_oidc:provider
|
||||
register: oidc_providers
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Configure Authentik OIDC provider
|
||||
shell: |
|
||||
docker exec -u www-data nextcloud php occ user_oidc:provider \
|
||||
--clientid="{{ authentik_oidc.client_id }}" \
|
||||
--clientsecret="{{ authentik_oidc.client_secret }}" \
|
||||
--discoveryuri="{{ authentik_oidc.discovery_uri }}" \
|
||||
"Authentik"
|
||||
when:
|
||||
- authentik_oidc is defined
|
||||
- authentik_oidc.success | default(false)
|
||||
- "'Authentik' not in oidc_providers.stdout"
|
||||
register: oidc_config
|
||||
changed_when: oidc_config.rc == 0
|
||||
|
||||
- name: Display OIDC status
|
||||
debug:
|
||||
msg: |
|
||||
✓ OIDC SSO fully configured!
|
||||
Users can login with Authentik credentials at: https://{{ nextcloud_domain }}
|
||||
|
||||
"Login with Authentik" button should be visible on the login page.
|
||||
when:
|
||||
- authentik_oidc is defined
|
||||
- authentik_oidc.success | default(false)
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
[clients]
|
||||
valk ansible_host=78.47.191.38 ansible_user=root ansible_ssh_private_key_file=../keys/ssh/valk
|
||||
kikker ansible_host=23.88.124.67 ansible_user=root ansible_ssh_private_key_file=../keys/ssh/kikker
|
||||
das ansible_host=49.13.49.246 ansible_user=root ansible_ssh_private_key_file=../keys/ssh/das
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
all:
|
||||
children:
|
||||
clients:
|
||||
hosts:
|
||||
valk:
|
||||
ansible_host: 78.47.191.38
|
||||
ansible_user: root
|
||||
ansible_ssh_private_key_file: ../keys/ssh/valk
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
---
|
||||
# Configure Diun to use webhook notifications instead of email
|
||||
# This playbook updates all servers to send container update notifications
|
||||
# to a Matrix room via webhook instead of individual emails per server
|
||||
#
|
||||
# Usage:
|
||||
# ansible-playbook -i hcloud.yml playbooks/260123-configure-diun-webhook.yml
|
||||
#
|
||||
# Or for specific servers:
|
||||
# ansible-playbook -i hcloud.yml playbooks/260123-configure-diun-webhook.yml --limit das,uil,vos
|
||||
|
||||
- name: Configure Diun webhook notifications on all servers
|
||||
hosts: all
|
||||
become: yes
|
||||
|
||||
vars:
|
||||
# Diun base configuration (from role defaults)
|
||||
diun_version: "latest"
|
||||
diun_log_level: "info"
|
||||
diun_watch_workers: 10
|
||||
diun_watch_all: true
|
||||
diun_exclude_containers: []
|
||||
diun_first_check_notif: false
|
||||
|
||||
# Schedule: Daily at 6am UTC
|
||||
diun_schedule: "0 6 * * *"
|
||||
|
||||
# Webhook configuration - sends to Matrix via custom webhook
|
||||
diun_notif_enabled: true
|
||||
diun_notif_type: webhook
|
||||
diun_webhook_endpoint: "https://diun-webhook.postxsociety.cloud"
|
||||
diun_webhook_method: POST
|
||||
diun_webhook_headers:
|
||||
Content-Type: application/json
|
||||
|
||||
# Disable email notifications
|
||||
diun_email_enabled: false
|
||||
|
||||
# SMTP defaults (not used when email disabled, but needed for template)
|
||||
diun_smtp_host: "smtp.eu.mailgun.org"
|
||||
diun_smtp_port: 587
|
||||
diun_smtp_from: "{{ client_name }}@mg.vrije.cloud"
|
||||
diun_smtp_to: "pieter@postxsociety.org"
|
||||
|
||||
# Optional notification defaults (unused but needed for template)
|
||||
diun_slack_webhook_url: ""
|
||||
diun_matrix_enabled: false
|
||||
diun_matrix_homeserver_url: ""
|
||||
diun_matrix_user: ""
|
||||
diun_matrix_password: ""
|
||||
diun_matrix_room_id: ""
|
||||
|
||||
pre_tasks:
|
||||
- name: Gather facts
|
||||
setup:
|
||||
|
||||
- name: Determine client name from hostname
|
||||
set_fact:
|
||||
client_name: "{{ inventory_hostname }}"
|
||||
|
||||
- name: Load client secrets
|
||||
community.sops.load_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/clients/{{ client_name }}.sops.yaml"
|
||||
name: client_secrets
|
||||
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||
no_log: true
|
||||
|
||||
- name: Load shared secrets
|
||||
community.sops.load_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/shared.sops.yaml"
|
||||
name: shared_secrets
|
||||
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||
no_log: true
|
||||
|
||||
- name: Merge shared secrets into client_secrets
|
||||
set_fact:
|
||||
client_secrets: "{{ client_secrets | combine(shared_secrets) }}"
|
||||
no_log: true
|
||||
|
||||
tasks:
|
||||
- name: Set SMTP credentials (required by template even if unused)
|
||||
set_fact:
|
||||
diun_smtp_username_final: "{{ client_secrets.mailgun_smtp_user | default('') }}"
|
||||
diun_smtp_password_final: ""
|
||||
no_log: true
|
||||
|
||||
- name: Display configuration summary
|
||||
debug:
|
||||
msg: |
|
||||
Configuring Diun on {{ inventory_hostname }}:
|
||||
- Webhook endpoint: {{ diun_webhook_endpoint }}
|
||||
- Email notifications: {{ 'enabled' if diun_email_enabled else 'disabled' }}
|
||||
- Schedule: {{ diun_schedule }} (Daily at 6am UTC)
|
||||
|
||||
- name: Deploy Diun configuration with webhook
|
||||
template:
|
||||
src: "{{ playbook_dir }}/../roles/diun/templates/diun.yml.j2"
|
||||
dest: /opt/docker/diun/diun.yml
|
||||
mode: '0644'
|
||||
notify: Restart Diun
|
||||
|
||||
- name: Restart Diun to apply new configuration
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: /opt/docker/diun
|
||||
state: restarted
|
||||
|
||||
- name: Wait for Diun to start
|
||||
pause:
|
||||
seconds: 5
|
||||
|
||||
- name: Check Diun status
|
||||
shell: docker ps --filter name=diun --format "{{ '{{' }}.Status{{ '}}' }}"
|
||||
register: diun_status
|
||||
changed_when: false
|
||||
|
||||
- name: Display Diun status
|
||||
debug:
|
||||
msg: "Diun status on {{ inventory_hostname }}: {{ diun_status.stdout }}"
|
||||
|
||||
handlers:
|
||||
- name: Restart Diun
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: /opt/docker/diun
|
||||
state: restarted
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
---
|
||||
# Nextcloud Upgrade Stage Task File (Fixed Version)
|
||||
# This file is included by 260123-upgrade-nextcloud-v2.yml for each upgrade stage
|
||||
# Do not run directly
|
||||
#
|
||||
# Improvements:
|
||||
# - Better version detection (actual running version)
|
||||
# - Proper error handling
|
||||
# - Clearer status messages
|
||||
# - Maintenance mode handling
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Starting v{{ stage.from }} → v{{ stage.to }}"
|
||||
debug:
|
||||
msg: |
|
||||
============================================================
|
||||
Stage {{ stage.stage }}: Upgrading v{{ stage.from }} → v{{ stage.to }}
|
||||
============================================================
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Get current running version"
|
||||
shell: docker exec -u www-data nextcloud php occ status --output=json
|
||||
register: stage_version_check
|
||||
changed_when: false
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Parse current version"
|
||||
set_fact:
|
||||
stage_current: "{{ (stage_version_check.stdout | from_json).versionstring }}"
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Display current version"
|
||||
debug:
|
||||
msg: "Currently running: v{{ stage_current }}"
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Check if already on target version"
|
||||
debug:
|
||||
msg: "✓ Already on v{{ stage_current }} - skipping this stage"
|
||||
when: stage_current is version(stage.to, '>=')
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Skip if already upgraded"
|
||||
meta: end_play
|
||||
when: stage_current is version(stage.to, '>=')
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Verify version is compatible"
|
||||
fail:
|
||||
msg: "Cannot upgrade from v{{ stage_current }} (expected v{{ stage.from }}.x)"
|
||||
when: stage_current is version(stage.from, '<') or (stage_current is version(stage.to, '>='))
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Update docker-compose.yml to v{{ stage.to }}"
|
||||
replace:
|
||||
path: "{{ nextcloud_base_dir }}/docker-compose.yml"
|
||||
regexp: 'image:\s*nextcloud:{{ stage.from }}'
|
||||
replace: 'image: nextcloud:{{ stage.to }}'
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Verify docker-compose.yml was updated"
|
||||
shell: grep "image{{ ':' }} nextcloud{{ ':' }}{{ stage.to }}" {{ nextcloud_base_dir }}/docker-compose.yml
|
||||
register: compose_verify
|
||||
changed_when: false
|
||||
failed_when: compose_verify.rc != 0
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Pull Nextcloud v{{ stage.to }} image"
|
||||
shell: docker pull nextcloud:{{ stage.to }}
|
||||
register: image_pull
|
||||
changed_when: "'Downloaded' in image_pull.stdout or 'Pulling' in image_pull.stdout or 'Downloaded newer' in image_pull.stderr"
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Stop containers before upgrade"
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ nextcloud_base_dir }}"
|
||||
state: stopped
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Start containers with new version"
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ nextcloud_base_dir }}"
|
||||
state: present
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Wait for Nextcloud container to be ready"
|
||||
shell: |
|
||||
count=0
|
||||
max_attempts=60
|
||||
while [ $count -lt $max_attempts ]; do
|
||||
if docker exec nextcloud curl -f http://localhost:80/status.php 2>/dev/null; then
|
||||
echo "Container ready after $count attempts"
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "Timeout waiting for container after $max_attempts attempts"
|
||||
exit 1
|
||||
register: container_ready
|
||||
changed_when: false
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Run occ upgrade"
|
||||
shell: docker exec -u www-data nextcloud php occ upgrade --no-interaction
|
||||
register: occ_upgrade
|
||||
changed_when: "'Update successful' in occ_upgrade.stdout or 'upgraded' in occ_upgrade.stdout"
|
||||
failed_when:
|
||||
- occ_upgrade.rc != 0
|
||||
- "'already latest version' not in occ_upgrade.stdout"
|
||||
- "'No upgrade required' not in occ_upgrade.stdout"
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Display upgrade output"
|
||||
debug:
|
||||
msg: "{{ occ_upgrade.stdout_lines }}"
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Verify upgrade succeeded"
|
||||
shell: docker exec -u www-data nextcloud php occ status --output=json
|
||||
register: stage_verify
|
||||
changed_when: false
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Parse upgraded version"
|
||||
set_fact:
|
||||
stage_upgraded: "{{ (stage_verify.stdout | from_json).versionstring }}"
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Check upgrade was successful"
|
||||
fail:
|
||||
msg: "Upgrade to v{{ stage.to }} failed - still on v{{ stage_upgraded }}"
|
||||
when: stage_upgraded is version(stage.to, '<')
|
||||
|
||||
- name: "Stage {{ stage.stage }}: Success"
|
||||
debug:
|
||||
msg: |
|
||||
============================================================
|
||||
✓ Stage {{ stage.stage }} completed successfully
|
||||
Upgraded from v{{ stage_current }} to v{{ stage_upgraded }}
|
||||
============================================================
|
||||
|
|
@ -1,378 +0,0 @@
|
|||
---
|
||||
# Nextcloud Major Version Upgrade Playbook (Fixed Version)
|
||||
# Created: 2026-01-23
|
||||
# Purpose: Safely upgrade Nextcloud from v30 to v32 via v31 (staged upgrade)
|
||||
#
|
||||
# Usage:
|
||||
# cd ansible/
|
||||
# HCLOUD_TOKEN="..." ansible-playbook -i hcloud.yml \
|
||||
# playbooks/260123-upgrade-nextcloud-v2.yml --limit <server> \
|
||||
# --private-key "../keys/ssh/<server>"
|
||||
#
|
||||
# Requirements:
|
||||
# - HCLOUD_TOKEN environment variable set
|
||||
# - SSH access to target server
|
||||
# - Sufficient disk space for backups
|
||||
#
|
||||
# Improvements over v1:
|
||||
# - Idempotent: can be re-run safely after failures
|
||||
# - Better version state tracking (reads actual running version)
|
||||
# - Proper maintenance mode handling
|
||||
# - Stage skipping if already on target version
|
||||
# - Better error messages and rollback instructions
|
||||
|
||||
- name: Upgrade Nextcloud from v30 to v32 (staged)
|
||||
hosts: all
|
||||
become: true
|
||||
gather_facts: true
|
||||
|
||||
vars:
|
||||
nextcloud_base_dir: "/opt/nextcloud"
|
||||
backup_dir: "/root/nextcloud-backup-{{ ansible_date_time.iso8601_basic_short }}"
|
||||
target_version: "32"
|
||||
|
||||
tasks:
|
||||
# ============================================================
|
||||
# PRE-UPGRADE CHECKS
|
||||
# ============================================================
|
||||
|
||||
- name: Display upgrade plan
|
||||
debug:
|
||||
msg: |
|
||||
============================================================
|
||||
Nextcloud Upgrade Plan - {{ inventory_hostname }}
|
||||
============================================================
|
||||
|
||||
Target: Nextcloud v{{ target_version }}
|
||||
Backup: {{ backup_dir }}
|
||||
|
||||
This playbook will:
|
||||
1. Detect current version
|
||||
2. Create backup if needed
|
||||
3. Upgrade through required stages (v30→v31→v32)
|
||||
4. Skip stages already completed
|
||||
5. Re-enable apps and disable maintenance mode
|
||||
|
||||
Estimated time: 10-20 minutes
|
||||
============================================================
|
||||
|
||||
- name: Check if Nextcloud is installed
|
||||
shell: docker ps --filter "name=^nextcloud$" --format "{{ '{{' }}.Names{{ '}}' }}"
|
||||
register: nextcloud_running
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Fail if Nextcloud is not running
|
||||
fail:
|
||||
msg: "Nextcloud container is not running on {{ inventory_hostname }}"
|
||||
when: "'nextcloud' not in nextcloud_running.stdout"
|
||||
|
||||
- name: Get current Nextcloud version
|
||||
shell: docker exec -u www-data nextcloud php occ status --output=json
|
||||
register: nextcloud_status
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Parse Nextcloud status
|
||||
set_fact:
|
||||
nc_status: "{{ nextcloud_status.stdout | from_json }}"
|
||||
when: nextcloud_status.rc == 0
|
||||
|
||||
- name: Handle Nextcloud in maintenance mode
|
||||
block:
|
||||
- name: Display maintenance mode warning
|
||||
debug:
|
||||
msg: "⚠ Nextcloud is in maintenance mode. Attempting to disable it..."
|
||||
|
||||
- name: Disable maintenance mode if enabled
|
||||
shell: docker exec -u www-data nextcloud php occ maintenance:mode --off
|
||||
register: maint_off
|
||||
changed_when: "'disabled' in maint_off.stdout"
|
||||
|
||||
- name: Wait a moment for mode change
|
||||
pause:
|
||||
seconds: 2
|
||||
|
||||
- name: Re-check status after disabling maintenance mode
|
||||
shell: docker exec -u www-data nextcloud php occ status --output=json
|
||||
register: nextcloud_status_retry
|
||||
changed_when: false
|
||||
|
||||
- name: Update status
|
||||
set_fact:
|
||||
nc_status: "{{ nextcloud_status_retry.stdout | from_json }}"
|
||||
when: nextcloud_status.rc != 0 or (nc_status is defined and nc_status.maintenance | bool)
|
||||
|
||||
- name: Display current version
|
||||
debug:
|
||||
msg: |
|
||||
Current: v{{ nc_status.versionstring }}
|
||||
Target: v{{ target_version }}
|
||||
Maintenance mode: {{ nc_status.maintenance }}
|
||||
|
||||
- name: Check if already on target version
|
||||
debug:
|
||||
msg: "✓ Nextcloud is already on v{{ nc_status.versionstring }} - nothing to do"
|
||||
when: nc_status.versionstring is version(target_version, '>=')
|
||||
|
||||
- name: End play if already upgraded
|
||||
meta: end_host
|
||||
when: nc_status.versionstring is version(target_version, '>=')
|
||||
|
||||
- name: Check disk space
|
||||
shell: df -BG {{ nextcloud_base_dir }} | tail -1 | awk '{print $4}' | sed 's/G//'
|
||||
register: disk_space_gb
|
||||
changed_when: false
|
||||
|
||||
- name: Verify sufficient disk space
|
||||
fail:
|
||||
msg: "Insufficient disk space: {{ disk_space_gb.stdout }}GB available, need at least 5GB"
|
||||
when: disk_space_gb.stdout | int < 5
|
||||
|
||||
- name: Display available disk space
|
||||
debug:
|
||||
msg: "Available disk space: {{ disk_space_gb.stdout }}GB"
|
||||
|
||||
# ============================================================
|
||||
# BACKUP PHASE (only if not already backed up)
|
||||
# ============================================================
|
||||
|
||||
- name: Check if backup already exists
|
||||
stat:
|
||||
path: "{{ backup_dir }}"
|
||||
register: backup_exists
|
||||
|
||||
- name: Skip backup if already exists
|
||||
debug:
|
||||
msg: "✓ Backup already exists at {{ backup_dir }} - skipping backup phase"
|
||||
when: backup_exists.stat.exists
|
||||
|
||||
- name: Create backup
|
||||
block:
|
||||
- name: Create backup directory
|
||||
file:
|
||||
path: "{{ backup_dir }}"
|
||||
state: directory
|
||||
mode: '0700'
|
||||
|
||||
- name: Enable maintenance mode for backup
|
||||
shell: docker exec -u www-data nextcloud php occ maintenance:mode --on
|
||||
register: maintenance_on
|
||||
changed_when: "'enabled' in maintenance_on.stdout"
|
||||
|
||||
- name: Backup Nextcloud database
|
||||
shell: |
|
||||
docker exec nextcloud-db pg_dump -U nextcloud nextcloud | gzip > {{ backup_dir }}/database.sql.gz
|
||||
args:
|
||||
creates: "{{ backup_dir }}/database.sql.gz"
|
||||
|
||||
- name: Get database backup size
|
||||
stat:
|
||||
path: "{{ backup_dir }}/database.sql.gz"
|
||||
register: db_backup
|
||||
|
||||
- name: Display database backup info
|
||||
debug:
|
||||
msg: "Database backup: {{ (db_backup.stat.size / 1024 / 1024) | round(2) }} MB"
|
||||
|
||||
- name: Stop Nextcloud containers for volume backup
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ nextcloud_base_dir }}"
|
||||
state: stopped
|
||||
|
||||
- name: Backup Nextcloud app volume
|
||||
shell: |
|
||||
tar -czf {{ backup_dir }}/nextcloud-app-volume.tar.gz -C /var/lib/docker/volumes/nextcloud-app/_data .
|
||||
args:
|
||||
creates: "{{ backup_dir }}/nextcloud-app-volume.tar.gz"
|
||||
|
||||
- name: Backup Nextcloud database volume
|
||||
shell: |
|
||||
tar -czf {{ backup_dir }}/nextcloud-db-volume.tar.gz -C /var/lib/docker/volumes/nextcloud-db-data/_data .
|
||||
args:
|
||||
creates: "{{ backup_dir }}/nextcloud-db-volume.tar.gz"
|
||||
|
||||
- name: Copy current docker-compose.yml to backup
|
||||
copy:
|
||||
src: "{{ nextcloud_base_dir }}/docker-compose.yml"
|
||||
dest: "{{ backup_dir }}/docker-compose.yml.backup"
|
||||
remote_src: true
|
||||
|
||||
- name: Display backup summary
|
||||
debug:
|
||||
msg: |
|
||||
============================================================
|
||||
✓ Backup completed: {{ backup_dir }}
|
||||
============================================================
|
||||
|
||||
To restore from backup if needed:
|
||||
1. cd {{ nextcloud_base_dir }} && docker compose down
|
||||
2. tar -xzf {{ backup_dir }}/nextcloud-app-volume.tar.gz -C /var/lib/docker/volumes/nextcloud-app/_data
|
||||
3. tar -xzf {{ backup_dir }}/nextcloud-db-volume.tar.gz -C /var/lib/docker/volumes/nextcloud-db-data/_data
|
||||
4. cp {{ backup_dir }}/docker-compose.yml.backup {{ nextcloud_base_dir }}/docker-compose.yml
|
||||
5. cd {{ nextcloud_base_dir }} && docker compose up -d
|
||||
============================================================
|
||||
|
||||
- name: Restart containers after backup
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ nextcloud_base_dir }}"
|
||||
state: present
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
shell: |
|
||||
count=0
|
||||
max_attempts=24
|
||||
while [ $count -lt $max_attempts ]; do
|
||||
if docker exec nextcloud curl -f http://localhost:80/status.php 2>/dev/null; then
|
||||
echo "Ready after $count attempts"
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "Timeout after $max_attempts attempts"
|
||||
exit 1
|
||||
register: nextcloud_ready
|
||||
changed_when: false
|
||||
|
||||
- name: Disable maintenance mode after backup
|
||||
shell: docker exec -u www-data nextcloud php occ maintenance:mode --off
|
||||
register: maint_off_backup
|
||||
changed_when: "'disabled' in maint_off_backup.stdout"
|
||||
|
||||
when: not backup_exists.stat.exists
|
||||
|
||||
# ============================================================
|
||||
# DETERMINE UPGRADE PATH
|
||||
# ============================================================
|
||||
|
||||
- name: Initialize stage counter
|
||||
set_fact:
|
||||
stage_number: 0
|
||||
|
||||
# ============================================================
|
||||
# STAGED UPGRADE LOOP - Dynamic version checking
|
||||
# ============================================================
|
||||
|
||||
- name: Stage 1 - Upgrade v30→v31 if needed
|
||||
block:
|
||||
- name: Get current version
|
||||
shell: docker exec -u www-data nextcloud php occ status --output=json
|
||||
register: version_check
|
||||
changed_when: false
|
||||
|
||||
- name: Parse version
|
||||
set_fact:
|
||||
current_version: "{{ (version_check.stdout | from_json).versionstring }}"
|
||||
|
||||
- name: Check if v30→v31 upgrade needed
|
||||
set_fact:
|
||||
needs_v31_upgrade: "{{ current_version is version('30', '>=') and current_version is version('31', '<') }}"
|
||||
|
||||
- name: Perform v30→v31 upgrade
|
||||
include_tasks: "{{ playbook_dir }}/260123-upgrade-nextcloud-stage-v2.yml"
|
||||
vars:
|
||||
stage:
|
||||
from: "30"
|
||||
to: "31"
|
||||
stage: 1
|
||||
when: needs_v31_upgrade
|
||||
|
||||
- name: Stage 2 - Upgrade v31→v32 if needed
|
||||
block:
|
||||
- name: Get current version
|
||||
shell: docker exec -u www-data nextcloud php occ status --output=json
|
||||
register: version_check
|
||||
changed_when: false
|
||||
|
||||
- name: Parse version
|
||||
set_fact:
|
||||
current_version: "{{ (version_check.stdout | from_json).versionstring }}"
|
||||
|
||||
- name: Check if v31→v32 upgrade needed
|
||||
set_fact:
|
||||
needs_v32_upgrade: "{{ current_version is version('31', '>=') and current_version is version('32', '<') }}"
|
||||
|
||||
- name: Perform v31→v32 upgrade
|
||||
include_tasks: "{{ playbook_dir }}/260123-upgrade-nextcloud-stage-v2.yml"
|
||||
vars:
|
||||
stage:
|
||||
from: "31"
|
||||
to: "32"
|
||||
stage: 2
|
||||
when: needs_v32_upgrade
|
||||
|
||||
# ============================================================
|
||||
# POST-UPGRADE
|
||||
# ============================================================
|
||||
|
||||
- name: Get final version
|
||||
shell: docker exec -u www-data nextcloud php occ status --output=json
|
||||
register: final_status
|
||||
changed_when: false
|
||||
|
||||
- name: Parse final version
|
||||
set_fact:
|
||||
final_version: "{{ (final_status.stdout | from_json).versionstring }}"
|
||||
|
||||
- name: Verify upgrade to target version
|
||||
fail:
|
||||
msg: "Upgrade incomplete - on v{{ final_version }}, expected v{{ target_version }}.x"
|
||||
when: final_version is version(target_version, '<')
|
||||
|
||||
- name: Run database optimizations
|
||||
shell: docker exec -u www-data nextcloud php occ db:add-missing-indices
|
||||
register: db_indices
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Run bigint conversion
|
||||
shell: docker exec -u www-data nextcloud php occ db:convert-filecache-bigint --no-interaction
|
||||
register: db_bigint
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
timeout: 600
|
||||
|
||||
- name: Re-enable critical apps
|
||||
shell: |
|
||||
docker exec -u www-data nextcloud php occ app:enable user_oidc || true
|
||||
docker exec -u www-data nextcloud php occ app:enable richdocuments || true
|
||||
register: apps_enabled
|
||||
changed_when: false
|
||||
|
||||
- name: Ensure maintenance mode is disabled
|
||||
shell: docker exec -u www-data nextcloud php occ maintenance:mode --off
|
||||
register: final_maint_off
|
||||
changed_when: "'disabled' in final_maint_off.stdout"
|
||||
failed_when: false
|
||||
|
||||
- name: Update docker-compose.yml to use latest tag
|
||||
replace:
|
||||
path: "{{ nextcloud_base_dir }}/docker-compose.yml"
|
||||
regexp: 'image:\s*nextcloud:\d+'
|
||||
replace: 'image: nextcloud:latest'
|
||||
|
||||
- name: Display success message
|
||||
debug:
|
||||
msg: |
|
||||
============================================================
|
||||
✓ UPGRADE SUCCESSFUL!
|
||||
============================================================
|
||||
|
||||
Server: {{ inventory_hostname }}
|
||||
From: v30.x
|
||||
To: v{{ final_version }}
|
||||
|
||||
Backup: {{ backup_dir }}
|
||||
|
||||
Next steps:
|
||||
1. Test login: https://nextcloud.{{ client_domain }}
|
||||
2. Test OIDC: Click "Login with Authentik"
|
||||
3. Test file operations
|
||||
4. Test Collabora Office
|
||||
|
||||
If all tests pass, remove backup:
|
||||
rm -rf {{ backup_dir }}
|
||||
|
||||
docker-compose.yml now uses 'nextcloud:latest' tag
|
||||
============================================================
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
---
|
||||
# Configure Diun to disable watchRepo and add Docker Hub authentication
|
||||
# This playbook updates all servers to:
|
||||
# - Only watch specific image tags (not entire repositories) to reduce API calls
|
||||
# - Add Docker Hub authentication for higher rate limits
|
||||
#
|
||||
# Background:
|
||||
# - watchRepo: true checks ALL tags in a repository (hundreds of API calls)
|
||||
# - watchRepo: false only checks the specific tag being used (1-2 API calls)
|
||||
# - Docker Hub auth increases rate limit from 100 to 5000 pulls per 6 hours
|
||||
#
|
||||
# Usage:
|
||||
# cd ansible/
|
||||
# SOPS_AGE_KEY_FILE="../keys/age-key.txt" HCLOUD_TOKEN="..." \
|
||||
# ansible-playbook -i hcloud.yml playbooks/260124-configure-diun-watchrepo.yml
|
||||
#
|
||||
# Or for specific servers:
|
||||
# SOPS_AGE_KEY_FILE="../keys/age-key.txt" HCLOUD_TOKEN="..." \
|
||||
# ansible-playbook -i hcloud.yml playbooks/260124-configure-diun-watchrepo.yml \
|
||||
# --limit das,uil,vos --private-key "../keys/ssh/das"
|
||||
|
||||
- name: Configure Diun watchRepo and Docker Hub authentication
|
||||
hosts: all
|
||||
become: yes
|
||||
|
||||
vars:
|
||||
# Diun base configuration
|
||||
diun_version: "latest"
|
||||
diun_log_level: "info"
|
||||
diun_watch_workers: 10
|
||||
diun_watch_all: true
|
||||
diun_exclude_containers: []
|
||||
diun_first_check_notif: false
|
||||
|
||||
# Schedule: Weekly on Monday at 6am UTC (to reduce API calls)
|
||||
diun_schedule: "0 6 * * 1"
|
||||
|
||||
# Disable watchRepo - only check the specific tags we're using
|
||||
diun_watch_repo: false
|
||||
|
||||
# Webhook configuration - sends to Matrix via custom webhook
|
||||
diun_notif_enabled: true
|
||||
diun_notif_type: webhook
|
||||
diun_webhook_endpoint: "https://diun-webhook.postxsociety.cloud"
|
||||
diun_webhook_method: POST
|
||||
diun_webhook_headers:
|
||||
Content-Type: application/json
|
||||
|
||||
# Disable email notifications
|
||||
diun_email_enabled: false
|
||||
|
||||
# SMTP defaults (not used when email disabled, but needed for template)
|
||||
diun_smtp_host: "smtp.eu.mailgun.org"
|
||||
diun_smtp_port: 587
|
||||
diun_smtp_from: "{{ client_name }}@mg.vrije.cloud"
|
||||
diun_smtp_to: "pieter@postxsociety.org"
|
||||
|
||||
# Optional notification defaults (unused but needed for template)
|
||||
diun_slack_webhook_url: ""
|
||||
diun_matrix_enabled: false
|
||||
diun_matrix_homeserver_url: ""
|
||||
diun_matrix_user: ""
|
||||
diun_matrix_password: ""
|
||||
diun_matrix_room_id: ""
|
||||
|
||||
pre_tasks:
|
||||
- name: Gather facts
|
||||
setup:
|
||||
|
||||
- name: Determine client name from hostname
|
||||
set_fact:
|
||||
client_name: "{{ inventory_hostname }}"
|
||||
|
||||
- name: Load client secrets
|
||||
community.sops.load_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/clients/{{ client_name }}.sops.yaml"
|
||||
name: client_secrets
|
||||
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||
no_log: true
|
||||
|
||||
- name: Load shared secrets
|
||||
community.sops.load_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/shared.sops.yaml"
|
||||
name: shared_secrets
|
||||
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||
no_log: true
|
||||
|
||||
- name: Merge shared secrets into client_secrets
|
||||
set_fact:
|
||||
client_secrets: "{{ client_secrets | combine(shared_secrets) }}"
|
||||
no_log: true
|
||||
|
||||
tasks:
|
||||
- name: Set SMTP credentials (required by template even if unused)
|
||||
set_fact:
|
||||
diun_smtp_username_final: "{{ client_secrets.mailgun_smtp_user | default('') }}"
|
||||
diun_smtp_password_final: ""
|
||||
no_log: true
|
||||
|
||||
- name: Set Docker Hub credentials for higher rate limits
|
||||
set_fact:
|
||||
diun_docker_hub_username: "{{ client_secrets.docker_hub_username }}"
|
||||
diun_docker_hub_password: "{{ client_secrets.docker_hub_password }}"
|
||||
no_log: true
|
||||
|
||||
- name: Display configuration summary
|
||||
debug:
|
||||
msg: |
|
||||
Configuring Diun on {{ inventory_hostname }}:
|
||||
- Webhook endpoint: {{ diun_webhook_endpoint }}
|
||||
- Email notifications: {{ 'enabled' if diun_email_enabled else 'disabled' }}
|
||||
- Schedule: {{ diun_schedule }} (Weekly on Monday at 6am UTC)
|
||||
- Watch entire repositories: {{ 'yes' if diun_watch_repo else 'no (only specific tags)' }}
|
||||
- Docker Hub auth: {{ 'enabled' if diun_docker_hub_username else 'disabled' }}
|
||||
|
||||
- name: Deploy Diun configuration with watchRepo disabled and Docker Hub auth
|
||||
template:
|
||||
src: "{{ playbook_dir }}/../roles/diun/templates/diun.yml.j2"
|
||||
dest: /opt/docker/diun/diun.yml
|
||||
mode: '0644'
|
||||
notify: Restart Diun
|
||||
|
||||
- name: Restart Diun to apply new configuration
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: /opt/docker/diun
|
||||
state: restarted
|
||||
|
||||
- name: Wait for Diun to start
|
||||
pause:
|
||||
seconds: 5
|
||||
|
||||
- name: Check Diun status
|
||||
shell: docker ps --filter name=diun --format "{{ '{{' }}.Status{{ '}}' }}"
|
||||
register: diun_status
|
||||
changed_when: false
|
||||
|
||||
- name: Display Diun status
|
||||
debug:
|
||||
msg: "Diun status on {{ inventory_hostname }}: {{ diun_status.stdout }}"
|
||||
|
||||
- name: Verify Diun configuration
|
||||
shell: docker exec diun cat /diun.yml | grep -E "(watchRepo|regopts)" || echo "Config deployed"
|
||||
register: diun_config_check
|
||||
changed_when: false
|
||||
|
||||
- name: Display configuration verification
|
||||
debug:
|
||||
msg: |
|
||||
Configuration applied on {{ inventory_hostname }}:
|
||||
{{ diun_config_check.stdout }}
|
||||
|
||||
handlers:
|
||||
- name: Restart Diun
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: /opt/docker/diun
|
||||
state: restarted
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
---
|
||||
# Nextcloud Maintenance Playbook
|
||||
# Created: 2026-01-24
|
||||
# Purpose: Run database and file maintenance tasks on Nextcloud instances
|
||||
#
|
||||
# This playbook performs:
|
||||
# 1. Add missing database indices (improves query performance)
|
||||
# 2. Update mimetypes database (ensures proper file type handling)
|
||||
#
|
||||
# Usage:
|
||||
# cd ansible/
|
||||
# HCLOUD_TOKEN="..." ansible-playbook -i hcloud.yml \
|
||||
# playbooks/nextcloud-maintenance.yml --limit <server> \
|
||||
# --private-key "../keys/ssh/<server>"
|
||||
#
|
||||
# To run on all servers:
|
||||
# HCLOUD_TOKEN="..." ansible-playbook -i hcloud.yml \
|
||||
# playbooks/nextcloud-maintenance.yml \
|
||||
# --private-key "../keys/ssh/<server>"
|
||||
#
|
||||
# Requirements:
|
||||
# - HCLOUD_TOKEN environment variable set
|
||||
# - SSH access to target server(s)
|
||||
# - Nextcloud container must be running
|
||||
|
||||
- name: Nextcloud Maintenance Tasks
|
||||
hosts: all
|
||||
become: true
|
||||
gather_facts: true
|
||||
|
||||
vars:
|
||||
nextcloud_container: "nextcloud"
|
||||
|
||||
tasks:
|
||||
# ============================================================
|
||||
# PRE-CHECK
|
||||
# ============================================================
|
||||
|
||||
- name: Display maintenance plan
|
||||
debug:
|
||||
msg: |
|
||||
============================================================
|
||||
Nextcloud Maintenance - {{ inventory_hostname }}
|
||||
============================================================
|
||||
|
||||
This playbook will:
|
||||
1. Add missing database indices
|
||||
2. Update mimetypes database
|
||||
3. Display results
|
||||
|
||||
Estimated time: 1-3 minutes per server
|
||||
============================================================
|
||||
|
||||
- name: Check if Nextcloud container is running
|
||||
shell: docker ps --filter "name=^{{ nextcloud_container }}$" --format "{{ '{{' }}.Names{{ '}}' }}"
|
||||
register: nextcloud_running
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Fail if Nextcloud is not running
|
||||
fail:
|
||||
msg: "Nextcloud container is not running on {{ inventory_hostname }}"
|
||||
when: "'nextcloud' not in nextcloud_running.stdout"
|
||||
|
||||
- name: Get current Nextcloud version
|
||||
shell: docker exec -u www-data {{ nextcloud_container }} php occ --version
|
||||
register: nextcloud_version
|
||||
changed_when: false
|
||||
|
||||
- name: Display Nextcloud version
|
||||
debug:
|
||||
msg: "{{ nextcloud_version.stdout }}"
|
||||
|
||||
# ============================================================
|
||||
# TASK 1: ADD MISSING DATABASE INDICES
|
||||
# ============================================================
|
||||
|
||||
- name: Check for missing database indices
|
||||
shell: docker exec -u www-data {{ nextcloud_container }} php occ db:add-missing-indices
|
||||
register: db_indices_result
|
||||
changed_when: "'updated successfully' in db_indices_result.stdout"
|
||||
failed_when: db_indices_result.rc != 0
|
||||
|
||||
- name: Display database indices results
|
||||
debug:
|
||||
msg: |
|
||||
============================================================
|
||||
Database Indices Results
|
||||
============================================================
|
||||
{{ db_indices_result.stdout }}
|
||||
============================================================
|
||||
|
||||
# ============================================================
|
||||
# TASK 2: UPDATE MIMETYPES DATABASE
|
||||
# ============================================================
|
||||
|
||||
- name: Update mimetypes database
|
||||
shell: docker exec -u www-data {{ nextcloud_container }} php occ maintenance:mimetype:update-db
|
||||
register: mimetype_result
|
||||
changed_when: "'Added' in mimetype_result.stdout"
|
||||
failed_when: mimetype_result.rc != 0
|
||||
|
||||
- name: Parse mimetype results
|
||||
set_fact:
|
||||
mimetypes_added: "{{ mimetype_result.stdout | regex_search('Added (\\d+) new mimetypes', '\\1') | default(['0'], true) | first }}"
|
||||
|
||||
- name: Display mimetype results
|
||||
debug:
|
||||
msg: |
|
||||
============================================================
|
||||
Mimetype Update Results
|
||||
============================================================
|
||||
Mimetypes added: {{ mimetypes_added }}
|
||||
{% if mimetypes_added | int > 0 %}
|
||||
✓ Mimetype database updated successfully
|
||||
{% else %}
|
||||
✓ All mimetypes already up to date
|
||||
{% endif %}
|
||||
============================================================
|
||||
|
||||
# ============================================================
|
||||
# SUMMARY
|
||||
# ============================================================
|
||||
|
||||
- name: Display maintenance summary
|
||||
debug:
|
||||
msg: |
|
||||
============================================================
|
||||
✓ MAINTENANCE COMPLETED - {{ inventory_hostname }}
|
||||
============================================================
|
||||
|
||||
Server: {{ inventory_hostname }}
|
||||
Version: {{ nextcloud_version.stdout }}
|
||||
|
||||
Tasks completed:
|
||||
{% if db_indices_result.changed %}
|
||||
✓ Database indices: Updated
|
||||
{% else %}
|
||||
✓ Database indices: Already optimized
|
||||
{% endif %}
|
||||
{% if mimetype_result.changed %}
|
||||
✓ Mimetypes: Added {{ mimetypes_added }} new types
|
||||
{% else %}
|
||||
✓ Mimetypes: Already up to date
|
||||
{% endif %}
|
||||
|
||||
Next steps:
|
||||
- Check admin interface for any remaining warnings
|
||||
- Warnings may take a few minutes to clear from cache
|
||||
|
||||
============================================================
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
---
|
||||
# Cleanup playbook - run before destroying servers
|
||||
# Removes SMTP credentials and other cloud resources
|
||||
|
||||
- name: Cleanup server resources before destruction
|
||||
hosts: all
|
||||
become: no
|
||||
gather_facts: no
|
||||
|
||||
pre_tasks:
|
||||
- name: Determine client name from hostname
|
||||
set_fact:
|
||||
client_name: "{{ inventory_hostname }}"
|
||||
|
||||
- name: Load client secrets
|
||||
community.sops.load_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/clients/{{ client_name }}.sops.yaml"
|
||||
name: client_secrets
|
||||
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||
no_log: true
|
||||
|
||||
tasks:
|
||||
- name: Delete Mailgun SMTP credentials
|
||||
include_role:
|
||||
name: mailgun
|
||||
tasks_from: delete
|
||||
|
||||
- name: Display cleanup summary
|
||||
debug:
|
||||
msg: |
|
||||
============================================================
|
||||
Cleanup complete for: {{ client_name }}
|
||||
============================================================
|
||||
|
||||
Removed:
|
||||
✓ Mailgun SMTP credential ({{ inventory_hostname }}@mg.vrije.cloud)
|
||||
|
||||
You can now safely destroy the server with:
|
||||
cd ../tofu && tofu destroy -target='hcloud_server.client["{{ client_name }}"]'
|
||||
============================================================
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
---
|
||||
# Configure email for a single server
|
||||
- hosts: all
|
||||
gather_facts: yes
|
||||
tasks:
|
||||
- name: Load client secrets
|
||||
community.sops.load_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/clients/{{ inventory_hostname }}.sops.yaml"
|
||||
name: client_secrets
|
||||
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||
no_log: true
|
||||
|
||||
- name: Load shared secrets
|
||||
community.sops.load_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/shared.sops.yaml"
|
||||
name: shared_secrets
|
||||
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||
no_log: true
|
||||
|
||||
- name: Merge secrets
|
||||
set_fact:
|
||||
client_secrets: "{{ client_secrets | combine(shared_secrets) }}"
|
||||
no_log: true
|
||||
|
||||
- name: Include mailgun role
|
||||
include_role:
|
||||
name: mailgun
|
||||
|
||||
- name: Configure Nextcloud email if credentials available
|
||||
shell: |
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtpmode --value="smtp"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtpsecure --value="tls"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtphost --value="smtp.eu.mailgun.org"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtpport --value="587"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtpauth --value="1"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtpname --value="{{ mailgun_smtp_user }}"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtppassword --value="{{ mailgun_smtp_password }}"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_from_address --value="{{ inventory_hostname }}"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_domain --value="mg.vrije.cloud"
|
||||
when: mailgun_smtp_user is defined
|
||||
no_log: true
|
||||
register: email_config
|
||||
|
||||
- name: Display email configuration status
|
||||
debug:
|
||||
msg: |
|
||||
========================================
|
||||
Email Configuration
|
||||
========================================
|
||||
Status: {{ 'Configured' if email_config.changed | default(false) else 'Skipped (credentials not available)' }}
|
||||
SMTP: smtp.eu.mailgun.org:587 (TLS)
|
||||
From: {{ inventory_hostname }}@mg.vrije.cloud
|
||||
========================================
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
# Deploy applications to client servers
|
||||
# This playbook deploys Authentik, Nextcloud, and other applications
|
||||
# This playbook deploys Nextcloud and other applications
|
||||
|
||||
- name: Deploy applications to client servers
|
||||
hosts: all
|
||||
|
|
@ -14,93 +14,25 @@
|
|||
set_fact:
|
||||
client_name: "{{ inventory_hostname }}"
|
||||
|
||||
- name: Check if base infrastructure is installed
|
||||
stat:
|
||||
path: /opt/docker/traefik/docker-compose.yml
|
||||
register: traefik_compose
|
||||
|
||||
- name: Fail if base infrastructure is not installed
|
||||
fail:
|
||||
msg: |
|
||||
❌ ERROR: Base infrastructure not installed!
|
||||
|
||||
Traefik reverse proxy is required but not found.
|
||||
|
||||
You must run the setup playbook BEFORE deploying applications:
|
||||
ansible-playbook -i hcloud.yml playbooks/setup.yml --limit {{ client_name }}
|
||||
|
||||
Or use the rebuild script which handles the correct order automatically:
|
||||
./scripts/rebuild-client.sh {{ client_name }}
|
||||
when: not traefik_compose.stat.exists
|
||||
|
||||
- name: Load client secrets
|
||||
community.sops.load_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/clients/{{ client_name }}.sops.yaml"
|
||||
name: client_secrets
|
||||
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||
age_key: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||
no_log: true
|
||||
tags: always
|
||||
|
||||
- name: Load shared secrets (Mailgun API key, etc.)
|
||||
community.sops.load_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/shared.sops.yaml"
|
||||
name: shared_secrets
|
||||
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||
no_log: true
|
||||
tags: always
|
||||
|
||||
- name: Merge shared secrets into client_secrets
|
||||
set_fact:
|
||||
client_secrets: "{{ client_secrets | combine(shared_secrets) }}"
|
||||
no_log: true
|
||||
tags: always
|
||||
|
||||
- name: Set client domain from secrets
|
||||
set_fact:
|
||||
client_domain: "{{ client_secrets.client_domain }}"
|
||||
when: client_secrets.client_domain is defined
|
||||
|
||||
- name: Set Authentik domain from secrets
|
||||
set_fact:
|
||||
authentik_domain: "{{ client_secrets.authentik_domain }}"
|
||||
when: client_secrets.authentik_domain is defined
|
||||
|
||||
roles:
|
||||
- role: mailgun
|
||||
- role: authentik
|
||||
- role: nextcloud
|
||||
- role: diun
|
||||
tags: diun
|
||||
- role: kuma
|
||||
tags: kuma
|
||||
|
||||
post_tasks:
|
||||
- name: Display deployment summary
|
||||
debug:
|
||||
msg: |
|
||||
============================================================
|
||||
🎉 Deployment complete for client: {{ client_name }}
|
||||
============================================================
|
||||
Deployment complete for client: {{ client_name }}
|
||||
|
||||
Services deployed and configured:
|
||||
✓ Authentik SSO: https://{{ authentik_domain }}
|
||||
✓ Nextcloud: https://nextcloud.{{ client_domain }}
|
||||
✓ SSO Integration: Fully automated (OAuth2/OIDC)
|
||||
|
||||
Authentik Admin Access:
|
||||
- Username: akadmin
|
||||
- Password: {{ client_secrets.authentik_bootstrap_password }}
|
||||
- API Token: Configured automatically
|
||||
|
||||
Nextcloud Admin Access:
|
||||
- Username: {{ client_secrets.nextcloud_admin_user }}
|
||||
- Password: {{ client_secrets.nextcloud_admin_password }}
|
||||
|
||||
End User Access:
|
||||
1. Create users in Authentik: https://{{ authentik_domain }}
|
||||
2. Users login to Nextcloud via "Login with Authentik" button
|
||||
3. First login creates linked Nextcloud account automatically
|
||||
|
||||
============================================================
|
||||
Ready to use! No manual configuration required.
|
||||
============================================================
|
||||
Nextcloud: https://nextcloud.{{ client_domain }}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,6 @@
|
|||
- name: Gather facts
|
||||
setup:
|
||||
|
||||
- name: Load shared secrets (Docker Hub, etc.)
|
||||
community.sops.load_vars:
|
||||
file: "{{ playbook_dir }}/../../secrets/shared.sops.yaml"
|
||||
name: shared_secrets
|
||||
age_keyfile: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||
no_log: true
|
||||
|
||||
roles:
|
||||
- role: common
|
||||
tags: ['common', 'security']
|
||||
|
|
|
|||
|
|
@ -1,311 +0,0 @@
|
|||
---
|
||||
# Playbook: Update Docker containers across clients
|
||||
# Usage:
|
||||
# # Update single client
|
||||
# ansible-playbook -i hcloud.yml playbooks/update-containers.yml --limit black
|
||||
#
|
||||
# # Update specific service only
|
||||
# ansible-playbook -i hcloud.yml playbooks/update-containers.yml --limit black --tags authentik
|
||||
#
|
||||
# # Dry run (check mode)
|
||||
# ansible-playbook -i hcloud.yml playbooks/update-containers.yml --limit black --check
|
||||
#
|
||||
# # Update multiple clients in sequence
|
||||
# ansible-playbook -i hcloud.yml playbooks/update-containers.yml --limit "dev,test"
|
||||
|
||||
- name: Update Docker containers
|
||||
hosts: all
|
||||
become: yes
|
||||
serial: 1 # Process one host at a time for safety
|
||||
|
||||
vars:
|
||||
# Services to update (override with -e "services_to_update=['authentik']")
|
||||
services_to_update:
|
||||
- traefik
|
||||
- authentik
|
||||
- nextcloud
|
||||
- diun
|
||||
|
||||
# Backup before update
|
||||
create_backup: true
|
||||
|
||||
# Wait time between service updates (seconds)
|
||||
update_delay: 30
|
||||
|
||||
pre_tasks:
|
||||
- name: Display update plan
|
||||
debug:
|
||||
msg: |
|
||||
Updating {{ inventory_hostname }}
|
||||
Services: {{ services_to_update | join(', ') }}
|
||||
Backup enabled: {{ create_backup }}
|
||||
tags: always
|
||||
|
||||
- name: Check if host is reachable
|
||||
ping:
|
||||
tags: always
|
||||
|
||||
- name: Get current container status (before)
|
||||
shell: docker ps --format 'table {{{{.Names}}}}\t{{{{.Status}}}}\t{{{{.Image}}}}'
|
||||
register: containers_before
|
||||
changed_when: false
|
||||
tags: always
|
||||
|
||||
- name: Display current containers
|
||||
debug:
|
||||
msg: "{{ containers_before.stdout_lines }}"
|
||||
tags: always
|
||||
|
||||
tasks:
|
||||
# ==========================================
|
||||
# Traefik Updates
|
||||
# ==========================================
|
||||
- name: Update Traefik
|
||||
block:
|
||||
- name: Create Traefik backup
|
||||
shell: |
|
||||
cd /opt/docker/traefik
|
||||
tar -czf /tmp/traefik-backup-$(date +%Y%m%d-%H%M%S).tar.gz \
|
||||
acme.json docker-compose.yml traefik.yml 2>/dev/null || true
|
||||
when: create_backup
|
||||
|
||||
- name: Pull latest Traefik image
|
||||
docker_image:
|
||||
name: traefik:latest
|
||||
source: pull
|
||||
force_source: yes
|
||||
|
||||
- name: Restart Traefik
|
||||
docker_compose:
|
||||
project_src: /opt/docker/traefik
|
||||
restarted: yes
|
||||
pull: yes
|
||||
|
||||
- name: Wait for Traefik to be healthy
|
||||
shell: docker inspect --format='{{{{.State.Status}}}}' traefik
|
||||
register: traefik_status
|
||||
until: traefik_status.stdout == "running"
|
||||
retries: 10
|
||||
delay: 5
|
||||
changed_when: false
|
||||
|
||||
- name: Verify Traefik SSL certificates
|
||||
shell: docker exec traefik ls -la /acme.json
|
||||
register: traefik_certs
|
||||
changed_when: false
|
||||
failed_when: traefik_certs.rc != 0
|
||||
|
||||
- name: Delay between services
|
||||
pause:
|
||||
seconds: "{{ update_delay }}"
|
||||
when: "'traefik' in services_to_update"
|
||||
tags: traefik
|
||||
|
||||
# ==========================================
|
||||
# Authentik Updates
|
||||
# ==========================================
|
||||
- name: Update Authentik
|
||||
block:
|
||||
- name: Create Authentik database backup
|
||||
shell: |
|
||||
docker exec authentik-db pg_dump -U authentik authentik | \
|
||||
gzip > /tmp/authentik-backup-$(date +%Y%m%d-%H%M%S).sql.gz
|
||||
when: create_backup
|
||||
|
||||
- name: Pull latest Authentik images
|
||||
docker_image:
|
||||
name: "{{ item }}"
|
||||
source: pull
|
||||
force_source: yes
|
||||
loop:
|
||||
- ghcr.io/goauthentik/server:latest
|
||||
- postgres:16-alpine
|
||||
- redis:alpine
|
||||
|
||||
- name: Restart Authentik services
|
||||
docker_compose:
|
||||
project_src: /opt/docker/authentik
|
||||
restarted: yes
|
||||
pull: yes
|
||||
|
||||
- name: Wait for Authentik server to be healthy
|
||||
shell: docker inspect --format='{{{{.State.Health.Status}}}}' authentik-server
|
||||
register: authentik_status
|
||||
until: authentik_status.stdout == "healthy"
|
||||
retries: 20
|
||||
delay: 10
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Authentik worker to be healthy
|
||||
shell: docker inspect --format='{{{{.State.Health.Status}}}}' authentik-worker
|
||||
register: authentik_worker_status
|
||||
until: authentik_worker_status.stdout == "healthy"
|
||||
retries: 20
|
||||
delay: 10
|
||||
changed_when: false
|
||||
|
||||
- name: Verify Authentik web interface
|
||||
uri:
|
||||
url: "https://auth.{{ client_name }}.vrije.cloud/if/flow/default-authentication-flow/"
|
||||
validate_certs: yes
|
||||
status_code: 200
|
||||
register: authentik_health
|
||||
retries: 5
|
||||
delay: 10
|
||||
|
||||
- name: Delay between services
|
||||
pause:
|
||||
seconds: "{{ update_delay }}"
|
||||
when: "'authentik' in services_to_update"
|
||||
tags: authentik
|
||||
|
||||
# ==========================================
|
||||
# Nextcloud Updates
|
||||
# ==========================================
|
||||
- name: Update Nextcloud
|
||||
block:
|
||||
- name: Create Nextcloud database backup
|
||||
shell: |
|
||||
docker exec nextcloud-db mysqldump -u nextcloud -p$(docker exec nextcloud-db cat /run/secrets/db_password 2>/dev/null || echo 'password') nextcloud | \
|
||||
gzip > /tmp/nextcloud-backup-$(date +%Y%m%d-%H%M%S).sql.gz
|
||||
when: create_backup
|
||||
ignore_errors: yes
|
||||
|
||||
- name: Enable Nextcloud maintenance mode
|
||||
shell: docker exec -u www-data nextcloud php occ maintenance:mode --on
|
||||
register: maintenance_mode
|
||||
changed_when: "'Maintenance mode enabled' in maintenance_mode.stdout"
|
||||
|
||||
- name: Pull latest Nextcloud images
|
||||
docker_image:
|
||||
name: "{{ item }}"
|
||||
source: pull
|
||||
force_source: yes
|
||||
loop:
|
||||
- nextcloud:latest
|
||||
- mariadb:11
|
||||
- redis:alpine
|
||||
- collabora/code:latest
|
||||
|
||||
- name: Restart Nextcloud services
|
||||
docker_compose:
|
||||
project_src: /opt/docker/nextcloud
|
||||
restarted: yes
|
||||
pull: yes
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
shell: docker exec nextcloud-db mysqladmin ping -h localhost -u root --silent
|
||||
register: nc_db_status
|
||||
until: nc_db_status.rc == 0
|
||||
retries: 20
|
||||
delay: 5
|
||||
changed_when: false
|
||||
|
||||
- name: Run Nextcloud upgrade (if needed)
|
||||
shell: docker exec -u www-data nextcloud php occ upgrade
|
||||
register: nc_upgrade
|
||||
changed_when: "'Updated database' in nc_upgrade.stdout"
|
||||
failed_when: nc_upgrade.rc != 0 and 'already latest version' not in nc_upgrade.stdout
|
||||
|
||||
- name: Disable Nextcloud maintenance mode
|
||||
shell: docker exec -u www-data nextcloud php occ maintenance:mode --off
|
||||
register: maintenance_off
|
||||
changed_when: "'Maintenance mode disabled' in maintenance_off.stdout"
|
||||
|
||||
- name: Verify Nextcloud web interface
|
||||
uri:
|
||||
url: "https://nextcloud.{{ client_name }}.vrije.cloud/status.php"
|
||||
validate_certs: yes
|
||||
status_code: 200
|
||||
register: nc_health
|
||||
retries: 10
|
||||
delay: 10
|
||||
|
||||
- name: Verify Nextcloud installed status
|
||||
uri:
|
||||
url: "https://nextcloud.{{ client_name }}.vrije.cloud/status.php"
|
||||
validate_certs: yes
|
||||
return_content: yes
|
||||
register: nc_status_check
|
||||
failed_when: "'\"installed\":true' not in nc_status_check.content"
|
||||
|
||||
- name: Delay between services
|
||||
pause:
|
||||
seconds: "{{ update_delay }}"
|
||||
when: "'nextcloud' in services_to_update"
|
||||
tags: nextcloud
|
||||
|
||||
# ==========================================
|
||||
# Diun Updates
|
||||
# ==========================================
|
||||
- name: Update Diun
|
||||
block:
|
||||
- name: Pull latest Diun image
|
||||
docker_image:
|
||||
name: crazymax/diun:latest
|
||||
source: pull
|
||||
force_source: yes
|
||||
|
||||
- name: Restart Diun
|
||||
docker_compose:
|
||||
project_src: /opt/docker/diun
|
||||
restarted: yes
|
||||
pull: yes
|
||||
|
||||
- name: Wait for Diun to be running
|
||||
shell: docker inspect --format='{{{{.State.Status}}}}' diun
|
||||
register: diun_status
|
||||
until: diun_status.stdout == "running"
|
||||
retries: 5
|
||||
delay: 3
|
||||
changed_when: false
|
||||
when: "'diun' in services_to_update"
|
||||
tags: diun
|
||||
|
||||
post_tasks:
|
||||
- name: Get final container status
|
||||
shell: docker ps --format 'table {{{{.Names}}}}\t{{{{.Status}}}}\t{{{{.Image}}}}'
|
||||
register: containers_after
|
||||
changed_when: false
|
||||
tags: always
|
||||
|
||||
- name: Display final container status
|
||||
debug:
|
||||
msg: "{{ containers_after.stdout_lines }}"
|
||||
tags: always
|
||||
|
||||
- name: Verify all expected containers are running
|
||||
shell: docker ps --filter "status=running" --format '{{{{.Names}}}}' | wc -l
|
||||
register: running_count
|
||||
changed_when: false
|
||||
tags: always
|
||||
|
||||
- name: Check for unhealthy containers
|
||||
shell: docker ps --filter "health=unhealthy" --format '{{{{.Names}}}}'
|
||||
register: unhealthy_containers
|
||||
changed_when: false
|
||||
failed_when: unhealthy_containers.stdout != ""
|
||||
tags: always
|
||||
|
||||
- name: Update summary
|
||||
debug:
|
||||
msg: |
|
||||
========================================
|
||||
Update Summary for {{ inventory_hostname }}
|
||||
========================================
|
||||
Running containers: {{ running_count.stdout }}
|
||||
Unhealthy containers: {{ unhealthy_containers.stdout or 'None' }}
|
||||
|
||||
Services updated: {{ services_to_update | join(', ') }}
|
||||
Status: SUCCESS
|
||||
tags: always
|
||||
|
||||
- name: Post-update validation
|
||||
hosts: all
|
||||
become: yes
|
||||
gather_facts: no
|
||||
|
||||
tasks:
|
||||
- name: Final health check
|
||||
debug:
|
||||
msg: "All updates completed successfully on {{ inventory_hostname }}"
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
---
|
||||
# Update enrollment flow blueprint on running Authentik instance
|
||||
- name: Update enrollment flow blueprint
|
||||
hosts: all
|
||||
gather_facts: no
|
||||
become: yes
|
||||
|
||||
vars:
|
||||
authentik_api_token: "ak_DtA2LG1Z9shl-tw9r0cs34B1G9l8Lpz76GxLf-4OBiUWbiHbAVJ04GYLcZ30"
|
||||
client_domain: "dev.vrije.cloud"
|
||||
|
||||
tasks:
|
||||
|
||||
- name: Create blueprints directory
|
||||
file:
|
||||
path: /opt/config/authentik/blueprints
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Copy enrollment flow blueprint
|
||||
copy:
|
||||
src: ../roles/authentik/files/enrollment-flow.yaml
|
||||
dest: /opt/config/authentik/blueprints/enrollment-flow.yaml
|
||||
mode: '0644'
|
||||
register: blueprint_copied
|
||||
|
||||
- name: Copy blueprint into authentik-worker container
|
||||
shell: |
|
||||
docker cp /opt/config/authentik/blueprints/enrollment-flow.yaml authentik-worker:/blueprints/enrollment-flow.yaml
|
||||
when: blueprint_copied.changed
|
||||
|
||||
- name: Copy blueprint into authentik-server container
|
||||
shell: |
|
||||
docker cp /opt/config/authentik/blueprints/enrollment-flow.yaml authentik-server:/blueprints/enrollment-flow.yaml
|
||||
when: blueprint_copied.changed
|
||||
|
||||
- name: Restart authentik-worker to force blueprint discovery
|
||||
shell: docker restart authentik-worker
|
||||
when: blueprint_copied.changed
|
||||
|
||||
- name: Wait for blueprint to be applied
|
||||
shell: |
|
||||
sleep 30
|
||||
docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
|
||||
'http://localhost:9000/api/v3/flows/instances/?slug=default-enrollment-flow'
|
||||
register: flow_check
|
||||
retries: 6
|
||||
delay: 10
|
||||
until: flow_check.rc == 0
|
||||
no_log: true
|
||||
|
||||
- name: Display success message
|
||||
debug:
|
||||
msg: |
|
||||
✓ Enrollment flow blueprint updated successfully!
|
||||
|
||||
The invitation-only enrollment flow is now set as the default.
|
||||
When you create invitations in Authentik, they will automatically
|
||||
use the correct flow.
|
||||
|
||||
Flow URL: https://auth.{{ client_domain }}/if/flow/default-enrollment-flow/
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
# Defaults for Authentik role
|
||||
|
||||
# Authentik version
|
||||
authentik_version: "2025.10.3"
|
||||
authentik_image: "ghcr.io/goauthentik/server"
|
||||
|
||||
# PostgreSQL configuration
|
||||
authentik_db_user: "authentik"
|
||||
authentik_db_name: "authentik"
|
||||
|
||||
# Ports (internal to Docker network, exposed via Traefik)
|
||||
authentik_http_port: 9000
|
||||
authentik_https_port: 9443
|
||||
|
||||
# Docker configuration
|
||||
authentik_config_dir: "/opt/docker/authentik"
|
||||
authentik_network: "authentik-internal"
|
||||
authentik_traefik_network: "traefik"
|
||||
|
||||
# Domain (set per client)
|
||||
# authentik_domain: "auth.example.com"
|
||||
|
||||
# Bootstrap settings
|
||||
authentik_bootstrap: true
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Authentik API client for automated OIDC provider configuration.
|
||||
|
||||
This script handles the complete automation of Authentik SSO setup:
|
||||
1. Bootstrap initial admin user (if needed)
|
||||
2. Create OAuth2/OIDC provider for Nextcloud
|
||||
3. Return client credentials for Nextcloud configuration
|
||||
|
||||
Usage:
|
||||
python3 authentik_api.py --domain https://auth.example.com \
|
||||
--app-name Nextcloud \
|
||||
--redirect-uri https://nextcloud.example.com/apps/user_oidc/code \
|
||||
--bootstrap-password <admin_password>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
|
||||
class AuthentikAPI:
|
||||
"""Client for Authentik API with bootstrapping support."""
|
||||
|
||||
def __init__(self, base_url: str, token: Optional[str] = None):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.token = token
|
||||
self.session_cookie = None
|
||||
|
||||
def _request(self, method: str, path: str, data: Optional[Dict] = None,
|
||||
headers: Optional[Dict] = None) -> Tuple[int, Dict]:
|
||||
"""Make HTTP request to Authentik API."""
|
||||
import ssl
|
||||
url = f"{self.base_url}{path}"
|
||||
req_headers = headers or {}
|
||||
|
||||
# Add authentication
|
||||
if self.token:
|
||||
req_headers['Authorization'] = f'Bearer {self.token}'
|
||||
elif self.session_cookie:
|
||||
req_headers['Cookie'] = self.session_cookie
|
||||
|
||||
req_headers['Content-Type'] = 'application/json'
|
||||
|
||||
body = json.dumps(data).encode('utf-8') if data else None
|
||||
request = urllib.request.Request(url, data=body, headers=req_headers, method=method)
|
||||
|
||||
# Create SSL context (don't verify for internal services)
|
||||
ctx = ssl.create_default_context()
|
||||
# For production, you'd want to verify certificates properly
|
||||
# But for automated deployments, we trust the internal network
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30, context=ctx) as response:
|
||||
response_data = json.loads(response.read().decode('utf-8'))
|
||||
# Capture session cookie if present
|
||||
cookie = response.headers.get('Set-Cookie')
|
||||
if cookie and not self.session_cookie:
|
||||
self.session_cookie = cookie.split(';')[0]
|
||||
return response.status, response_data
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = e.read().decode('utf-8')
|
||||
try:
|
||||
error_data = json.loads(error_body)
|
||||
except json.JSONDecodeError:
|
||||
error_data = {'error': error_body}
|
||||
return e.code, error_data
|
||||
except urllib.error.URLError as e:
|
||||
return 0, {'error': str(e)}
|
||||
|
||||
def wait_for_ready(self, timeout: int = 300) -> bool:
|
||||
"""Wait for Authentik to be ready and responding."""
|
||||
print(f"Waiting for Authentik at {self.base_url} to be ready...", file=sys.stderr)
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
status, _ = self._request('GET', '/')
|
||||
if status in [200, 302]:
|
||||
print("Authentik is ready!", file=sys.stderr)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
print(f"Timeout waiting for Authentik after {timeout}s", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def check_bootstrap_needed(self) -> bool:
|
||||
"""Check if initial setup is needed."""
|
||||
status, data = self._request('GET', '/if/flow/initial-setup/')
|
||||
# 200 = setup needed, 302/404 = already configured
|
||||
return status == 200
|
||||
|
||||
def bootstrap_admin(self, username: str, password: str, email: str) -> bool:
|
||||
"""Bootstrap initial admin account via initial setup flow."""
|
||||
print(f"Bootstrapping admin user: {username}", file=sys.stderr)
|
||||
|
||||
# This is a simplified approach - real implementation would need to:
|
||||
# 1. Get CSRF token from initial setup page
|
||||
# 2. Submit form with proper flow context
|
||||
# 3. Handle multi-step flow if needed
|
||||
|
||||
# For now, we'll document that manual setup is required
|
||||
print("WARNING: Automatic bootstrap not yet implemented", file=sys.stderr)
|
||||
print(f"Please complete initial setup at: {self.base_url}/if/flow/initial-setup/",
|
||||
file=sys.stderr)
|
||||
return False
|
||||
|
||||
def create_service_account_token(self, username: str, password: str) -> Optional[str]:
|
||||
"""Login and create service account token."""
|
||||
print("Creating service account token...", file=sys.stderr)
|
||||
|
||||
# Try to authenticate
|
||||
status, data = self._request('POST', '/api/v3/core/tokens/', {
|
||||
'identifier': username,
|
||||
'password': password,
|
||||
'intent': 'app_password',
|
||||
'description': 'Ansible automation token'
|
||||
})
|
||||
|
||||
if status == 201:
|
||||
token = data.get('key')
|
||||
print("Service account token created successfully", file=sys.stderr)
|
||||
return token
|
||||
else:
|
||||
print(f"Failed to create token: {data}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def get_default_authorization_flow(self) -> Optional[str]:
|
||||
"""Get the default authorization flow UUID."""
|
||||
status, data = self._request('GET', '/api/v3/flows/instances/')
|
||||
|
||||
if status == 200:
|
||||
for flow in data.get('results', []):
|
||||
if flow.get('slug') == 'default-authorization-flow':
|
||||
return flow['pk']
|
||||
|
||||
# Fallback: get any authorization flow
|
||||
for flow in data.get('results', []):
|
||||
if flow.get('designation') == 'authorization':
|
||||
return flow['pk']
|
||||
|
||||
print("ERROR: No authorization flow found", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def get_default_signing_key(self) -> Optional[str]:
|
||||
"""Get the default signing key UUID."""
|
||||
status, data = self._request('GET', '/api/v3/crypto/certificatekeypairs/')
|
||||
|
||||
if status == 200:
|
||||
results = data.get('results', [])
|
||||
if results:
|
||||
# Return first available key
|
||||
return results[0]['pk']
|
||||
|
||||
print("ERROR: No signing key found", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def create_oidc_provider(self, name: str, redirect_uris: str,
|
||||
flow_uuid: str, key_uuid: str) -> Optional[Dict]:
|
||||
"""Create OAuth2/OIDC provider."""
|
||||
print(f"Creating OIDC provider for {name}...", file=sys.stderr)
|
||||
|
||||
provider_data = {
|
||||
'name': name,
|
||||
'authorization_flow': flow_uuid,
|
||||
'client_type': 'confidential',
|
||||
'redirect_uris': redirect_uris,
|
||||
'signing_key': key_uuid,
|
||||
'sub_mode': 'hashed_user_id',
|
||||
'include_claims_in_id_token': True,
|
||||
}
|
||||
|
||||
status, data = self._request('POST', '/api/v3/providers/oauth2/', provider_data)
|
||||
|
||||
if status == 201:
|
||||
print(f"OIDC provider created: {data['pk']}", file=sys.stderr)
|
||||
return data
|
||||
else:
|
||||
print(f"ERROR: Failed to create OIDC provider: {data}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def create_application(self, name: str, slug: str, provider_id: int,
|
||||
launch_url: str) -> Optional[Dict]:
|
||||
"""Create application linked to OIDC provider."""
|
||||
print(f"Creating application {name}...", file=sys.stderr)
|
||||
|
||||
app_data = {
|
||||
'name': name,
|
||||
'slug': slug,
|
||||
'provider': provider_id,
|
||||
'meta_launch_url': launch_url,
|
||||
}
|
||||
|
||||
status, data = self._request('POST', '/api/v3/core/applications/', app_data)
|
||||
|
||||
if status == 201:
|
||||
print(f"Application created: {data['pk']}", file=sys.stderr)
|
||||
return data
|
||||
else:
|
||||
print(f"ERROR: Failed to create application: {data}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Automate Authentik OIDC provider setup')
|
||||
parser.add_argument('--domain', required=True, help='Authentik domain (https://auth.example.com)')
|
||||
parser.add_argument('--app-name', required=True, help='Application name (e.g., Nextcloud)')
|
||||
parser.add_argument('--app-slug', help='Application slug (defaults to lowercase app-name)')
|
||||
parser.add_argument('--redirect-uri', required=True, help='OAuth2 redirect URI')
|
||||
parser.add_argument('--launch-url', help='Application launch URL (defaults to redirect-uri base)')
|
||||
parser.add_argument('--token', help='Authentik API token (if already bootstrapped)')
|
||||
parser.add_argument('--bootstrap-user', default='akadmin', help='Bootstrap admin username')
|
||||
parser.add_argument('--bootstrap-password', help='Bootstrap admin password')
|
||||
parser.add_argument('--bootstrap-email', default='admin@localhost', help='Bootstrap admin email')
|
||||
parser.add_argument('--wait-timeout', type=int, default=300, help='Timeout for waiting (seconds)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Derive defaults
|
||||
app_slug = args.app_slug or args.app_name.lower()
|
||||
launch_url = args.launch_url or args.redirect_uri.rsplit('/', 2)[0]
|
||||
|
||||
# Initialize API client
|
||||
api = AuthentikAPI(args.domain, args.token)
|
||||
|
||||
# Wait for Authentik to be ready
|
||||
if not api.wait_for_ready(args.wait_timeout):
|
||||
print(json.dumps({'error': 'Authentik not ready'}))
|
||||
sys.exit(1)
|
||||
|
||||
# Check if bootstrap is needed
|
||||
if not args.token:
|
||||
if api.check_bootstrap_needed():
|
||||
if not args.bootstrap_password:
|
||||
print(json.dumps({
|
||||
'error': 'Bootstrap needed but no password provided',
|
||||
'action_required': f'Visit {args.domain}/if/flow/initial-setup/ to complete setup',
|
||||
'next_step': 'Create service account and provide --token'
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
# Try to bootstrap (not yet implemented)
|
||||
if not api.bootstrap_admin(args.bootstrap_user, args.bootstrap_password,
|
||||
args.bootstrap_email):
|
||||
print(json.dumps({
|
||||
'error': 'Bootstrap not yet automated',
|
||||
'action_required': f'Visit {args.domain}/if/flow/initial-setup/ manually',
|
||||
'instructions': [
|
||||
f'1. Create admin user: {args.bootstrap_user}',
|
||||
'2. Create API token in admin UI',
|
||||
'3. Re-run with --token <token>'
|
||||
]
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
print("ERROR: No API token provided and bootstrap needed", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Get required UUIDs
|
||||
flow_uuid = api.get_default_authorization_flow()
|
||||
key_uuid = api.get_default_signing_key()
|
||||
|
||||
if not flow_uuid or not key_uuid:
|
||||
print(json.dumps({'error': 'Failed to get required Authentik configuration'}))
|
||||
sys.exit(1)
|
||||
|
||||
# Create OIDC provider
|
||||
provider = api.create_oidc_provider(args.app_name, args.redirect_uri, flow_uuid, key_uuid)
|
||||
if not provider:
|
||||
print(json.dumps({'error': 'Failed to create OIDC provider'}))
|
||||
sys.exit(1)
|
||||
|
||||
# Create application
|
||||
application = api.create_application(args.app_name, app_slug, provider['pk'], launch_url)
|
||||
if not application:
|
||||
print(json.dumps({'error': 'Failed to create application'}))
|
||||
sys.exit(1)
|
||||
|
||||
# Output credentials
|
||||
result = {
|
||||
'success': True,
|
||||
'provider_id': provider['pk'],
|
||||
'application_id': application['pk'],
|
||||
'client_id': provider['client_id'],
|
||||
'client_secret': provider['client_secret'],
|
||||
'discovery_uri': f"{args.domain}/application/o/{app_slug}/.well-known/openid-configuration",
|
||||
'issuer': f"{args.domain}/application/o/{app_slug}/",
|
||||
}
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configure 2FA enforcement in Authentik.
|
||||
Modifies the default-authentication-mfa-validation stage to force users to configure MFA.
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
def api_request(base_url, token, path, method='GET', data=None):
|
||||
"""Make API request to Authentik"""
|
||||
url = f"{base_url}{path}"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
request_data = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=request_data, headers=headers, method=method)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = e.read().decode()
|
||||
try:
|
||||
error_data = json.loads(error_body)
|
||||
except:
|
||||
error_data = {'error': error_body}
|
||||
return e.code, error_data
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print(json.dumps({'error': 'Usage: configure_2fa_enforcement.py <base_url> <api_token>'}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
base_url = sys.argv[1]
|
||||
token = sys.argv[2]
|
||||
|
||||
# Step 1: Find the default MFA validation stage
|
||||
status, stages_response = api_request(base_url, token, '/api/v3/stages/authenticator/validate/')
|
||||
if status != 200:
|
||||
print(json.dumps({'error': 'Failed to list authenticator validate stages', 'details': stages_response}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
mfa_stage = next((s for s in stages_response.get('results', [])
|
||||
if 'default-authentication-mfa-validation' in s.get('name', '').lower()), None)
|
||||
|
||||
if not mfa_stage:
|
||||
print(json.dumps({'error': 'default-authentication-mfa-validation stage not found'}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
stage_pk = mfa_stage['pk']
|
||||
|
||||
# Step 2: Find the default TOTP setup stage to use as configuration stage
|
||||
status, totp_stages_response = api_request(base_url, token, '/api/v3/stages/authenticator/totp/')
|
||||
if status != 200:
|
||||
print(json.dumps({'error': 'Failed to list TOTP setup stages', 'details': totp_stages_response}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
totp_setup_stage = next((s for s in totp_stages_response.get('results', [])
|
||||
if 'setup' in s.get('name', '').lower()), None)
|
||||
|
||||
if not totp_setup_stage:
|
||||
print(json.dumps({'error': 'TOTP setup stage not found'}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
totp_setup_pk = totp_setup_stage['pk']
|
||||
|
||||
# Step 3: Update the MFA validation stage to force configuration
|
||||
update_data = {
|
||||
'name': mfa_stage['name'],
|
||||
'not_configured_action': 'configure', # Force user to configure
|
||||
'configuration_stages': [totp_setup_pk] # Use TOTP setup stage
|
||||
}
|
||||
|
||||
status, updated_stage = api_request(base_url, token, f'/api/v3/stages/authenticator/validate/{stage_pk}/', 'PATCH', update_data)
|
||||
if status not in [200, 201]:
|
||||
print(json.dumps({'error': 'Failed to update MFA validation stage', 'details': updated_stage}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(json.dumps({
|
||||
'success': True,
|
||||
'message': '2FA enforcement configured',
|
||||
'stage_name': mfa_stage['name'],
|
||||
'stage_pk': stage_pk,
|
||||
'note': 'Users will be forced to configure TOTP on login'
|
||||
}))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configure Authentik invitation flow.
|
||||
Creates an invitation stage and binds it to the default enrollment flow.
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
def api_request(base_url, token, path, method='GET', data=None):
|
||||
"""Make API request to Authentik"""
|
||||
url = f"{base_url}{path}"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
request_data = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=request_data, headers=headers, method=method)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.status, json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = e.read().decode()
|
||||
try:
|
||||
error_data = json.loads(error_body)
|
||||
except:
|
||||
error_data = {'error': error_body}
|
||||
return e.code, error_data
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print(json.dumps({'error': 'Usage: configure_invitation_flow.py <base_url> <api_token>'}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
base_url = sys.argv[1]
|
||||
token = sys.argv[2]
|
||||
|
||||
# Step 1: Get the default enrollment flow
|
||||
status, flows_response = api_request(base_url, token, '/api/v3/flows/instances/')
|
||||
if status != 200:
|
||||
print(json.dumps({'error': 'Failed to list flows', 'details': flows_response}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
enrollment_flow = next((f for f in flows_response.get('results', [])
|
||||
if f.get('designation') == 'enrollment'), None)
|
||||
|
||||
if not enrollment_flow:
|
||||
print(json.dumps({'error': 'No enrollment flow found'}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
flow_slug = enrollment_flow['slug']
|
||||
flow_pk = enrollment_flow['pk']
|
||||
|
||||
# Step 2: Check if invitation stage already exists
|
||||
status, stages_response = api_request(base_url, token, '/api/v3/stages/invitation/')
|
||||
if status != 200:
|
||||
print(json.dumps({'error': 'Failed to list invitation stages', 'details': stages_response}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
invitation_stage = next((s for s in stages_response.get('results', [])
|
||||
if s.get('name') == 'default-enrollment-invitation'), None)
|
||||
|
||||
# Step 3: Create invitation stage if it doesn't exist
|
||||
if not invitation_stage:
|
||||
stage_data = {
|
||||
'name': 'default-enrollment-invitation',
|
||||
'continue_flow_without_invitation': True
|
||||
}
|
||||
status, invitation_stage = api_request(base_url, token, '/api/v3/stages/invitation/', 'POST', stage_data)
|
||||
if status not in [200, 201]:
|
||||
print(json.dumps({'error': 'Failed to create invitation stage', 'details': invitation_stage}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
stage_pk = invitation_stage['pk']
|
||||
|
||||
# Step 4: Check if the stage is already bound to the enrollment flow
|
||||
status, bindings_response = api_request(base_url, token, f'/api/v3/flows/bindings/?target={flow_pk}')
|
||||
if status != 200:
|
||||
print(json.dumps({'error': 'Failed to list flow bindings', 'details': bindings_response}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Check if invitation stage is already bound
|
||||
invitation_binding = next((b for b in bindings_response.get('results', [])
|
||||
if b.get('stage') == stage_pk), None)
|
||||
|
||||
# Step 5: Bind the invitation stage to the enrollment flow if not already bound
|
||||
if not invitation_binding:
|
||||
# Find the highest order number to insert at the beginning
|
||||
max_order = max([b.get('order', 0) for b in bindings_response.get('results', [])], default=0)
|
||||
|
||||
binding_data = {
|
||||
'target': flow_pk,
|
||||
'stage': stage_pk,
|
||||
'order': 0, # Put invitation stage first
|
||||
'evaluate_on_plan': True,
|
||||
're_evaluate_policies': False
|
||||
}
|
||||
status, binding = api_request(base_url, token, '/api/v3/flows/bindings/', 'POST', binding_data)
|
||||
if status not in [200, 201]:
|
||||
print(json.dumps({'error': 'Failed to bind invitation stage to flow', 'details': binding}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(json.dumps({
|
||||
'success': True,
|
||||
'message': 'Invitation flow configured',
|
||||
'flow_slug': flow_slug,
|
||||
'stage_pk': stage_pk,
|
||||
'note': 'Invitation stage bound to enrollment flow'
|
||||
}))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,522 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Authentik Recovery Flow Automation Script
|
||||
|
||||
This script creates a complete password recovery flow in Authentik with:
|
||||
- Password complexity policy (12 chars, mixed case, digit, symbol)
|
||||
- Recovery identification stage (username/email)
|
||||
- Recovery email stage (sends recovery token)
|
||||
- Password change stages (with validation)
|
||||
- Integration with default authentication flow
|
||||
- Brand default recovery flow configuration
|
||||
|
||||
Usage:
|
||||
python3 create_recovery_flow.py <api_token> <authentik_domain>
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
|
||||
def api_request(base_url, token, path, method='GET', data=None):
|
||||
"""Make an API request to Authentik"""
|
||||
url = f"{base_url}{path}"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
request_data = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=request_data, headers=headers, method=method)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
body = resp.read().decode()
|
||||
if body:
|
||||
return resp.status, json.loads(body)
|
||||
return resp.status, {}
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = e.read().decode()
|
||||
try:
|
||||
error_data = json.loads(error_body) if error_body else {'error': 'Empty error response'}
|
||||
except:
|
||||
error_data = {'error': error_body or 'Unknown error'}
|
||||
return e.code, error_data
|
||||
except Exception as e:
|
||||
return 0, {'error': str(e)}
|
||||
|
||||
|
||||
def get_or_create_password_policy(base_url, token):
|
||||
"""Create password complexity policy"""
|
||||
print("Checking for password complexity policy...")
|
||||
|
||||
policy_data = {
|
||||
"name": "password-complexity",
|
||||
"password_field": "password",
|
||||
"amount_digits": 1,
|
||||
"amount_uppercase": 1,
|
||||
"amount_lowercase": 1,
|
||||
"amount_symbols": 1,
|
||||
"length_min": 12,
|
||||
"symbol_charset": "!\\\"#$%&'()*+,-./:;<=>?@[]^_`{|}~",
|
||||
"error_message": "Enter a minimum of 12 characters, with at least 1 lowercase, uppercase, digit and symbol",
|
||||
"check_static_rules": True,
|
||||
"check_have_i_been_pwned": True,
|
||||
"check_zxcvbn": True,
|
||||
"hibp_allowed_count": 0,
|
||||
"zxcvbn_score_threshold": 2
|
||||
}
|
||||
|
||||
# Check if policy already exists
|
||||
status, policies = api_request(base_url, token, '/api/v3/policies/password/')
|
||||
print(f" Initial check status: {status}")
|
||||
if status == 200:
|
||||
results = policies.get('results', [])
|
||||
print(f" Found {len(results)} existing policies")
|
||||
for policy in results:
|
||||
policy_name = policy.get('name')
|
||||
print(f" - {policy_name}")
|
||||
if policy_name == 'password-complexity':
|
||||
print(f" ✓ Password policy already exists: {policy['pk']}")
|
||||
return policy['pk']
|
||||
else:
|
||||
print(f" Initial check failed: {policies}")
|
||||
|
||||
# Create new policy
|
||||
status, policy = api_request(base_url, token, '/api/v3/policies/password/', 'POST', policy_data)
|
||||
if status == 201:
|
||||
print(f" ✓ Created password policy: {policy['pk']}")
|
||||
return policy['pk']
|
||||
elif status == 400 and 'name' in policy:
|
||||
# Policy with same name already exists, search for it again
|
||||
print(f" ! Policy name already exists, retrieving existing policy...")
|
||||
status, policies = api_request(base_url, token, '/api/v3/policies/password/')
|
||||
if status == 200:
|
||||
for existing_policy in policies.get('results', []):
|
||||
if existing_policy.get('name') == 'password-complexity':
|
||||
print(f" ✓ Found existing password policy: {existing_policy['pk']}")
|
||||
return existing_policy['pk']
|
||||
print(f" ✗ Failed to find existing policy after creation conflict")
|
||||
return None
|
||||
else:
|
||||
print(f" ✗ Failed to create password policy: {policy}")
|
||||
return None
|
||||
|
||||
|
||||
def get_or_create_recovery_identification_stage(base_url, token):
|
||||
"""Create recovery identification stage"""
|
||||
print("Creating recovery identification stage...")
|
||||
|
||||
stage_data = {
|
||||
"name": "recovery-authentication-identification",
|
||||
"user_fields": ["username", "email"],
|
||||
"password_stage": None,
|
||||
"case_insensitive_matching": True,
|
||||
"show_matched_user": True,
|
||||
"pretend_user_exists": True,
|
||||
"enable_remember_me": False
|
||||
}
|
||||
|
||||
# Check if stage already exists
|
||||
status, stages = api_request(base_url, token, '/api/v3/stages/identification/')
|
||||
if status == 200:
|
||||
for stage in stages.get('results', []):
|
||||
if stage.get('name') == 'recovery-authentication-identification':
|
||||
print(f" ✓ Recovery identification stage already exists: {stage['pk']}")
|
||||
return stage['pk']
|
||||
|
||||
# Create new stage
|
||||
status, stage = api_request(base_url, token, '/api/v3/stages/identification/', 'POST', stage_data)
|
||||
if status == 201:
|
||||
print(f" ✓ Created recovery identification stage: {stage['pk']}")
|
||||
return stage['pk']
|
||||
elif status == 400 and 'name' in stage:
|
||||
# Stage with same name already exists
|
||||
print(f" ! Stage name already exists, retrieving existing stage...")
|
||||
status, stages = api_request(base_url, token, '/api/v3/stages/identification/')
|
||||
if status == 200:
|
||||
for existing_stage in stages.get('results', []):
|
||||
if existing_stage.get('name') == 'recovery-authentication-identification':
|
||||
print(f" ✓ Found existing recovery identification stage: {existing_stage['pk']}")
|
||||
return existing_stage['pk']
|
||||
print(f" ✗ Failed to find existing stage after creation conflict")
|
||||
return None
|
||||
else:
|
||||
print(f" ✗ Failed to create recovery identification stage: {stage}")
|
||||
return None
|
||||
|
||||
|
||||
def get_or_create_recovery_email_stage(base_url, token):
|
||||
"""Create recovery email stage"""
|
||||
print("Creating recovery email stage...")
|
||||
|
||||
stage_data = {
|
||||
"name": "recovery-email",
|
||||
"use_global_settings": True,
|
||||
"token_expiry": "minutes=30",
|
||||
"subject": "Password recovery",
|
||||
"template": "email/password_reset.html",
|
||||
"activate_user_on_success": True,
|
||||
"recovery_max_attempts": 5,
|
||||
"recovery_cache_timeout": "minutes=5"
|
||||
}
|
||||
|
||||
# Check if stage already exists
|
||||
status, stages = api_request(base_url, token, '/api/v3/stages/email/')
|
||||
if status == 200:
|
||||
for stage in stages.get('results', []):
|
||||
if stage.get('name') == 'recovery-email':
|
||||
print(f" ✓ Recovery email stage already exists: {stage['pk']}")
|
||||
return stage['pk']
|
||||
|
||||
# Create new stage
|
||||
status, stage = api_request(base_url, token, '/api/v3/stages/email/', 'POST', stage_data)
|
||||
if status == 201:
|
||||
print(f" ✓ Created recovery email stage: {stage['pk']}")
|
||||
return stage['pk']
|
||||
elif status == 400 and 'name' in stage:
|
||||
# Stage with same name already exists
|
||||
print(f" ! Stage name already exists, retrieving existing stage...")
|
||||
status, stages = api_request(base_url, token, '/api/v3/stages/email/')
|
||||
if status == 200:
|
||||
for existing_stage in stages.get('results', []):
|
||||
if existing_stage.get('name') == 'recovery-email':
|
||||
print(f" ✓ Found existing recovery email stage: {existing_stage['pk']}")
|
||||
return existing_stage['pk']
|
||||
print(f" ✗ Failed to find existing stage after creation conflict")
|
||||
return None
|
||||
else:
|
||||
print(f" ✗ Failed to create recovery email stage: {stage}")
|
||||
return None
|
||||
|
||||
|
||||
def get_existing_stage_uuid(base_url, token, stage_name, stage_type):
|
||||
"""Get UUID of an existing stage"""
|
||||
status, stages = api_request(base_url, token, f'/api/v3/stages/{stage_type}/')
|
||||
if status == 200:
|
||||
for stage in stages.get('results', []):
|
||||
if stage.get('name') == stage_name:
|
||||
return stage['pk']
|
||||
return None
|
||||
|
||||
|
||||
def get_or_create_recovery_flow(base_url, token, stage_ids):
|
||||
"""Create recovery flow with stage bindings"""
|
||||
print("Creating recovery flow...")
|
||||
|
||||
flow_data = {
|
||||
"name": "recovery",
|
||||
"slug": "recovery",
|
||||
"title": "Recovery",
|
||||
"designation": "recovery",
|
||||
"policy_engine_mode": "any",
|
||||
"compatibility_mode": False,
|
||||
"layout": "stacked",
|
||||
"denied_action": "message_continue"
|
||||
}
|
||||
|
||||
# Check if flow already exists
|
||||
status, flows = api_request(base_url, token, '/api/v3/flows/instances/')
|
||||
if status == 200:
|
||||
for flow in flows.get('results', []):
|
||||
if flow.get('slug') == 'recovery':
|
||||
print(f" ✓ Recovery flow already exists: {flow['pk']}")
|
||||
return flow['pk']
|
||||
|
||||
# Create new flow
|
||||
status, flow = api_request(base_url, token, '/api/v3/flows/instances/', 'POST', flow_data)
|
||||
if status != 201:
|
||||
print(f" ✗ Failed to create recovery flow: {flow}")
|
||||
return None
|
||||
|
||||
flow_uuid = flow['pk']
|
||||
print(f" ✓ Created recovery flow: {flow_uuid}")
|
||||
|
||||
# Create stage bindings
|
||||
bindings = [
|
||||
{"stage": stage_ids['recovery_identification'], "order": 0},
|
||||
{"stage": stage_ids['recovery_email'], "order": 10},
|
||||
{"stage": stage_ids['password_change_prompt'], "order": 20},
|
||||
{"stage": stage_ids['password_change_write'], "order": 30},
|
||||
]
|
||||
|
||||
for binding in bindings:
|
||||
binding_data = {
|
||||
"target": flow_uuid,
|
||||
"stage": binding['stage'],
|
||||
"order": binding['order'],
|
||||
"evaluate_on_plan": False,
|
||||
"re_evaluate_policies": True,
|
||||
"policy_engine_mode": "any",
|
||||
"invalid_response_action": "retry"
|
||||
}
|
||||
|
||||
status, result = api_request(base_url, token, '/api/v3/flows/bindings/', 'POST', binding_data)
|
||||
if status == 201:
|
||||
print(f" ✓ Bound stage {binding['stage']} at order {binding['order']}")
|
||||
else:
|
||||
print(f" ✗ Failed to bind stage: {result}")
|
||||
|
||||
return flow_uuid
|
||||
|
||||
|
||||
def update_password_change_prompt_stage(base_url, token, stage_uuid, password_complexity_uuid):
|
||||
"""Add password complexity policy to password change prompt stage"""
|
||||
print("Updating password change prompt stage...")
|
||||
|
||||
# Get current stage configuration
|
||||
status, stage = api_request(base_url, token, f'/api/v3/stages/prompt/stages/{stage_uuid}/')
|
||||
if status != 200:
|
||||
print(f" ✗ Failed to get stage: {stage}")
|
||||
return False
|
||||
|
||||
# Add password complexity to validation policies
|
||||
validation_policies = stage.get('validation_policies', [])
|
||||
if password_complexity_uuid not in validation_policies:
|
||||
validation_policies.append(password_complexity_uuid)
|
||||
|
||||
update_data = {
|
||||
"validation_policies": validation_policies
|
||||
}
|
||||
|
||||
status, result = api_request(base_url, token, f'/api/v3/stages/prompt/stages/{stage_uuid}/', 'PATCH', update_data)
|
||||
if status == 200:
|
||||
print(f" ✓ Added password complexity policy to validation")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Failed to update stage: {result}")
|
||||
return False
|
||||
else:
|
||||
print(f" ✓ Password complexity policy already in validation")
|
||||
return True
|
||||
|
||||
|
||||
def remove_separate_password_stage_from_auth_flow(base_url, token, auth_flow_uuid, password_stage_uuid):
|
||||
"""Remove separate password stage from authentication flow if it exists"""
|
||||
print("Checking for separate password stage in authentication flow...")
|
||||
|
||||
# Get all flow bindings
|
||||
status, bindings_data = api_request(base_url, token, '/api/v3/flows/bindings/')
|
||||
if status != 200:
|
||||
print(f" ✗ Failed to get flow bindings: {bindings_data}")
|
||||
return False
|
||||
|
||||
# Find password stage binding in auth flow
|
||||
password_binding = None
|
||||
for binding in bindings_data.get('results', []):
|
||||
if binding.get('target') == auth_flow_uuid and binding.get('stage') == password_stage_uuid:
|
||||
password_binding = binding
|
||||
break
|
||||
|
||||
if not password_binding:
|
||||
print(f" ✓ No separate password stage found (already removed)")
|
||||
return True
|
||||
|
||||
# Delete the password stage binding
|
||||
binding_uuid = password_binding.get('pk')
|
||||
status, result = api_request(base_url, token, f'/api/v3/flows/bindings/{binding_uuid}/', 'DELETE')
|
||||
if status == 204 or status == 200:
|
||||
print(f" ✓ Removed separate password stage from authentication flow")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Failed to remove password stage: {result}")
|
||||
return False
|
||||
|
||||
|
||||
def update_authentication_identification_stage(base_url, token, stage_uuid, password_stage_uuid, recovery_flow_uuid):
|
||||
"""Update authentication identification stage with password field and recovery flow"""
|
||||
print("Updating authentication identification stage...")
|
||||
|
||||
# First get the current stage configuration
|
||||
status, current_stage = api_request(base_url, token, f'/api/v3/stages/identification/{stage_uuid}/')
|
||||
if status != 200:
|
||||
print(f" ✗ Failed to get current stage: {current_stage}")
|
||||
return False
|
||||
|
||||
# Check if already configured
|
||||
if current_stage.get('password_stage') == password_stage_uuid and current_stage.get('recovery_flow') == recovery_flow_uuid:
|
||||
print(f" ✓ Authentication identification stage already configured")
|
||||
return True
|
||||
|
||||
# Update with new values while preserving existing configuration
|
||||
update_data = {
|
||||
"name": current_stage.get('name'),
|
||||
"user_fields": current_stage.get('user_fields', ["username", "email"]),
|
||||
"password_stage": password_stage_uuid,
|
||||
"recovery_flow": recovery_flow_uuid,
|
||||
"case_insensitive_matching": current_stage.get('case_insensitive_matching', True),
|
||||
"show_matched_user": current_stage.get('show_matched_user', True),
|
||||
"pretend_user_exists": current_stage.get('pretend_user_exists', True)
|
||||
}
|
||||
|
||||
status, result = api_request(base_url, token, f'/api/v3/stages/identification/{stage_uuid}/', 'PATCH', update_data)
|
||||
if status == 200:
|
||||
print(f" ✓ Updated authentication identification stage")
|
||||
print(f" - Added password field on same page")
|
||||
print(f" - Added recovery flow link")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Failed to update stage: {result}")
|
||||
return False
|
||||
|
||||
|
||||
def update_brand_recovery_flow(base_url, token, recovery_flow_uuid):
|
||||
"""Update the default brand to use the recovery flow"""
|
||||
print("Updating brand default recovery flow...")
|
||||
|
||||
# Get the default brand (authentik has one brand by default)
|
||||
status, brands = api_request(base_url, token, '/api/v3/core/brands/')
|
||||
if status != 200:
|
||||
print(f" ✗ Failed to get brands: {brands}")
|
||||
return False
|
||||
|
||||
results = brands.get('results', [])
|
||||
if not results:
|
||||
print(f" ✗ No brands found")
|
||||
return False
|
||||
|
||||
# Use the first/default brand
|
||||
brand = results[0]
|
||||
brand_uuid = brand.get('brand_uuid')
|
||||
|
||||
# Check if already configured
|
||||
if brand.get('flow_recovery') == recovery_flow_uuid:
|
||||
print(f" ✓ Brand recovery flow already configured")
|
||||
return True
|
||||
|
||||
# Update the brand with recovery flow
|
||||
update_data = {
|
||||
"domain": brand.get('domain'),
|
||||
"flow_recovery": recovery_flow_uuid
|
||||
}
|
||||
|
||||
status, result = api_request(base_url, token, f'/api/v3/core/brands/{brand_uuid}/', 'PATCH', update_data)
|
||||
if status == 200:
|
||||
print(f" ✓ Updated brand default recovery flow")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Failed to update brand: {result}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python3 create_recovery_flow.py <api_token> <authentik_domain>")
|
||||
sys.exit(1)
|
||||
|
||||
token = sys.argv[1]
|
||||
authentik_domain = sys.argv[2]
|
||||
|
||||
# Use internal localhost URL when running inside Authentik container
|
||||
# This avoids SSL/DNS issues
|
||||
base_url = "http://localhost:9000"
|
||||
print(f"Using internal API endpoint: {base_url}")
|
||||
print(f"External domain: https://{authentik_domain}\n")
|
||||
|
||||
print("=" * 80)
|
||||
print("Authentik Recovery Flow Automation")
|
||||
print("=" * 80)
|
||||
print(f"Target: {base_url}\n")
|
||||
|
||||
# Step 1: Create password complexity policy
|
||||
password_complexity_uuid = get_or_create_password_policy(base_url, token)
|
||||
if not password_complexity_uuid:
|
||||
print("\n✗ Failed to create password complexity policy")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 2: Create recovery identification stage
|
||||
recovery_identification_uuid = get_or_create_recovery_identification_stage(base_url, token)
|
||||
if not recovery_identification_uuid:
|
||||
print("\n✗ Failed to create recovery identification stage")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 3: Create recovery email stage
|
||||
recovery_email_uuid = get_or_create_recovery_email_stage(base_url, token)
|
||||
if not recovery_email_uuid:
|
||||
print("\n✗ Failed to create recovery email stage")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 4: Get existing stage and flow UUIDs
|
||||
print("\nGetting existing stage and flow UUIDs...")
|
||||
password_change_prompt_uuid = get_existing_stage_uuid(base_url, token, 'default-password-change-prompt', 'prompt/stages')
|
||||
password_change_write_uuid = get_existing_stage_uuid(base_url, token, 'default-password-change-write', 'user_write')
|
||||
auth_identification_uuid = get_existing_stage_uuid(base_url, token, 'default-authentication-identification', 'identification')
|
||||
auth_password_uuid = get_existing_stage_uuid(base_url, token, 'default-authentication-password', 'password')
|
||||
|
||||
# Get default authentication flow UUID
|
||||
status, flows = api_request(base_url, token, '/api/v3/flows/instances/')
|
||||
auth_flow_uuid = None
|
||||
if status == 200:
|
||||
for flow in flows.get('results', []):
|
||||
if flow.get('slug') == 'default-authentication-flow':
|
||||
auth_flow_uuid = flow.get('pk')
|
||||
break
|
||||
|
||||
if not all([password_change_prompt_uuid, password_change_write_uuid, auth_identification_uuid, auth_password_uuid, auth_flow_uuid]):
|
||||
print(" ✗ Failed to find all required existing stages and flows")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" ✓ Found all existing stages and flows")
|
||||
|
||||
# Step 5: Create recovery flow
|
||||
stage_ids = {
|
||||
'recovery_identification': recovery_identification_uuid,
|
||||
'recovery_email': recovery_email_uuid,
|
||||
'password_change_prompt': password_change_prompt_uuid,
|
||||
'password_change_write': password_change_write_uuid
|
||||
}
|
||||
|
||||
recovery_flow_uuid = get_or_create_recovery_flow(base_url, token, stage_ids)
|
||||
if not recovery_flow_uuid:
|
||||
print("\n✗ Failed to create recovery flow")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 6: Update password change prompt stage
|
||||
if not update_password_change_prompt_stage(base_url, token, password_change_prompt_uuid, password_complexity_uuid):
|
||||
print("\n⚠ Warning: Failed to update password change prompt stage")
|
||||
|
||||
# Step 7: Update authentication identification stage
|
||||
if not update_authentication_identification_stage(base_url, token, auth_identification_uuid, auth_password_uuid, recovery_flow_uuid):
|
||||
print("\n⚠ Warning: Failed to update authentication identification stage")
|
||||
|
||||
# Step 8: Remove separate password stage from authentication flow
|
||||
if not remove_separate_password_stage_from_auth_flow(base_url, token, auth_flow_uuid, auth_password_uuid):
|
||||
print("\n⚠ Warning: Failed to remove separate password stage (may not exist)")
|
||||
|
||||
# Step 9: Update brand default recovery flow
|
||||
if not update_brand_recovery_flow(base_url, token, recovery_flow_uuid):
|
||||
print("\n⚠ Warning: Failed to update brand recovery flow (non-critical)")
|
||||
|
||||
# Success!
|
||||
print("\n" + "=" * 80)
|
||||
print("✓ Recovery Flow Configuration Complete!")
|
||||
print("=" * 80)
|
||||
print(f"\nRecovery Flow UUID: {recovery_flow_uuid}")
|
||||
print(f"Recovery URL: https://{authentik_domain}/if/flow/recovery/")
|
||||
print(f"\nFeatures enabled:")
|
||||
print(" ✓ Password complexity policy (12 chars, mixed case, digit, symbol)")
|
||||
print(" ✓ Recovery email with 30-minute token")
|
||||
print(" ✓ Password + username on same login page")
|
||||
print(" ✓ 'Forgot password?' link on login page")
|
||||
print(" ✓ Brand default recovery flow configured")
|
||||
print("\nTest the recovery flow:")
|
||||
print(f" 1. Visit: https://{authentik_domain}/if/flow/default-authentication-flow/")
|
||||
print(" 2. Click 'Forgot password?' link")
|
||||
print(" 3. Enter username or email")
|
||||
print(" 4. Check email for recovery link")
|
||||
print("=" * 80)
|
||||
|
||||
# Output JSON for Ansible
|
||||
result = {
|
||||
"success": True,
|
||||
"recovery_flow_uuid": recovery_flow_uuid,
|
||||
"password_complexity_uuid": password_complexity_uuid,
|
||||
"recovery_url": f"https://{authentik_domain}/if/flow/recovery/"
|
||||
}
|
||||
print("\n" + json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
version: 1
|
||||
metadata:
|
||||
name: custom-flow-configuration
|
||||
labels:
|
||||
blueprints.goauthentik.io/description: "Configure invitation and 2FA enforcement"
|
||||
blueprints.goauthentik.io/instantiate: "true"
|
||||
|
||||
entries:
|
||||
# 1. CREATE INVITATION STAGE
|
||||
# This stage allows enrollment flows to work with or without invitation tokens
|
||||
- model: authentik_stages_invitation.invitationstage
|
||||
identifiers:
|
||||
name: default-enrollment-invitation
|
||||
id: invitation-stage
|
||||
attrs:
|
||||
continue_flow_without_invitation: true
|
||||
|
||||
# 2. BIND INVITATION STAGE TO ENROLLMENT FLOW
|
||||
# Adds the invitation stage as the first stage in the enrollment flow
|
||||
- model: authentik_flows.flowstagebinding
|
||||
identifiers:
|
||||
target: !Find [authentik_flows.flow, [slug, default-enrollment-flow]]
|
||||
stage: !KeyOf invitation-stage
|
||||
order: 0
|
||||
attrs:
|
||||
evaluate_on_plan: true
|
||||
re_evaluate_policies: false
|
||||
|
||||
# 3. ENFORCE 2FA CONFIGURATION
|
||||
# Updates MFA validation stage to force users to configure TOTP
|
||||
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
|
||||
identifiers:
|
||||
name: default-authentication-mfa-validation
|
||||
attrs:
|
||||
not_configured_action: configure
|
||||
device_classes:
|
||||
- totp
|
||||
- webauthn
|
||||
configuration_stages:
|
||||
- !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]]
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
version: 1
|
||||
metadata:
|
||||
name: invitation-enrollment-flow
|
||||
labels:
|
||||
blueprints.goauthentik.io/description: "Invitation-only enrollment flow"
|
||||
blueprints.goauthentik.io/instantiate: "true"
|
||||
|
||||
entries:
|
||||
# 1. CREATE ENROLLMENT FLOW
|
||||
- attrs:
|
||||
designation: enrollment
|
||||
name: Default enrollment Flow
|
||||
title: Welcome to authentik!
|
||||
authentication: none
|
||||
denied_action: message_continue
|
||||
identifiers:
|
||||
slug: default-enrollment-flow
|
||||
model: authentik_flows.flow
|
||||
id: flow
|
||||
|
||||
# 2. CREATE INVITATION STAGE
|
||||
- attrs:
|
||||
continue_flow_without_invitation: false
|
||||
identifiers:
|
||||
name: default-enrollment-invitation
|
||||
id: invitation-stage
|
||||
model: authentik_stages_invitation.invitationstage
|
||||
|
||||
# 3. CREATE PROMPT FIELDS
|
||||
- attrs:
|
||||
order: 0
|
||||
placeholder: Username
|
||||
placeholder_expression: false
|
||||
required: true
|
||||
type: username
|
||||
field_key: username
|
||||
label: Username
|
||||
identifiers:
|
||||
name: default-enrollment-field-username
|
||||
id: prompt-field-username
|
||||
model: authentik_stages_prompt.prompt
|
||||
|
||||
- attrs:
|
||||
order: 1
|
||||
placeholder: Name
|
||||
placeholder_expression: false
|
||||
required: true
|
||||
type: text
|
||||
field_key: name
|
||||
label: Name
|
||||
identifiers:
|
||||
name: default-enrollment-field-name
|
||||
id: prompt-field-name
|
||||
model: authentik_stages_prompt.prompt
|
||||
|
||||
- attrs:
|
||||
order: 2
|
||||
placeholder: Email
|
||||
placeholder_expression: false
|
||||
required: true
|
||||
type: email
|
||||
field_key: email
|
||||
label: Email
|
||||
identifiers:
|
||||
name: default-enrollment-field-email
|
||||
id: prompt-field-email
|
||||
model: authentik_stages_prompt.prompt
|
||||
|
||||
- attrs:
|
||||
order: 3
|
||||
placeholder: Password
|
||||
placeholder_expression: false
|
||||
required: true
|
||||
type: password
|
||||
field_key: password
|
||||
label: Password
|
||||
identifiers:
|
||||
name: default-enrollment-field-password
|
||||
id: prompt-field-password
|
||||
model: authentik_stages_prompt.prompt
|
||||
|
||||
- attrs:
|
||||
order: 4
|
||||
placeholder: Password (repeat)
|
||||
placeholder_expression: false
|
||||
required: true
|
||||
type: password
|
||||
field_key: password_repeat
|
||||
label: Password (repeat)
|
||||
identifiers:
|
||||
name: default-enrollment-field-password-repeat
|
||||
id: prompt-field-password-repeat
|
||||
model: authentik_stages_prompt.prompt
|
||||
|
||||
# 4. CREATE PROMPT STAGE
|
||||
- attrs:
|
||||
fields:
|
||||
- !KeyOf prompt-field-username
|
||||
- !KeyOf prompt-field-name
|
||||
- !KeyOf prompt-field-email
|
||||
- !KeyOf prompt-field-password
|
||||
- !KeyOf prompt-field-password-repeat
|
||||
validation_policies: []
|
||||
identifiers:
|
||||
name: default-enrollment-prompt
|
||||
id: prompt-stage
|
||||
model: authentik_stages_prompt.promptstage
|
||||
|
||||
# 5. CREATE USER WRITE STAGE
|
||||
- attrs:
|
||||
user_creation_mode: always_create
|
||||
create_users_as_inactive: false
|
||||
create_users_group: null
|
||||
user_path_template: ""
|
||||
identifiers:
|
||||
name: default-enrollment-user-write
|
||||
id: user-write-stage
|
||||
model: authentik_stages_user_write.userwritestage
|
||||
|
||||
# 6. BIND INVITATION STAGE TO FLOW (order 0)
|
||||
- attrs:
|
||||
evaluate_on_plan: true
|
||||
re_evaluate_policies: false
|
||||
identifiers:
|
||||
order: 0
|
||||
stage: !KeyOf invitation-stage
|
||||
target: !KeyOf flow
|
||||
model: authentik_flows.flowstagebinding
|
||||
|
||||
# 8. BIND PROMPT STAGE TO FLOW (order 10)
|
||||
- attrs:
|
||||
evaluate_on_plan: true
|
||||
re_evaluate_policies: false
|
||||
identifiers:
|
||||
order: 10
|
||||
stage: !KeyOf prompt-stage
|
||||
target: !KeyOf flow
|
||||
model: authentik_flows.flowstagebinding
|
||||
|
||||
# 9. BIND USER WRITE STAGE TO FLOW (order 20)
|
||||
- attrs:
|
||||
evaluate_on_plan: true
|
||||
re_evaluate_policies: false
|
||||
identifiers:
|
||||
order: 20
|
||||
stage: !KeyOf user-write-stage
|
||||
target: !KeyOf flow
|
||||
model: authentik_flows.flowstagebinding
|
||||
|
||||
# Note: Brand enrollment flow configuration must be done via API
|
||||
# The tenant model is restricted in blueprints
|
||||
# Use: PATCH /api/v3/core/tenants/{tenant_uuid}/
|
||||
# Body: {"flow_enrollment": "<flow_uuid>"}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
version: 1
|
||||
metadata:
|
||||
name: invitation-flow-configuration
|
||||
labels:
|
||||
blueprints.goauthentik.io/description: "Configure invitation stage for enrollment"
|
||||
blueprints.goauthentik.io/instantiate: "true"
|
||||
|
||||
entries:
|
||||
# 1. CREATE INVITATION STAGE
|
||||
- model: authentik_stages_invitation.invitationstage
|
||||
identifiers:
|
||||
name: default-enrollment-invitation
|
||||
id: invitation-stage
|
||||
attrs:
|
||||
continue_flow_without_invitation: true
|
||||
|
||||
# 2. BIND INVITATION STAGE TO ENROLLMENT FLOW
|
||||
- model: authentik_flows.flowstagebinding
|
||||
identifiers:
|
||||
target: !Find [authentik_flows.flow, [designation, enrollment]]
|
||||
stage: !KeyOf invitation-stage
|
||||
order: 0
|
||||
attrs:
|
||||
evaluate_on_plan: true
|
||||
re_evaluate_policies: false
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
---
|
||||
# Handlers for Authentik role
|
||||
|
||||
- name: Restart Authentik
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ authentik_config_dir }}"
|
||||
state: restarted
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
# Bootstrap tasks for initial Authentik configuration
|
||||
|
||||
- name: Display bootstrap status
|
||||
debug:
|
||||
msg: |
|
||||
========================================
|
||||
Authentik is running!
|
||||
========================================
|
||||
|
||||
URL: https://{{ authentik_domain }}
|
||||
|
||||
Bootstrap Configuration:
|
||||
✓ Admin user 'akadmin' automatically created
|
||||
✓ Password: (stored in secrets file)
|
||||
✓ API token: (stored in secrets file)
|
||||
|
||||
The admin account and API token are automatically configured
|
||||
via AUTHENTIK_BOOTSTRAP_* environment variables.
|
||||
|
||||
Note: HTTPS access requires DNS propagation and SSL certificate
|
||||
issuance. This typically takes 1-5 minutes after deployment.
|
||||
Authentik is accessible internally and the deployment will continue.
|
||||
|
||||
Documentation: https://docs.goauthentik.io
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
---
|
||||
# Docker Compose setup for Authentik
|
||||
|
||||
- name: Create Authentik configuration directory
|
||||
file:
|
||||
path: "{{ authentik_config_dir }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Create Authentik internal network
|
||||
community.docker.docker_network:
|
||||
name: "{{ authentik_network }}"
|
||||
driver: bridge
|
||||
internal: yes
|
||||
|
||||
- name: Deploy Authentik Docker Compose configuration
|
||||
template:
|
||||
src: docker-compose.authentik.yml.j2
|
||||
dest: "{{ authentik_config_dir }}/docker-compose.yml"
|
||||
mode: '0644'
|
||||
notify: Restart Authentik
|
||||
|
||||
- name: Start Authentik services
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ authentik_config_dir }}"
|
||||
state: present
|
||||
|
||||
- name: Wait for Authentik database to be ready
|
||||
community.docker.docker_container_info:
|
||||
name: authentik-db
|
||||
register: db_container
|
||||
until: db_container.container.State.Health.Status == "healthy"
|
||||
retries: 30
|
||||
delay: 5
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Authentik server to be healthy (via docker)
|
||||
shell: "docker exec authentik-server curl -s -o /dev/null -w '%{http_code}' http://localhost:9000/"
|
||||
register: authentik_health
|
||||
until: authentik_health.stdout in ['200', '302']
|
||||
retries: 30
|
||||
delay: 10
|
||||
changed_when: false
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
---
|
||||
# Display Authentik email configuration status
|
||||
# Email settings are configured via docker-compose environment variables
|
||||
|
||||
- name: Display Authentik email configuration status
|
||||
debug:
|
||||
msg: |
|
||||
========================================
|
||||
Authentik Email Configuration
|
||||
========================================
|
||||
|
||||
Email is configured via Docker Compose environment variables:
|
||||
AUTHENTIK_EMAIL__HOST: smtp.eu.mailgun.org
|
||||
AUTHENTIK_EMAIL__FROM: {{ inventory_hostname }}@mg.vrije.cloud
|
||||
|
||||
Status: ✓ Configured
|
||||
|
||||
Authentik can now send:
|
||||
- Password reset emails
|
||||
- User invitation emails
|
||||
- Notification emails
|
||||
========================================
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
---
|
||||
# Configure Authentik flows (invitation, 2FA) via Blueprints
|
||||
|
||||
- name: Use bootstrap token for API access
|
||||
set_fact:
|
||||
authentik_api_token: "{{ client_secrets.authentik_bootstrap_token }}"
|
||||
|
||||
- name: Wait for Authentik API to be ready
|
||||
shell: |
|
||||
i=1
|
||||
while [ $i -le 30 ]; do
|
||||
if docker exec authentik-server curl -sf -H "Authorization: Bearer {{ authentik_api_token }}" http://localhost:9000/api/v3/flows/instances/ > /dev/null 2>&1; then
|
||||
echo "Authentik API is ready"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for Authentik API... attempt $i/30"
|
||||
sleep 5
|
||||
i=$((i+1))
|
||||
done
|
||||
exit 1
|
||||
register: api_wait
|
||||
changed_when: false
|
||||
|
||||
- name: Create blueprints directory on server
|
||||
file:
|
||||
path: "{{ authentik_config_dir }}/blueprints"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Copy flow blueprints to server
|
||||
copy:
|
||||
src: "{{ item }}"
|
||||
dest: "{{ authentik_config_dir }}/blueprints/{{ item }}"
|
||||
mode: '0644'
|
||||
loop:
|
||||
- custom-flows.yaml
|
||||
- enrollment-flow.yaml
|
||||
register: blueprints_copied
|
||||
|
||||
- name: Copy blueprints into authentik-worker container
|
||||
shell: |
|
||||
docker cp "{{ authentik_config_dir }}/blueprints/{{ item }}" authentik-worker:/blueprints/{{ item }}
|
||||
loop:
|
||||
- custom-flows.yaml
|
||||
- enrollment-flow.yaml
|
||||
when: blueprints_copied.changed
|
||||
|
||||
- name: Copy blueprints into authentik-server container
|
||||
shell: |
|
||||
docker cp "{{ authentik_config_dir }}/blueprints/{{ item }}" authentik-server:/blueprints/{{ item }}
|
||||
loop:
|
||||
- custom-flows.yaml
|
||||
- enrollment-flow.yaml
|
||||
when: blueprints_copied.changed
|
||||
|
||||
- name: Wait for blueprint to be discovered and applied
|
||||
shell: |
|
||||
echo "Waiting for blueprint to be discovered and applied..."
|
||||
sleep 10
|
||||
|
||||
# Check if blueprint instance was created
|
||||
i=1
|
||||
while [ $i -le 24 ]; do
|
||||
result=$(docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
|
||||
'http://localhost:9000/api/v3/managed/blueprints/' 2>/dev/null || echo '')
|
||||
|
||||
if echo "$result" | grep -q 'custom-flow-configuration'; then
|
||||
echo "Blueprint instance found"
|
||||
|
||||
# Check if it has been applied successfully
|
||||
if echo "$result" | grep -A 10 'custom-flow-configuration' | grep -q 'successful'; then
|
||||
echo "Blueprint applied successfully"
|
||||
exit 0
|
||||
else
|
||||
echo "Blueprint found but not yet applied, waiting..."
|
||||
fi
|
||||
else
|
||||
echo "Waiting for blueprint discovery... attempt $i/24"
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
i=$((i+1))
|
||||
done
|
||||
|
||||
echo "Blueprint may still be applying, continuing..."
|
||||
exit 0
|
||||
register: blueprint_wait
|
||||
changed_when: false
|
||||
|
||||
- name: Verify invitation stage was created
|
||||
shell: |
|
||||
docker exec authentik-server curl -sf -H "Authorization: Bearer {{ authentik_api_token }}" \
|
||||
"http://localhost:9000/api/v3/stages/all/" | \
|
||||
python3 -c "import sys, json; data = json.load(sys.stdin); stages = [s for s in data['results'] if 'invitation' in s.get('name', '').lower()]; print(json.dumps({'found': len(stages) > 0, 'count': len(stages)}))"
|
||||
register: invitation_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
|
||||
- name: Display flows configuration status
|
||||
debug:
|
||||
msg: |
|
||||
========================================
|
||||
Authentik Flows Configuration
|
||||
========================================
|
||||
|
||||
Configuration Method: YAML Blueprints
|
||||
Blueprints Deployed:
|
||||
- /blueprints/custom-flows.yaml (2FA enforcement)
|
||||
- /blueprints/enrollment-flow.yaml (invitation-only registration)
|
||||
|
||||
✓ Blueprints Deployed: {{ blueprints_copied.changed }}
|
||||
✓ Blueprints Applied: {{ 'Yes' if 'successfully' in blueprint_wait.stdout else 'In Progress' }}
|
||||
|
||||
Verification:
|
||||
{{ invitation_check.stdout | default('Invitation stage: Checking...') }}
|
||||
|
||||
Note: Authentik applies blueprints asynchronously.
|
||||
Changes should be visible within 1-2 minutes.
|
||||
Recovery flows must be configured manually in Authentik admin UI.
|
||||
|
||||
Flow URLs:
|
||||
- Enrollment: https://{{ authentik_domain }}/if/flow/default-enrollment-flow/
|
||||
|
||||
Email configuration is active - emails sent via Mailgun SMTP.
|
||||
========================================
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
---
|
||||
# Configure invitation stage for enrollment flow
|
||||
|
||||
- name: Use bootstrap token for API access
|
||||
set_fact:
|
||||
authentik_api_token: "{{ client_secrets.authentik_bootstrap_token }}"
|
||||
|
||||
- name: Wait for Authentik API to be ready
|
||||
uri:
|
||||
url: "https://{{ authentik_domain }}/api/v3/root/config/"
|
||||
method: GET
|
||||
validate_certs: no
|
||||
status_code: 200
|
||||
register: api_result
|
||||
until: api_result.status == 200
|
||||
retries: 12
|
||||
delay: 5
|
||||
ignore_errors: yes
|
||||
failed_when: false
|
||||
|
||||
- name: Create blueprints directory on server
|
||||
file:
|
||||
path: /opt/config/authentik/blueprints
|
||||
state: directory
|
||||
mode: '0755'
|
||||
when: api_result.status is defined and api_result.status == 200
|
||||
|
||||
- name: Copy public enrollment flow blueprint to server
|
||||
copy:
|
||||
src: enrollment-flow.yaml
|
||||
dest: /opt/config/authentik/blueprints/enrollment-flow.yaml
|
||||
mode: '0644'
|
||||
register: enrollment_blueprint_copied
|
||||
when: api_result.status is defined and api_result.status == 200
|
||||
|
||||
- name: Copy enrollment blueprint into authentik-worker container
|
||||
shell: |
|
||||
docker cp /opt/config/authentik/blueprints/enrollment-flow.yaml authentik-worker:/blueprints/enrollment-flow.yaml
|
||||
when: api_result.status is defined and api_result.status == 200
|
||||
|
||||
- name: Copy enrollment blueprint into authentik-server container
|
||||
shell: |
|
||||
docker cp /opt/config/authentik/blueprints/enrollment-flow.yaml authentik-server:/blueprints/enrollment-flow.yaml
|
||||
when: api_result.status is defined and api_result.status == 200
|
||||
|
||||
- name: Wait for enrollment blueprint to be discovered and applied
|
||||
shell: |
|
||||
echo "Waiting for public enrollment blueprint to be discovered and applied..."
|
||||
sleep 10
|
||||
# Check if blueprint instance was created
|
||||
i=1
|
||||
while [ $i -le 24 ]; do
|
||||
result=$(docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
|
||||
'http://localhost:9000/api/v3/managed/blueprints/' 2>/dev/null || echo '')
|
||||
if echo "$result" | grep -q 'public-enrollment-flow'; then
|
||||
echo "Blueprint instance found"
|
||||
if echo "$result" | grep -A 10 'public-enrollment-flow' | grep -q 'successful'; then
|
||||
echo "Blueprint applied successfully"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
sleep 5
|
||||
i=$((i+1))
|
||||
done
|
||||
echo "Blueprint deployment in progress (may take 1-2 minutes)"
|
||||
register: enrollment_blueprint_result
|
||||
changed_when: false
|
||||
when: api_result.status is defined and api_result.status == 200
|
||||
|
||||
- name: Verify enrollment flow was created
|
||||
shell: |
|
||||
docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
|
||||
'http://localhost:9000/api/v3/flows/instances/?slug=default-enrollment-flow' | \
|
||||
python3 -c "import sys, json; d = json.load(sys.stdin); print(json.dumps({'found': len(d.get('results', [])) > 0, 'count': len(d.get('results', []))}))"
|
||||
register: enrollment_flow_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: api_result.status is defined and api_result.status == 200
|
||||
|
||||
- name: Display public enrollment flow configuration status
|
||||
debug:
|
||||
msg: |
|
||||
========================================
|
||||
Authentik Public Enrollment Flow
|
||||
========================================
|
||||
|
||||
Configuration Method: YAML Blueprints
|
||||
Blueprint File: /blueprints/enrollment-flow.yaml
|
||||
|
||||
✓ Blueprint Deployed: {{ enrollment_blueprint_copied.changed | default(false) }}
|
||||
✓ Blueprint Applied: {{ 'In Progress' if (enrollment_blueprint_result is defined and enrollment_blueprint_result.rc is defined and enrollment_blueprint_result.rc != 0) else 'Complete' }}
|
||||
|
||||
Verification: {{ enrollment_flow_check.stdout | default('{}') }}
|
||||
|
||||
Features:
|
||||
- Invitation-only enrollment (requires valid invitation token)
|
||||
- User prompts: username, name, email, password
|
||||
- Automatic user creation and login
|
||||
|
||||
Note: Brand enrollment flow is NOT auto-configured (API restriction).
|
||||
Flow is accessible via direct URL even without brand configuration.
|
||||
|
||||
To use enrollment:
|
||||
1. Create invitation: Directory > Invitations > Create Invitation
|
||||
2. Share invitation link: https://{{ authentik_domain }}/if/flow/default-enrollment-flow/?itoken=TOKEN
|
||||
|
||||
To verify:
|
||||
- Login to https://{{ authentik_domain }}
|
||||
- Check Admin > Flows for "default-enrollment-flow"
|
||||
- Test enrollment URL: https://{{ authentik_domain }}/if/flow/default-enrollment-flow/
|
||||
========================================
|
||||
when: api_result.status is defined and api_result.status == 200
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
---
|
||||
# Main tasks file for Authentik role
|
||||
|
||||
- name: Include Docker Compose setup
|
||||
include_tasks: docker.yml
|
||||
|
||||
- name: Include bootstrap setup
|
||||
include_tasks: bootstrap.yml
|
||||
when: authentik_bootstrap | default(true)
|
||||
|
||||
- name: Include OIDC provider configuration
|
||||
include_tasks: providers.yml
|
||||
tags: ['authentik', 'oidc']
|
||||
|
||||
- name: Include email configuration
|
||||
include_tasks: email.yml
|
||||
when: mailgun_smtp_user is defined or (client_secrets.mailgun_smtp_user is defined and client_secrets.mailgun_smtp_user != "" and "PLACEHOLDER" not in client_secrets.mailgun_smtp_user)
|
||||
tags: ['authentik', 'email']
|
||||
|
||||
- name: Include flows configuration (invitation, 2FA)
|
||||
include_tasks: flows.yml
|
||||
when: authentik_bootstrap | default(true)
|
||||
tags: ['authentik', 'flows']
|
||||
|
||||
- name: Include MFA/2FA enforcement configuration
|
||||
include_tasks: mfa.yml
|
||||
when: authentik_bootstrap | default(true)
|
||||
tags: ['authentik', 'mfa', '2fa']
|
||||
|
||||
- name: Include invitation stage configuration
|
||||
include_tasks: invitation.yml
|
||||
when: authentik_bootstrap | default(true)
|
||||
tags: ['authentik', 'invitation']
|
||||
|
||||
- name: Include password recovery flow configuration
|
||||
include_tasks: recovery.yml
|
||||
when: authentik_bootstrap | default(true)
|
||||
tags: ['authentik', 'recovery']
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
---
|
||||
# Configure 2FA/MFA enforcement in Authentik
|
||||
|
||||
- name: Use bootstrap token for API access
|
||||
set_fact:
|
||||
authentik_api_token: "{{ client_secrets.authentik_bootstrap_token }}"
|
||||
|
||||
- name: Get TOTP setup stage UUID
|
||||
shell: |
|
||||
docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
|
||||
'http://localhost:9000/api/v3/stages/authenticator/totp/?name=default-authenticator-totp-setup'
|
||||
register: totp_stage_result
|
||||
changed_when: false
|
||||
|
||||
- name: Parse TOTP stage UUID
|
||||
set_fact:
|
||||
totp_stage_pk: "{{ (totp_stage_result.stdout | from_json).results[0].pk }}"
|
||||
|
||||
- name: Get current MFA validation stage configuration
|
||||
shell: |
|
||||
docker exec authentik-server curl -sf -H 'Authorization: Bearer {{ authentik_api_token }}' \
|
||||
'http://localhost:9000/api/v3/stages/authenticator/validate/?name=default-authentication-mfa-validation'
|
||||
register: mfa_stage_result
|
||||
changed_when: false
|
||||
|
||||
- name: Parse MFA validation stage
|
||||
set_fact:
|
||||
mfa_stage: "{{ (mfa_stage_result.stdout | from_json).results[0] }}"
|
||||
|
||||
- name: Check if MFA enforcement needs configuration
|
||||
set_fact:
|
||||
mfa_needs_update: "{{ mfa_stage.not_configured_action != 'configure' or totp_stage_pk not in (mfa_stage.configuration_stages | default([])) }}"
|
||||
|
||||
- name: Create Python script for MFA enforcement
|
||||
copy:
|
||||
content: |
|
||||
import sys, json, urllib.request
|
||||
|
||||
base_url = "http://localhost:9000"
|
||||
token = "{{ authentik_api_token }}"
|
||||
stage_pk = "{{ mfa_stage.pk }}"
|
||||
totp_stage_pk = "{{ totp_stage_pk }}"
|
||||
|
||||
# Prepare the update payload
|
||||
payload = {
|
||||
"name": "{{ mfa_stage.name }}",
|
||||
"not_configured_action": "configure",
|
||||
"device_classes": ["totp", "webauthn", "static"],
|
||||
"configuration_stages": [totp_stage_pk]
|
||||
}
|
||||
|
||||
# Make PATCH request to update the stage
|
||||
url = f"{base_url}/api/v3/stages/authenticator/validate/{stage_pk}/"
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, method='PATCH')
|
||||
req.add_header('Authorization', f'Bearer {token}')
|
||||
req.add_header('Content-Type', 'application/json')
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read())
|
||||
print(json.dumps({"success": True, "message": "MFA enforcement configured", "not_configured_action": result.get("not_configured_action")}))
|
||||
except urllib.error.HTTPError as e:
|
||||
error_data = e.read().decode()
|
||||
print(json.dumps({"success": False, "error": error_data}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
dest: /tmp/configure_mfa.py
|
||||
mode: '0755'
|
||||
when: mfa_needs_update
|
||||
|
||||
- name: Configure MFA enforcement via API
|
||||
shell: docker exec -i authentik-server python3 < /tmp/configure_mfa.py
|
||||
register: mfa_config_result
|
||||
when: mfa_needs_update
|
||||
|
||||
- name: Cleanup MFA script
|
||||
file:
|
||||
path: /tmp/configure_mfa.py
|
||||
state: absent
|
||||
when: mfa_needs_update
|
||||
|
||||
- name: Display MFA configuration status
|
||||
debug:
|
||||
msg: |
|
||||
========================================
|
||||
Authentik 2FA/MFA Enforcement
|
||||
========================================
|
||||
|
||||
Status: {% if mfa_needs_update %}✓ Configured{% else %}✓ Already configured{% endif %}
|
||||
|
||||
Configuration:
|
||||
- Not configured action: Force user to configure authenticator
|
||||
- Supported methods: TOTP, WebAuthn, Static backup codes
|
||||
- Configuration stage: default-authenticator-totp-setup
|
||||
|
||||
Users will be required to set up 2FA on their next login.
|
||||
========================================
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
---
|
||||
# Create OIDC providers in Authentik for application integration
|
||||
|
||||
- name: Use bootstrap token for API access
|
||||
set_fact:
|
||||
authentik_api_token: "{{ client_secrets.authentik_bootstrap_token }}"
|
||||
|
||||
- name: Create Python script for OIDC provider setup
|
||||
copy:
|
||||
content: |
|
||||
import sys, json, urllib.request
|
||||
base_url, token = "http://localhost:9000", "{{ authentik_api_token }}"
|
||||
def req(p, m='GET', d=None):
|
||||
r = urllib.request.Request(f"{base_url}{p}", json.dumps(d).encode() if d else None, {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, method=m)
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=30) as resp: return resp.status, json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e: return e.code, json.loads(e.read()) if e.headers.get('Content-Type', '').startswith('application/json') else {'error': e.read().decode()}
|
||||
s, d = req('/api/v3/flows/instances/')
|
||||
auth_flow = next((f['pk'] for f in d.get('results', []) if f.get('slug') == 'default-authorization-flow' or f.get('designation') == 'authorization'), None)
|
||||
inval_flow = next((f['pk'] for f in d.get('results', []) if f.get('slug') == 'default-invalidation-flow' or f.get('designation') == 'invalidation'), None)
|
||||
s, d = req('/api/v3/crypto/certificatekeypairs/')
|
||||
key = d.get('results', [{}])[0].get('pk') if d.get('results') else None
|
||||
if not auth_flow or not key: print(json.dumps({'error': 'Config missing'}), file=sys.stderr); sys.exit(1)
|
||||
s, prov = req('/api/v3/providers/oauth2/', 'POST', {'name': 'Nextcloud', 'authorization_flow': auth_flow, 'invalidation_flow': inval_flow, 'client_type': 'confidential', 'redirect_uris': [{'matching_mode': 'strict', 'url': 'https://{{ nextcloud_domain }}/apps/user_oidc/code'}], 'signing_key': key, 'sub_mode': 'hashed_user_id', 'include_claims_in_id_token': True})
|
||||
if s != 201: print(json.dumps({'error': 'Provider failed', 'details': prov}), file=sys.stderr); sys.exit(1)
|
||||
s, app = req('/api/v3/core/applications/', 'POST', {'name': 'Nextcloud', 'slug': 'nextcloud', 'provider': prov['pk'], 'meta_launch_url': 'https://nextcloud.{{ client_domain }}'})
|
||||
if s != 201: print(json.dumps({'error': 'App failed', 'details': app}), file=sys.stderr); sys.exit(1)
|
||||
print(json.dumps({'success': True, 'provider_id': prov['pk'], 'application_id': app['pk'], 'client_id': prov['client_id'], 'client_secret': prov['client_secret'], 'discovery_uri': f"https://{{ authentik_domain }}/application/o/nextcloud/.well-known/openid-configuration", 'issuer': f"https://{{ authentik_domain }}/application/o/nextcloud/"}))
|
||||
dest: /tmp/create_oidc.py
|
||||
mode: '0755'
|
||||
|
||||
- name: Create Nextcloud OIDC provider in Authentik
|
||||
shell: docker exec -i authentik-server python3 < /tmp/create_oidc.py
|
||||
register: oidc_provider_result
|
||||
failed_when: false
|
||||
|
||||
- name: Cleanup OIDC script
|
||||
file:
|
||||
path: /tmp/create_oidc.py
|
||||
state: absent
|
||||
|
||||
- name: Parse OIDC provider credentials
|
||||
set_fact:
|
||||
oidc_credentials: "{{ oidc_provider_result.stdout | from_json }}"
|
||||
when: oidc_provider_result.rc == 0
|
||||
|
||||
- name: Display OIDC provider creation result
|
||||
debug:
|
||||
msg: |
|
||||
OIDC Provider Created Successfully!
|
||||
|
||||
Client ID: {{ oidc_credentials.client_id }}
|
||||
Discovery URI: {{ oidc_credentials.discovery_uri }}
|
||||
|
||||
These credentials will be automatically configured in Nextcloud.
|
||||
when:
|
||||
- oidc_credentials is defined
|
||||
- oidc_credentials.success | default(false)
|
||||
|
||||
- name: Save OIDC credentials to temporary file for Nextcloud configuration
|
||||
copy:
|
||||
content: "{{ oidc_credentials | to_json }}"
|
||||
dest: "/tmp/authentik_oidc_credentials.json"
|
||||
mode: '0600'
|
||||
when:
|
||||
- oidc_credentials is defined
|
||||
- oidc_credentials.success | default(false)
|
||||
|
||||
- name: Display error if OIDC provider creation failed
|
||||
debug:
|
||||
msg: |
|
||||
ERROR: Failed to create OIDC provider
|
||||
|
||||
{{ oidc_provider_result.stdout | default('No output') }}
|
||||
{{ oidc_provider_result.stderr | default('') }}
|
||||
when: oidc_provider_result.rc != 0
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
---
|
||||
# Configure Authentik password recovery flow
|
||||
# This creates a complete recovery flow with email verification and password complexity validation
|
||||
|
||||
- name: Use bootstrap token for API access
|
||||
set_fact:
|
||||
authentik_api_token: "{{ client_secrets.authentik_bootstrap_token }}"
|
||||
|
||||
- name: Copy recovery flow creation script to server
|
||||
copy:
|
||||
src: create_recovery_flow.py
|
||||
dest: /tmp/create_recovery_flow.py
|
||||
mode: '0755'
|
||||
|
||||
- name: Copy recovery flow script into Authentik container
|
||||
shell: docker cp /tmp/create_recovery_flow.py authentik-server:/tmp/create_recovery_flow.py
|
||||
changed_when: false
|
||||
|
||||
- name: Create recovery flow via Authentik API
|
||||
shell: |
|
||||
docker exec authentik-server python3 /tmp/create_recovery_flow.py "{{ authentik_api_token }}" "{{ authentik_domain }}"
|
||||
register: recovery_flow_result
|
||||
failed_when: false
|
||||
changed_when: "'Recovery Flow Configuration Complete' in recovery_flow_result.stdout"
|
||||
|
||||
- name: Cleanup recovery flow script from server
|
||||
file:
|
||||
path: /tmp/create_recovery_flow.py
|
||||
state: absent
|
||||
|
||||
- name: Cleanup recovery flow script from container
|
||||
shell: docker exec authentik-server rm -f /tmp/create_recovery_flow.py
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Parse recovery flow result
|
||||
set_fact:
|
||||
recovery_flow: "{{ recovery_flow_result.stdout | regex_search('\\{.*\\}', multiline=True) | from_json }}"
|
||||
when: recovery_flow_result.rc == 0
|
||||
failed_when: false
|
||||
|
||||
- name: Display recovery flow configuration result
|
||||
debug:
|
||||
msg: |
|
||||
========================================
|
||||
Authentik Password Recovery Flow
|
||||
========================================
|
||||
|
||||
{% if recovery_flow is defined and recovery_flow.success | default(false) %}
|
||||
Status: ✓ Configured Successfully
|
||||
|
||||
Recovery Flow UUID: {{ recovery_flow.recovery_flow_uuid }}
|
||||
Password Policy UUID: {{ recovery_flow.password_complexity_uuid }}
|
||||
|
||||
Features:
|
||||
- Password complexity: 12+ chars, mixed case, digit, symbol
|
||||
- Recovery email with 30-minute expiry token
|
||||
- Username + password on same login page
|
||||
- "Forgot password?" link on login page
|
||||
|
||||
Test Recovery Flow:
|
||||
1. Go to: https://{{ authentik_domain }}/if/flow/default-authentication-flow/
|
||||
2. Click "Forgot password?" link
|
||||
3. Enter username or email
|
||||
4. Check email for recovery link (sent via Mailgun)
|
||||
5. Set new password (must meet complexity requirements)
|
||||
|
||||
========================================
|
||||
{% else %}
|
||||
Status: ⚠ Configuration incomplete or failed
|
||||
|
||||
This is non-critical - recovery flow can be configured manually.
|
||||
|
||||
To configure manually:
|
||||
1. Login to https://{{ authentik_domain }}
|
||||
2. Go to Admin > Flows & Stages
|
||||
3. Create recovery flow with email verification
|
||||
|
||||
Details: {{ recovery_flow_result.stdout | default('No output') }}
|
||||
========================================
|
||||
{% endif %}
|
||||
|
||||
- name: Set recovery flow status fact
|
||||
set_fact:
|
||||
recovery_flow_configured: "{{ recovery_flow is defined and recovery_flow.success | default(false) }}"
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
services:
|
||||
authentik-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: authentik-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: "{{ authentik_db_name }}"
|
||||
POSTGRES_USER: "{{ authentik_db_user }}"
|
||||
POSTGRES_PASSWORD: "{{ client_secrets.authentik_db_password }}"
|
||||
|
||||
volumes:
|
||||
- authentik-db-data:/var/lib/postgresql/data
|
||||
|
||||
networks:
|
||||
- {{ authentik_network }}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d {{ authentik_db_name }} -U {{ authentik_db_user }}"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "0.5"
|
||||
|
||||
authentik-server:
|
||||
image: {{ authentik_image }}:{{ authentik_version }}
|
||||
container_name: authentik-server
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
# PostgreSQL connection
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__NAME: "{{ authentik_db_name }}"
|
||||
AUTHENTIK_POSTGRESQL__USER: "{{ authentik_db_user }}"
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: "{{ client_secrets.authentik_db_password }}"
|
||||
|
||||
# Secret key for encryption
|
||||
AUTHENTIK_SECRET_KEY: "{{ client_secrets.authentik_secret_key }}"
|
||||
|
||||
# Error reporting (optional)
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||
|
||||
# Branding
|
||||
AUTHENTIK_BRANDING__TITLE: "{{ client_name | title }} SSO"
|
||||
|
||||
# Email configuration
|
||||
{% if mailgun_smtp_user is defined or (client_secrets.mailgun_smtp_user is defined and client_secrets.mailgun_smtp_user != "" and "PLACEHOLDER" not in client_secrets.mailgun_smtp_user) %}
|
||||
AUTHENTIK_EMAIL__HOST: "smtp.eu.mailgun.org"
|
||||
AUTHENTIK_EMAIL__PORT: "587"
|
||||
AUTHENTIK_EMAIL__USERNAME: "{{ mailgun_smtp_user | default(client_secrets.mailgun_smtp_user) }}"
|
||||
AUTHENTIK_EMAIL__PASSWORD: "{{ mailgun_smtp_password | default(client_secrets.mailgun_smtp_password) }}"
|
||||
AUTHENTIK_EMAIL__USE_TLS: "true"
|
||||
AUTHENTIK_EMAIL__FROM: "Vrije Cloud <{{ inventory_hostname }}@mg.vrije.cloud>"
|
||||
{% else %}
|
||||
# Email not configured - set mailgun_smtp_user/password in secrets
|
||||
{% endif %}
|
||||
|
||||
volumes:
|
||||
- authentik-media:/media
|
||||
- authentik-templates:/templates
|
||||
|
||||
networks:
|
||||
- {{ authentik_traefik_network }}
|
||||
- {{ authentik_network }}
|
||||
|
||||
depends_on:
|
||||
authentik-db:
|
||||
condition: service_healthy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.authentik.rule=Host(`{{ authentik_domain }}`)"
|
||||
- "traefik.http.routers.authentik.tls=true"
|
||||
- "traefik.http.routers.authentik.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.authentik.entrypoints=websecure"
|
||||
- "traefik.http.services.authentik.loadbalancer.server.port={{ authentik_http_port }}"
|
||||
# Security headers
|
||||
- "traefik.http.routers.authentik.middlewares=authentik-headers"
|
||||
- "traefik.http.middlewares.authentik-headers.headers.stsSeconds=31536000"
|
||||
- "traefik.http.middlewares.authentik-headers.headers.stsIncludeSubdomains=true"
|
||||
- "traefik.http.middlewares.authentik-headers.headers.stsPreload=true"
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
cpus: "1.0"
|
||||
|
||||
authentik-worker:
|
||||
image: {{ authentik_image }}:{{ authentik_version }}
|
||||
container_name: authentik-worker
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
# PostgreSQL connection
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__NAME: "{{ authentik_db_name }}"
|
||||
AUTHENTIK_POSTGRESQL__USER: "{{ authentik_db_user }}"
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: "{{ client_secrets.authentik_db_password }}"
|
||||
|
||||
# Secret key for encryption (must match server)
|
||||
AUTHENTIK_SECRET_KEY: "{{ client_secrets.authentik_secret_key }}"
|
||||
|
||||
# Error reporting (optional)
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||
|
||||
# Bootstrap configuration (only used on first startup)
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: "{{ client_secrets.authentik_bootstrap_password }}"
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN: "{{ client_secrets.authentik_bootstrap_token }}"
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: "{{ client_secrets.authentik_bootstrap_email | default('admin@' + client_domain) }}"
|
||||
|
||||
# Email configuration (must match server)
|
||||
{% if mailgun_smtp_user is defined or (client_secrets.mailgun_smtp_user is defined and client_secrets.mailgun_smtp_user != "" and "PLACEHOLDER" not in client_secrets.mailgun_smtp_user) %}
|
||||
AUTHENTIK_EMAIL__HOST: "smtp.eu.mailgun.org"
|
||||
AUTHENTIK_EMAIL__PORT: "587"
|
||||
AUTHENTIK_EMAIL__USERNAME: "{{ mailgun_smtp_user | default(client_secrets.mailgun_smtp_user) }}"
|
||||
AUTHENTIK_EMAIL__PASSWORD: "{{ mailgun_smtp_password | default(client_secrets.mailgun_smtp_password) }}"
|
||||
AUTHENTIK_EMAIL__USE_TLS: "true"
|
||||
AUTHENTIK_EMAIL__FROM: "Vrije Cloud <{{ inventory_hostname }}@mg.vrije.cloud>"
|
||||
{% endif %}
|
||||
|
||||
volumes:
|
||||
- authentik-media:/media
|
||||
- authentik-templates:/templates
|
||||
|
||||
networks:
|
||||
- {{ authentik_traefik_network }}
|
||||
- {{ authentik_network }}
|
||||
|
||||
depends_on:
|
||||
authentik-db:
|
||||
condition: service_healthy
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "0.5"
|
||||
|
||||
volumes:
|
||||
authentik-db-data:
|
||||
driver: local
|
||||
authentik-media:
|
||||
driver: local
|
||||
authentik-templates:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
{{ authentik_traefik_network }}:
|
||||
external: true
|
||||
{{ authentik_network }}:
|
||||
driver: bridge
|
||||
internal: true
|
||||
|
|
@ -1,11 +1,6 @@
|
|||
---
|
||||
# Handlers for common role
|
||||
|
||||
- name: Restart systemd-resolved
|
||||
service:
|
||||
name: systemd-resolved
|
||||
state: restarted
|
||||
|
||||
- name: Restart SSH
|
||||
service:
|
||||
name: ssh
|
||||
|
|
|
|||
|
|
@ -1,28 +1,6 @@
|
|||
---
|
||||
# Main tasks for common role - base system setup and hardening
|
||||
|
||||
- name: Ensure systemd-resolved config directory exists
|
||||
file:
|
||||
path: /etc/systemd/resolved.conf.d
|
||||
state: directory
|
||||
mode: '0755'
|
||||
tags: [dns]
|
||||
|
||||
- name: Configure DNS (systemd-resolved)
|
||||
copy:
|
||||
dest: /etc/systemd/resolved.conf.d/dns_servers.conf
|
||||
content: |
|
||||
[Resolve]
|
||||
DNS=8.8.8.8 8.8.4.4
|
||||
FallbackDNS=1.1.1.1 1.0.0.1
|
||||
mode: '0644'
|
||||
notify: Restart systemd-resolved
|
||||
tags: [dns]
|
||||
|
||||
- name: Flush handlers (apply DNS config immediately)
|
||||
meta: flush_handlers
|
||||
tags: [dns]
|
||||
|
||||
- name: Update apt cache
|
||||
apt:
|
||||
update_cache: yes
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
---
|
||||
# Diun default configuration
|
||||
diun_version: "latest"
|
||||
diun_schedule: "0 6 * * *" # Daily at 6am UTC
|
||||
diun_log_level: "info"
|
||||
diun_watch_workers: 10
|
||||
|
||||
# Notification configuration
|
||||
diun_notif_enabled: true
|
||||
diun_notif_type: "webhook" # Options: webhook, slack, discord, email, gotify
|
||||
diun_webhook_endpoint: "" # Set per environment or via secrets
|
||||
diun_webhook_method: "POST"
|
||||
diun_webhook_headers: {}
|
||||
|
||||
# Optional: Slack notification
|
||||
diun_slack_webhook_url: ""
|
||||
|
||||
# Optional: Email notification (Mailgun)
|
||||
# Note: Uses per-client SMTP credentials from mailgun role
|
||||
diun_email_enabled: true
|
||||
diun_smtp_host: "smtp.eu.mailgun.org"
|
||||
diun_smtp_port: 587
|
||||
diun_smtp_from: "{{ client_name }}@mg.vrije.cloud"
|
||||
diun_smtp_to: "pieter@postxsociety.org"
|
||||
|
||||
# Which containers to watch
|
||||
diun_watch_all: true
|
||||
diun_exclude_containers: []
|
||||
|
||||
# Don't send notifications on first check (prevents spam on initial run)
|
||||
diun_first_check_notif: false
|
||||
|
||||
# Optional: Matrix notification
|
||||
diun_matrix_enabled: false
|
||||
diun_matrix_homeserver_url: "" # e.g., https://matrix.postxsociety.cloud
|
||||
diun_matrix_user: "" # e.g., @diun:matrix.postxsociety.cloud
|
||||
diun_matrix_password: "" # Bot user password (if using password auth)
|
||||
diun_matrix_access_token: "" # Bot access token (preferred over password)
|
||||
diun_matrix_room_id: "" # e.g., !abc123:matrix.postxsociety.cloud
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
- name: Restart Diun
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: /opt/docker/diun
|
||||
state: restarted
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
---
|
||||
- name: Set SMTP credentials from mailgun role facts or client_secrets
|
||||
set_fact:
|
||||
diun_smtp_username_final: "{{ mailgun_smtp_user | default(client_secrets.mailgun_smtp_user | default(client_name ~ '@mg.vrije.cloud')) }}"
|
||||
diun_smtp_password_final: "{{ mailgun_smtp_password | default(client_secrets.mailgun_smtp_password | default('')) }}"
|
||||
when: mailgun_smtp_user is defined or client_secrets.mailgun_smtp_user is defined or client_name is defined
|
||||
no_log: true
|
||||
|
||||
- name: Create monitoring Docker network
|
||||
community.docker.docker_network:
|
||||
name: monitoring
|
||||
state: present
|
||||
|
||||
- name: Create Diun directory
|
||||
file:
|
||||
path: /opt/docker/diun
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Create Diun data directory
|
||||
file:
|
||||
path: /opt/docker/diun/data
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Deploy Diun configuration
|
||||
template:
|
||||
src: diun.yml.j2
|
||||
dest: /opt/docker/diun/diun.yml
|
||||
mode: '0644'
|
||||
notify: Restart Diun
|
||||
|
||||
- name: Deploy Diun docker-compose.yml
|
||||
template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: /opt/docker/diun/docker-compose.yml
|
||||
mode: '0644'
|
||||
notify: Restart Diun
|
||||
|
||||
- name: Start Diun container
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: /opt/docker/diun
|
||||
state: present
|
||||
pull: always
|
||||
register: diun_deploy
|
||||
|
||||
- name: Wait for Diun to be healthy
|
||||
shell: docker inspect --format='{{"{{"}} .State.Status {{"}}"}}' diun
|
||||
register: diun_status
|
||||
until: diun_status.stdout == "running"
|
||||
retries: 5
|
||||
delay: 3
|
||||
changed_when: false
|
||||
|
||||
- name: Display Diun status
|
||||
debug:
|
||||
msg: "Diun is {{ diun_status.stdout }} on {{ inventory_hostname }}"
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
---
|
||||
# Diun configuration for {{ inventory_hostname }}
|
||||
# Documentation: https://crazymax.dev/diun/
|
||||
|
||||
db:
|
||||
path: /data/diun.db
|
||||
|
||||
watch:
|
||||
workers: {{ diun_watch_workers }}
|
||||
schedule: "{{ diun_schedule }}"
|
||||
firstCheckNotif: {{ diun_first_check_notif | lower }}
|
||||
|
||||
defaults:
|
||||
watchRepo: {{ diun_watch_repo | default(true) | lower }}
|
||||
notifyOn:
|
||||
- new
|
||||
- update
|
||||
|
||||
{% if diun_docker_hub_username is defined and diun_docker_hub_password is defined %}
|
||||
regopts:
|
||||
- selector: image
|
||||
username: {{ diun_docker_hub_username }}
|
||||
password: {{ diun_docker_hub_password }}
|
||||
{% endif %}
|
||||
|
||||
providers:
|
||||
docker:
|
||||
watchByDefault: {{ diun_watch_all | lower }}
|
||||
{% if diun_exclude_containers | length > 0 %}
|
||||
excludeContainers:
|
||||
{% for container in diun_exclude_containers %}
|
||||
- {{ container }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
notif:
|
||||
{% if diun_notif_enabled and diun_notif_type == 'webhook' and diun_webhook_endpoint %}
|
||||
webhook:
|
||||
endpoint: {{ diun_webhook_endpoint }}
|
||||
method: {{ diun_webhook_method }}
|
||||
timeout: 10s
|
||||
{% if diun_webhook_headers | length > 0 %}
|
||||
headers:
|
||||
{% for key, value in diun_webhook_headers.items() %}
|
||||
{{ key }}: {{ value }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if diun_slack_webhook_url %}
|
||||
slack:
|
||||
webhookURL: {{ diun_slack_webhook_url }}
|
||||
{% endif %}
|
||||
|
||||
{% if diun_email_enabled and diun_smtp_username_final is defined and diun_smtp_password_final is defined and diun_smtp_password_final != '' %}
|
||||
mail:
|
||||
host: {{ diun_smtp_host }}
|
||||
port: {{ diun_smtp_port }}
|
||||
ssl: false
|
||||
insecureSkipVerify: false
|
||||
username: {{ diun_smtp_username_final }}
|
||||
password: {{ diun_smtp_password_final }}
|
||||
from: {{ diun_smtp_from }}
|
||||
to: {{ diun_smtp_to }}
|
||||
{% endif %}
|
||||
|
||||
{% if diun_matrix_enabled and diun_matrix_homeserver_url and diun_matrix_user and diun_matrix_room_id %}
|
||||
matrix:
|
||||
homeserverURL: {{ diun_matrix_homeserver_url }}
|
||||
user: "{{ diun_matrix_user }}"
|
||||
{% if diun_matrix_access_token %}
|
||||
accessToken: {{ diun_matrix_access_token }}
|
||||
{% elif diun_matrix_password %}
|
||||
password: "{{ diun_matrix_password }}"
|
||||
{% endif %}
|
||||
roomID: "{{ diun_matrix_room_id }}"
|
||||
{% endif %}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
diun:
|
||||
image: crazymax/diun:{{ diun_version }}
|
||||
container_name: diun
|
||||
restart: unless-stopped
|
||||
command: serve
|
||||
volumes:
|
||||
- "./data:/data"
|
||||
- "./diun.yml:/diun.yml:ro"
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
environment:
|
||||
- TZ=UTC
|
||||
- LOG_LEVEL={{ diun_log_level }}
|
||||
labels:
|
||||
- "diun.enable=true"
|
||||
networks:
|
||||
- monitoring
|
||||
|
||||
networks:
|
||||
monitoring:
|
||||
name: monitoring
|
||||
external: true
|
||||
|
|
@ -66,13 +66,3 @@
|
|||
path: /opt/docker
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Login to Docker Hub (if credentials provided)
|
||||
community.docker.docker_login:
|
||||
username: "{{ shared_secrets.docker_hub_username }}"
|
||||
password: "{{ shared_secrets.docker_hub_password }}"
|
||||
state: present
|
||||
when:
|
||||
- shared_secrets.docker_hub_username is defined
|
||||
- shared_secrets.docker_hub_password is defined
|
||||
tags: [docker, docker-login]
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
---
|
||||
# Uptime Kuma monitoring registration
|
||||
kuma_enabled: true
|
||||
kuma_url: "https://status.vrije.cloud"
|
||||
|
||||
# Authentication - credentials loaded from shared_secrets in tasks/main.yml
|
||||
# Uses username/password (required for Socket.io API used by Python library)
|
||||
kuma_username: "" # Loaded from shared_secrets.kuma_username
|
||||
kuma_password: "" # Loaded from shared_secrets.kuma_password
|
||||
|
||||
# Monitors to create for each client
|
||||
kuma_monitors:
|
||||
- name: "{{ client_name }} - Authentik SSO"
|
||||
type: "http"
|
||||
url: "https://auth.{{ client_domain }}"
|
||||
method: "GET"
|
||||
interval: 60
|
||||
maxretries: 3
|
||||
retry_interval: 60
|
||||
expected_status: "200,302"
|
||||
|
||||
- name: "{{ client_name }} - Nextcloud"
|
||||
type: "http"
|
||||
url: "https://nextcloud.{{ client_domain }}"
|
||||
method: "GET"
|
||||
interval: 60
|
||||
maxretries: 3
|
||||
retry_interval: 60
|
||||
expected_status: "200,302"
|
||||
|
||||
- name: "{{ client_name }} - Collabora Office"
|
||||
type: "http"
|
||||
url: "https://office.{{ client_domain }}"
|
||||
method: "GET"
|
||||
interval: 60
|
||||
maxretries: 3
|
||||
retry_interval: 60
|
||||
expected_status: "200"
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
---
|
||||
# Register client services with Uptime Kuma monitoring
|
||||
# Uses uptime-kuma-api Python library with Socket.io
|
||||
|
||||
- name: Set Kuma credentials from shared secrets
|
||||
set_fact:
|
||||
kuma_username: "{{ shared_secrets.kuma_username | default('') }}"
|
||||
kuma_password: "{{ shared_secrets.kuma_password | default('') }}"
|
||||
when: shared_secrets is defined
|
||||
|
||||
- name: Check if Kuma monitoring is enabled
|
||||
set_fact:
|
||||
kuma_registration_enabled: "{{ (kuma_enabled | bool) and (kuma_url | length > 0) and (kuma_username | length > 0) and (kuma_password | length > 0) }}"
|
||||
|
||||
- name: Kuma registration block
|
||||
when: kuma_registration_enabled
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
block:
|
||||
- name: Ensure uptime-kuma-api Python package is installed
|
||||
pip:
|
||||
name: uptime-kuma-api
|
||||
state: present
|
||||
|
||||
- name: Create Kuma registration script
|
||||
template:
|
||||
src: register_monitors.py.j2
|
||||
dest: /tmp/kuma_register_{{ client_name }}.py
|
||||
mode: '0700'
|
||||
|
||||
- name: Register monitors with Uptime Kuma
|
||||
command: "{{ ansible_playbook_python }} /tmp/kuma_register_{{ client_name }}.py"
|
||||
register: kuma_result
|
||||
changed_when: "'Added' in kuma_result.stdout or 'Updated' in kuma_result.stdout"
|
||||
failed_when: kuma_result.rc != 0
|
||||
|
||||
- name: Display Kuma registration result
|
||||
debug:
|
||||
msg: "{{ kuma_result.stdout_lines }}"
|
||||
|
||||
- name: Cleanup registration script
|
||||
file:
|
||||
path: /tmp/kuma_register_{{ client_name }}.py
|
||||
state: absent
|
||||
|
||||
- name: Skip Kuma registration message
|
||||
debug:
|
||||
msg: "Kuma monitoring registration skipped (not enabled or missing credentials)"
|
||||
when: not kuma_registration_enabled
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Uptime Kuma Monitor Registration Script
|
||||
Auto-generated for client: {{ client_name }}
|
||||
"""
|
||||
|
||||
import sys
|
||||
from uptime_kuma_api import UptimeKumaApi, MonitorType
|
||||
|
||||
# Configuration
|
||||
KUMA_URL = "{{ kuma_url }}"
|
||||
KUMA_USERNAME = "{{ kuma_username | default('') }}"
|
||||
KUMA_PASSWORD = "{{ kuma_password | default('') }}"
|
||||
CLIENT_NAME = "{{ client_name }}"
|
||||
CLIENT_DOMAIN = "{{ client_domain }}"
|
||||
|
||||
# Monitor definitions
|
||||
MONITORS = {{ kuma_monitors | to_json }}
|
||||
|
||||
# Monitor type mapping
|
||||
TYPE_MAP = {
|
||||
"http": MonitorType.HTTP,
|
||||
"https": MonitorType.HTTP,
|
||||
"ping": MonitorType.PING,
|
||||
"tcp": MonitorType.PORT,
|
||||
"dns": MonitorType.DNS,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Register monitors with Uptime Kuma"""
|
||||
|
||||
# Check if credentials are provided
|
||||
if not KUMA_USERNAME or not KUMA_PASSWORD:
|
||||
print("⚠️ Kuma registration skipped: No credentials provided")
|
||||
print("")
|
||||
print("To enable automated monitor registration, add to your secrets:")
|
||||
print(" kuma_username: your_username")
|
||||
print(" kuma_password: your_password")
|
||||
print("")
|
||||
print("Note: API keys (uk1_*) are only for REST endpoints, not monitor management")
|
||||
print("Manual registration required at: https://status.vrije.cloud")
|
||||
sys.exit(0) # Exit with success (not a failure, just skipped)
|
||||
|
||||
try:
|
||||
# Connect to Uptime Kuma (Socket.io connection)
|
||||
print(f"🔌 Connecting to Uptime Kuma at {KUMA_URL}...")
|
||||
api = UptimeKumaApi(KUMA_URL)
|
||||
|
||||
# Login with username/password
|
||||
print(f"🔐 Authenticating as {KUMA_USERNAME}...")
|
||||
api.login(KUMA_USERNAME, KUMA_PASSWORD)
|
||||
|
||||
# Get existing monitors
|
||||
print("📋 Fetching existing monitors...")
|
||||
existing_monitors = api.get_monitors()
|
||||
existing_names = {m['name']: m['id'] for m in existing_monitors}
|
||||
|
||||
# Register each monitor
|
||||
added_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for monitor_config in MONITORS:
|
||||
monitor_name = monitor_config['name']
|
||||
monitor_type_str = monitor_config.get('type', 'http').lower()
|
||||
monitor_type = TYPE_MAP.get(monitor_type_str, MonitorType.HTTP)
|
||||
|
||||
# Build monitor parameters
|
||||
params = {
|
||||
'type': monitor_type,
|
||||
'name': monitor_name,
|
||||
'interval': monitor_config.get('interval', 60),
|
||||
'maxretries': monitor_config.get('maxretries', 3),
|
||||
'retryInterval': monitor_config.get('retry_interval', 60),
|
||||
}
|
||||
|
||||
# Add type-specific parameters
|
||||
if monitor_type == MonitorType.HTTP:
|
||||
params['url'] = monitor_config['url']
|
||||
params['method'] = monitor_config.get('method', 'GET')
|
||||
if 'expected_status' in monitor_config:
|
||||
params['accepted_statuscodes'] = monitor_config['expected_status'].split(',')
|
||||
elif monitor_type == MonitorType.PING:
|
||||
params['hostname'] = monitor_config.get('hostname', monitor_config.get('url', ''))
|
||||
|
||||
# Check if monitor already exists
|
||||
if monitor_name in existing_names:
|
||||
print(f"⚠️ Monitor '{monitor_name}' already exists (ID: {existing_monitors[monitor_name]})")
|
||||
print(f" Skipping (update not implemented)")
|
||||
skipped_count += 1
|
||||
else:
|
||||
print(f"➕ Adding monitor: {monitor_name}")
|
||||
try:
|
||||
result = api.add_monitor(**params)
|
||||
print(f" ✓ Added (ID: {result.get('monitorID', 'unknown')})")
|
||||
added_count += 1
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed: {e}")
|
||||
|
||||
# Disconnect
|
||||
api.disconnect()
|
||||
|
||||
# Summary
|
||||
print("")
|
||||
print("=" * 60)
|
||||
print(f"📊 Registration Summary for {CLIENT_NAME}:")
|
||||
print(f" Added: {added_count}")
|
||||
print(f" Skipped (already exist): {skipped_count}")
|
||||
print(f" Total monitors: {len(MONITORS)}")
|
||||
print("=" * 60)
|
||||
|
||||
if added_count > 0:
|
||||
print(f"✅ Successfully registered {added_count} new monitor(s)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ ERROR: Failed to register monitors: {e}")
|
||||
print("")
|
||||
print("Troubleshooting:")
|
||||
print(f" 1. Verify Kuma is accessible: {KUMA_URL}")
|
||||
print(" 2. Check username/password are correct")
|
||||
print(" 3. Ensure uptime-kuma-api Python package is installed")
|
||||
print(" 4. Check network connectivity from deployment machine")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
---
|
||||
# Delete Mailgun SMTP credential for a server
|
||||
|
||||
- name: Check if Mailgun API key is configured
|
||||
set_fact:
|
||||
mailgun_api_configured: "{{ client_secrets.mailgun_api_key is defined and client_secrets.mailgun_api_key != '' and 'PLACEHOLDER' not in client_secrets.mailgun_api_key }}"
|
||||
|
||||
- name: Delete SMTP credential for this server
|
||||
block:
|
||||
- name: Create Python script for Mailgun API credential deletion
|
||||
copy:
|
||||
content: |
|
||||
import sys, json, urllib.request, urllib.parse
|
||||
|
||||
domain = "mg.vrije.cloud"
|
||||
login = "{{ inventory_hostname }}@mg.vrije.cloud"
|
||||
api_key = "{{ client_secrets.mailgun_api_key }}"
|
||||
|
||||
# Delete SMTP credential via Mailgun API (EU region)
|
||||
url = f"https://api.eu.mailgun.net/v3/{domain}/credentials/{urllib.parse.quote(login)}"
|
||||
req = urllib.request.Request(url, method='DELETE')
|
||||
req.add_header('Authorization', f'Basic {__import__("base64").b64encode(f"api:{api_key}".encode()).decode()}')
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read())
|
||||
print(json.dumps({"success": True, "message": f"Deleted credential for {login}"}))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print(json.dumps({"success": True, "message": f"Credential {login} does not exist (already deleted)"}))
|
||||
else:
|
||||
error_data = e.read().decode()
|
||||
print(json.dumps({"success": False, "error": error_data}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
dest: /tmp/mailgun_delete_credential.py
|
||||
mode: '0700'
|
||||
delegate_to: localhost
|
||||
|
||||
- name: Execute Mailgun credential deletion
|
||||
command: python3 /tmp/mailgun_delete_credential.py
|
||||
register: mailgun_delete_result
|
||||
changed_when: true
|
||||
delegate_to: localhost
|
||||
failed_when: false
|
||||
|
||||
- name: Cleanup deletion script
|
||||
file:
|
||||
path: /tmp/mailgun_delete_credential.py
|
||||
state: absent
|
||||
delegate_to: localhost
|
||||
|
||||
- name: Display deletion result
|
||||
debug:
|
||||
msg: |
|
||||
========================================
|
||||
Mailgun SMTP Credential Deleted
|
||||
========================================
|
||||
|
||||
Server: {{ inventory_hostname }}
|
||||
Email: {{ inventory_hostname }}@mg.vrije.cloud
|
||||
Status: {{ (mailgun_delete_result.stdout | from_json).message }}
|
||||
========================================
|
||||
|
||||
when: mailgun_api_configured
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
---
|
||||
# Mailgun SMTP credential management via API
|
||||
|
||||
- name: Check if Mailgun API key is configured
|
||||
set_fact:
|
||||
mailgun_api_configured: "{{ client_secrets.mailgun_api_key is defined and client_secrets.mailgun_api_key != '' and 'PLACEHOLDER' not in client_secrets.mailgun_api_key }}"
|
||||
smtp_credentials_exist: "{{ client_secrets.mailgun_smtp_user is defined and client_secrets.mailgun_smtp_user != '' and 'PLACEHOLDER' not in client_secrets.mailgun_smtp_user and client_secrets.mailgun_smtp_password is defined and client_secrets.mailgun_smtp_password != '' }}"
|
||||
|
||||
- name: Use existing SMTP credentials from secrets (skip API creation)
|
||||
set_fact:
|
||||
mailgun_smtp_user: "{{ client_secrets.mailgun_smtp_user }}"
|
||||
mailgun_smtp_password: "{{ client_secrets.mailgun_smtp_password }}"
|
||||
when: smtp_credentials_exist
|
||||
no_log: true
|
||||
|
||||
- name: Create unique SMTP credential via Mailgun API
|
||||
when: mailgun_api_configured and not smtp_credentials_exist
|
||||
block:
|
||||
- name: Generate secure SMTP password
|
||||
shell: python3 -c "import secrets, string; print(''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32)))"
|
||||
register: generated_password
|
||||
changed_when: false
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
|
||||
- name: Create Python script for Mailgun API credential creation
|
||||
copy:
|
||||
content: |
|
||||
import sys, json, urllib.request
|
||||
|
||||
domain = "{{ 'mg.vrije.cloud' }}"
|
||||
login = "{{ inventory_hostname }}@mg.vrije.cloud"
|
||||
password = "{{ generated_password.stdout }}"
|
||||
api_key = "{{ client_secrets.mailgun_api_key }}"
|
||||
|
||||
# Create SMTP credential via Mailgun API (EU region)
|
||||
url = f"https://api.eu.mailgun.net/v3/domains/{domain}/credentials"
|
||||
data = urllib.parse.urlencode({'login': login, 'password': password}).encode()
|
||||
req = urllib.request.Request(url, data=data, method='POST')
|
||||
req.add_header('Authorization', f'Basic {__import__("base64").b64encode(f"api:{api_key}".encode()).decode()}')
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read())
|
||||
print(json.dumps({"success": True, "login": login, "password": password, "message": result.get("message", "Created")}))
|
||||
except urllib.error.HTTPError as e:
|
||||
error_data = e.read().decode()
|
||||
if "already exists" in error_data or e.code == 409:
|
||||
# Credential already exists - update password instead
|
||||
update_url = f"https://api.eu.mailgun.net/v3/domains/{domain}/credentials/{urllib.parse.quote(login)}"
|
||||
update_data = urllib.parse.urlencode({'password': password}).encode()
|
||||
update_req = urllib.request.Request(update_url, data=update_data, method='PUT')
|
||||
update_req.add_header('Authorization', f'Basic {__import__("base64").b64encode(f"api:{api_key}".encode()).decode()}')
|
||||
with urllib.request.urlopen(update_req, timeout=30) as resp:
|
||||
result = json.loads(resp.read())
|
||||
print(json.dumps({"success": True, "login": login, "password": password, "message": "Updated existing credential"}))
|
||||
else:
|
||||
print(json.dumps({"success": False, "error": error_data}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
dest: /tmp/mailgun_create_credential.py
|
||||
mode: '0700'
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
|
||||
- name: Execute Mailgun credential creation
|
||||
command: python3 /tmp/mailgun_create_credential.py
|
||||
register: mailgun_result
|
||||
changed_when: true
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
no_log: false
|
||||
|
||||
- name: Parse Mailgun API result
|
||||
set_fact:
|
||||
mailgun_credential: "{{ mailgun_result.stdout | from_json }}"
|
||||
no_log: true
|
||||
|
||||
- name: Cleanup credential creation script
|
||||
file:
|
||||
path: /tmp/mailgun_create_credential.py
|
||||
state: absent
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
|
||||
- name: Display credential creation result
|
||||
debug:
|
||||
msg: |
|
||||
========================================
|
||||
Mailgun SMTP Credential Created
|
||||
========================================
|
||||
|
||||
Server: {{ inventory_hostname }}
|
||||
Email: {{ mailgun_credential.login }}
|
||||
Status: {{ mailgun_credential.message }}
|
||||
|
||||
This credential is unique to this server for security isolation.
|
||||
========================================
|
||||
|
||||
- name: Store credentials in fact for email configuration
|
||||
set_fact:
|
||||
mailgun_smtp_user: "{{ mailgun_credential.login }}"
|
||||
mailgun_smtp_password: "{{ mailgun_credential.password }}"
|
||||
no_log: true
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# Default variables for nextcloud role
|
||||
|
||||
# Nextcloud version
|
||||
nextcloud_version: "latest" # Always use latest stable version
|
||||
nextcloud_version: "30" # Latest stable version (uses major version tag)
|
||||
|
||||
# Database configuration
|
||||
nextcloud_db_type: "pgsql"
|
||||
|
|
@ -22,12 +22,10 @@ nextcloud_redis_host: "nextcloud-redis"
|
|||
nextcloud_redis_port: "6379"
|
||||
|
||||
# OIDC configuration
|
||||
# Note: OIDC credentials are provided dynamically by the Authentik role
|
||||
# via /tmp/authentik_oidc_credentials.json during deployment
|
||||
nextcloud_oidc_enabled: true
|
||||
nextcloud_oidc_provider_url: "https://{{ authentik_domain }}"
|
||||
nextcloud_oidc_client_id: "" # Set dynamically from Authentik
|
||||
nextcloud_oidc_client_secret: "" # Set dynamically from Authentik
|
||||
nextcloud_oidc_provider_url: "https://{{ zitadel_domain }}"
|
||||
nextcloud_oidc_client_id: "" # Will be set after creating app in Zitadel
|
||||
nextcloud_oidc_client_secret: "" # Will be set after creating app in Zitadel
|
||||
|
||||
# Trusted domains (for Nextcloud config)
|
||||
nextcloud_trusted_domains:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
shell: docker exec -u www-data nextcloud php occ app:install richdocuments
|
||||
register: collabora_install
|
||||
changed_when: "'richdocuments installed' in collabora_install.stdout"
|
||||
failed_when: collabora_install.rc != 0 and 'already installed' not in collabora_install.stdout
|
||||
failed_when: collabora_install.rc != 0 and 'richdocuments already installed' not in collabora_install.stderr
|
||||
when: collabora_enabled | default(true)
|
||||
|
||||
- name: Enable Collabora Office app
|
||||
|
|
@ -20,8 +20,7 @@
|
|||
changed_when: true
|
||||
|
||||
- name: Get Nextcloud internal network info
|
||||
shell: |
|
||||
docker inspect nextcloud-internal -f {% raw %}'{{ .IPAM.Config }}'{% endraw %}
|
||||
shell: docker inspect nextcloud-internal -f '{{{{ .IPAM.Config }}}}'
|
||||
register: nextcloud_network
|
||||
changed_when: false
|
||||
when: collabora_enabled | default(true)
|
||||
|
|
@ -40,7 +39,7 @@
|
|||
- twofactor_backupcodes
|
||||
register: twofactor_install
|
||||
changed_when: "'installed' in twofactor_install.stdout"
|
||||
failed_when: twofactor_install.rc != 0 and 'already installed' not in twofactor_install.stdout
|
||||
failed_when: twofactor_install.rc != 0 and 'already installed' not in twofactor_install.stderr
|
||||
|
||||
- name: Enable two-factor authentication apps
|
||||
shell: docker exec -u www-data nextcloud php occ app:enable {{ item }}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,10 @@
|
|||
state: present
|
||||
register: nextcloud_deploy
|
||||
|
||||
- name: Wait for Nextcloud container to be ready
|
||||
shell: docker exec nextcloud sh -c 'until curl -f http://localhost:80 >/dev/null 2>&1; do sleep 2; done'
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: nextcloud_ready
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
timeout: 300
|
||||
- name: Wait for Nextcloud to be ready
|
||||
wait_for:
|
||||
host: localhost
|
||||
port: 80
|
||||
delay: 10
|
||||
timeout: 120
|
||||
when: nextcloud_deploy.changed
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
---
|
||||
# Configure Nextcloud email settings via Mailgun SMTP
|
||||
|
||||
- name: Determine SMTP credentials source
|
||||
set_fact:
|
||||
smtp_user: "{{ mailgun_smtp_user | default(client_secrets.mailgun_smtp_user) }}"
|
||||
smtp_password: "{{ mailgun_smtp_password | default(client_secrets.mailgun_smtp_password) }}"
|
||||
no_log: true
|
||||
|
||||
- name: Configure SMTP email settings
|
||||
shell: |
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtpmode --value="smtp"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtpsecure --value="tls"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtphost --value="smtp.eu.mailgun.org"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtpport --value="587"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtpauth --value="1"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtpname --value="{{ smtp_user }}"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_smtppassword --value="{{ smtp_password }}"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_from_address --value="{{ inventory_hostname }}"
|
||||
docker exec -u www-data nextcloud php occ config:system:set mail_domain --value="mg.vrije.cloud"
|
||||
no_log: true
|
||||
register: email_config
|
||||
changed_when: true
|
||||
|
||||
- name: Configure admin user email address
|
||||
shell: |
|
||||
docker exec -u www-data nextcloud php occ user:setting {{ client_secrets.nextcloud_admin_user }} settings email "{{ inventory_hostname }}@mg.vrije.cloud"
|
||||
register: admin_email_set
|
||||
changed_when: true
|
||||
|
||||
- name: Display email configuration status
|
||||
debug:
|
||||
msg: |
|
||||
========================================
|
||||
Nextcloud Email Configuration
|
||||
========================================
|
||||
|
||||
SMTP Host: smtp.eu.mailgun.org
|
||||
SMTP Port: 587 (TLS)
|
||||
From Address: {{ inventory_hostname }}@mg.vrije.cloud
|
||||
Admin Email: {{ inventory_hostname }}@mg.vrije.cloud
|
||||
|
||||
Status: ✓ Configured
|
||||
|
||||
Test: Settings → Basic settings → Send email
|
||||
========================================
|
||||
|
|
@ -26,11 +26,6 @@
|
|||
docker exec -u www-data nextcloud php occ config:system:set overwriteprotocol --value="https"
|
||||
docker exec -u www-data nextcloud php occ config:system:set overwritehost --value="{{ nextcloud_domain }}"
|
||||
docker exec -u www-data nextcloud php occ config:system:set overwrite.cli.url --value="https://{{ nextcloud_domain }}"
|
||||
docker exec -u www-data nextcloud php occ config:system:set trusted_proxies 0 --value="172.18.0.0/16"
|
||||
docker exec -u www-data nextcloud php occ config:system:set trusted_proxies 1 --value="172.19.0.0/16"
|
||||
docker exec -u www-data nextcloud php occ config:system:set trusted_proxies 2 --value="172.20.0.0/16"
|
||||
docker exec -u www-data nextcloud php occ config:system:set trusted_proxies 3 --value="172.21.0.0/16"
|
||||
docker exec -u www-data nextcloud php occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR"
|
||||
|
||||
- name: Configure Redis for caching
|
||||
shell: |
|
||||
|
|
@ -46,25 +41,3 @@
|
|||
- name: Run background jobs via cron
|
||||
shell: |
|
||||
docker exec -u www-data nextcloud php occ background:cron
|
||||
|
||||
- name: Add missing database indices
|
||||
shell: |
|
||||
docker exec -u www-data nextcloud php occ db:add-missing-indices
|
||||
register: add_indices
|
||||
changed_when: "'Adding' in add_indices.stdout"
|
||||
failed_when: false
|
||||
|
||||
- name: Run expensive maintenance repairs (mimetype migrations)
|
||||
shell: |
|
||||
docker exec -u www-data nextcloud php occ maintenance:repair --include-expensive
|
||||
register: maintenance_repair
|
||||
changed_when: "'mimetype' in maintenance_repair.stdout"
|
||||
failed_when: false
|
||||
async: 600
|
||||
poll: 10
|
||||
|
||||
- name: Clear any initial background job errors from log
|
||||
shell: |
|
||||
docker exec nextcloud truncate -s 0 /var/www/html/data/nextcloud.log
|
||||
when: maintenance_repair.changed
|
||||
changed_when: false
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
---
|
||||
# Main tasks for Nextcloud deployment
|
||||
|
||||
- name: Include volume mounting tasks
|
||||
include_tasks: mount-volume.yml
|
||||
tags:
|
||||
- nextcloud
|
||||
- volume
|
||||
|
||||
- name: Include Docker deployment tasks
|
||||
include_tasks: docker.yml
|
||||
tags:
|
||||
|
|
@ -31,10 +25,3 @@
|
|||
tags:
|
||||
- nextcloud
|
||||
- apps
|
||||
|
||||
- name: Include email configuration
|
||||
include_tasks: email.yml
|
||||
when: mailgun_smtp_user is defined or (client_secrets.mailgun_smtp_user is defined and client_secrets.mailgun_smtp_user != "" and "PLACEHOLDER" not in client_secrets.mailgun_smtp_user)
|
||||
tags:
|
||||
- nextcloud
|
||||
- email
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
---
|
||||
# Mount Hetzner Volume for Nextcloud Data Storage
|
||||
#
|
||||
# This task file handles mounting the Hetzner Volume that stores Nextcloud user data.
|
||||
# The volume is created and attached by OpenTofu, we just mount it here.
|
||||
|
||||
- name: Wait for volume device to appear
|
||||
wait_for:
|
||||
path: /dev/disk/by-id/
|
||||
timeout: 30
|
||||
register: disk_ready
|
||||
|
||||
- name: Find Nextcloud volume device
|
||||
shell: |
|
||||
ls -1 /dev/disk/by-id/scsi-0HC_Volume_* 2>/dev/null | head -1
|
||||
register: volume_device_result
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Set volume device fact
|
||||
set_fact:
|
||||
volume_device: "{{ volume_device_result.stdout }}"
|
||||
|
||||
- name: Display found volume device
|
||||
debug:
|
||||
msg: "Found Nextcloud volume at: {{ volume_device }}"
|
||||
|
||||
- name: Check if volume is already formatted
|
||||
shell: |
|
||||
blkid {{ volume_device }} | grep -q 'TYPE="ext4"'
|
||||
register: volume_formatted
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Format volume as ext4 if not formatted
|
||||
filesystem:
|
||||
fstype: ext4
|
||||
dev: "{{ volume_device }}"
|
||||
when: volume_formatted.rc != 0
|
||||
|
||||
- name: Create mount point directory
|
||||
file:
|
||||
path: /mnt/nextcloud-data
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Mount Nextcloud data volume
|
||||
mount:
|
||||
path: /mnt/nextcloud-data
|
||||
src: "{{ volume_device }}"
|
||||
fstype: ext4
|
||||
state: mounted
|
||||
opts: defaults,discard
|
||||
register: mount_result
|
||||
|
||||
- name: Ensure mount persists across reboots
|
||||
mount:
|
||||
path: /mnt/nextcloud-data
|
||||
src: "{{ volume_device }}"
|
||||
fstype: ext4
|
||||
state: present
|
||||
opts: defaults,discard
|
||||
|
||||
- name: Create Nextcloud data directory on volume
|
||||
file:
|
||||
path: /mnt/nextcloud-data/data
|
||||
state: directory
|
||||
owner: www-data
|
||||
group: www-data
|
||||
mode: '0750'
|
||||
|
||||
- name: Display mount success
|
||||
debug:
|
||||
msg: "Nextcloud volume successfully mounted at /mnt/nextcloud-data"
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
# OIDC/SSO integration tasks for Nextcloud with Authentik
|
||||
# OIDC/SSO integration tasks for Nextcloud with Zitadel
|
||||
|
||||
- name: Check if user_oidc app is installed
|
||||
shell: docker exec -u www-data nextcloud php occ app:list --output=json
|
||||
|
|
@ -20,71 +20,33 @@
|
|||
shell: docker exec -u www-data nextcloud php occ app:enable user_oidc
|
||||
when: not user_oidc_installed
|
||||
|
||||
- name: Check if Authentik OIDC credentials are available
|
||||
stat:
|
||||
path: /tmp/authentik_oidc_credentials.json
|
||||
register: oidc_creds_file
|
||||
|
||||
- name: Load OIDC credentials from Authentik
|
||||
slurp:
|
||||
path: /tmp/authentik_oidc_credentials.json
|
||||
register: oidc_creds_content
|
||||
when: oidc_creds_file.stat.exists
|
||||
|
||||
- name: Parse OIDC credentials
|
||||
set_fact:
|
||||
authentik_oidc: "{{ oidc_creds_content.content | b64decode | from_json }}"
|
||||
when: oidc_creds_file.stat.exists
|
||||
|
||||
- name: Check if OIDC provider is already configured
|
||||
shell: docker exec -u www-data nextcloud php occ user_oidc:provider
|
||||
register: oidc_providers
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Configure Authentik OIDC provider
|
||||
- name: Configure OIDC provider if credentials are available
|
||||
shell: |
|
||||
docker exec -u www-data nextcloud php occ user_oidc:provider \
|
||||
--clientid="{{ authentik_oidc.client_id }}" \
|
||||
--clientsecret="{{ authentik_oidc.client_secret }}" \
|
||||
--discoveryuri="{{ authentik_oidc.discovery_uri }}" \
|
||||
"Authentik"
|
||||
docker exec -u www-data nextcloud php occ user_oidc:provider:add \
|
||||
--clientid="{{ nextcloud_oidc_client_id }}" \
|
||||
--clientsecret="{{ nextcloud_oidc_client_secret }}" \
|
||||
--discoveryuri="https://{{ zitadel_domain }}/.well-known/openid-configuration" \
|
||||
"Zitadel"
|
||||
when:
|
||||
- authentik_oidc is defined
|
||||
- authentik_oidc.success | default(false)
|
||||
- "'Authentik' not in oidc_providers.stdout"
|
||||
- nextcloud_oidc_client_id is defined
|
||||
- nextcloud_oidc_client_secret is defined
|
||||
- "'Zitadel' not in oidc_providers.stdout"
|
||||
register: oidc_config
|
||||
changed_when: oidc_config.rc == 0
|
||||
|
||||
- name: Configure OIDC settings (allow native login + OIDC)
|
||||
shell: |
|
||||
docker exec -u www-data nextcloud php occ config:app:set user_oidc allow_multiple_user_backends --value=1
|
||||
docker exec -u www-data nextcloud php occ config:app:set user_oidc auto_provision --value=1
|
||||
docker exec -u www-data nextcloud php occ config:app:set user_oidc single_logout --value=0
|
||||
when:
|
||||
- authentik_oidc is defined
|
||||
- authentik_oidc.success | default(false)
|
||||
register: oidc_settings
|
||||
changed_when: oidc_settings.rc == 0
|
||||
|
||||
- name: Cleanup OIDC credentials file
|
||||
file:
|
||||
path: /tmp/authentik_oidc_credentials.json
|
||||
state: absent
|
||||
when: oidc_creds_file.stat.exists
|
||||
changed_when: "'Provider Zitadel has been created' in oidc_config.stdout"
|
||||
|
||||
- name: Display OIDC status
|
||||
debug:
|
||||
msg: |
|
||||
{% if authentik_oidc is defined and authentik_oidc.success | default(false) %}
|
||||
✓ OIDC SSO fully configured!
|
||||
Users can login with Authentik credentials at: https://{{ nextcloud_domain }}
|
||||
|
||||
"Login with Authentik" button should be visible on the login page.
|
||||
{% if nextcloud_oidc_client_id is defined %}
|
||||
OIDC SSO fully configured!
|
||||
Users can login with Zitadel credentials at: https://{{ nextcloud_domain }}
|
||||
{% else %}
|
||||
⚠ OIDC app installed but not yet configured.
|
||||
|
||||
To complete setup:
|
||||
1. Ensure Authentik API token is in secrets (authentik_api_token)
|
||||
2. Re-run deployment with: --tags authentik,oidc
|
||||
OIDC app installed but not yet configured.
|
||||
OIDC credentials will be configured automatically by Zitadel role.
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -10,17 +10,8 @@ services:
|
|||
POSTGRES_DB: {{ nextcloud_db_name }}
|
||||
POSTGRES_USER: {{ nextcloud_db_user }}
|
||||
POSTGRES_PASSWORD: {{ client_secrets.nextcloud_db_password }}
|
||||
# Grant full privileges to the user
|
||||
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256"
|
||||
command: >
|
||||
postgres
|
||||
-c shared_buffers=256MB
|
||||
-c max_connections=200
|
||||
-c shared_preload_libraries=''
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U {{ nextcloud_db_user }} -d {{ nextcloud_db_name }}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- nextcloud-internal
|
||||
|
||||
|
|
@ -44,8 +35,7 @@ services:
|
|||
- nextcloud-db
|
||||
- nextcloud-redis
|
||||
volumes:
|
||||
- nextcloud-app:/var/www/html
|
||||
- /mnt/nextcloud-data/data:/var/www/html/data # User data on Hetzner Volume
|
||||
- nextcloud-data:/var/www/html
|
||||
entrypoint: /cron.sh
|
||||
networks:
|
||||
- nextcloud-internal
|
||||
|
|
@ -59,8 +49,7 @@ services:
|
|||
- nextcloud-db
|
||||
- nextcloud-redis
|
||||
volumes:
|
||||
- nextcloud-app:/var/www/html
|
||||
- /mnt/nextcloud-data/data:/var/www/html/data # User data on Hetzner Volume
|
||||
- nextcloud-data:/var/www/html
|
||||
environment:
|
||||
# Database configuration
|
||||
POSTGRES_HOST: {{ nextcloud_db_host }}
|
||||
|
|
@ -126,18 +115,11 @@ services:
|
|||
image: collabora/code:latest
|
||||
container_name: collabora
|
||||
restart: unless-stopped
|
||||
# Required capabilities for optimal performance (bind-mount instead of copy)
|
||||
cap_add:
|
||||
- MKNOD
|
||||
- SYS_CHROOT
|
||||
environment:
|
||||
- domain={{ nextcloud_domain | regex_replace('\.', '\\.') }}
|
||||
- username={{ collabora_admin_user }}
|
||||
- password={{ client_secrets.collabora_admin_password }}
|
||||
# Performance tuning based on available CPU cores
|
||||
# num_prespawn_children: Number of child processes to keep started (default: 1)
|
||||
# per_document.max_concurrency: Max threads per document (should be <= CPU cores)
|
||||
- extra_params=--o:ssl.enable=false --o:ssl.termination=true --o:num_prespawn_children=1 --o:per_document.max_concurrency=2
|
||||
- extra_params=--o:ssl.enable=false --o:ssl.termination=true
|
||||
- MEMPROPORTION=60.0
|
||||
- MAX_DOCUMENTS=10
|
||||
- MAX_CONNECTIONS=20
|
||||
|
|
@ -176,6 +158,5 @@ volumes:
|
|||
name: nextcloud-db-data
|
||||
nextcloud-redis-data:
|
||||
name: nextcloud-redis-data
|
||||
nextcloud-app:
|
||||
name: nextcloud-app
|
||||
# Note: nextcloud-data volume removed - user data now stored on Hetzner Volume at /mnt/nextcloud-data
|
||||
nextcloud-data:
|
||||
name: nextcloud-data
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
# Default variables for traefik role
|
||||
|
||||
# Traefik version (v3.6.1+ fixes Docker API 1.44 compatibility with Docker 29+)
|
||||
traefik_version: "v3.6"
|
||||
# Traefik version (v3.2+ fixes Docker API compatibility)
|
||||
traefik_version: "v3.2"
|
||||
|
||||
# Let's Encrypt configuration
|
||||
traefik_acme_email: "admin@example.com" # Override this!
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ services:
|
|||
image: traefik:{{ traefik_version }}
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Fix Docker API version compatibility - use 1.44 for modern Docker
|
||||
- DOCKER_API_VERSION=1.44
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,66 @@
|
|||
# Traefik dynamic configuration
|
||||
# Managed by Ansible - Client-specific routes come from Docker labels
|
||||
# Managed by Ansible - do not edit manually
|
||||
|
||||
http:
|
||||
routers:
|
||||
# Zitadel identity provider
|
||||
zitadel:
|
||||
rule: "Host(`zitadel.test.vrije.cloud`)"
|
||||
service: zitadel
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- zitadel-headers
|
||||
|
||||
# Nextcloud file sync/share
|
||||
nextcloud:
|
||||
rule: "Host(`nextcloud.test.vrije.cloud`)"
|
||||
service: nextcloud
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- nextcloud-headers
|
||||
- nextcloud-redirectregex
|
||||
|
||||
services:
|
||||
# Zitadel service
|
||||
zitadel:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "h2c://zitadel:8080"
|
||||
|
||||
# Nextcloud service
|
||||
nextcloud:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://nextcloud:80"
|
||||
|
||||
middlewares:
|
||||
# Zitadel-specific headers
|
||||
zitadel-headers:
|
||||
headers:
|
||||
stsSeconds: 31536000
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
|
||||
# Nextcloud-specific headers
|
||||
nextcloud-headers:
|
||||
headers:
|
||||
stsSeconds: 31536000
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
|
||||
# CalDAV/CardDAV redirect for Nextcloud
|
||||
nextcloud-redirectregex:
|
||||
redirectRegex:
|
||||
permanent: true
|
||||
regex: "https://(.*)/.well-known/(card|cal)dav"
|
||||
replacement: "https://$1/remote.php/dav/"
|
||||
|
||||
# Security headers
|
||||
security-headers:
|
||||
headers:
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
# Client Registry
|
||||
|
||||
This directory contains the client registry system for tracking all deployed infrastructure.
|
||||
|
||||
## Files
|
||||
|
||||
- **[registry.yml](registry.yml)** - Single source of truth for all clients
|
||||
- Deployment status and lifecycle
|
||||
- Server specifications
|
||||
- Application versions
|
||||
- Maintenance history
|
||||
- Access URLs
|
||||
|
||||
## Management Scripts
|
||||
|
||||
All scripts are located in [`../scripts/`](../scripts/):
|
||||
|
||||
### View Clients
|
||||
|
||||
View the registry directly:
|
||||
```bash
|
||||
# View full registry
|
||||
cat registry.yml
|
||||
|
||||
# View specific client (requires yq)
|
||||
yq eval '.clients.dev' registry.yml
|
||||
```
|
||||
|
||||
### View Client Details
|
||||
|
||||
```bash
|
||||
# Show detailed status with live health checks
|
||||
../scripts/client-status.sh <client_name>
|
||||
```
|
||||
|
||||
### Update Registry
|
||||
|
||||
The registry is **automatically updated** by deployment scripts:
|
||||
- `deploy-client.sh` - Creates/updates entry on deployment
|
||||
- `rebuild-client.sh` - Updates entry on rebuild
|
||||
- `destroy-client.sh` - Marks as destroyed
|
||||
|
||||
For manual updates, edit `registry.yml` directly.
|
||||
|
||||
## Registry Structure
|
||||
|
||||
Each client entry tracks:
|
||||
- **Status**: `pending` → `deployed` → `maintenance` → `offboarding` → `destroyed`
|
||||
- **Role**: `canary` (testing) or `production` (live)
|
||||
- **Server**: Type, location, IP, Hetzner ID
|
||||
- **Apps**: Installed applications
|
||||
- **Versions**: Application and OS versions
|
||||
- **Maintenance**: Update and backup history
|
||||
- **URLs**: Access endpoints
|
||||
- **Notes**: Operational documentation
|
||||
|
||||
## Canary Deployment
|
||||
|
||||
The `dev` client has role `canary` and is used for testing:
|
||||
|
||||
```bash
|
||||
# 1. Test on canary first
|
||||
../scripts/deploy-client.sh dev
|
||||
|
||||
# 2. Verify it works
|
||||
../scripts/client-status.sh dev
|
||||
|
||||
# 3. Roll out to production clients manually
|
||||
# Review registry.yml for production clients, then rebuild each one
|
||||
```
|
||||
|
||||
## Registry Structure Details
|
||||
|
||||
The `registry.yml` file uses YAML format with the following structure:
|
||||
- Complete registry structure reference in the file itself
|
||||
- Client lifecycle states and metadata
|
||||
- Server specifications and IP addresses
|
||||
- Deployment timestamps and version tracking
|
||||
|
||||
## Requirements
|
||||
|
||||
- **yq**: YAML processor (`brew install yq`)
|
||||
- **jq**: JSON processor (`brew install jq`)
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
# Client Registry
|
||||
#
|
||||
# Single source of truth for all clients in the infrastructure.
|
||||
# This file tracks client lifecycle, deployment state, and versions.
|
||||
#
|
||||
# Status values:
|
||||
# - pending: Client configuration created, not yet deployed
|
||||
# - deployed: Client is live and operational
|
||||
# - maintenance: Under maintenance, may be temporarily unavailable
|
||||
# - offboarding: Being decommissioned
|
||||
# - destroyed: Infrastructure removed, secrets archived
|
||||
#
|
||||
# Role values:
|
||||
# - canary: Used for testing updates before production rollout
|
||||
# - production: Live client serving real users
|
||||
|
||||
clients:
|
||||
dev:
|
||||
status: deployed
|
||||
role: canary
|
||||
deployed_date: 2026-01-17
|
||||
destroyed_date: null
|
||||
|
||||
server:
|
||||
type: cpx22 # 3 vCPU, 4 GB RAM, 80 GB SSD
|
||||
location: fsn1 # Falkenstein, Germany
|
||||
ip: 78.47.191.38
|
||||
id: "117714358" # Hetzner server ID
|
||||
|
||||
apps:
|
||||
- authentik
|
||||
- nextcloud
|
||||
|
||||
versions:
|
||||
authentik: "2025.10.3"
|
||||
nextcloud: "30.0.17"
|
||||
traefik: "v3.0"
|
||||
ubuntu: "24.04"
|
||||
|
||||
maintenance:
|
||||
last_full_update: 2026-01-17
|
||||
last_security_patch: 2026-01-17
|
||||
last_os_update: 2026-01-17
|
||||
last_backup_verified: null
|
||||
|
||||
urls:
|
||||
authentik: "https://auth.dev.vrije.cloud"
|
||||
nextcloud: "https://nextcloud.dev.vrije.cloud"
|
||||
|
||||
notes: |
|
||||
Canary/test server. Used for testing updates before production rollout.
|
||||
Server was recreated on 2026-01-17 for per-client SSH key implementation.
|
||||
|
||||
# Add new clients here as they are deployed
|
||||
# Template:
|
||||
#
|
||||
# clientname:
|
||||
# status: deployed
|
||||
# role: production
|
||||
# deployed_date: YYYY-MM-DD
|
||||
# destroyed_date: null
|
||||
#
|
||||
# server:
|
||||
# type: cx22
|
||||
# location: nbg1
|
||||
# ip: 1.2.3.4
|
||||
# id: "12345678"
|
||||
#
|
||||
# apps:
|
||||
# - authentik
|
||||
# - nextcloud
|
||||
#
|
||||
# versions:
|
||||
# authentik: "2025.10.3"
|
||||
# nextcloud: "30.0.17"
|
||||
# traefik: "v3.0"
|
||||
# ubuntu: "24.04"
|
||||
#
|
||||
# maintenance:
|
||||
# last_full_update: YYYY-MM-DD
|
||||
# last_security_patch: YYYY-MM-DD
|
||||
# last_os_update: YYYY-MM-DD
|
||||
# last_backup_verified: null
|
||||
#
|
||||
# urls:
|
||||
# authentik: "https://auth.clientname.vrije.cloud"
|
||||
# nextcloud: "https://nextcloud.clientname.vrije.cloud"
|
||||
#
|
||||
# notes: ""
|
||||
808
docs/architecture-decisions.md
Normal file
808
docs/architecture-decisions.md
Normal file
|
|
@ -0,0 +1,808 @@
|
|||
# Infrastructure Architecture Decision Record
|
||||
|
||||
## Post-X Society Multi-Tenant VPS Platform
|
||||
|
||||
**Document Status:** Living document
|
||||
**Created:** December 2024
|
||||
**Last Updated:** December 2025
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document captures architectural decisions for a scalable, multi-tenant infrastructure platform starting with 10 identical VPS instances running Keycloak and Nextcloud, with plans to expand both server count and application offerings.
|
||||
|
||||
**Key Technology Choices:**
|
||||
- **OpenTofu** over Terraform (truly open source, MPL 2.0)
|
||||
- **SOPS + Age** over HashiCorp Vault (simple, no server, European-friendly)
|
||||
- **Hetzner** for all infrastructure (GDPR-compliant, EU-based)
|
||||
|
||||
---
|
||||
|
||||
## 1. Infrastructure Provisioning
|
||||
|
||||
### Decision: OpenTofu + Ansible with Dynamic Inventory
|
||||
|
||||
**Choice:** Infrastructure as Code using OpenTofu for resource provisioning and Ansible for configuration management.
|
||||
|
||||
**Why OpenTofu over Terraform:**
|
||||
- Truly open source (MPL 2.0) vs HashiCorp's BSL 1.1
|
||||
- Drop-in replacement - same syntax, same providers
|
||||
- Linux Foundation governance - no single company can close the license
|
||||
- Active community after HashiCorp's 2023 license change
|
||||
- No risk of future license restrictions
|
||||
|
||||
**Approach:**
|
||||
- **OpenTofu** manages Hetzner resources (VPS instances, networks, firewalls, DNS)
|
||||
- **Ansible** configures servers using the `hcloud` dynamic inventory plugin
|
||||
- No static inventory files - Ansible queries Hetzner API at runtime
|
||||
|
||||
**Rationale:**
|
||||
- 10+ identical servers makes manual management unsustainable
|
||||
- Version-controlled infrastructure in Git
|
||||
- Dynamic inventory eliminates sync issues between OpenTofu and Ansible
|
||||
- Skills transfer to other providers if needed
|
||||
|
||||
**Implementation:**
|
||||
```yaml
|
||||
# ansible.cfg
|
||||
[inventory]
|
||||
enable_plugins = hetzner.hcloud.hcloud
|
||||
|
||||
# hcloud.yml (inventory config)
|
||||
plugin: hetzner.hcloud.hcloud
|
||||
locations:
|
||||
- fsn1
|
||||
keyed_groups:
|
||||
- key: labels.role
|
||||
prefix: role
|
||||
- key: labels.client
|
||||
prefix: client
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Application Deployment
|
||||
|
||||
### Decision: Modular Ansible Roles with Feature Flags
|
||||
|
||||
**Choice:** Each application is a separate Ansible role, enabled per-server via inventory variables.
|
||||
|
||||
**Rationale:**
|
||||
- Allows heterogeneous deployments (client A wants Pretix, client B doesn't)
|
||||
- Test new applications on single server before fleet rollout
|
||||
- Clear separation of concerns
|
||||
- Minimal refactoring when adding new applications
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
ansible/
|
||||
├── roles/
|
||||
│ ├── common/ # Base setup, hardening, Docker
|
||||
│ ├── traefik/ # Reverse proxy, SSL
|
||||
│ ├── nextcloud/ # File sync and collaboration
|
||||
│ ├── pretix/ # Future: Event ticketing
|
||||
│ ├── listmonk/ # Future: Newsletter/mailing
|
||||
│ ├── backup/ # Restic configuration
|
||||
│ └── monitoring/ # Node exporter, promtail
|
||||
```
|
||||
|
||||
**Inventory Example:**
|
||||
```yaml
|
||||
all:
|
||||
children:
|
||||
clients:
|
||||
hosts:
|
||||
client-alpha:
|
||||
client_name: alpha
|
||||
domain: alpha.platform.nl
|
||||
apps:
|
||||
- nextcloud
|
||||
client-beta:
|
||||
client_name: beta
|
||||
domain: beta.platform.nl
|
||||
apps:
|
||||
- nextcloud
|
||||
- pretix
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DNS Management
|
||||
|
||||
### Decision: Hetzner DNS via OpenTofu
|
||||
|
||||
**Choice:** Manage all DNS records through Hetzner DNS using OpenTofu.
|
||||
|
||||
**Rationale:**
|
||||
- Single provider for infrastructure and DNS simplifies management
|
||||
- OpenTofu provider available and well-maintained (same as Terraform provider)
|
||||
- Cost-effective (included with Hetzner)
|
||||
- GDPR-compliant (EU-based)
|
||||
|
||||
**Domain Strategy:**
|
||||
- Start with subdomains: `{client}.platform.nl`
|
||||
- Support custom domains later via variable override
|
||||
- Wildcard approach not used - explicit records per service
|
||||
|
||||
**Implementation:**
|
||||
```hcl
|
||||
resource "hcloud_server" "client" {
|
||||
for_each = var.clients
|
||||
name = each.key
|
||||
server_type = each.value.server_type
|
||||
# ...
|
||||
}
|
||||
|
||||
resource "hetznerdns_record" "client_a" {
|
||||
for_each = var.clients
|
||||
zone_id = data.hetznerdns_zone.main.id
|
||||
name = each.value.subdomain
|
||||
type = "A"
|
||||
value = hcloud_server.client[each.key].ipv4_address
|
||||
}
|
||||
```
|
||||
|
||||
**SSL Certificates:** Handled by Traefik with Let's Encrypt, automatic per-domain.
|
||||
|
||||
---
|
||||
|
||||
## 4. Identity Provider
|
||||
|
||||
### Decision: Removed (previously Zitadel)
|
||||
|
||||
**Status:** Identity provider removed from architecture.
|
||||
|
||||
**Reason for Removal:**
|
||||
- Zitadel v2.63.7 has critical bugs with FirstInstance initialization
|
||||
- ALL `ZITADEL_FIRSTINSTANCE_*` environment variables cause database migration errors
|
||||
- Requires manual web UI setup for each instance (not scalable for multi-tenant deployment)
|
||||
- See: https://github.com/zitadel/zitadel/issues/8791
|
||||
|
||||
**Future Consideration:**
|
||||
- May revisit with Authentik or other identity providers when needed
|
||||
- Currently focusing on Nextcloud as standalone solution
|
||||
|
||||
---
|
||||
|
||||
## 4. Backup Strategy
|
||||
|
||||
### Decision: Dual Backup Approach
|
||||
|
||||
**Choice:** Hetzner automated snapshots + Restic application-level backups to Hetzner Storage Box.
|
||||
|
||||
#### Layer 1: Hetzner Snapshots
|
||||
|
||||
**Purpose:** Disaster recovery (complete server loss)
|
||||
|
||||
| Aspect | Configuration |
|
||||
|--------|---------------|
|
||||
| Frequency | Daily (Hetzner automated) |
|
||||
| Retention | 7 snapshots |
|
||||
| Cost | 20% of VPS price |
|
||||
| Restoration | Full server restore via Hetzner console/API |
|
||||
|
||||
**Limitations:**
|
||||
- Crash-consistent only (may catch database mid-write)
|
||||
- Same datacenter (not true off-site)
|
||||
- Coarse granularity (all or nothing)
|
||||
|
||||
#### Layer 2: Restic to Hetzner Storage Box
|
||||
|
||||
**Purpose:** Granular application recovery, off-server storage
|
||||
|
||||
**Backend Choice:** Hetzner Storage Box
|
||||
|
||||
**Rationale:**
|
||||
- GDPR-compliant (German/EU data residency)
|
||||
- Same Hetzner network = fast transfers, no egress costs
|
||||
- Cost-effective (~€3.81/month for BX10 with 1TB)
|
||||
- Supports SFTP, CIFS/Samba, rsync, Restic-native
|
||||
- Can be accessed from all VPSs simultaneously
|
||||
|
||||
**Storage Hierarchy:**
|
||||
```
|
||||
Storage Box (BX10 or larger)
|
||||
└── /backups/
|
||||
├── /client-alpha/
|
||||
│ ├── /restic-repo/ # Encrypted Restic repository
|
||||
│ └── /manual/ # Ad-hoc exports if needed
|
||||
├── /client-beta/
|
||||
│ └── /restic-repo/
|
||||
└── /client-gamma/
|
||||
└── /restic-repo/
|
||||
```
|
||||
|
||||
**Connection Method:**
|
||||
- Primary: SFTP (native Restic support, encrypted in transit)
|
||||
- Optional: CIFS mount for manual file access
|
||||
- Each client VPS gets Storage Box sub-account or uses main credentials with path restrictions
|
||||
|
||||
| Aspect | Configuration |
|
||||
|--------|---------------|
|
||||
| Frequency | Nightly (after DB dumps) |
|
||||
| Time | 03:00 local time |
|
||||
| Retention | 7 daily, 4 weekly, 6 monthly |
|
||||
| Encryption | Restic default (AES-256) |
|
||||
| Repo passwords | Stored in SOPS-encrypted files |
|
||||
|
||||
**What Gets Backed Up:**
|
||||
```
|
||||
/opt/docker/
|
||||
├── nextcloud/
|
||||
│ └── data/ # ✓ User files
|
||||
├── pretix/
|
||||
│ └── data/ # ✓ When applicable
|
||||
└── configs/ # ✓ docker-compose files, env
|
||||
```
|
||||
|
||||
**Backup Ansible Role Tasks:**
|
||||
1. Install Restic
|
||||
2. Initialize repo (if not exists)
|
||||
3. Configure SFTP connection to Storage Box
|
||||
4. Create pre-backup script (database dumps)
|
||||
5. Create backup script
|
||||
6. Create systemd timer
|
||||
7. Configure backup monitoring (alert on failure)
|
||||
|
||||
**Sizing Guidance:**
|
||||
- Start with BX10 (1TB) for 10 clients
|
||||
- Monitor usage monthly
|
||||
- Scale to BX20 (2TB) when approaching 70% capacity
|
||||
|
||||
**Verification:**
|
||||
- Weekly `restic check` via cron
|
||||
- Monthly test restore to staging environment
|
||||
- Alerts on backup job failures
|
||||
|
||||
---
|
||||
|
||||
## 5. Secrets Management
|
||||
|
||||
### Decision: SOPS + Age Encryption
|
||||
|
||||
**Choice:** File-based secrets encryption using SOPS with Age encryption, stored in Git.
|
||||
|
||||
**Why SOPS + Age over HashiCorp Vault:**
|
||||
- No additional server to maintain
|
||||
- Truly open source (MPL 2.0 for SOPS, Apache 2.0 for Age)
|
||||
- Secrets versioned alongside infrastructure code
|
||||
- Simple to understand and debug
|
||||
- Age developed with European privacy values (FiloSottile)
|
||||
- Perfect for 10-50 server scale
|
||||
- No vendor lock-in concerns
|
||||
|
||||
**How It Works:**
|
||||
1. Secrets stored in YAML files, encrypted with Age
|
||||
2. Only the values are encrypted, keys remain readable
|
||||
3. Decryption happens at Ansible runtime
|
||||
4. One Age key per environment (or shared across all)
|
||||
|
||||
**Example Encrypted File:**
|
||||
```yaml
|
||||
# secrets/client-alpha.sops.yaml
|
||||
db_password: ENC[AES256_GCM,data:kH3x9...,iv:abc...,tag:def...,type:str]
|
||||
keycloak_admin: ENC[AES256_GCM,data:mN4y2...,iv:ghi...,tag:jkl...,type:str]
|
||||
nextcloud_admin: ENC[AES256_GCM,data:pQ5z7...,iv:mno...,tag:pqr...,type:str]
|
||||
restic_repo_password: ENC[AES256_GCM,data:rS6a1...,iv:stu...,tag:vwx...,type:str]
|
||||
```
|
||||
|
||||
**Key Management:**
|
||||
```
|
||||
keys/
|
||||
├── age-key.txt # Master key (NEVER in Git, backed up securely)
|
||||
└── .sops.yaml # SOPS configuration (in Git)
|
||||
```
|
||||
|
||||
**.sops.yaml Configuration:**
|
||||
```yaml
|
||||
creation_rules:
|
||||
- path_regex: secrets/.*\.sops\.yaml$
|
||||
age: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
**Secret Structure:**
|
||||
```
|
||||
secrets/
|
||||
├── .sops.yaml # SOPS config
|
||||
├── shared.sops.yaml # Shared secrets (Storage Box, API tokens)
|
||||
└── clients/
|
||||
├── alpha.sops.yaml # Client-specific secrets
|
||||
├── beta.sops.yaml
|
||||
└── gamma.sops.yaml
|
||||
```
|
||||
|
||||
**Ansible Integration:**
|
||||
```yaml
|
||||
# Using community.sops collection
|
||||
- name: Load client secrets
|
||||
community.sops.load_vars:
|
||||
file: "secrets/clients/{{ client_name }}.sops.yaml"
|
||||
name: client_secrets
|
||||
|
||||
- name: Use decrypted secret
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: /opt/docker/docker-compose.yml
|
||||
vars:
|
||||
db_password: "{{ client_secrets.db_password }}"
|
||||
```
|
||||
|
||||
**Daily Operations:**
|
||||
```bash
|
||||
# Encrypt a new file
|
||||
sops --encrypt --age $(cat keys/age-key.pub) secrets/clients/new.yaml > secrets/clients/new.sops.yaml
|
||||
|
||||
# Edit existing secrets (decrypts, opens editor, re-encrypts)
|
||||
SOPS_AGE_KEY_FILE=keys/age-key.txt sops secrets/clients/alpha.sops.yaml
|
||||
|
||||
# View decrypted content
|
||||
SOPS_AGE_KEY_FILE=keys/age-key.txt sops --decrypt secrets/clients/alpha.sops.yaml
|
||||
```
|
||||
|
||||
**Key Backup Strategy:**
|
||||
- Age private key stored in password manager (Bitwarden/1Password)
|
||||
- Printed paper backup in secure location
|
||||
- Key never stored in Git repository
|
||||
- Consider key escrow for bus factor
|
||||
|
||||
**Advantages for Your Setup:**
|
||||
| Aspect | Benefit |
|
||||
|--------|---------|
|
||||
| Simplicity | No Vault server to maintain, secure, update |
|
||||
| Auditability | Git history shows who changed what secrets when |
|
||||
| Portability | Works offline, no network dependency |
|
||||
| Reliability | No secrets server = no secrets server downtime |
|
||||
| Cost | Zero infrastructure cost |
|
||||
|
||||
---
|
||||
|
||||
## 6. Monitoring
|
||||
|
||||
### Decision: Centralized Uptime Kuma
|
||||
|
||||
**Choice:** Uptime Kuma on dedicated monitoring server.
|
||||
|
||||
**Rationale:**
|
||||
- Simple to deploy and maintain
|
||||
- Beautiful UI for status overview
|
||||
- Flexible alerting (email, Slack, webhook)
|
||||
- Self-hosted (data stays in-house)
|
||||
- Sufficient for "is it up?" monitoring at current scale
|
||||
|
||||
**Deployment:**
|
||||
- Dedicated VPS or container on monitoring server
|
||||
- Monitors all client servers and services
|
||||
- Public status page optional per client
|
||||
|
||||
**Monitors per Client:**
|
||||
- HTTPS endpoint (Nextcloud)
|
||||
- TCP port checks (database, if exposed)
|
||||
- Docker container health (via API or agent)
|
||||
|
||||
**Alerting:**
|
||||
- Primary: Email
|
||||
- Secondary: Slack/Mattermost webhook
|
||||
- Escalation: SMS for extended downtime (future)
|
||||
|
||||
**Future Expansion Path:**
|
||||
When deeper metrics needed:
|
||||
1. Add Prometheus + Node Exporter
|
||||
2. Add Grafana dashboards
|
||||
3. Add Loki for log aggregation
|
||||
4. Uptime Kuma remains for synthetic monitoring
|
||||
|
||||
---
|
||||
|
||||
## 7. Client Isolation
|
||||
|
||||
### Decision: Full Isolation
|
||||
|
||||
**Choice:** Maximum isolation between clients at all levels.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
| Layer | Isolation Method |
|
||||
|-------|------------------|
|
||||
| Compute | Separate VPS per client |
|
||||
| Network | Hetzner firewall rules, no inter-VPS traffic |
|
||||
| Database | Separate PostgreSQL container per client |
|
||||
| Storage | Separate Docker volumes |
|
||||
| Backups | Separate Restic repositories |
|
||||
| Secrets | Separate SOPS files per client |
|
||||
| DNS | Separate records/domains |
|
||||
|
||||
**Network Rules:**
|
||||
- Each VPS accepts traffic only on 80, 443, 22 (management IP only)
|
||||
- No private network between client VPSs
|
||||
- Monitoring server can reach all clients (outbound checks)
|
||||
|
||||
**Rationale:**
|
||||
- Security: Compromise of one client cannot spread
|
||||
- Compliance: Data separation demonstrable
|
||||
- Operations: Can maintain/upgrade clients independently
|
||||
- Billing: Clear resource attribution
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment Strategy
|
||||
|
||||
### Decision: Canary Deployments with Version Pinning
|
||||
|
||||
**Choice:** Staged rollouts with explicit version control.
|
||||
|
||||
#### Version Pinning
|
||||
|
||||
All container images use explicit tags:
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
nextcloud:
|
||||
image: nextcloud:28.0.1 # Never use :latest
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:23.0.1
|
||||
postgres:
|
||||
image: postgres:16.1
|
||||
```
|
||||
|
||||
Version updates require explicit change and commit.
|
||||
|
||||
#### Canary Process
|
||||
|
||||
**Inventory Groups:**
|
||||
```yaml
|
||||
all:
|
||||
children:
|
||||
canary:
|
||||
hosts:
|
||||
client-alpha: # Designated test client (internal or willing partner)
|
||||
production:
|
||||
hosts:
|
||||
client-beta:
|
||||
client-gamma:
|
||||
# ... remaining clients
|
||||
```
|
||||
|
||||
**Deployment Script:**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Deploying to canary ==="
|
||||
ansible-playbook deploy.yml --limit canary
|
||||
|
||||
echo "=== Waiting for verification ==="
|
||||
read -p "Canary OK? Proceed to production? [y/N] " confirm
|
||||
if [[ $confirm != "y" ]]; then
|
||||
echo "Deployment aborted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Deploying to production ==="
|
||||
ansible-playbook deploy.yml --limit production
|
||||
```
|
||||
|
||||
#### Rollback Procedures
|
||||
|
||||
**Scenario 1: Bad container version**
|
||||
```bash
|
||||
# Revert version in docker-compose
|
||||
git revert HEAD
|
||||
# Redeploy
|
||||
ansible-playbook deploy.yml --limit affected_hosts
|
||||
```
|
||||
|
||||
**Scenario 2: Database migration issue**
|
||||
```bash
|
||||
# Restore from pre-upgrade Restic backup
|
||||
restic -r sftp:user@backup-server:/client-x/restic-repo restore latest --target /tmp/restore
|
||||
# Restore database dump
|
||||
psql < /tmp/restore/db-dumps/keycloak.sql
|
||||
# Revert and redeploy application
|
||||
```
|
||||
|
||||
**Scenario 3: Complete server failure**
|
||||
```bash
|
||||
# Restore Hetzner snapshot via API
|
||||
hcloud server rebuild <server-id> --image <snapshot-id>
|
||||
# Or via OpenTofu
|
||||
tofu apply -replace="hcloud_server.client[\"affected\"]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Security Baseline
|
||||
|
||||
### Decision: Comprehensive Hardening
|
||||
|
||||
All servers receive the `common` Ansible role with:
|
||||
|
||||
#### SSH Hardening
|
||||
```yaml
|
||||
# /etc/ssh/sshd_config (managed by Ansible)
|
||||
PermitRootLogin: no
|
||||
PasswordAuthentication: no
|
||||
PubkeyAuthentication: yes
|
||||
AllowUsers: deploy
|
||||
```
|
||||
|
||||
#### Firewall (UFW)
|
||||
```yaml
|
||||
- 22/tcp: Management IPs only
|
||||
- 80/tcp: Any (redirects to 443)
|
||||
- 443/tcp: Any
|
||||
- All other: Deny
|
||||
```
|
||||
|
||||
#### Automatic Updates
|
||||
```yaml
|
||||
# unattended-upgrades configuration
|
||||
Unattended-Upgrade::Allowed-Origins {
|
||||
"${distro_id}:${distro_codename}-security";
|
||||
};
|
||||
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
|
||||
Unattended-Upgrade::Automatic-Reboot "false"; # Manual reboot control
|
||||
```
|
||||
|
||||
#### Fail2ban
|
||||
```yaml
|
||||
# Jails enabled
|
||||
- sshd
|
||||
- traefik-auth (custom, for repeated 401s)
|
||||
```
|
||||
|
||||
#### Container Security
|
||||
```yaml
|
||||
# Trivy scanning in CI/CD
|
||||
- Scan images before deployment
|
||||
- Block critical vulnerabilities
|
||||
- Weekly scheduled scans of running containers
|
||||
```
|
||||
|
||||
#### Additional Measures
|
||||
- No password authentication anywhere
|
||||
- Secrets encrypted with SOPS + Age, never plaintext in Git
|
||||
- Regular dependency updates via Dependabot/Renovate
|
||||
- SSH keys rotated annually
|
||||
|
||||
---
|
||||
|
||||
## 10. Onboarding Procedure
|
||||
|
||||
### New Client Checklist
|
||||
|
||||
```markdown
|
||||
## Client Onboarding: {CLIENT_NAME}
|
||||
|
||||
### Prerequisites
|
||||
- [ ] Client agreement signed
|
||||
- [ ] Domain/subdomain confirmed: _______________
|
||||
- [ ] Contact email: _______________
|
||||
- [ ] Desired applications: [ ] Keycloak [ ] Nextcloud [ ] Pretix [ ] Listmonk
|
||||
|
||||
### Infrastructure
|
||||
- [ ] Add client to `tofu/variables.tf`
|
||||
- [ ] Add client to `ansible/inventory/clients.yml`
|
||||
- [ ] Create secrets file: `sops secrets/clients/{name}.sops.yaml`
|
||||
- [ ] Create Storage Box subdirectory for backups
|
||||
- [ ] Run: `tofu apply`
|
||||
- [ ] Run: `ansible-playbook playbooks/setup.yml --limit {client}`
|
||||
|
||||
### Verification
|
||||
- [ ] HTTPS accessible
|
||||
- [ ] Nextcloud admin login works
|
||||
- [ ] Backup job runs successfully
|
||||
- [ ] Monitoring checks green
|
||||
|
||||
### Handover
|
||||
- [ ] Send credentials securely (1Password link, Signal, etc.)
|
||||
- [ ] Schedule onboarding call if needed
|
||||
- [ ] Add to status page (if applicable)
|
||||
- [ ] Document any custom configuration
|
||||
|
||||
### Estimated Time: 30-45 minutes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Offboarding Procedure
|
||||
|
||||
### Client Removal Checklist
|
||||
|
||||
```markdown
|
||||
## Client Offboarding: {CLIENT_NAME}
|
||||
|
||||
### Pre-Offboarding
|
||||
- [ ] Confirm termination date: _______________
|
||||
- [ ] Data export requested? [ ] Yes [ ] No
|
||||
- [ ] Final invoice sent
|
||||
|
||||
### Data Export (if requested)
|
||||
- [ ] Export Nextcloud data
|
||||
- [ ] Confirm receipt
|
||||
|
||||
### Infrastructure Removal
|
||||
- [ ] Disable monitoring checks (set maintenance mode first)
|
||||
- [ ] Create final backup (retain per policy)
|
||||
- [ ] Remove from Ansible inventory
|
||||
- [ ] Remove from OpenTofu config
|
||||
- [ ] Run: `tofu apply` (destroys VPS)
|
||||
- [ ] Remove DNS records (automatic via OpenTofu)
|
||||
- [ ] Remove/archive SOPS secrets file
|
||||
|
||||
### Backup Retention
|
||||
- [ ] Move Restic repo to archive path
|
||||
- [ ] Set deletion date: _______ (default: 90 days post-termination)
|
||||
- [ ] Schedule deletion job
|
||||
|
||||
### Cleanup
|
||||
- [ ] Remove from status page
|
||||
- [ ] Update client count in documentation
|
||||
- [ ] Archive client folder in documentation
|
||||
|
||||
### Verification
|
||||
- [ ] DNS no longer resolves
|
||||
- [ ] IP returns nothing
|
||||
- [ ] Monitoring shows no alerts (host removed)
|
||||
- [ ] Billing stopped
|
||||
|
||||
### Estimated Time: 15-30 minutes
|
||||
```
|
||||
|
||||
### Data Retention Policy
|
||||
|
||||
| Data Type | Retention Post-Offboarding |
|
||||
|-----------|---------------------------|
|
||||
| Application data (Restic) | 90 days |
|
||||
| Hetzner snapshots | Deleted immediately (with VPS) |
|
||||
| SOPS secrets files | Archived 90 days, then deleted |
|
||||
| Logs | 30 days |
|
||||
| Invoices/contracts | 7 years (legal requirement) |
|
||||
|
||||
---
|
||||
|
||||
## 12. Repository Structure
|
||||
|
||||
```
|
||||
infrastructure/
|
||||
├── README.md
|
||||
├── docs/
|
||||
│ ├── architecture-decisions.md # This document
|
||||
│ ├── runbook.md # Operational procedures
|
||||
│ └── clients/ # Per-client notes
|
||||
│ ├── alpha.md
|
||||
│ └── beta.md
|
||||
├── tofu/ # OpenTofu configuration
|
||||
│ ├── main.tf
|
||||
│ ├── variables.tf
|
||||
│ ├── outputs.tf
|
||||
│ ├── dns.tf
|
||||
│ ├── firewall.tf
|
||||
│ └── versions.tf
|
||||
├── ansible/
|
||||
│ ├── ansible.cfg
|
||||
│ ├── hcloud.yml # Dynamic inventory config
|
||||
│ ├── playbooks/
|
||||
│ │ ├── setup.yml # Initial server setup
|
||||
│ │ ├── deploy.yml # Deploy/update applications
|
||||
│ │ ├── upgrade.yml # System updates
|
||||
│ │ └── backup-restore.yml # Manual backup/restore
|
||||
│ ├── roles/
|
||||
│ │ ├── common/
|
||||
│ │ ├── docker/
|
||||
│ │ ├── traefik/
|
||||
│ │ ├── nextcloud/
|
||||
│ │ ├── backup/
|
||||
│ │ └── monitoring-agent/
|
||||
│ └── group_vars/
|
||||
│ └── all.yml
|
||||
├── secrets/ # SOPS-encrypted secrets
|
||||
│ ├── .sops.yaml # SOPS configuration
|
||||
│ ├── shared.sops.yaml # Shared secrets
|
||||
│ └── clients/
|
||||
│ ├── alpha.sops.yaml
|
||||
│ └── beta.sops.yaml
|
||||
├── docker/
|
||||
│ ├── docker-compose.base.yml # Common services
|
||||
│ └── docker-compose.apps.yml # Application services
|
||||
└── scripts/
|
||||
├── deploy.sh # Canary deployment wrapper
|
||||
├── onboard-client.sh
|
||||
└── offboard-client.sh
|
||||
```
|
||||
|
||||
**Note:** The Age private key (`age-key.txt`) is NOT stored in this repository. It must be:
|
||||
- Stored in a password manager
|
||||
- Backed up securely offline
|
||||
- Available on deployment machine only
|
||||
|
||||
---
|
||||
|
||||
## 13. Open Decisions / Future Considerations
|
||||
|
||||
### To Decide Later
|
||||
- [ ] Identity provider (Authentik or other) - if SSO needed
|
||||
- [ ] Prometheus metrics - when/if needed
|
||||
- [ ] Custom domain SSL workflow
|
||||
- [ ] Client self-service portal
|
||||
|
||||
### Scaling Triggers
|
||||
- **20+ servers:** Consider Kubernetes or Nomad
|
||||
- **Multi-region:** Add OpenTofu workspaces per region
|
||||
- **Team growth:** Consider moving from SOPS to Infisical for better access control
|
||||
- **Complex secret rotation:** May need dedicated secrets server
|
||||
|
||||
---
|
||||
|
||||
## 14. Technology Choices Rationale
|
||||
|
||||
### Why We Chose Open Source / European-Friendly Tools
|
||||
|
||||
| Tool | Chosen | Avoided | Reason |
|
||||
|------|--------|---------|--------|
|
||||
| IaC | OpenTofu | Terraform | BSL license concerns, HashiCorp trust issues |
|
||||
| Secrets | SOPS + Age | HashiCorp Vault | Simplicity, no US vendor dependency, truly open source |
|
||||
| Identity | (Removed) | Keycloak/Zitadel | Removed due to complexity; may add Authentik in future |
|
||||
| Hosting | Hetzner | AWS/GCP/Azure | EU-based, cost-effective, GDPR-compliant |
|
||||
| Backup | Restic + Hetzner Storage Box | Cloud backup services | Open source, EU data residency |
|
||||
|
||||
**Guiding Principles:**
|
||||
1. Prefer truly open source (OSI-approved) over source-available
|
||||
2. Prefer EU-based services for GDPR simplicity
|
||||
3. Avoid vendor lock-in where practical
|
||||
4. Choose simplicity appropriate to scale (10-50 servers)
|
||||
|
||||
---
|
||||
|
||||
## 15. Development Environment and Tooling
|
||||
|
||||
### Decision: Isolated Python Environments with pipx
|
||||
|
||||
**Choice:** Use `pipx` for installing Python CLI tools (Ansible) in isolated virtual environments.
|
||||
|
||||
**Why pipx:**
|
||||
- Prevents dependency conflicts between tools
|
||||
- Each tool has its own Python environment
|
||||
- No interference with system Python packages
|
||||
- Easy to upgrade/rollback individual tools
|
||||
- Modern best practice for Python CLI tools
|
||||
|
||||
**Implementation:**
|
||||
```bash
|
||||
# Install pipx
|
||||
brew install pipx
|
||||
pipx ensurepath
|
||||
|
||||
# Install Ansible in isolation
|
||||
pipx install --include-deps ansible
|
||||
|
||||
# Inject additional dependencies as needed
|
||||
pipx inject ansible requests python-dateutil
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
| Aspect | Benefit |
|
||||
|--------|---------|
|
||||
| Isolation | No conflicts with other Python tools |
|
||||
| Reproducibility | Each team member gets same isolated environment |
|
||||
| Maintainability | Easy to upgrade Ansible without breaking other tools |
|
||||
| Clean system | No pollution of system Python packages |
|
||||
|
||||
**Alternatives Considered:**
|
||||
- **Homebrew Ansible** - Rejected: Can conflict with system Python, harder to manage dependencies
|
||||
- **System pip install** - Rejected: Pollutes global Python environment
|
||||
- **Manual venv** - Rejected: More manual work, pipx automates this
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2024-12 | Initial architecture decisions | Pieter / Claude |
|
||||
| 2024-12 | Added Hetzner Storage Box as Restic backend | Pieter / Claude |
|
||||
| 2024-12 | Switched from Terraform to OpenTofu (licensing concerns) | Pieter / Claude |
|
||||
| 2024-12 | Switched from HashiCorp Vault to SOPS + Age (simplicity, open source) | Pieter / Claude |
|
||||
| 2024-12 | Switched from Keycloak to Zitadel (Swiss company, GDPR jurisdiction) | Pieter / Claude |
|
||||
| 2026-01 | Removed Zitadel due to FirstInstance bugs; may add Authentik in future | Pieter / Claude |
|
||||
```
|
||||
7
keys/ssh/.gitignore
vendored
7
keys/ssh/.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
|||
# NEVER commit SSH private keys
|
||||
*
|
||||
|
||||
# Allow README and public keys only
|
||||
!.gitignore
|
||||
!README.md
|
||||
!*.pub
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
# SSH Keys Directory
|
||||
|
||||
This directory contains **per-client SSH key pairs** for server access.
|
||||
|
||||
## Purpose
|
||||
|
||||
Each client gets a dedicated SSH key pair to ensure:
|
||||
- **Isolation**: Compromise of one client ≠ access to others
|
||||
- **Granular control**: Rotate or revoke keys per-client
|
||||
- **Security**: Defense in depth, minimize blast radius
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
keys/ssh/
|
||||
├── .gitignore # Protects private keys from git
|
||||
├── README.md # This file
|
||||
├── dev # Private key for dev server (gitignored)
|
||||
├── dev.pub # Public key for dev server (committed)
|
||||
├── client1 # Private key for client1 (gitignored)
|
||||
└── client1.pub # Public key for client1 (committed)
|
||||
```
|
||||
|
||||
## Generating Keys
|
||||
|
||||
Use the helper script:
|
||||
|
||||
```bash
|
||||
./scripts/generate-client-keys.sh <client_name>
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f keys/ssh/<client_name> -C "client-<client_name>-deploy-key" -N ""
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### What Gets Committed
|
||||
|
||||
- ✅ **Public keys** (`*.pub`) - Safe to commit
|
||||
- ✅ **README.md** - Documentation
|
||||
- ✅ **`.gitignore`** - Protection rules
|
||||
|
||||
### What NEVER Gets Committed
|
||||
|
||||
- ❌ **Private keys** (no `.pub` extension) - Gitignored
|
||||
- ❌ **Temporary files** - Gitignored
|
||||
- ❌ **Backup keys** - Gitignored
|
||||
|
||||
The `.gitignore` file in this directory ensures private keys are never committed:
|
||||
|
||||
```gitignore
|
||||
# NEVER commit SSH private keys
|
||||
*
|
||||
|
||||
# Allow README and public keys only
|
||||
!.gitignore
|
||||
!README.md
|
||||
!*.pub
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
**⚠️ IMPORTANT: Backup private keys securely!**
|
||||
|
||||
Private keys must be backed up to prevent lockout:
|
||||
|
||||
1. **Password Manager** (Recommended):
|
||||
- Store in 1Password, Bitwarden, etc.
|
||||
- Tag with client name and server IP
|
||||
|
||||
2. **Encrypted Archive**:
|
||||
```bash
|
||||
tar czf - keys/ssh/ | gpg -c > ssh-keys-backup.tar.gz.gpg
|
||||
```
|
||||
|
||||
3. **Team Vault**:
|
||||
- Share securely with team members who need access
|
||||
- Document key ownership
|
||||
|
||||
## Usage
|
||||
|
||||
### SSH Connection
|
||||
|
||||
```bash
|
||||
# Connect to client server
|
||||
ssh -i keys/ssh/dev root@<server_ip>
|
||||
|
||||
# Run command
|
||||
ssh -i keys/ssh/dev root@<server_ip> "docker ps"
|
||||
```
|
||||
|
||||
### Ansible
|
||||
|
||||
Ansible automatically uses the correct key (via dynamic inventory and OpenTofu):
|
||||
|
||||
```bash
|
||||
ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit dev
|
||||
```
|
||||
|
||||
### SSH Config
|
||||
|
||||
Add to `~/.ssh/config` for convenience:
|
||||
|
||||
```
|
||||
Host dev.vrije.cloud
|
||||
User root
|
||||
IdentityFile ~/path/to/infrastructure/keys/ssh/dev
|
||||
```
|
||||
|
||||
Then: `ssh dev.vrije.cloud`
|
||||
|
||||
## Key Rotation
|
||||
|
||||
Rotate keys annually or on security events:
|
||||
|
||||
```bash
|
||||
# Generate new key (backs up old automatically)
|
||||
./scripts/generate-client-keys.sh dev
|
||||
|
||||
# Apply to server (recreates server with new key)
|
||||
cd tofu && tofu apply
|
||||
|
||||
# Test new key
|
||||
ssh -i keys/ssh/dev root@<new_ip> hostname
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
### Check Key Fingerprint
|
||||
|
||||
```bash
|
||||
# Show fingerprint of private key
|
||||
ssh-keygen -lf keys/ssh/dev
|
||||
|
||||
# Show fingerprint of public key
|
||||
ssh-keygen -lf keys/ssh/dev.pub
|
||||
|
||||
# Should match!
|
||||
```
|
||||
|
||||
### Check What's in Git
|
||||
|
||||
```bash
|
||||
# Verify no private keys committed
|
||||
git ls-files keys/ssh/
|
||||
|
||||
# Should only show:
|
||||
# keys/ssh/.gitignore
|
||||
# keys/ssh/README.md
|
||||
# keys/ssh/*.pub
|
||||
```
|
||||
|
||||
### Check Permissions
|
||||
|
||||
```bash
|
||||
# Private keys must be 600
|
||||
ls -la keys/ssh/dev
|
||||
|
||||
# Should show: -rw------- (600)
|
||||
|
||||
# Fix if needed:
|
||||
chmod 600 keys/ssh/*
|
||||
chmod 644 keys/ssh/*.pub
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Permission denied (publickey)"
|
||||
|
||||
1. Check you're using the correct private key for the client
|
||||
2. Verify public key is on server (check OpenTofu state)
|
||||
3. Ensure private key has correct permissions (600)
|
||||
|
||||
### "No such file or directory"
|
||||
|
||||
Generate the key first:
|
||||
```bash
|
||||
./scripts/generate-client-keys.sh <client_name>
|
||||
```
|
||||
|
||||
### "Bad permissions"
|
||||
|
||||
Fix key permissions:
|
||||
```bash
|
||||
chmod 600 keys/ssh/<client_name>
|
||||
chmod 644 keys/ssh/<client_name>.pub
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [../docs/ssh-key-management.md](../../docs/ssh-key-management.md) - Complete SSH key management guide
|
||||
- [../../scripts/generate-client-keys.sh](../../scripts/generate-client-keys.sh) - Key generation script
|
||||
- [../../tofu/main.tf](../../tofu/main.tf) - OpenTofu SSH key resources
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILKuSYRVVWCYqjNvJ5pHZTErkmVbEb1g3ac8olXUcXy7 client-bever-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGsGfzhrcVtYEn2YHzxVGibBDXPd571unltfOaVo5JlR client-das-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE75mnMfKHTIeq5Hp8LKaKYHGbzdFke1a9N7e0UEMNBu client-egel-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAa4QHMVKnTSS/q5kptQYzas7ln2MbgE5Db47GM2DjRI client-haas-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICtZzQTzNWLcFi4NNqg6l53kqPVDsgau1O7GWWKwZh9l client-kikker-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPXF5COMplFqwxCRymXN7y4b+RWiBbVQpIMmFoK10qgh client-kraai-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDGPPukFDhM4eIolsowRsD6jYrNYoM3/B9yLi2KNqmPi client-mees-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHAsLbdkl0peC15KnxhSsCI45Z2FwQu2Hy1LArzHoXu5 client-mol-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINAoeg3LDX5zRuw5Yt5WwbYNRXo70H7e5OYE3oMbJRyL client-mus-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG3edQhsIBD9Ers7wuFWSww8r3ROkKNJF8YcxgRtQdov client-otter-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB4QOkx75M28l7JAkQPl8bLjGuV/kKDFQINkUGRVRgIk client-ree-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAXFskaLenHy4FJHUZL2gpehFUAYaUdNfwP0BTMqp4La client-specht-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEhDcLx3ZaBXSHbhOoAgb5sI5xUVJwZEXl2HYq5+eRID client-uil-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILLDJCSNj3OZDDwGgoWSxy17K8DmJ8eqUXQ4Wmu/vRtG client-valk-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDg8F6LIVfdBdhD/CiNavs+xfFSiu9jxMmZcyigskuIQ client-vos-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKUcrgfG+JWtieySkcSZNyBehf/rB0YEQ35IQ93L+HHP client-wolf-deploy-key
|
||||
|
|
@ -1 +0,0 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG76TbSdY1o5T7PlzGkbfu0HNGOKsiW5vtbAKLDz0BGv client-zwaan-deploy-key
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
# Management Scripts
|
||||
|
||||
Automated scripts for managing client infrastructure.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Set SOPS Age key location (optional, scripts use default):
|
||||
|
||||
```bash
|
||||
export SOPS_AGE_KEY_FILE="./keys/age-key.txt"
|
||||
```
|
||||
|
||||
**Note**: The Hetzner API token is now automatically loaded from SOPS-encrypted `secrets/shared.sops.yaml`. No need to manually set `HCLOUD_TOKEN`.
|
||||
|
||||
## Scripts
|
||||
|
||||
### 1. Deploy Fresh Client
|
||||
|
||||
**Purpose**: Deploy a brand new client from scratch
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
./scripts/deploy-client.sh <client_name>
|
||||
```
|
||||
|
||||
**What it does** (automatically):
|
||||
1. **Generates SSH key** (if missing) - Unique per-client key pair
|
||||
2. **Creates secrets file** (if missing) - From template, opens in editor
|
||||
3. Provisions VPS server (if not exists)
|
||||
4. Sets up base system (Docker, Traefik)
|
||||
5. Deploys Authentik + Nextcloud
|
||||
6. Configures SSO integration automatically
|
||||
|
||||
**Time**: ~10-15 minutes
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
# Just run the script - it handles everything!
|
||||
./scripts/deploy-client.sh newclient
|
||||
|
||||
# Script will:
|
||||
# 1. Generate keys/ssh/newclient + keys/ssh/newclient.pub
|
||||
# 2. Copy secrets/clients/template.sops.yaml → secrets/clients/newclient.sops.yaml
|
||||
# 3. Open SOPS editor for you to customize secrets
|
||||
# 4. Continue with deployment
|
||||
```
|
||||
|
||||
**Requirements**:
|
||||
- Client must be defined in `tofu/terraform.tfvars`
|
||||
- SOPS Age key available at `keys/age-key.txt` (or set `SOPS_AGE_KEY_FILE`)
|
||||
|
||||
---
|
||||
|
||||
### 2. Rebuild Client
|
||||
|
||||
**Purpose**: Destroy and recreate a client's infrastructure from scratch
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
./scripts/rebuild-client.sh <client_name>
|
||||
```
|
||||
|
||||
**What it does**:
|
||||
1. Destroys existing infrastructure (asks for confirmation)
|
||||
2. Provisions new VPS server
|
||||
3. Sets up base system
|
||||
4. Deploys applications
|
||||
5. Configures SSO
|
||||
|
||||
**Time**: ~10-15 minutes
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
./scripts/rebuild-client.sh test
|
||||
```
|
||||
|
||||
**Warning**: This is **destructive** - all data on the server will be lost!
|
||||
|
||||
---
|
||||
|
||||
### 3. Destroy Client
|
||||
|
||||
**Purpose**: Completely remove a client's infrastructure
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
./scripts/destroy-client.sh <client_name>
|
||||
```
|
||||
|
||||
**What it does**:
|
||||
1. Stops and removes all Docker containers
|
||||
2. Removes all Docker volumes
|
||||
3. Destroys VPS server via OpenTofu
|
||||
4. Removes DNS records
|
||||
|
||||
**Time**: ~2-3 minutes
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
./scripts/destroy-client.sh test
|
||||
```
|
||||
|
||||
**Warning**: This is **destructive and irreversible**! All data will be lost.
|
||||
|
||||
**Note**: Secrets file is preserved after destruction.
|
||||
|
||||
---
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
### Deploy a New Client (Fully Automated)
|
||||
|
||||
```bash
|
||||
# 1. Add to terraform.tfvars
|
||||
vim tofu/terraform.tfvars
|
||||
# Add:
|
||||
# newclient = {
|
||||
# server_type = "cx22"
|
||||
# location = "fsn1"
|
||||
# subdomain = "newclient"
|
||||
# apps = ["authentik", "nextcloud"]
|
||||
# }
|
||||
|
||||
# 2. Deploy (script handles SSH key + secrets automatically)
|
||||
./scripts/deploy-client.sh newclient
|
||||
|
||||
# That's it! Script will:
|
||||
# - Generate SSH key if missing
|
||||
# - Create secrets file from template if missing (opens editor)
|
||||
# - Deploy everything
|
||||
```
|
||||
|
||||
### Test Changes (Rebuild)
|
||||
|
||||
```bash
|
||||
# Make changes to Ansible roles/playbooks
|
||||
|
||||
# Test by rebuilding
|
||||
./scripts/rebuild-client.sh test
|
||||
|
||||
# Verify changes worked
|
||||
```
|
||||
|
||||
### Clean Up
|
||||
|
||||
```bash
|
||||
# Remove test infrastructure
|
||||
./scripts/destroy-client.sh test
|
||||
```
|
||||
|
||||
## Script Output
|
||||
|
||||
All scripts provide:
|
||||
- ✓ Colored output (green = success, yellow = warning, red = error)
|
||||
- Progress indicators for each step
|
||||
- Total time taken
|
||||
- Service URLs and credentials
|
||||
- Next steps guidance
|
||||
|
||||
## Error Handling
|
||||
|
||||
Scripts will exit if:
|
||||
- Required environment variables not set
|
||||
- Secrets file doesn't exist
|
||||
- Confirmation not provided (for destructive operations)
|
||||
- Any command fails (set -e)
|
||||
|
||||
## Safety Features
|
||||
|
||||
### Destroy Script
|
||||
- Requires typing client name to confirm
|
||||
- Shows what will be deleted
|
||||
- Preserves secrets file
|
||||
|
||||
### Rebuild Script
|
||||
- Asks for confirmation before destroying
|
||||
- 10-second delay after destroy before rebuilding
|
||||
- Shows existing infrastructure before proceeding
|
||||
|
||||
### Deploy Script
|
||||
- Checks for existing infrastructure
|
||||
- Skips provisioning if server exists
|
||||
- Validates secrets file exists
|
||||
|
||||
## Integration with CI/CD
|
||||
|
||||
These scripts can be used in automation:
|
||||
|
||||
```bash
|
||||
# Non-interactive deployment
|
||||
export SOPS_AGE_KEY_FILE="..."
|
||||
|
||||
./scripts/deploy-client.sh production
|
||||
```
|
||||
|
||||
For rebuild (skip confirmation):
|
||||
```bash
|
||||
# Modify rebuild-client.sh to accept --yes flag
|
||||
./scripts/rebuild-client.sh production --yes
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Script fails with "HCLOUD_TOKEN not set"
|
||||
|
||||
The token should be automatically loaded from SOPS. If this fails:
|
||||
|
||||
1. Ensure SOPS Age key is available:
|
||||
```bash
|
||||
export SOPS_AGE_KEY_FILE="./keys/age-key.txt"
|
||||
ls -la keys/age-key.txt
|
||||
```
|
||||
|
||||
2. Verify token is in shared secrets:
|
||||
```bash
|
||||
sops -d secrets/shared.sops.yaml | grep hcloud_token
|
||||
```
|
||||
|
||||
3. Manually load secrets:
|
||||
```bash
|
||||
source scripts/load-secrets-env.sh
|
||||
```
|
||||
|
||||
### Script fails with "Secrets file not found"
|
||||
|
||||
Create the secrets file:
|
||||
```bash
|
||||
cp secrets/clients/test.sops.yaml secrets/clients/<client>.sops.yaml
|
||||
sops secrets/clients/<client>.sops.yaml
|
||||
```
|
||||
|
||||
### Server not reachable during destroy
|
||||
|
||||
This is normal if server is already destroyed. The script will skip Docker cleanup and proceed to OpenTofu destroy.
|
||||
|
||||
### OpenTofu state conflicts
|
||||
|
||||
If multiple people are managing infrastructure:
|
||||
```bash
|
||||
cd tofu
|
||||
tofu state pull
|
||||
tofu state push
|
||||
```
|
||||
|
||||
Consider using remote state (S3, Terraform Cloud, etc.)
|
||||
|
||||
## Performance
|
||||
|
||||
Typical timings:
|
||||
|
||||
| Operation | Time |
|
||||
|-----------|------|
|
||||
| Deploy fresh | 10-15 min |
|
||||
| Rebuild | 10-15 min |
|
||||
| Destroy | 2-3 min |
|
||||
|
||||
Breakdown:
|
||||
- Infrastructure provisioning: 2 min
|
||||
- Server initialization: 1 min
|
||||
- Base system setup: 3 min
|
||||
- Application deployment: 5-7 min
|
||||
|
||||
## See Also
|
||||
|
||||
- [AUTOMATION_STATUS.md](../docs/AUTOMATION_STATUS.md) - Full automation details
|
||||
- [sso-automation.md](../docs/sso-automation.md) - SSO integration workflow
|
||||
- [architecture-decisions.md](../docs/architecture-decisions.md) - Design decisions
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Add client monitors to Uptime Kuma
|
||||
#
|
||||
# Usage: ./scripts/add-client-to-monitoring.sh <client_name>
|
||||
#
|
||||
# This script creates HTTP(S) and SSL monitors for a client's services
|
||||
# Currently uses manual instructions - future: use Uptime Kuma API
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Check arguments
|
||||
if [ $# -ne 1 ]; then
|
||||
echo -e "${RED}Error: Client name required${NC}"
|
||||
echo "Usage: $0 <client_name>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLIENT_NAME="$1"
|
||||
BASE_DOMAIN="vrije.cloud"
|
||||
|
||||
# Calculate URLs
|
||||
AUTH_URL="https://auth.${CLIENT_NAME}.${BASE_DOMAIN}"
|
||||
NEXTCLOUD_URL="https://nextcloud.${CLIENT_NAME}.${BASE_DOMAIN}"
|
||||
AUTH_DOMAIN="auth.${CLIENT_NAME}.${BASE_DOMAIN}"
|
||||
NEXTCLOUD_DOMAIN="nextcloud.${CLIENT_NAME}.${BASE_DOMAIN}"
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE}Add Client to Monitoring${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Client: ${CLIENT_NAME}${NC}"
|
||||
echo ""
|
||||
|
||||
# TODO: Implement automated monitor creation via Uptime Kuma API
|
||||
# For now, provide manual instructions
|
||||
|
||||
echo -e "${YELLOW}Manual Setup Required:${NC}"
|
||||
echo ""
|
||||
echo "Please add the following monitors in Uptime Kuma:"
|
||||
echo "🔗 Access: https://status.vrije.cloud"
|
||||
echo ""
|
||||
echo -e "${GREEN}HTTP(S) Monitors:${NC}"
|
||||
echo ""
|
||||
echo "1. ${CLIENT_NAME} - Authentik"
|
||||
echo " Type: HTTP(S)"
|
||||
echo " URL: ${AUTH_URL}"
|
||||
echo " Interval: 300 seconds (5 min)"
|
||||
echo " Retries: 3"
|
||||
echo ""
|
||||
echo "2. ${CLIENT_NAME} - Nextcloud"
|
||||
echo " Type: HTTP(S)"
|
||||
echo " URL: ${NEXTCLOUD_URL}"
|
||||
echo " Interval: 300 seconds (5 min)"
|
||||
echo " Retries: 3"
|
||||
echo ""
|
||||
echo -e "${GREEN}SSL Certificate Monitors:${NC}"
|
||||
echo ""
|
||||
echo "3. ${CLIENT_NAME} - Authentik SSL"
|
||||
echo " Type: Certificate Expiry"
|
||||
echo " Hostname: ${AUTH_DOMAIN}"
|
||||
echo " Port: 443"
|
||||
echo " Expiry Days: 30"
|
||||
echo " Interval: 86400 seconds (1 day)"
|
||||
echo ""
|
||||
echo "4. ${CLIENT_NAME} - Nextcloud SSL"
|
||||
echo " Type: Certificate Expiry"
|
||||
echo " Hostname: ${NEXTCLOUD_DOMAIN}"
|
||||
echo " Port: 443"
|
||||
echo " Expiry Days: 30"
|
||||
echo " Interval: 86400 seconds (1 day)"
|
||||
echo ""
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note: Automated monitor creation via API is planned for future enhancement.${NC}"
|
||||
echo ""
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Add a new client to OpenTofu configuration
|
||||
#
|
||||
# Usage: ./scripts/add-client-to-terraform.sh <client_name> [options]
|
||||
#
|
||||
# Options:
|
||||
# --server-type=TYPE Server type (default: cpx22)
|
||||
# --location=LOC Data center location (default: fsn1)
|
||||
# --volume-size=SIZE Nextcloud volume size in GB (default: 100)
|
||||
# --apps=APP1,APP2 Applications to deploy (default: zitadel,nextcloud)
|
||||
# --non-interactive Don't prompt, use defaults
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
TFVARS_FILE="$PROJECT_ROOT/tofu/terraform.tfvars"
|
||||
|
||||
# Check arguments
|
||||
if [ $# -lt 1 ]; then
|
||||
echo -e "${RED}Error: Client name required${NC}"
|
||||
echo "Usage: $0 <client_name> [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --server-type=TYPE Server type (default: cpx22)"
|
||||
echo " --location=LOC Data center (default: fsn1)"
|
||||
echo " --volume-size=SIZE Nextcloud volume GB (default: 100)"
|
||||
echo " --apps=APP1,APP2 Apps (default: zitadel,nextcloud)"
|
||||
echo " --non-interactive Use defaults, don't prompt"
|
||||
echo ""
|
||||
echo "Example: $0 blue --server-type=cx22 --location=nbg1 --volume-size=50"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLIENT_NAME="$1"
|
||||
shift
|
||||
|
||||
# Default values
|
||||
SERVER_TYPE="cpx22"
|
||||
LOCATION="fsn1"
|
||||
VOLUME_SIZE="100"
|
||||
APPS="authentik,nextcloud"
|
||||
PRIVATE_IP=""
|
||||
PUBLIC_IP_ENABLED="false"
|
||||
NON_INTERACTIVE=false
|
||||
|
||||
# Parse options
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--server-type=*)
|
||||
SERVER_TYPE="${arg#*=}"
|
||||
;;
|
||||
--location=*)
|
||||
LOCATION="${arg#*=}"
|
||||
;;
|
||||
--volume-size=*)
|
||||
VOLUME_SIZE="${arg#*=}"
|
||||
;;
|
||||
--apps=*)
|
||||
APPS="${arg#*=}"
|
||||
;;
|
||||
--private-ip=*)
|
||||
PRIVATE_IP="${arg#*=}"
|
||||
;;
|
||||
--public-ip)
|
||||
PUBLIC_IP_ENABLED="true"
|
||||
;;
|
||||
--non-interactive)
|
||||
NON_INTERACTIVE=true
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown option: $arg${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Auto-assign private IP if not provided
|
||||
if [ -z "$PRIVATE_IP" ]; then
|
||||
# Find the highest existing IP in terraform.tfvars and increment
|
||||
LAST_IP=$(grep -oP 'private_ip\s*=\s*"10\.0\.0\.\K\d+' "$TFVARS_FILE" 2>/dev/null | sort -n | tail -1)
|
||||
if [ -z "$LAST_IP" ]; then
|
||||
NEXT_IP=40 # Start from 10.0.0.40 (edge is .2)
|
||||
else
|
||||
NEXT_IP=$((LAST_IP + 1))
|
||||
fi
|
||||
PRIVATE_IP="10.0.0.$NEXT_IP"
|
||||
echo -e "${BLUE}Auto-assigned private IP: $PRIVATE_IP${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Validate client name
|
||||
if [[ ! "$CLIENT_NAME" =~ ^[a-z0-9-]+$ ]]; then
|
||||
echo -e "${RED}Error: Client name must contain only lowercase letters, numbers, and hyphens${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if tfvars file exists
|
||||
if [ ! -f "$TFVARS_FILE" ]; then
|
||||
echo -e "${RED}Error: terraform.tfvars not found at $TFVARS_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if client already exists
|
||||
if grep -q "^[[:space:]]*${CLIENT_NAME}[[:space:]]*=" "$TFVARS_FILE"; then
|
||||
echo -e "${YELLOW}⚠ Client '${CLIENT_NAME}' already exists in terraform.tfvars${NC}"
|
||||
echo ""
|
||||
echo "Existing configuration:"
|
||||
grep -A 7 "^[[:space:]]*${CLIENT_NAME}[[:space:]]*=" "$TFVARS_FILE" | head -8
|
||||
echo ""
|
||||
read -p "Update configuration? (yes/no): " confirm
|
||||
if [ "$confirm" != "yes" ]; then
|
||||
echo "Cancelled"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Remove existing entry
|
||||
# This is complex - for now just error and let user handle manually
|
||||
echo -e "${RED}Error: Updating existing clients not yet implemented${NC}"
|
||||
echo "Please manually edit $TFVARS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Interactive prompts (if not non-interactive)
|
||||
if [ "$NON_INTERACTIVE" = false ]; then
|
||||
echo -e "${BLUE}Adding client '${CLIENT_NAME}' to OpenTofu configuration${NC}"
|
||||
echo ""
|
||||
echo "Current defaults:"
|
||||
echo " Server type: $SERVER_TYPE"
|
||||
echo " Location: $LOCATION"
|
||||
echo " Volume size: $VOLUME_SIZE GB"
|
||||
echo " Apps: $APPS"
|
||||
echo ""
|
||||
read -p "Use these defaults? (yes/no): " use_defaults
|
||||
|
||||
if [ "$use_defaults" != "yes" ]; then
|
||||
# Prompt for each value
|
||||
echo ""
|
||||
read -p "Server type [$SERVER_TYPE]: " input
|
||||
SERVER_TYPE="${input:-$SERVER_TYPE}"
|
||||
|
||||
read -p "Location [$LOCATION]: " input
|
||||
LOCATION="${input:-$LOCATION}"
|
||||
|
||||
read -p "Volume size GB [$VOLUME_SIZE]: " input
|
||||
VOLUME_SIZE="${input:-$VOLUME_SIZE}"
|
||||
|
||||
read -p "Apps (comma-separated) [$APPS]: " input
|
||||
APPS="${input:-$APPS}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Convert apps list to array format
|
||||
APPS_ARRAY=$(echo "$APPS" | sed 's/,/", "/g' | sed 's/^/["/' | sed 's/$/"]/')
|
||||
|
||||
# Find the closing brace of the clients block
|
||||
CLIENTS_CLOSE_LINE=$(grep -n "^}" "$TFVARS_FILE" | head -1 | cut -d: -f1)
|
||||
|
||||
if [ -z "$CLIENTS_CLOSE_LINE" ]; then
|
||||
echo -e "${RED}Error: Could not find closing brace in terraform.tfvars${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create the new client configuration
|
||||
NEW_CLIENT_CONFIG="
|
||||
# ${CLIENT_NAME} server
|
||||
${CLIENT_NAME} = {
|
||||
server_type = \"${SERVER_TYPE}\"
|
||||
location = \"${LOCATION}\"
|
||||
subdomain = \"${CLIENT_NAME}\"
|
||||
apps = ${APPS_ARRAY}
|
||||
nextcloud_volume_size = ${VOLUME_SIZE}
|
||||
private_ip = \"${PRIVATE_IP}\"
|
||||
public_ip_enabled = ${PUBLIC_IP_ENABLED}
|
||||
}"
|
||||
|
||||
# Create temporary file with new config inserted before closing brace
|
||||
TMP_FILE=$(mktemp)
|
||||
head -n $((CLIENTS_CLOSE_LINE - 1)) "$TFVARS_FILE" > "$TMP_FILE"
|
||||
echo "$NEW_CLIENT_CONFIG" >> "$TMP_FILE"
|
||||
tail -n +$CLIENTS_CLOSE_LINE "$TFVARS_FILE" >> "$TMP_FILE"
|
||||
|
||||
# Show the diff
|
||||
echo ""
|
||||
echo -e "${CYAN}Configuration to be added:${NC}"
|
||||
echo "$NEW_CLIENT_CONFIG"
|
||||
echo ""
|
||||
|
||||
# Confirm
|
||||
if [ "$NON_INTERACTIVE" = false ]; then
|
||||
read -p "Add this configuration to terraform.tfvars? (yes/no): " confirm
|
||||
if [ "$confirm" != "yes" ]; then
|
||||
rm "$TMP_FILE"
|
||||
echo "Cancelled"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Apply changes
|
||||
mv "$TMP_FILE" "$TFVARS_FILE"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Client '${CLIENT_NAME}' added to terraform.tfvars${NC}"
|
||||
echo ""
|
||||
|
||||
# Create Ansible host_vars file
|
||||
HOST_VARS_FILE="$PROJECT_ROOT/ansible/host_vars/${CLIENT_NAME}.yml"
|
||||
if [ ! -f "$HOST_VARS_FILE" ]; then
|
||||
echo -e "${BLUE}Creating Ansible host_vars file...${NC}"
|
||||
|
||||
mkdir -p "$(dirname "$HOST_VARS_FILE")"
|
||||
|
||||
cat > "$HOST_VARS_FILE" << EOF
|
||||
---
|
||||
# ${CLIENT_NAME} server configuration
|
||||
|
||||
ansible_host: ${PRIVATE_IP}
|
||||
|
||||
# Client identification
|
||||
client_name: ${CLIENT_NAME}
|
||||
client_domain: ${CLIENT_NAME}.vrije.cloud
|
||||
client_secrets_file: ${CLIENT_NAME}.sops.yaml
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}✓ Created host_vars file: $HOST_VARS_FILE${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Configuration added:"
|
||||
echo " Server: $SERVER_TYPE in $LOCATION"
|
||||
echo " Volume: $VOLUME_SIZE GB"
|
||||
echo " Apps: $APPS"
|
||||
echo " Private IP: $PRIVATE_IP"
|
||||
echo ""
|
||||
echo -e "${CYAN}Next steps:${NC}"
|
||||
echo "1. Review changes: cat tofu/terraform.tfvars"
|
||||
echo "2. Plan infrastructure: cd tofu && tofu plan"
|
||||
echo "3. Apply infrastructure: cd tofu && tofu apply"
|
||||
echo "4. Deploy services: ./scripts/deploy-client.sh $CLIENT_NAME"
|
||||
echo ""
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Report software versions across all clients
|
||||
#
|
||||
# Usage: ./scripts/check-client-versions.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --format=table Show as colorized table (default)
|
||||
# --format=csv Export as CSV
|
||||
# --format=json Export as JSON
|
||||
# --app=<name> Filter by application (authentik|nextcloud|traefik|ubuntu)
|
||||
# --outdated Show only clients with outdated versions
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
REGISTRY_FILE="$PROJECT_ROOT/clients/registry.yml"
|
||||
|
||||
# Default options
|
||||
FORMAT="table"
|
||||
FILTER_APP=""
|
||||
SHOW_OUTDATED=false
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--format=*)
|
||||
FORMAT="${arg#*=}"
|
||||
;;
|
||||
--app=*)
|
||||
FILTER_APP="${arg#*=}"
|
||||
;;
|
||||
--outdated)
|
||||
SHOW_OUTDATED=true
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
echo "Usage: $0 [--format=table|csv|json] [--app=<name>] [--outdated]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check if yq is available
|
||||
if ! command -v yq &> /dev/null; then
|
||||
echo -e "${RED}Error: 'yq' not found. Install with: brew install yq${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if registry exists
|
||||
if [ ! -f "$REGISTRY_FILE" ]; then
|
||||
echo -e "${RED}Error: Registry file not found: $REGISTRY_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get list of clients
|
||||
CLIENTS=$(yq eval '.clients | keys | .[]' "$REGISTRY_FILE" 2>/dev/null)
|
||||
|
||||
if [ -z "$CLIENTS" ]; then
|
||||
echo -e "${YELLOW}No clients found in registry${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Determine latest versions (from canary/dev or most common)
|
||||
declare -A LATEST_VERSIONS
|
||||
LATEST_VERSIONS[authentik]=$(yq eval '.clients | to_entries | .[].value.versions.authentik' "$REGISTRY_FILE" | sort -V | tail -1)
|
||||
LATEST_VERSIONS[nextcloud]=$(yq eval '.clients | to_entries | .[].value.versions.nextcloud' "$REGISTRY_FILE" | sort -V | tail -1)
|
||||
LATEST_VERSIONS[traefik]=$(yq eval '.clients | to_entries | .[].value.versions.traefik' "$REGISTRY_FILE" | sort -V | tail -1)
|
||||
LATEST_VERSIONS[ubuntu]=$(yq eval '.clients | to_entries | .[].value.versions.ubuntu' "$REGISTRY_FILE" | sort -V | tail -1)
|
||||
|
||||
# Function to check if version is outdated
|
||||
is_outdated() {
|
||||
local app=$1
|
||||
local version=$2
|
||||
local latest=${LATEST_VERSIONS[$app]}
|
||||
|
||||
if [ "$version" != "$latest" ] && [ "$version" != "null" ] && [ "$version" != "unknown" ]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
case $FORMAT in
|
||||
table)
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} CLIENT VERSION REPORT${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
# Header
|
||||
printf "${CYAN}%-15s %-15s %-15s %-15s %-15s %-15s${NC}\n" \
|
||||
"CLIENT" "STATUS" "AUTHENTIK" "NEXTCLOUD" "TRAEFIK" "UBUNTU"
|
||||
echo -e "${CYAN}$(printf '─%.0s' {1..90})${NC}"
|
||||
|
||||
# Rows
|
||||
for client in $CLIENTS; do
|
||||
status=$(yq eval ".clients.\"$client\".status" "$REGISTRY_FILE")
|
||||
authentik=$(yq eval ".clients.\"$client\".versions.authentik" "$REGISTRY_FILE")
|
||||
nextcloud=$(yq eval ".clients.\"$client\".versions.nextcloud" "$REGISTRY_FILE")
|
||||
traefik=$(yq eval ".clients.\"$client\".versions.traefik" "$REGISTRY_FILE")
|
||||
ubuntu=$(yq eval ".clients.\"$client\".versions.ubuntu" "$REGISTRY_FILE")
|
||||
|
||||
# Skip if filtering by outdated and not outdated
|
||||
if [ "$SHOW_OUTDATED" = true ]; then
|
||||
has_outdated=false
|
||||
is_outdated "authentik" "$authentik" && has_outdated=true
|
||||
is_outdated "nextcloud" "$nextcloud" && has_outdated=true
|
||||
is_outdated "traefik" "$traefik" && has_outdated=true
|
||||
is_outdated "ubuntu" "$ubuntu" && has_outdated=true
|
||||
|
||||
if [ "$has_outdated" = false ]; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Colorize versions (red if outdated)
|
||||
authentik_color=$NC
|
||||
is_outdated "authentik" "$authentik" && authentik_color=$RED
|
||||
|
||||
nextcloud_color=$NC
|
||||
is_outdated "nextcloud" "$nextcloud" && nextcloud_color=$RED
|
||||
|
||||
traefik_color=$NC
|
||||
is_outdated "traefik" "$traefik" && traefik_color=$RED
|
||||
|
||||
ubuntu_color=$NC
|
||||
is_outdated "ubuntu" "$ubuntu" && ubuntu_color=$RED
|
||||
|
||||
# Status color
|
||||
status_color=$GREEN
|
||||
[ "$status" != "deployed" ] && status_color=$YELLOW
|
||||
|
||||
printf "%-15s ${status_color}%-15s${NC} ${authentik_color}%-15s${NC} ${nextcloud_color}%-15s${NC} ${traefik_color}%-15s${NC} ${ubuntu_color}%-15s${NC}\n" \
|
||||
"$client" "$status" "$authentik" "$nextcloud" "$traefik" "$ubuntu"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}Latest versions:${NC}"
|
||||
echo " Authentik: ${LATEST_VERSIONS[authentik]}"
|
||||
echo " Nextcloud: ${LATEST_VERSIONS[nextcloud]}"
|
||||
echo " Traefik: ${LATEST_VERSIONS[traefik]}"
|
||||
echo " Ubuntu: ${LATEST_VERSIONS[ubuntu]}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note: ${RED}Red${NC} indicates outdated version${NC}"
|
||||
echo ""
|
||||
;;
|
||||
|
||||
csv)
|
||||
# CSV header
|
||||
echo "client,status,authentik,nextcloud,traefik,ubuntu,last_update,outdated"
|
||||
|
||||
# CSV rows
|
||||
for client in $CLIENTS; do
|
||||
status=$(yq eval ".clients.\"$client\".status" "$REGISTRY_FILE")
|
||||
authentik=$(yq eval ".clients.\"$client\".versions.authentik" "$REGISTRY_FILE")
|
||||
nextcloud=$(yq eval ".clients.\"$client\".versions.nextcloud" "$REGISTRY_FILE")
|
||||
traefik=$(yq eval ".clients.\"$client\".versions.traefik" "$REGISTRY_FILE")
|
||||
ubuntu=$(yq eval ".clients.\"$client\".versions.ubuntu" "$REGISTRY_FILE")
|
||||
last_update=$(yq eval ".clients.\"$client\".maintenance.last_full_update" "$REGISTRY_FILE")
|
||||
|
||||
# Check if any version is outdated
|
||||
outdated="no"
|
||||
is_outdated "authentik" "$authentik" && outdated="yes"
|
||||
is_outdated "nextcloud" "$nextcloud" && outdated="yes"
|
||||
is_outdated "traefik" "$traefik" && outdated="yes"
|
||||
is_outdated "ubuntu" "$ubuntu" && outdated="yes"
|
||||
|
||||
# Skip if filtering by outdated
|
||||
if [ "$SHOW_OUTDATED" = true ] && [ "$outdated" = "no" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "$client,$status,$authentik,$nextcloud,$traefik,$ubuntu,$last_update,$outdated"
|
||||
done
|
||||
;;
|
||||
|
||||
json)
|
||||
# Build JSON array
|
||||
echo "{"
|
||||
echo " \"latest_versions\": {"
|
||||
echo " \"authentik\": \"${LATEST_VERSIONS[authentik]}\","
|
||||
echo " \"nextcloud\": \"${LATEST_VERSIONS[nextcloud]}\","
|
||||
echo " \"traefik\": \"${LATEST_VERSIONS[traefik]}\","
|
||||
echo " \"ubuntu\": \"${LATEST_VERSIONS[ubuntu]}\""
|
||||
echo " },"
|
||||
echo " \"clients\": ["
|
||||
|
||||
first=true
|
||||
for client in $CLIENTS; do
|
||||
status=$(yq eval ".clients.\"$client\".status" "$REGISTRY_FILE")
|
||||
authentik=$(yq eval ".clients.\"$client\".versions.authentik" "$REGISTRY_FILE")
|
||||
nextcloud=$(yq eval ".clients.\"$client\".versions.nextcloud" "$REGISTRY_FILE")
|
||||
traefik=$(yq eval ".clients.\"$client\".versions.traefik" "$REGISTRY_FILE")
|
||||
ubuntu=$(yq eval ".clients.\"$client\".versions.ubuntu" "$REGISTRY_FILE")
|
||||
last_update=$(yq eval ".clients.\"$client\".maintenance.last_full_update" "$REGISTRY_FILE")
|
||||
|
||||
# Check if any version is outdated
|
||||
outdated=false
|
||||
is_outdated "authentik" "$authentik" && outdated=true
|
||||
is_outdated "nextcloud" "$nextcloud" && outdated=true
|
||||
is_outdated "traefik" "$traefik" && outdated=true
|
||||
is_outdated "ubuntu" "$ubuntu" && outdated=true
|
||||
|
||||
# Skip if filtering by outdated
|
||||
if [ "$SHOW_OUTDATED" = true ] && [ "$outdated" = false ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$first" = false ]; then
|
||||
echo " ,"
|
||||
fi
|
||||
first=false
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"name": "$client",
|
||||
"status": "$status",
|
||||
"versions": {
|
||||
"authentik": "$authentik",
|
||||
"nextcloud": "$nextcloud",
|
||||
"traefik": "$traefik",
|
||||
"ubuntu": "$ubuntu"
|
||||
},
|
||||
"last_update": "$last_update",
|
||||
"outdated": $outdated
|
||||
}
|
||||
EOF
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo " ]"
|
||||
echo "}"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo -e "${RED}Error: Unknown format '$FORMAT'${NC}"
|
||||
echo "Valid formats: table, csv, json"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Show detailed status for a specific client
|
||||
#
|
||||
# Usage: ./scripts/client-status.sh <client_name>
|
||||
#
|
||||
# Displays:
|
||||
# - Deployment status and metadata
|
||||
# - Server information
|
||||
# - Application versions
|
||||
# - Maintenance history
|
||||
# - URLs and access information
|
||||
# - Live health checks (optional)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
REGISTRY_FILE="$PROJECT_ROOT/clients/registry.yml"
|
||||
|
||||
# Check arguments
|
||||
if [ $# -ne 1 ]; then
|
||||
echo -e "${RED}Error: Client name required${NC}"
|
||||
echo "Usage: $0 <client_name>"
|
||||
echo ""
|
||||
echo "Example: $0 dev"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLIENT_NAME="$1"
|
||||
|
||||
# Check if yq is available
|
||||
if ! command -v yq &> /dev/null; then
|
||||
echo -e "${RED}Error: 'yq' not found. Install with: brew install yq${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if registry exists
|
||||
if [ ! -f "$REGISTRY_FILE" ]; then
|
||||
echo -e "${RED}Error: Registry file not found: $REGISTRY_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if client exists
|
||||
if yq eval ".clients.\"$CLIENT_NAME\"" "$REGISTRY_FILE" | grep -q "null"; then
|
||||
echo -e "${RED}Error: Client '$CLIENT_NAME' not found in registry${NC}"
|
||||
echo ""
|
||||
echo "Available clients:"
|
||||
yq eval '.clients | keys | .[]' "$REGISTRY_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract client information
|
||||
STATUS=$(yq eval ".clients.\"$CLIENT_NAME\".status" "$REGISTRY_FILE")
|
||||
ROLE=$(yq eval ".clients.\"$CLIENT_NAME\".role" "$REGISTRY_FILE")
|
||||
DEPLOYED_DATE=$(yq eval ".clients.\"$CLIENT_NAME\".deployed_date" "$REGISTRY_FILE")
|
||||
DESTROYED_DATE=$(yq eval ".clients.\"$CLIENT_NAME\".destroyed_date" "$REGISTRY_FILE")
|
||||
|
||||
SERVER_TYPE=$(yq eval ".clients.\"$CLIENT_NAME\".server.type" "$REGISTRY_FILE")
|
||||
SERVER_LOCATION=$(yq eval ".clients.\"$CLIENT_NAME\".server.location" "$REGISTRY_FILE")
|
||||
SERVER_IP=$(yq eval ".clients.\"$CLIENT_NAME\".server.ip" "$REGISTRY_FILE")
|
||||
SERVER_ID=$(yq eval ".clients.\"$CLIENT_NAME\".server.id" "$REGISTRY_FILE")
|
||||
|
||||
APPS=$(yq eval ".clients.\"$CLIENT_NAME\".apps | join(\", \")" "$REGISTRY_FILE")
|
||||
|
||||
AUTHENTIK_VERSION=$(yq eval ".clients.\"$CLIENT_NAME\".versions.authentik" "$REGISTRY_FILE")
|
||||
NEXTCLOUD_VERSION=$(yq eval ".clients.\"$CLIENT_NAME\".versions.nextcloud" "$REGISTRY_FILE")
|
||||
TRAEFIK_VERSION=$(yq eval ".clients.\"$CLIENT_NAME\".versions.traefik" "$REGISTRY_FILE")
|
||||
UBUNTU_VERSION=$(yq eval ".clients.\"$CLIENT_NAME\".versions.ubuntu" "$REGISTRY_FILE")
|
||||
|
||||
LAST_FULL_UPDATE=$(yq eval ".clients.\"$CLIENT_NAME\".maintenance.last_full_update" "$REGISTRY_FILE")
|
||||
LAST_SECURITY_PATCH=$(yq eval ".clients.\"$CLIENT_NAME\".maintenance.last_security_patch" "$REGISTRY_FILE")
|
||||
LAST_OS_UPDATE=$(yq eval ".clients.\"$CLIENT_NAME\".maintenance.last_os_update" "$REGISTRY_FILE")
|
||||
LAST_BACKUP_VERIFIED=$(yq eval ".clients.\"$CLIENT_NAME\".maintenance.last_backup_verified" "$REGISTRY_FILE")
|
||||
|
||||
AUTHENTIK_URL=$(yq eval ".clients.\"$CLIENT_NAME\".urls.authentik" "$REGISTRY_FILE")
|
||||
NEXTCLOUD_URL=$(yq eval ".clients.\"$CLIENT_NAME\".urls.nextcloud" "$REGISTRY_FILE")
|
||||
|
||||
NOTES=$(yq eval ".clients.\"$CLIENT_NAME\".notes" "$REGISTRY_FILE")
|
||||
|
||||
# Display header
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} CLIENT STATUS: $CLIENT_NAME${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
# Status section
|
||||
echo -e "${CYAN}━━━ Deployment Status ━━━${NC}"
|
||||
echo ""
|
||||
|
||||
# Color status
|
||||
STATUS_COLOR=$NC
|
||||
case $STATUS in
|
||||
deployed) STATUS_COLOR=$GREEN ;;
|
||||
pending) STATUS_COLOR=$YELLOW ;;
|
||||
maintenance) STATUS_COLOR=$CYAN ;;
|
||||
offboarding) STATUS_COLOR=$RED ;;
|
||||
destroyed) STATUS_COLOR=$RED ;;
|
||||
esac
|
||||
|
||||
# Color role
|
||||
ROLE_COLOR=$NC
|
||||
case $ROLE in
|
||||
canary) ROLE_COLOR=$YELLOW ;;
|
||||
production) ROLE_COLOR=$GREEN ;;
|
||||
esac
|
||||
|
||||
echo -e "Status: ${STATUS_COLOR}$STATUS${NC}"
|
||||
echo -e "Role: ${ROLE_COLOR}$ROLE${NC}"
|
||||
echo -e "Deployed: $DEPLOYED_DATE"
|
||||
if [ "$DESTROYED_DATE" != "null" ]; then
|
||||
echo -e "Destroyed: ${RED}$DESTROYED_DATE${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Server section
|
||||
echo -e "${CYAN}━━━ Server Information ━━━${NC}"
|
||||
echo ""
|
||||
echo -e "Server Type: $SERVER_TYPE"
|
||||
echo -e "Location: $SERVER_LOCATION"
|
||||
echo -e "IP Address: $SERVER_IP"
|
||||
echo -e "Server ID: $SERVER_ID"
|
||||
echo ""
|
||||
|
||||
# Applications section
|
||||
echo -e "${CYAN}━━━ Applications ━━━${NC}"
|
||||
echo ""
|
||||
echo -e "Installed: $APPS"
|
||||
echo ""
|
||||
|
||||
# Versions section
|
||||
echo -e "${CYAN}━━━ Versions ━━━${NC}"
|
||||
echo ""
|
||||
echo -e "Authentik: $AUTHENTIK_VERSION"
|
||||
echo -e "Nextcloud: $NEXTCLOUD_VERSION"
|
||||
echo -e "Traefik: $TRAEFIK_VERSION"
|
||||
echo -e "Ubuntu: $UBUNTU_VERSION"
|
||||
echo ""
|
||||
|
||||
# Maintenance section
|
||||
echo -e "${CYAN}━━━ Maintenance History ━━━${NC}"
|
||||
echo ""
|
||||
echo -e "Last Full Update: $LAST_FULL_UPDATE"
|
||||
echo -e "Last Security Patch: $LAST_SECURITY_PATCH"
|
||||
echo -e "Last OS Update: $LAST_OS_UPDATE"
|
||||
if [ "$LAST_BACKUP_VERIFIED" != "null" ]; then
|
||||
echo -e "Last Backup Verified: $LAST_BACKUP_VERIFIED"
|
||||
else
|
||||
echo -e "Last Backup Verified: ${YELLOW}Never${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# URLs section
|
||||
echo -e "${CYAN}━━━ Access URLs ━━━${NC}"
|
||||
echo ""
|
||||
echo -e "Authentik: $AUTHENTIK_URL"
|
||||
echo -e "Nextcloud: $NEXTCLOUD_URL"
|
||||
echo ""
|
||||
|
||||
# Notes section
|
||||
if [ "$NOTES" != "null" ] && [ -n "$NOTES" ]; then
|
||||
echo -e "${CYAN}━━━ Notes ━━━${NC}"
|
||||
echo ""
|
||||
echo "$NOTES" | sed 's/^/ /'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Live health check (if server is deployed and reachable)
|
||||
if [ "$STATUS" = "deployed" ]; then
|
||||
echo -e "${CYAN}━━━ Live Health Check ━━━${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if server is reachable via SSH (if Ansible is configured)
|
||||
if command -v ansible &> /dev/null && [ -n "${HCLOUD_TOKEN:-}" ]; then
|
||||
cd "$PROJECT_ROOT/ansible"
|
||||
if timeout 10 ~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m ping -o &>/dev/null; then
|
||||
echo -e "SSH Access: ${GREEN}✓ Reachable${NC}"
|
||||
|
||||
# Get Docker status
|
||||
DOCKER_STATUS=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "docker ps --format '{{.Names}}' 2>/dev/null | wc -l" -o 2>/dev/null | tail -1 | awk '{print $NF}' || echo "0")
|
||||
if [ "$DOCKER_STATUS" != "0" ]; then
|
||||
echo -e "Docker: ${GREEN}✓ Running ($DOCKER_STATUS containers)${NC}"
|
||||
else
|
||||
echo -e "Docker: ${RED}✗ No containers running${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "SSH Access: ${RED}✗ Not reachable${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}Note: Install Ansible and set HCLOUD_TOKEN for live health checks${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Check HTTPS endpoints
|
||||
echo -e "HTTPS Endpoints:"
|
||||
|
||||
# Check Authentik
|
||||
if command -v curl &> /dev/null; then
|
||||
if timeout 10 curl -sSf -o /dev/null "$AUTHENTIK_URL" 2>/dev/null; then
|
||||
echo -e " Authentik: ${GREEN}✓ Responding${NC}"
|
||||
else
|
||||
echo -e " Authentik: ${RED}<EFBFBD><EFBFBD> Not responding${NC}"
|
||||
fi
|
||||
|
||||
# Check Nextcloud
|
||||
if timeout 10 curl -sSf -o /dev/null "$NEXTCLOUD_URL" 2>/dev/null; then
|
||||
echo -e " Nextcloud: ${GREEN}✓ Responding${NC}"
|
||||
else
|
||||
echo -e " Nextcloud: ${RED}✗ Not responding${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}Install curl for endpoint checks${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Management commands section
|
||||
echo -e "${CYAN}━━━ Management Commands ━━━${NC}"
|
||||
echo ""
|
||||
echo -e "View secrets: ${BLUE}sops secrets/clients/${CLIENT_NAME}.sops.yaml${NC}"
|
||||
echo -e "Rebuild server: ${BLUE}./scripts/rebuild-client.sh $CLIENT_NAME${NC}"
|
||||
echo -e "Destroy server: ${BLUE}./scripts/destroy-client.sh $CLIENT_NAME${NC}"
|
||||
echo -e "List all: ${BLUE}./scripts/list-clients.sh${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}"
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Collect deployed software versions from a client and update registry
|
||||
#
|
||||
# Usage: ./scripts/collect-client-versions.sh <client_name>
|
||||
#
|
||||
# Queries the deployed server for actual running versions:
|
||||
# - Docker container image versions
|
||||
# - Ubuntu OS version
|
||||
# - Updates the client registry with collected versions
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
REGISTRY_FILE="$PROJECT_ROOT/clients/registry.yml"
|
||||
|
||||
# Check arguments
|
||||
if [ $# -ne 1 ]; then
|
||||
echo -e "${RED}Error: Client name required${NC}"
|
||||
echo "Usage: $0 <client_name>"
|
||||
echo ""
|
||||
echo "Example: $0 dev"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLIENT_NAME="$1"
|
||||
|
||||
# Check if yq is available
|
||||
if ! command -v yq &> /dev/null; then
|
||||
echo -e "${RED}Error: 'yq' not found. Install with: brew install yq${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load Hetzner API token from SOPS if not already set
|
||||
if [ -z "${HCLOUD_TOKEN:-}" ]; then
|
||||
# shellcheck source=scripts/load-secrets-env.sh
|
||||
source "$SCRIPT_DIR/load-secrets-env.sh" > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Check if registry exists
|
||||
if [ ! -f "$REGISTRY_FILE" ]; then
|
||||
echo -e "${RED}Error: Registry file not found: $REGISTRY_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if client exists in registry
|
||||
if yq eval ".clients.\"$CLIENT_NAME\"" "$REGISTRY_FILE" | grep -q "null"; then
|
||||
echo -e "${RED}Error: Client '$CLIENT_NAME' not found in registry${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Collecting versions for client: $CLIENT_NAME${NC}"
|
||||
echo ""
|
||||
|
||||
cd "$PROJECT_ROOT/ansible"
|
||||
|
||||
# Check if server is reachable
|
||||
if ! timeout 10 ~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m ping -o &>/dev/null; then
|
||||
echo -e "${RED}Error: Cannot reach server for client '$CLIENT_NAME'${NC}"
|
||||
echo "Server may not be deployed or network is unreachable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Querying deployed versions...${NC}"
|
||||
echo ""
|
||||
|
||||
# Query Docker container versions
|
||||
echo "Collecting Docker container versions..."
|
||||
|
||||
# Function to extract version from image tag
|
||||
extract_version() {
|
||||
local image=$1
|
||||
# Extract version after the colon, or return "latest"
|
||||
if [[ $image == *":"* ]]; then
|
||||
echo "$image" | awk -F: '{print $2}'
|
||||
else
|
||||
echo "latest"
|
||||
fi
|
||||
}
|
||||
|
||||
# Collect Authentik version
|
||||
AUTHENTIK_IMAGE=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "docker inspect authentik-server 2>/dev/null | jq -r '.[0].Config.Image' 2>/dev/null || echo 'unknown'" -o 2>/dev/null | tail -1 | awk '{print $NF}')
|
||||
AUTHENTIK_VERSION=$(extract_version "$AUTHENTIK_IMAGE")
|
||||
|
||||
# Collect Nextcloud version
|
||||
NEXTCLOUD_IMAGE=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "docker inspect nextcloud 2>/dev/null | jq -r '.[0].Config.Image' 2>/dev/null || echo 'unknown'" -o 2>/dev/null | tail -1 | awk '{print $NF}')
|
||||
NEXTCLOUD_VERSION=$(extract_version "$NEXTCLOUD_IMAGE")
|
||||
|
||||
# Collect Traefik version
|
||||
TRAEFIK_IMAGE=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "docker inspect traefik 2>/dev/null | jq -r '.[0].Config.Image' 2>/dev/null || echo 'unknown'" -o 2>/dev/null | tail -1 | awk '{print $NF}')
|
||||
TRAEFIK_VERSION=$(extract_version "$TRAEFIK_IMAGE")
|
||||
|
||||
# Collect Ubuntu version
|
||||
UBUNTU_VERSION=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "lsb_release -rs 2>/dev/null || echo 'unknown'" -o 2>/dev/null | tail -1 | awk '{print $NF}')
|
||||
|
||||
echo -e "${GREEN}✓ Versions collected${NC}"
|
||||
echo ""
|
||||
|
||||
# Display collected versions
|
||||
echo "Collected versions:"
|
||||
echo " Authentik: $AUTHENTIK_VERSION"
|
||||
echo " Nextcloud: $NEXTCLOUD_VERSION"
|
||||
echo " Traefik: $TRAEFIK_VERSION"
|
||||
echo " Ubuntu: $UBUNTU_VERSION"
|
||||
echo ""
|
||||
|
||||
# Update registry
|
||||
echo -e "${YELLOW}Updating registry...${NC}"
|
||||
|
||||
# Update versions in registry
|
||||
yq eval -i ".clients.\"$CLIENT_NAME\".versions.authentik = \"$AUTHENTIK_VERSION\"" "$REGISTRY_FILE"
|
||||
yq eval -i ".clients.\"$CLIENT_NAME\".versions.nextcloud = \"$NEXTCLOUD_VERSION\"" "$REGISTRY_FILE"
|
||||
yq eval -i ".clients.\"$CLIENT_NAME\".versions.traefik = \"$TRAEFIK_VERSION\"" "$REGISTRY_FILE"
|
||||
yq eval -i ".clients.\"$CLIENT_NAME\".versions.ubuntu = \"$UBUNTU_VERSION\"" "$REGISTRY_FILE"
|
||||
|
||||
echo -e "${GREEN}✓ Registry updated${NC}"
|
||||
echo ""
|
||||
echo "Updated: $REGISTRY_FILE"
|
||||
echo ""
|
||||
echo "To view registry:"
|
||||
echo " ./scripts/client-status.sh $CLIENT_NAME"
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Configure Diun on all servers (disable watchRepo, add Docker Hub auth)
|
||||
# Created: 2026-01-24
|
||||
#
|
||||
# This script runs the diun configuration playbook on each server
|
||||
# with its corresponding SSH key.
|
||||
#
|
||||
# Usage:
|
||||
# cd infrastructure/
|
||||
# SOPS_AGE_KEY_FILE="keys/age-key.txt" HCLOUD_TOKEN="..." ./scripts/configure-diun-all-servers.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ANSIBLE_DIR="$PROJECT_ROOT/ansible"
|
||||
KEYS_DIR="$PROJECT_ROOT/keys/ssh"
|
||||
PLAYBOOK="playbooks/260124-configure-diun-watchrepo.yml"
|
||||
|
||||
# Check required environment variables
|
||||
if [ -z "${HCLOUD_TOKEN:-}" ]; then
|
||||
echo -e "${RED}Error: HCLOUD_TOKEN environment variable is required${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${SOPS_AGE_KEY_FILE:-}" ]; then
|
||||
echo -e "${RED}Error: SOPS_AGE_KEY_FILE environment variable is required${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Convert SOPS_AGE_KEY_FILE to absolute path if it's relative
|
||||
if [[ ! "$SOPS_AGE_KEY_FILE" = /* ]]; then
|
||||
export SOPS_AGE_KEY_FILE="$PROJECT_ROOT/$SOPS_AGE_KEY_FILE"
|
||||
fi
|
||||
|
||||
# Change to ansible directory
|
||||
cd "$ANSIBLE_DIR"
|
||||
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo -e "${BLUE}Diun Configuration - All Servers${NC}"
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo ""
|
||||
echo "Playbook: $PLAYBOOK"
|
||||
echo "Ansible directory: $ANSIBLE_DIR"
|
||||
echo ""
|
||||
echo "Configuration changes:"
|
||||
echo " - Disable watchRepo (only check specific tags, not entire repos)"
|
||||
echo " - Add Docker Hub authentication (5000 pulls/6h limit)"
|
||||
echo " - Schedule: Weekly on Monday at 6am UTC"
|
||||
echo ""
|
||||
|
||||
# Get list of all servers with SSH keys
|
||||
SERVERS=()
|
||||
for keyfile in "$KEYS_DIR"/*.pub; do
|
||||
if [ -f "$keyfile" ]; then
|
||||
server=$(basename "$keyfile" .pub)
|
||||
# Skip special servers
|
||||
if [[ "$server" != "README" ]] && [[ "$server" != "edge" ]]; then
|
||||
SERVERS+=("$server")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${BLUE}Found ${#SERVERS[@]} servers:${NC}"
|
||||
printf '%s\n' "${SERVERS[@]}" | sort
|
||||
echo ""
|
||||
|
||||
# Counters
|
||||
SUCCESS_COUNT=0
|
||||
FAILED_COUNT=0
|
||||
SKIPPED_COUNT=0
|
||||
declare -a SUCCESS_SERVERS
|
||||
declare -a FAILED_SERVERS
|
||||
declare -a SKIPPED_SERVERS
|
||||
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo -e "${BLUE}Starting configuration run...${NC}"
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Run playbook for each server
|
||||
for server in "${SERVERS[@]}"; do
|
||||
echo -e "${YELLOW}-----------------------------------------------------------${NC}"
|
||||
echo -e "${YELLOW}Processing: $server${NC}"
|
||||
echo -e "${YELLOW}-----------------------------------------------------------${NC}"
|
||||
|
||||
SSH_KEY="$KEYS_DIR/$server"
|
||||
|
||||
if [ ! -f "$SSH_KEY" ]; then
|
||||
echo -e "${RED}✗ SSH key not found: $SSH_KEY${NC}"
|
||||
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
|
||||
SKIPPED_SERVERS+=("$server")
|
||||
echo ""
|
||||
continue
|
||||
fi
|
||||
|
||||
# Run the playbook (with SSH options to prevent agent key issues)
|
||||
if env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
|
||||
SOPS_AGE_KEY_FILE="$SOPS_AGE_KEY_FILE" \
|
||||
ANSIBLE_SSH_ARGS="-o IdentitiesOnly=yes" \
|
||||
~/.local/bin/ansible-playbook \
|
||||
-i hcloud.yml \
|
||||
"$PLAYBOOK" \
|
||||
--limit "$server" \
|
||||
--private-key "$SSH_KEY" 2>&1; then
|
||||
|
||||
echo -e "${GREEN}✓ Success: $server${NC}"
|
||||
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
||||
SUCCESS_SERVERS+=("$server")
|
||||
else
|
||||
echo -e "${RED}✗ Failed: $server${NC}"
|
||||
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||
FAILED_SERVERS+=("$server")
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
# Summary
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo -e "${BLUE}CONFIGURATION RUN SUMMARY${NC}"
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo ""
|
||||
echo "Total servers: ${#SERVERS[@]}"
|
||||
echo -e "${GREEN}Successful: $SUCCESS_COUNT${NC}"
|
||||
echo -e "${RED}Failed: $FAILED_COUNT${NC}"
|
||||
echo -e "${YELLOW}Skipped: $SKIPPED_COUNT${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $SUCCESS_COUNT -gt 0 ]; then
|
||||
echo -e "${GREEN}Successful servers:${NC}"
|
||||
printf ' %s\n' "${SUCCESS_SERVERS[@]}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ $FAILED_COUNT -gt 0 ]; then
|
||||
echo -e "${RED}Failed servers:${NC}"
|
||||
printf ' %s\n' "${FAILED_SERVERS[@]}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ $SKIPPED_COUNT -gt 0 ]; then
|
||||
echo -e "${YELLOW}Skipped servers:${NC}"
|
||||
printf ' %s\n' "${SKIPPED_SERVERS[@]}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Wait for next Monday at 6am UTC for scheduled run"
|
||||
echo " 2. Or manually trigger: docker exec diun diun once"
|
||||
echo " 3. Check logs: docker logs diun"
|
||||
echo ""
|
||||
|
||||
# Exit with error if any failures
|
||||
if [ $FAILED_COUNT -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Configure OIDC for a single client
|
||||
#
|
||||
# Usage: ./scripts/configure-oidc.sh <client_name>
|
||||
#
|
||||
# This script:
|
||||
# 1. Creates OIDC provider in Authentik
|
||||
# 2. Installs user_oidc app in Nextcloud
|
||||
# 3. Configures OIDC connection
|
||||
# 4. Enables multiple user backends
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Check arguments
|
||||
if [ $# -ne 1 ]; then
|
||||
echo -e "${RED}Error: Client name required${NC}"
|
||||
echo "Usage: $0 <client_name>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLIENT_NAME="$1"
|
||||
|
||||
# Check environment variables
|
||||
if [ -z "${SOPS_AGE_KEY_FILE:-}" ]; then
|
||||
export SOPS_AGE_KEY_FILE="$PROJECT_ROOT/keys/age-key.txt"
|
||||
fi
|
||||
|
||||
if [ -z "${HCLOUD_TOKEN:-}" ]; then
|
||||
echo -e "${RED}Error: HCLOUD_TOKEN not set${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Configuring OIDC for ${CLIENT_NAME}...${NC}"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Step 1: Get credentials from secrets
|
||||
echo "Getting credentials from secrets..."
|
||||
TOKEN=$(sops -d "secrets/clients/${CLIENT_NAME}.sops.yaml" | grep authentik_bootstrap_token | awk '{print $2}')
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo -e "${RED}Error: Could not get Authentik token${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Create OIDC provider in Authentik
|
||||
echo "Creating OIDC provider in Authentik..."
|
||||
|
||||
# Create Python script
|
||||
cat > /tmp/create_oidc_${CLIENT_NAME}.py << EOFPYTHON
|
||||
import sys, json, urllib.request
|
||||
base_url, token = "http://localhost:9000", "${TOKEN}"
|
||||
def req(p, m='GET', d=None):
|
||||
r = urllib.request.Request(f"{base_url}{p}", json.dumps(d).encode() if d else None, {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, method=m)
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=30) as resp: return resp.status, json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e: return e.code, json.loads(e.read()) if e.headers.get('Content-Type', '').startswith('application/json') else {'error': e.read().decode()}
|
||||
s, d = req('/api/v3/flows/instances/')
|
||||
auth_flow = next((f['pk'] for f in d.get('results', []) if f.get('slug') == 'default-authorization-flow' or f.get('designation') == 'authorization'), None)
|
||||
inval_flow = next((f['pk'] for f in d.get('results', []) if f.get('slug') == 'default-invalidation-flow' or f.get('designation') == 'invalidation'), None)
|
||||
s, d = req('/api/v3/crypto/certificatekeypairs/')
|
||||
key = d.get('results', [{}])[0].get('pk') if d.get('results') else None
|
||||
if not auth_flow or not key: print(json.dumps({'error': 'Config missing', 'auth_flow': auth_flow, 'key': key}), file=sys.stderr); sys.exit(1)
|
||||
s, prov = req('/api/v3/providers/oauth2/', 'POST', {'name': 'Nextcloud', 'authorization_flow': auth_flow, 'invalidation_flow': inval_flow, 'client_type': 'confidential', 'redirect_uris': [{'matching_mode': 'strict', 'url': 'https://nextcloud.${CLIENT_NAME}.vrije.cloud/apps/user_oidc/code'}], 'signing_key': key, 'sub_mode': 'hashed_user_id', 'include_claims_in_id_token': True})
|
||||
if s != 201: print(json.dumps({'error': 'Provider failed', 'status': s, 'details': prov}), file=sys.stderr); sys.exit(1)
|
||||
s, app = req('/api/v3/core/applications/', 'POST', {'name': 'Nextcloud', 'slug': 'nextcloud', 'provider': prov['pk'], 'meta_launch_url': 'https://nextcloud.${CLIENT_NAME}.vrije.cloud'})
|
||||
if s != 201: print(json.dumps({'error': 'App failed', 'status': s, 'details': app}), file=sys.stderr); sys.exit(1)
|
||||
print(json.dumps({'success': True, 'provider_id': prov['pk'], 'application_id': app['pk'], 'client_id': prov['client_id'], 'client_secret': prov['client_secret'], 'discovery_uri': f"https://auth.${CLIENT_NAME}.vrije.cloud/application/o/nextcloud/.well-known/openid-configuration", 'issuer': f"https://auth.${CLIENT_NAME}.vrije.cloud/application/o/nextcloud/"}))
|
||||
EOFPYTHON
|
||||
|
||||
# Copy script to server and execute
|
||||
cd ansible
|
||||
env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
|
||||
ansible "${CLIENT_NAME}" \
|
||||
-i hcloud.yml \
|
||||
-m copy \
|
||||
-a "src=/tmp/create_oidc_${CLIENT_NAME}.py dest=/tmp/create_oidc.py mode=0755" \
|
||||
--private-key "../keys/ssh/${CLIENT_NAME}" > /dev/null 2>&1
|
||||
|
||||
# Execute the script
|
||||
OIDC_RESULT=$(env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
|
||||
ansible "${CLIENT_NAME}" \
|
||||
-i hcloud.yml \
|
||||
-m shell \
|
||||
-a "docker exec -i authentik-server python3 < /tmp/create_oidc.py" \
|
||||
--private-key "../keys/ssh/${CLIENT_NAME}" 2>/dev/null | grep -A1 "CHANGED" | tail -1)
|
||||
|
||||
if [ -z "$OIDC_RESULT" ]; then
|
||||
echo -e "${RED}Error: Failed to create OIDC provider${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse credentials
|
||||
CLIENT_ID=$(echo "$OIDC_RESULT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d['client_id'])")
|
||||
CLIENT_SECRET=$(echo "$OIDC_RESULT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d['client_secret'])")
|
||||
DISCOVERY_URI=$(echo "$OIDC_RESULT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d['discovery_uri'])")
|
||||
|
||||
if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ] || [ -z "$DISCOVERY_URI" ]; then
|
||||
echo -e "${RED}Error: Failed to parse OIDC credentials${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ OIDC provider created${NC}"
|
||||
|
||||
# Step 3: Install user_oidc app in Nextcloud
|
||||
echo "Installing user_oidc app..."
|
||||
|
||||
env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
|
||||
ansible "${CLIENT_NAME}" \
|
||||
-i hcloud.yml \
|
||||
-m shell \
|
||||
-a "docker exec -u www-data nextcloud php occ app:install user_oidc" \
|
||||
--private-key "../keys/ssh/${CLIENT_NAME}" > /dev/null 2>&1 || true
|
||||
|
||||
echo -e "${GREEN}✓ user_oidc app installed${NC}"
|
||||
|
||||
# Step 4: Configure OIDC provider in Nextcloud
|
||||
echo "Configuring OIDC provider..."
|
||||
|
||||
env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
|
||||
ansible "${CLIENT_NAME}" \
|
||||
-i hcloud.yml \
|
||||
-m shell \
|
||||
-a "docker exec -u www-data nextcloud php occ user_oidc:provider --clientid=\"${CLIENT_ID}\" --clientsecret=\"${CLIENT_SECRET}\" --discoveryuri=\"${DISCOVERY_URI}\" \"Authentik\"" \
|
||||
--private-key "../keys/ssh/${CLIENT_NAME}" > /dev/null 2>&1
|
||||
|
||||
echo -e "${GREEN}✓ OIDC provider configured${NC}"
|
||||
|
||||
# Step 5: Configure OIDC settings
|
||||
echo "Configuring OIDC settings..."
|
||||
|
||||
env HCLOUD_TOKEN="$HCLOUD_TOKEN" \
|
||||
ansible "${CLIENT_NAME}" \
|
||||
-i hcloud.yml \
|
||||
-m shell \
|
||||
-a "docker exec -u www-data nextcloud php occ config:app:set user_oidc allow_multiple_user_backends --value=1 && docker exec -u www-data nextcloud php occ config:app:set user_oidc auto_provision --value=1 && docker exec -u www-data nextcloud php occ config:app:set user_oidc single_logout --value=0" \
|
||||
--private-key "../keys/ssh/${CLIENT_NAME}" > /dev/null 2>&1
|
||||
|
||||
echo -e "${GREEN}✓ OIDC settings configured${NC}"
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/create_oidc_${CLIENT_NAME}.py
|
||||
|
||||
echo -e "${GREEN}✓ OIDC configuration complete for ${CLIENT_NAME}${NC}"
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Deploy a fresh client from scratch
|
||||
#
|
||||
# Usage: ./scripts/deploy-client.sh <client_name>
|
||||
#
|
||||
# This script will:
|
||||
# 1. Provision new VPS server (if not exists)
|
||||
# 2. Setup base system (Docker, Traefik)
|
||||
# 3. Deploy applications (Authentik, Nextcloud)
|
||||
# 4. Configure SSO integration
|
||||
#
|
||||
# Result: Fully functional Authentik + Nextcloud with automated SSO
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Check arguments
|
||||
if [ $# -ne 1 ]; then
|
||||
echo -e "${RED}Error: Client name required${NC}"
|
||||
echo "Usage: $0 <client_name>"
|
||||
echo ""
|
||||
echo "Example: $0 test"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLIENT_NAME="$1"
|
||||
|
||||
# Check if SSH key exists, generate if missing
|
||||
SSH_KEY_FILE="$PROJECT_ROOT/keys/ssh/${CLIENT_NAME}"
|
||||
if [ ! -f "$SSH_KEY_FILE" ]; then
|
||||
echo -e "${YELLOW}SSH key not found for client: $CLIENT_NAME${NC}"
|
||||
echo "Generating SSH key pair automatically..."
|
||||
echo ""
|
||||
|
||||
# Generate SSH key
|
||||
"$SCRIPT_DIR/generate-client-keys.sh" "$CLIENT_NAME"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ SSH key generated${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check if secrets file exists, create from template if missing
|
||||
SECRETS_FILE="$PROJECT_ROOT/secrets/clients/${CLIENT_NAME}.sops.yaml"
|
||||
TEMPLATE_FILE="$PROJECT_ROOT/secrets/clients/template.sops.yaml"
|
||||
|
||||
if [ ! -f "$SECRETS_FILE" ]; then
|
||||
echo -e "${YELLOW}Secrets file not found for client: $CLIENT_NAME${NC}"
|
||||
echo "Creating from template and opening for editing..."
|
||||
echo ""
|
||||
|
||||
# Check if template exists
|
||||
if [ ! -f "$TEMPLATE_FILE" ]; then
|
||||
echo -e "${RED}Error: Template file not found: $TEMPLATE_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy template and decrypt to temporary file
|
||||
if [ -z "${SOPS_AGE_KEY_FILE:-}" ]; then
|
||||
export SOPS_AGE_KEY_FILE="$PROJECT_ROOT/keys/age-key.txt"
|
||||
fi
|
||||
|
||||
# Decrypt template to temp file
|
||||
TEMP_PLAIN=$(mktemp)
|
||||
sops -d "$TEMPLATE_FILE" > "$TEMP_PLAIN"
|
||||
|
||||
# Replace client name placeholders
|
||||
sed -i '' "s/test/${CLIENT_NAME}/g" "$TEMP_PLAIN"
|
||||
|
||||
# Create unencrypted file in correct location (matching .sops.yaml regex)
|
||||
# This is necessary because SOPS needs the file path to match creation rules
|
||||
TEMP_SOPS="${SECRETS_FILE%.sops.yaml}-unenc.sops.yaml"
|
||||
cat "$TEMP_PLAIN" > "$TEMP_SOPS"
|
||||
|
||||
# Encrypt in-place (SOPS finds creation rules because path matches regex)
|
||||
sops --encrypt --in-place "$TEMP_SOPS"
|
||||
|
||||
# Rename to final name
|
||||
mv "$TEMP_SOPS" "$SECRETS_FILE"
|
||||
|
||||
# Cleanup
|
||||
rm "$TEMP_PLAIN"
|
||||
|
||||
echo -e "${GREEN}✓ Created secrets file with client-specific domains${NC}"
|
||||
echo ""
|
||||
|
||||
# Automatically generate unique passwords
|
||||
echo -e "${BLUE}Generating unique passwords for ${CLIENT_NAME}...${NC}"
|
||||
echo ""
|
||||
|
||||
# Call the password generator script
|
||||
"$SCRIPT_DIR/generate-passwords.sh" "$CLIENT_NAME"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Secrets file configured with unique passwords${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}To view credentials:${NC}"
|
||||
echo -e " ${BLUE}./scripts/get-passwords.sh ${CLIENT_NAME}${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Load Hetzner API token from SOPS if not already set
|
||||
if [ -z "${HCLOUD_TOKEN:-}" ]; then
|
||||
echo -e "${BLUE}Loading Hetzner API token from SOPS...${NC}"
|
||||
# shellcheck source=scripts/load-secrets-env.sh
|
||||
source "$SCRIPT_DIR/load-secrets-env.sh"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -z "${SOPS_AGE_KEY_FILE:-}" ]; then
|
||||
echo -e "${YELLOW}Warning: SOPS_AGE_KEY_FILE not set, using default${NC}"
|
||||
export SOPS_AGE_KEY_FILE="$PROJECT_ROOT/keys/age-key.txt"
|
||||
fi
|
||||
|
||||
# Verify client is defined in terraform.tfvars
|
||||
cd "$PROJECT_ROOT/tofu"
|
||||
|
||||
if ! grep -q "\"$CLIENT_NAME\"" terraform.tfvars 2>/dev/null; then
|
||||
echo -e "${YELLOW}⚠ Client '$CLIENT_NAME' not found in terraform.tfvars${NC}"
|
||||
echo ""
|
||||
echo "Add the following to tofu/terraform.tfvars:"
|
||||
echo ""
|
||||
echo "clients = {"
|
||||
echo " \"$CLIENT_NAME\" = {"
|
||||
echo " server_type = \"cx22\" # 2 vCPU, 4GB RAM"
|
||||
echo " location = \"nbg1\" # Nuremberg, Germany"
|
||||
echo " }"
|
||||
echo "}"
|
||||
echo ""
|
||||
read -p "Continue anyway? (yes/no): " continue_confirm
|
||||
if [ "$continue_confirm" != "yes" ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if client exists in terraform.tfvars
|
||||
TFVARS_FILE="$PROJECT_ROOT/tofu/terraform.tfvars"
|
||||
if ! grep -q "^[[:space:]]*${CLIENT_NAME}[[:space:]]*=" "$TFVARS_FILE"; then
|
||||
echo -e "${YELLOW}⚠ Client '${CLIENT_NAME}' not found in terraform.tfvars${NC}"
|
||||
echo ""
|
||||
echo "The client must be added to OpenTofu configuration before deployment."
|
||||
echo ""
|
||||
read -p "Would you like to add it now? (yes/no): " add_confirm
|
||||
|
||||
if [ "$add_confirm" = "yes" ]; then
|
||||
echo ""
|
||||
"$SCRIPT_DIR/add-client-to-terraform.sh" "$CLIENT_NAME"
|
||||
echo ""
|
||||
else
|
||||
echo -e "${RED}Error: Cannot deploy without OpenTofu configuration${NC}"
|
||||
echo ""
|
||||
echo "Add the client manually to tofu/terraform.tfvars, or run:"
|
||||
echo " ./scripts/add-client-to-terraform.sh $CLIENT_NAME"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start timer
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE}Deploying fresh client: $CLIENT_NAME${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 1: Provision infrastructure
|
||||
echo -e "${YELLOW}[1/5] Provisioning infrastructure with OpenTofu...${NC}"
|
||||
|
||||
cd "$PROJECT_ROOT/tofu"
|
||||
|
||||
# Export TF_VAR environment variables if HCLOUD_TOKEN is set
|
||||
if [ -n "${HCLOUD_TOKEN:-}" ]; then
|
||||
export TF_VAR_hcloud_token="$HCLOUD_TOKEN"
|
||||
export TF_VAR_hetznerdns_token="$HCLOUD_TOKEN"
|
||||
fi
|
||||
|
||||
# Check if already exists
|
||||
if tofu state list 2>/dev/null | grep -q "hcloud_server.client\[\"$CLIENT_NAME\"\]"; then
|
||||
echo -e "${YELLOW}⚠ Server already exists, applying any missing DNS records...${NC}"
|
||||
tofu apply -auto-approve -var-file="terraform.tfvars"
|
||||
else
|
||||
# Apply full infrastructure (server + DNS)
|
||||
tofu apply -auto-approve -var-file="terraform.tfvars"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Infrastructure provisioned${NC}"
|
||||
echo ""
|
||||
|
||||
# Wait for server to be ready
|
||||
echo -e "${YELLOW}Waiting 60 seconds for server to initialize...${NC}"
|
||||
sleep 60
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 2: Setup base system
|
||||
echo -e "${YELLOW}[2/5] Setting up base system (Docker, Traefik)...${NC}"
|
||||
|
||||
cd "$PROJECT_ROOT/ansible"
|
||||
|
||||
~/.local/bin/ansible-playbook -i hcloud.yml playbooks/setup.yml --limit "$CLIENT_NAME" --private-key "../keys/ssh/$CLIENT_NAME"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Base system configured${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 3: Deploy applications
|
||||
echo -e "${YELLOW}[3/5] Deploying applications (Authentik, Nextcloud, SSO)...${NC}"
|
||||
|
||||
~/.local/bin/ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit "$CLIENT_NAME" --private-key "../keys/ssh/$CLIENT_NAME"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Applications deployed${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 4: Update client registry
|
||||
echo -e "${YELLOW}[4/5] Updating client registry...${NC}"
|
||||
|
||||
cd "$PROJECT_ROOT/tofu"
|
||||
|
||||
# Get server information from Terraform state
|
||||
SERVER_IP=$(tofu output -json client_ips 2>/dev/null | jq -r ".\"$CLIENT_NAME\"" || echo "")
|
||||
SERVER_ID=$(tofu state show "hcloud_server.client[\"$CLIENT_NAME\"]" 2>/dev/null | grep "^[[:space:]]*id[[:space:]]*=" | awk '{print $3}' | tr -d '"' || echo "")
|
||||
SERVER_TYPE=$(tofu state show "hcloud_server.client[\"$CLIENT_NAME\"]" 2>/dev/null | grep "^[[:space:]]*server_type[[:space:]]*=" | awk '{print $3}' | tr -d '"' || echo "")
|
||||
SERVER_LOCATION=$(tofu state show "hcloud_server.client[\"$CLIENT_NAME\"]" 2>/dev/null | grep "^[[:space:]]*location[[:space:]]*=" | awk '{print $3}' | tr -d '"' || echo "")
|
||||
|
||||
# Determine role (dev is canary, everything else is production by default)
|
||||
ROLE="production"
|
||||
if [ "$CLIENT_NAME" = "dev" ]; then
|
||||
ROLE="canary"
|
||||
fi
|
||||
|
||||
# Update registry
|
||||
"$SCRIPT_DIR/update-registry.sh" "$CLIENT_NAME" deploy \
|
||||
--role="$ROLE" \
|
||||
--server-ip="$SERVER_IP" \
|
||||
--server-id="$SERVER_ID" \
|
||||
--server-type="$SERVER_TYPE" \
|
||||
--server-location="$SERVER_LOCATION"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Registry updated${NC}"
|
||||
echo ""
|
||||
|
||||
# Collect deployed versions
|
||||
echo -e "${YELLOW}Collecting deployed versions...${NC}"
|
||||
|
||||
"$SCRIPT_DIR/collect-client-versions.sh" "$CLIENT_NAME" 2>/dev/null || {
|
||||
echo -e "${YELLOW}⚠ Could not collect versions automatically${NC}"
|
||||
echo "Run manually later: ./scripts/collect-client-versions.sh $CLIENT_NAME"
|
||||
}
|
||||
|
||||
echo ""
|
||||
|
||||
# Add to monitoring
|
||||
echo -e "${YELLOW}[5/5] Adding client to monitoring...${NC}"
|
||||
echo ""
|
||||
|
||||
if [ -f "$SCRIPT_DIR/add-client-to-monitoring.sh" ]; then
|
||||
"$SCRIPT_DIR/add-client-to-monitoring.sh" "$CLIENT_NAME"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Monitoring script not found${NC}"
|
||||
echo "Manually add monitors at: https://status.vrije.cloud"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Calculate duration
|
||||
END_TIME=$(date +%s)
|
||||
DURATION=$((END_TIME - START_TIME))
|
||||
MINUTES=$((DURATION / 60))
|
||||
SECONDS=$((DURATION % 60))
|
||||
|
||||
# Success summary
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}✓ Deployment complete!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Time taken: ${MINUTES}m ${SECONDS}s${NC}"
|
||||
echo ""
|
||||
echo "Services deployed:"
|
||||
|
||||
# Load client domains from secrets
|
||||
CLIENT_DOMAIN=$(sops -d "$SECRETS_FILE" | grep "^client_domain:" | awk '{print $2}')
|
||||
AUTHENTIK_DOMAIN=$(sops -d "$SECRETS_FILE" | grep "^authentik_domain:" | awk '{print $2}')
|
||||
NEXTCLOUD_DOMAIN=$(sops -d "$SECRETS_FILE" | grep "^nextcloud_domain:" | awk '{print $2}')
|
||||
BOOTSTRAP_PASSWORD=$(sops -d "$SECRETS_FILE" | grep "^authentik_bootstrap_password:" | awk '{print $2}')
|
||||
NEXTCLOUD_PASSWORD=$(sops -d "$SECRETS_FILE" | grep "^nextcloud_admin_password:" | awk '{print $2}')
|
||||
|
||||
echo " ✓ Authentik SSO: https://$AUTHENTIK_DOMAIN"
|
||||
echo " ✓ Nextcloud: https://$NEXTCLOUD_DOMAIN"
|
||||
echo ""
|
||||
echo "Admin credentials:"
|
||||
echo " Authentik:"
|
||||
echo " Username: akadmin"
|
||||
echo " Password: $BOOTSTRAP_PASSWORD"
|
||||
echo ""
|
||||
echo " Nextcloud:"
|
||||
echo " Username: admin"
|
||||
echo " Password: $NEXTCLOUD_PASSWORD"
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ SSO Integration: Fully automated and configured${NC}"
|
||||
echo " Users can login to Nextcloud with Authentik credentials"
|
||||
echo " 'Login with Authentik' button is already visible"
|
||||
echo ""
|
||||
echo -e "${GREEN}Ready to use! No manual configuration required.${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Login to Authentik: https://$AUTHENTIK_DOMAIN"
|
||||
echo " 2. Create user accounts in Authentik"
|
||||
echo " 3. Users can login to Nextcloud with those credentials"
|
||||
echo ""
|
||||
echo "Management commands:"
|
||||
echo " View secrets: sops $SECRETS_FILE"
|
||||
echo " Rebuild server: ./scripts/rebuild-client.sh $CLIENT_NAME"
|
||||
echo " Destroy server: ./scripts/destroy-client.sh $CLIENT_NAME"
|
||||
echo ""
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Destroy a client's infrastructure
|
||||
#
|
||||
# Usage: ./scripts/destroy-client.sh <client_name>
|
||||
#
|
||||
# This script will:
|
||||
# 1. Remove all Docker containers and volumes on the server
|
||||
# 2. Destroy the VPS server via OpenTofu
|
||||
# 3. Remove DNS records
|
||||
#
|
||||
# WARNING: This is DESTRUCTIVE and IRREVERSIBLE!
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Check arguments
|
||||
if [ $# -ne 1 ]; then
|
||||
echo -e "${RED}Error: Client name required${NC}"
|
||||
echo "Usage: $0 <client_name>"
|
||||
echo ""
|
||||
echo "Example: $0 test"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLIENT_NAME="$1"
|
||||
|
||||
# Check if secrets file exists
|
||||
SECRETS_FILE="$PROJECT_ROOT/secrets/clients/${CLIENT_NAME}.sops.yaml"
|
||||
if [ ! -f "$SECRETS_FILE" ]; then
|
||||
echo -e "${RED}Error: Secrets file not found: $SECRETS_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load Hetzner API token from SOPS if not already set
|
||||
if [ -z "${HCLOUD_TOKEN:-}" ]; then
|
||||
echo -e "${BLUE}Loading Hetzner API token from SOPS...${NC}"
|
||||
# shellcheck source=scripts/load-secrets-env.sh
|
||||
source "$SCRIPT_DIR/load-secrets-env.sh"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -z "${SOPS_AGE_KEY_FILE:-}" ]; then
|
||||
echo -e "${YELLOW}Warning: SOPS_AGE_KEY_FILE not set, using default${NC}"
|
||||
export SOPS_AGE_KEY_FILE="$PROJECT_ROOT/keys/age-key.txt"
|
||||
fi
|
||||
|
||||
# Confirmation prompt
|
||||
echo -e "${RED}========================================${NC}"
|
||||
echo -e "${RED}WARNING: DESTRUCTIVE OPERATION${NC}"
|
||||
echo -e "${RED}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "This will ${RED}PERMANENTLY DELETE${NC}:"
|
||||
echo " - VPS server for client: $CLIENT_NAME"
|
||||
echo " - All Docker containers and volumes"
|
||||
echo " - All DNS records"
|
||||
echo " - All data on the server"
|
||||
echo ""
|
||||
echo -e "${YELLOW}This operation CANNOT be undone!${NC}"
|
||||
echo ""
|
||||
read -p "Type the client name '$CLIENT_NAME' to confirm: " confirmation
|
||||
|
||||
if [ "$confirmation" != "$CLIENT_NAME" ]; then
|
||||
echo -e "${RED}Confirmation failed. Aborting.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Starting destruction of client: $CLIENT_NAME${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 0: Remove from monitoring
|
||||
echo -e "${YELLOW}[0/7] Removing client from monitoring...${NC}"
|
||||
echo ""
|
||||
|
||||
if [ -f "$SCRIPT_DIR/remove-client-from-monitoring.sh" ]; then
|
||||
"$SCRIPT_DIR/remove-client-from-monitoring.sh" "$CLIENT_NAME"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Monitoring script not found${NC}"
|
||||
echo "Manually remove monitors at: https://status.vrije.cloud"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 1: Delete Mailgun SMTP credentials
|
||||
echo -e "${YELLOW}[1/7] Deleting Mailgun SMTP credentials...${NC}"
|
||||
|
||||
cd "$PROJECT_ROOT/ansible"
|
||||
|
||||
# Run cleanup playbook to delete SMTP credentials
|
||||
~/.local/bin/ansible-playbook -i hcloud.yml playbooks/cleanup.yml --limit "$CLIENT_NAME" 2>/dev/null || echo -e "${YELLOW}⚠ Could not delete SMTP credentials (API key may not be configured)${NC}"
|
||||
|
||||
echo -e "${GREEN}✓ SMTP credentials cleanup attempted${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 2: Clean up Docker containers and volumes on the server (if reachable)
|
||||
echo -e "${YELLOW}[2/7] Cleaning up Docker containers and volumes...${NC}"
|
||||
|
||||
# Try to use per-client SSH key if it exists
|
||||
SSH_KEY_ARG=""
|
||||
if [ -f "$PROJECT_ROOT/keys/ssh/${CLIENT_NAME}" ]; then
|
||||
SSH_KEY_ARG="--private-key=$PROJECT_ROOT/keys/ssh/${CLIENT_NAME}"
|
||||
fi
|
||||
|
||||
if ~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" $SSH_KEY_ARG -m ping -o &>/dev/null; then
|
||||
echo "Server is reachable, cleaning up Docker resources..."
|
||||
|
||||
# Stop and remove all containers
|
||||
~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" $SSH_KEY_ARG -m shell -a "docker ps -aq | xargs -r docker stop" -b 2>/dev/null || true
|
||||
~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" $SSH_KEY_ARG -m shell -a "docker ps -aq | xargs -r docker rm -f" -b 2>/dev/null || true
|
||||
|
||||
# Remove all volumes
|
||||
~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" $SSH_KEY_ARG -m shell -a "docker volume ls -q | xargs -r docker volume rm -f" -b 2>/dev/null || true
|
||||
|
||||
# Remove all networks (except defaults)
|
||||
~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" $SSH_KEY_ARG -m shell -a "docker network ls --filter type=custom -q | xargs -r docker network rm" -b 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}✓ Docker cleanup complete${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Server not reachable, skipping Docker cleanup${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 3: Destroy infrastructure with OpenTofu
|
||||
echo -e "${YELLOW}[3/7] Destroying infrastructure with OpenTofu...${NC}"
|
||||
|
||||
cd "$PROJECT_ROOT/tofu"
|
||||
|
||||
# Destroy all resources for this client (server, volume, SSH key, DNS)
|
||||
echo "Checking current infrastructure..."
|
||||
tofu plan -destroy -var-file="terraform.tfvars" \
|
||||
-target="hcloud_server.client[\"$CLIENT_NAME\"]" \
|
||||
-target="hcloud_volume.nextcloud_data[\"$CLIENT_NAME\"]" \
|
||||
-target="hcloud_volume_attachment.nextcloud_data[\"$CLIENT_NAME\"]" \
|
||||
-target="hcloud_ssh_key.client[\"$CLIENT_NAME\"]" \
|
||||
-target="hcloud_zone_rrset.client_a[\"$CLIENT_NAME\"]" \
|
||||
-target="hcloud_zone_rrset.client_wildcard[\"$CLIENT_NAME\"]" \
|
||||
-out=destroy.tfplan
|
||||
|
||||
echo ""
|
||||
echo "Verifying plan only targets $CLIENT_NAME resources..."
|
||||
|
||||
# Verify the plan only contains the client's resources
|
||||
PLAN_OUTPUT=$(tofu show destroy.tfplan 2>&1)
|
||||
if echo "$PLAN_OUTPUT" | grep -E "will be destroyed" | grep -v "\"$CLIENT_NAME\"" | grep -q .; then
|
||||
echo -e "${RED}ERROR: Plan contains resources NOT belonging to $CLIENT_NAME!${NC}"
|
||||
echo ""
|
||||
echo "Resources in plan:"
|
||||
echo "$PLAN_OUTPUT" | grep -E "# .* will be destroyed" | head -20
|
||||
echo ""
|
||||
echo "Aborting to prevent accidental destruction of other clients."
|
||||
rm -f destroy.tfplan
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Plan verified - only $CLIENT_NAME resources will be destroyed${NC}"
|
||||
echo ""
|
||||
echo "Applying destruction..."
|
||||
tofu apply -auto-approve destroy.tfplan
|
||||
|
||||
# Cleanup plan file
|
||||
rm -f destroy.tfplan
|
||||
|
||||
echo -e "${GREEN}✓ Infrastructure destroyed${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 4: Remove client from terraform.tfvars
|
||||
echo -e "${YELLOW}[4/7] Removing client from terraform.tfvars...${NC}"
|
||||
|
||||
TFVARS_FILE="$PROJECT_ROOT/tofu/terraform.tfvars"
|
||||
if grep -q "^[[:space:]]*${CLIENT_NAME}[[:space:]]*=" "$TFVARS_FILE"; then
|
||||
# Create backup
|
||||
cp "$TFVARS_FILE" "$TFVARS_FILE.bak"
|
||||
|
||||
# Remove the client block (from "client_name = {" to the closing "}")
|
||||
# This uses awk to find and remove the entire block
|
||||
awk -v client="$CLIENT_NAME" '
|
||||
BEGIN { skip=0; in_block=0 }
|
||||
/^[[:space:]]*#.*[Cc]lient/ { if (skip==0) print; next }
|
||||
$0 ~ "^[[:space:]]*" client "[[:space:]]*=" { skip=1; in_block=1; brace_count=0; next }
|
||||
skip==1 {
|
||||
for(i=1; i<=length($0); i++) {
|
||||
c=substr($0,i,1)
|
||||
if(c=="{") brace_count++
|
||||
if(c=="}") brace_count--
|
||||
}
|
||||
if(brace_count<0 || (brace_count==0 && $0 ~ /^[[:space:]]*}/)) {
|
||||
skip=0
|
||||
in_block=0
|
||||
next
|
||||
}
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$TFVARS_FILE" > "$TFVARS_FILE.tmp"
|
||||
|
||||
mv "$TFVARS_FILE.tmp" "$TFVARS_FILE"
|
||||
echo -e "${GREEN}✓ Removed $CLIENT_NAME from terraform.tfvars${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Client not found in terraform.tfvars${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 5: Remove SSH keys
|
||||
echo -e "${YELLOW}[5/7] Removing SSH keys...${NC}"
|
||||
|
||||
SSH_PRIVATE="$PROJECT_ROOT/keys/ssh/${CLIENT_NAME}"
|
||||
SSH_PUBLIC="$PROJECT_ROOT/keys/ssh/${CLIENT_NAME}.pub"
|
||||
|
||||
if [ -f "$SSH_PRIVATE" ]; then
|
||||
rm -f "$SSH_PRIVATE"
|
||||
echo -e "${GREEN}✓ Removed private key: $SSH_PRIVATE${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Private key not found${NC}"
|
||||
fi
|
||||
|
||||
if [ -f "$SSH_PUBLIC" ]; then
|
||||
rm -f "$SSH_PUBLIC"
|
||||
echo -e "${GREEN}✓ Removed public key: $SSH_PUBLIC${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Public key not found${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 6: Remove secrets file
|
||||
echo -e "${YELLOW}[6/7] Removing secrets file...${NC}"
|
||||
|
||||
if [ -f "$SECRETS_FILE" ]; then
|
||||
rm -f "$SECRETS_FILE"
|
||||
echo -e "${GREEN}✓ Removed secrets file: $SECRETS_FILE${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Secrets file not found${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 7: Update client registry
|
||||
echo -e "${YELLOW}[7/7] Updating client registry...${NC}"
|
||||
|
||||
"$SCRIPT_DIR/update-registry.sh" "$CLIENT_NAME" destroy
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Registry updated${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}✓ Client '$CLIENT_NAME' destroyed successfully${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo "The following have been removed:"
|
||||
echo " ✓ Mailgun SMTP credentials"
|
||||
echo " ✓ VPS server"
|
||||
echo " ✓ Hetzner Volume"
|
||||
echo " ✓ SSH keys (Hetzner + local)"
|
||||
echo " ✓ DNS records"
|
||||
echo " ✓ Firewall rules"
|
||||
echo " ✓ Secrets file"
|
||||
echo " ✓ terraform.tfvars entry"
|
||||
echo " ✓ Registry entry"
|
||||
echo ""
|
||||
echo "The client has been completely removed from the infrastructure."
|
||||
echo ""
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Detect version drift between clients
|
||||
#
|
||||
# Usage: ./scripts/detect-version-drift.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --threshold=<days> Only report clients not updated in X days (default: 30)
|
||||
# --app=<name> Check specific app only (authentik|nextcloud|traefik|ubuntu)
|
||||
# --format=table Show as table (default)
|
||||
# --format=summary Show summary only
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 - No drift detected
|
||||
# 1 - Drift detected
|
||||
# 2 - Error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
REGISTRY_FILE="$PROJECT_ROOT/clients/registry.yml"
|
||||
|
||||
# Default options
|
||||
THRESHOLD_DAYS=30
|
||||
FILTER_APP=""
|
||||
FORMAT="table"
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--threshold=*)
|
||||
THRESHOLD_DAYS="${arg#*=}"
|
||||
;;
|
||||
--app=*)
|
||||
FILTER_APP="${arg#*=}"
|
||||
;;
|
||||
--format=*)
|
||||
FORMAT="${arg#*=}"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg"
|
||||
echo "Usage: $0 [--threshold=<days>] [--app=<name>] [--format=table|summary]"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check if yq is available
|
||||
if ! command -v yq &> /dev/null; then
|
||||
echo -e "${RED}Error: 'yq' not found. Install with: brew install yq${NC}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Check if registry exists
|
||||
if [ ! -f "$REGISTRY_FILE" ]; then
|
||||
echo -e "${RED}Error: Registry file not found: $REGISTRY_FILE${NC}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Get list of deployed clients only
|
||||
CLIENTS=$(yq eval '.clients | to_entries | map(select(.value.status == "deployed")) | .[].key' "$REGISTRY_FILE" 2>/dev/null)
|
||||
|
||||
if [ -z "$CLIENTS" ]; then
|
||||
echo -e "${YELLOW}No deployed clients found${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Determine latest versions
|
||||
declare -A LATEST_VERSIONS
|
||||
LATEST_VERSIONS[authentik]=$(yq eval '.clients | to_entries | .[].value.versions.authentik' "$REGISTRY_FILE" | sort -V | tail -1)
|
||||
LATEST_VERSIONS[nextcloud]=$(yq eval '.clients | to_entries | .[].value.versions.nextcloud' "$REGISTRY_FILE" | sort -V | tail -1)
|
||||
LATEST_VERSIONS[traefik]=$(yq eval '.clients | to_entries | .[].value.versions.traefik' "$REGISTRY_FILE" | sort -V | tail -1)
|
||||
LATEST_VERSIONS[ubuntu]=$(yq eval '.clients | to_entries | .[].value.versions.ubuntu' "$REGISTRY_FILE" | sort -V | tail -1)
|
||||
|
||||
# Calculate date threshold
|
||||
if command -v gdate &> /dev/null; then
|
||||
# macOS with GNU coreutils
|
||||
THRESHOLD_DATE=$(gdate -d "$THRESHOLD_DAYS days ago" +%Y-%m-%d)
|
||||
elif date --version &> /dev/null 2>&1; then
|
||||
# GNU date (Linux)
|
||||
THRESHOLD_DATE=$(date -d "$THRESHOLD_DAYS days ago" +%Y-%m-%d)
|
||||
else
|
||||
# BSD date (macOS default)
|
||||
THRESHOLD_DATE=$(date -v-${THRESHOLD_DAYS}d +%Y-%m-%d)
|
||||
fi
|
||||
|
||||
# Counters
|
||||
DRIFT_FOUND=0
|
||||
OUTDATED_COUNT=0
|
||||
STALE_COUNT=0
|
||||
|
||||
# Arrays to store drift details
|
||||
declare -a DRIFT_CLIENTS
|
||||
declare -a DRIFT_DETAILS
|
||||
|
||||
# Analyze each client
|
||||
for client in $CLIENTS; do
|
||||
authentik=$(yq eval ".clients.\"$client\".versions.authentik" "$REGISTRY_FILE")
|
||||
nextcloud=$(yq eval ".clients.\"$client\".versions.nextcloud" "$REGISTRY_FILE")
|
||||
traefik=$(yq eval ".clients.\"$client\".versions.traefik" "$REGISTRY_FILE")
|
||||
ubuntu=$(yq eval ".clients.\"$client\".versions.ubuntu" "$REGISTRY_FILE")
|
||||
last_update=$(yq eval ".clients.\"$client\".maintenance.last_full_update" "$REGISTRY_FILE")
|
||||
|
||||
has_drift=false
|
||||
drift_reasons=()
|
||||
|
||||
# Check version drift
|
||||
if [ -z "$FILTER_APP" ] || [ "$FILTER_APP" = "authentik" ]; then
|
||||
if [ "$authentik" != "${LATEST_VERSIONS[authentik]}" ] && [ "$authentik" != "null" ] && [ "$authentik" != "unknown" ]; then
|
||||
has_drift=true
|
||||
drift_reasons+=("Authentik: $authentik → ${LATEST_VERSIONS[authentik]}")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$FILTER_APP" ] || [ "$FILTER_APP" = "nextcloud" ]; then
|
||||
if [ "$nextcloud" != "${LATEST_VERSIONS[nextcloud]}" ] && [ "$nextcloud" != "null" ] && [ "$nextcloud" != "unknown" ]; then
|
||||
has_drift=true
|
||||
drift_reasons+=("Nextcloud: $nextcloud → ${LATEST_VERSIONS[nextcloud]}")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$FILTER_APP" ] || [ "$FILTER_APP" = "traefik" ]; then
|
||||
if [ "$traefik" != "${LATEST_VERSIONS[traefik]}" ] && [ "$traefik" != "null" ] && [ "$traefik" != "unknown" ]; then
|
||||
has_drift=true
|
||||
drift_reasons+=("Traefik: $traefik → ${LATEST_VERSIONS[traefik]}")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$FILTER_APP" ] || [ "$FILTER_APP" = "ubuntu" ]; then
|
||||
if [ "$ubuntu" != "${LATEST_VERSIONS[ubuntu]}" ] && [ "$ubuntu" != "null" ] && [ "$ubuntu" != "unknown" ]; then
|
||||
has_drift=true
|
||||
drift_reasons+=("Ubuntu: $ubuntu → ${LATEST_VERSIONS[ubuntu]}")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if update is stale (older than threshold)
|
||||
is_stale=false
|
||||
if [ "$last_update" != "null" ] && [ -n "$last_update" ]; then
|
||||
if [[ "$last_update" < "$THRESHOLD_DATE" ]]; then
|
||||
is_stale=true
|
||||
drift_reasons+=("Last update: $last_update (>$THRESHOLD_DAYS days ago)")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Record drift
|
||||
if [ "$has_drift" = true ] || [ "$is_stale" = true ]; then
|
||||
DRIFT_FOUND=1
|
||||
DRIFT_CLIENTS+=("$client")
|
||||
DRIFT_DETAILS+=("$(IFS='; '; echo "${drift_reasons[*]}")")
|
||||
|
||||
[ "$has_drift" = true ] && ((OUTDATED_COUNT++)) || true
|
||||
[ "$is_stale" = true ] && ((STALE_COUNT++)) || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Output results
|
||||
case $FORMAT in
|
||||
table)
|
||||
if [ $DRIFT_FOUND -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ No version drift detected${NC}"
|
||||
echo ""
|
||||
echo "All deployed clients are running latest versions:"
|
||||
echo " Authentik: ${LATEST_VERSIONS[authentik]}"
|
||||
echo " Nextcloud: ${LATEST_VERSIONS[nextcloud]}"
|
||||
echo " Traefik: ${LATEST_VERSIONS[traefik]}"
|
||||
echo " Ubuntu: ${LATEST_VERSIONS[ubuntu]}"
|
||||
echo ""
|
||||
else
|
||||
echo -e "${RED}⚠ VERSION DRIFT DETECTED${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}Clients with outdated versions:${NC}"
|
||||
echo ""
|
||||
|
||||
for i in "${!DRIFT_CLIENTS[@]}"; do
|
||||
client="${DRIFT_CLIENTS[$i]}"
|
||||
details="${DRIFT_DETAILS[$i]}"
|
||||
|
||||
echo -e "${YELLOW}• $client${NC}"
|
||||
IFS=';' read -ra REASONS <<< "$details"
|
||||
for reason in "${REASONS[@]}"; do
|
||||
echo " $reason"
|
||||
done
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo -e "${CYAN}Recommended actions:${NC}"
|
||||
echo ""
|
||||
echo "1. Test updates on canary server first:"
|
||||
echo " ${BLUE}./scripts/rebuild-client.sh dev${NC}"
|
||||
echo ""
|
||||
echo "2. Verify canary health:"
|
||||
echo " ${BLUE}./scripts/client-status.sh dev${NC}"
|
||||
echo ""
|
||||
echo "3. Update outdated clients:"
|
||||
for client in "${DRIFT_CLIENTS[@]}"; do
|
||||
echo " ${BLUE}./scripts/rebuild-client.sh $client${NC}"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
;;
|
||||
|
||||
summary)
|
||||
if [ $DRIFT_FOUND -eq 0 ]; then
|
||||
echo "Status: OK"
|
||||
echo "Drift: No"
|
||||
echo "Clients checked: $(echo "$CLIENTS" | wc -l | xargs)"
|
||||
else
|
||||
echo "Status: DRIFT DETECTED"
|
||||
echo "Drift: Yes"
|
||||
echo "Clients checked: $(echo "$CLIENTS" | wc -l | xargs)"
|
||||
echo "Clients with outdated versions: $OUTDATED_COUNT"
|
||||
echo "Clients not updated in $THRESHOLD_DAYS days: $STALE_COUNT"
|
||||
echo "Affected clients: ${DRIFT_CLIENTS[*]}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exit $DRIFT_FOUND
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue