feat: full untested ansible setup
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,3 +8,9 @@ wheels/
|
|||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
# Ansible secrets
|
||||||
|
group_vars/all/vault.yml
|
||||||
|
|
||||||
|
# OS-generated files
|
||||||
|
.DS_Store
|
||||||
|
|||||||
64
README.md
64
README.md
@@ -1,3 +1,65 @@
|
|||||||
# Ansible Domo
|
# Ansible Domo
|
||||||
|
|
||||||
Ansible stuff for Twirre, by Twirre. Going for an Esperanto naming theme.
|
Going for an Esperanto naming theme.
|
||||||
|
|
||||||
|
Portable Ansible provisioning for Twirre infrastructure. The current layout is built around Debian-family hosts, `/srv` for deployed services, `/etc` for config, and systemd-managed apps.
|
||||||
|
|
||||||
|
## What this provisions
|
||||||
|
|
||||||
|
- Base host packages, timezone, and static content roots
|
||||||
|
- SSH admin users with key-based login
|
||||||
|
- A dedicated `backupagent` user for rsync-based backups with restricted SSH settings and passwordless `sudo /usr/bin/rsync`
|
||||||
|
- Docker from distro packages
|
||||||
|
- Bun installed to `/opt/bun` with `/usr/local/bin/bun`
|
||||||
|
- Two Bun app services: `twirre.io` and `twirre.me`
|
||||||
|
- Gitea and Docker Mailserver as Compose stacks under `/srv`
|
||||||
|
- nginx virtual hosts for the retained public domains
|
||||||
|
- apt-based certbot with ACME webroot support
|
||||||
|
- WireGuard with `wg-quick@wg0` enabled at boot
|
||||||
|
- fail2ban with the audit-derived SSH jail settings
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `site.yml`: top-level playbook
|
||||||
|
- `inventory/hosts.yml`: example inventory
|
||||||
|
- `group_vars/all/main.yml`: shared variables and service declarations
|
||||||
|
- `group_vars/all/vault.example.yml`: secrets shape to move into an encrypted Vault file
|
||||||
|
- `roles/`: reusable server roles
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Create an encrypted Vault file at `group_vars/all/vault.yml` based on `group_vars/all/vault.example.yml`. The playbook will use Vault variables when present and otherwise fall back to safe placeholders for syntax checking.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ansible-vault create group_vars/all/vault.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Update the inventory and variables first, especially:
|
||||||
|
|
||||||
|
- `inventory/hosts.yml`
|
||||||
|
- `group_vars/all/main.yml`
|
||||||
|
- `group_vars/all/vault.yml`
|
||||||
|
- repository URLs and domains for the Bun apps
|
||||||
|
- `backupagent.authorized_keys`
|
||||||
|
|
||||||
|
Run a syntax check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/ansible-playbook --syntax-check site.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the playbook:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/ansible-playbook site.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- nginx falls back to snakeoil certificates until a matching ACME certificate already exists on disk.
|
||||||
|
- If you enable `certbot_manage_certificates`, run the playbook a second time after the first successful issuance so nginx can switch to the live certificates automatically.
|
||||||
|
- ACME issuance is disabled by default through `certbot_manage_certificates: false` so the first provisioning run can complete before DNS and public reachability are finalized.
|
||||||
|
|||||||
12
ansible.cfg
Normal file
12
ansible.cfg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[defaults]
|
||||||
|
inventory = inventory/hosts.yml
|
||||||
|
roles_path = roles
|
||||||
|
host_key_checking = False
|
||||||
|
retry_files_enabled = False
|
||||||
|
interpreter_python = auto_silent
|
||||||
|
stdout_callback = yaml
|
||||||
|
local_tmp = /tmp/ansible-local
|
||||||
|
remote_tmp = /tmp/.ansible/tmp
|
||||||
|
|
||||||
|
[ssh_connection]
|
||||||
|
pipelining = True
|
||||||
316
group_vars/all/main.yml
Normal file
316
group_vars/all/main.yml
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
---
|
||||||
|
timezone: Europe/Amsterdam
|
||||||
|
|
||||||
|
base_packages_common:
|
||||||
|
- apt-transport-https
|
||||||
|
- ca-certificates
|
||||||
|
- curl
|
||||||
|
- git
|
||||||
|
- gnupg
|
||||||
|
- python3
|
||||||
|
- rsync
|
||||||
|
- ssl-cert
|
||||||
|
- sudo
|
||||||
|
|
||||||
|
docker_enabled: true
|
||||||
|
bun_enabled: true
|
||||||
|
nginx_enabled: true
|
||||||
|
certbot_enabled: true
|
||||||
|
wireguard_enabled: true
|
||||||
|
fail2ban_enabled: true
|
||||||
|
gitea_enabled: true
|
||||||
|
mailserver_enabled: true
|
||||||
|
|
||||||
|
ssh_admin_groups:
|
||||||
|
- sudo
|
||||||
|
|
||||||
|
ssh_admin_users:
|
||||||
|
- name: twirre
|
||||||
|
shell: /bin/bash
|
||||||
|
groups: "{{ ssh_admin_groups }}"
|
||||||
|
authorized_keys:
|
||||||
|
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKSmroAJ4SDziZtwg+PCNITuhPim8oseq/sNwW0jTLJc twirre@gwen
|
||||||
|
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIfapo7P0vmwkTdD9kkHaalk9U+JYIZuCp/hFTnPRqTp twirre@ben
|
||||||
|
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFoC9Wp3nOI2a/u6G+7iKdF1WMJYdXr/RRp2uzGXJWio bob@bob
|
||||||
|
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDTD4O3ABkThFXaUpuKE14eRZYYqCBns1/MY7EAsLmlq iPhone
|
||||||
|
|
||||||
|
ssh_packages:
|
||||||
|
- openssh-server
|
||||||
|
|
||||||
|
backupagent_enabled: true
|
||||||
|
backupagent:
|
||||||
|
name: backupagent
|
||||||
|
shell: /bin/sh
|
||||||
|
authorized_keys:
|
||||||
|
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFoC9Wp3nOI2a/u6G+7iKdF1WMJYdXr/RRp2uzGXJWio bob@bob
|
||||||
|
sudo_commands:
|
||||||
|
- /usr/bin/rsync
|
||||||
|
|
||||||
|
docker_packages:
|
||||||
|
- docker.io
|
||||||
|
- docker-compose-plugin
|
||||||
|
|
||||||
|
bun_version: "1.3.10"
|
||||||
|
bun_arch_map:
|
||||||
|
x86_64: x64
|
||||||
|
aarch64: aarch64
|
||||||
|
bun_install_root: "/opt/bun/{{ bun_version }}"
|
||||||
|
bun_bin_path: /usr/local/bin/bun
|
||||||
|
|
||||||
|
certbot_packages:
|
||||||
|
- certbot
|
||||||
|
- python3-certbot-nginx
|
||||||
|
certbot_email: admin@twirre.io
|
||||||
|
certbot_manage_certificates: false
|
||||||
|
certbot_certificates:
|
||||||
|
- name: twirre.io
|
||||||
|
domains:
|
||||||
|
- twirre.io
|
||||||
|
- name: twirre.me
|
||||||
|
domains:
|
||||||
|
- twirre.me
|
||||||
|
- name: git.twirre.io
|
||||||
|
domains:
|
||||||
|
- git.twirre.io
|
||||||
|
- name: lagrange.meulenbelt.nl
|
||||||
|
domains:
|
||||||
|
- lagrange.meulenbelt.nl
|
||||||
|
- name: map.twirre.io
|
||||||
|
domains:
|
||||||
|
- map.twirre.io
|
||||||
|
- name: chat.twirre.io
|
||||||
|
domains:
|
||||||
|
- chat.twirre.io
|
||||||
|
- name: overleaf.twirre.io
|
||||||
|
domains:
|
||||||
|
- overleaf.twirre.io
|
||||||
|
- name: mail.twirre.io
|
||||||
|
domains:
|
||||||
|
- mail.twirre.io
|
||||||
|
|
||||||
|
fail2ban_ignoreip:
|
||||||
|
- 127.0.0.1/8
|
||||||
|
- ::1
|
||||||
|
- 10.0.0.0/24
|
||||||
|
fail2ban_bantime: 15m
|
||||||
|
fail2ban_findtime: 24h
|
||||||
|
fail2ban_maxretry: 3
|
||||||
|
|
||||||
|
wireguard_interface:
|
||||||
|
name: wg0
|
||||||
|
address:
|
||||||
|
- 10.0.0.1/32
|
||||||
|
listen_port: 51820
|
||||||
|
private_key: "{{ vault_wireguard_private_key | default('') }}"
|
||||||
|
peers:
|
||||||
|
- name: bob
|
||||||
|
public_key: 4PjCLHHodDBCqRRjc8qvhwiT/oTElL+e5wnbiLN5N1c=
|
||||||
|
preshared_key: "{{ vault_wireguard_bob_preshared_key | default('') }}"
|
||||||
|
allowed_ips:
|
||||||
|
- 10.0.0.2/32
|
||||||
|
persistent_keepalive: 25
|
||||||
|
- name: ben
|
||||||
|
public_key: pqEEPBsVPVsNALuYHC3nggwmAAeAcB+6NXhh/z+MazU=
|
||||||
|
preshared_key: "{{ vault_wireguard_ben_preshared_key | default('') }}"
|
||||||
|
allowed_ips:
|
||||||
|
- 10.0.0.3/32
|
||||||
|
- name: iPhone
|
||||||
|
public_key: /pZPnxXHBPxfYvJPwtPMmy09cOHIPATamVEloPJj/n0=
|
||||||
|
preshared_key: "{{ vault_wireguard_iPhone_preshared_key | default('') }}"
|
||||||
|
allowed_ips:
|
||||||
|
- 10.0.0.6/32
|
||||||
|
- name: iPad
|
||||||
|
public_key: GKTAOHRoRTTWayaHYype2QCO1o02UxNCHYrZDfvh1ns=
|
||||||
|
preshared_key: "{{ vault_wireguard_iPad_preshared_key | default('') }}"
|
||||||
|
allowed_ips:
|
||||||
|
- 10.0.0.7/32
|
||||||
|
- name: alternate1
|
||||||
|
public_key: 8BcmHZgxXJosvbeq/cpb6qYkOZXqmTbryS17j9ZsXTo=
|
||||||
|
preshared_key: "{{ vault_wireguard_alternate1_preshared_key | default('') }}"
|
||||||
|
allowed_ips:
|
||||||
|
- 10.0.0.8/32
|
||||||
|
- name: alternate2
|
||||||
|
public_key: Dy7zzlR9/oLXElABRlZYH4SifWMq2qHsh7m1XIWS2kU=
|
||||||
|
preshared_key: "{{ vault_wireguard_alternate2_preshared_key | default('') }}"
|
||||||
|
allowed_ips:
|
||||||
|
- 10.0.0.9/32
|
||||||
|
- name: alternate3
|
||||||
|
public_key: RKgTlbAI0Rp72geRPK9ViReGREGNI097fu8mDQQe1Xo=
|
||||||
|
preshared_key: "{{ vault_wireguard_alternate3_preshared_key | default('') }}"
|
||||||
|
allowed_ips:
|
||||||
|
- 10.0.0.10/32
|
||||||
|
- name: alternate4
|
||||||
|
public_key: JsI1ldD5f+2cqX6oLUGYt72JELFy4eDTb3N6Q9VFBgU=
|
||||||
|
preshared_key: "{{ vault_wireguard_alternate4_preshared_key | default('') }}"
|
||||||
|
allowed_ips:
|
||||||
|
- 10.0.0.11/32
|
||||||
|
- name: alternate5
|
||||||
|
public_key: OFvhjnpc9NtBUTrgRDU9Ya8G+WaoiHKHAxWy9v9N5nY=
|
||||||
|
preshared_key: "{{ vault_wireguard_alternate5_preshared_key | default('') }}"
|
||||||
|
allowed_ips:
|
||||||
|
- 10.0.0.12/32
|
||||||
|
- name: bill
|
||||||
|
public_key: upNSfWXN9pvUGcX5G6xFniClJAmlv6WatpVxIsJ2/lg=
|
||||||
|
preshared_key: "{{ vault_wireguard_bill_preshared_key | default('') }}"
|
||||||
|
allowed_ips:
|
||||||
|
- 10.0.0.13/32
|
||||||
|
|
||||||
|
srv_root: /srv
|
||||||
|
twirre_io_files:
|
||||||
|
visible_dir: /var/lib/twirre-io/files
|
||||||
|
hidden_dir: /var/lib/twirre-io/hfiles
|
||||||
|
|
||||||
|
bun_apps:
|
||||||
|
- name: twirre-io
|
||||||
|
repo: git@github.com:twirre/twirre.io.git
|
||||||
|
version: main
|
||||||
|
deploy_user: twirre-io
|
||||||
|
deploy_group: twirre-io
|
||||||
|
path: /srv/twirre/twirre.io
|
||||||
|
service_name: twirre_io
|
||||||
|
entrypoint: index.ts
|
||||||
|
port: 14014
|
||||||
|
git_ssh_key: "{{ vault_twirre_io_deploy_key | default('') }}"
|
||||||
|
env:
|
||||||
|
PORT: "14014"
|
||||||
|
ORIGIN: https://twirre.io
|
||||||
|
RPNAME: Twirre IO
|
||||||
|
RPID: twirre.io
|
||||||
|
SQLITE_PATH: /var/lib/twirre-io/app.sqlite3
|
||||||
|
VISIBLE_FILE_DIR: "{{ twirre_io_files.visible_dir }}"
|
||||||
|
HIDDEN_FILE_DIR: "{{ twirre_io_files.hidden_dir }}"
|
||||||
|
non_vault_env_keys:
|
||||||
|
- VISIBLE_FILE_DIR
|
||||||
|
- HIDDEN_FILE_DIR
|
||||||
|
extra_directories:
|
||||||
|
- path: "{{ twirre_io_files.visible_dir }}"
|
||||||
|
- path: "{{ twirre_io_files.hidden_dir }}"
|
||||||
|
- name: twirre-me
|
||||||
|
repo: git@github.com:twirre/twirre.me.git
|
||||||
|
version: main
|
||||||
|
deploy_user: twirre-me
|
||||||
|
deploy_group: twirre-me
|
||||||
|
path: /srv/twirre/twirre.me
|
||||||
|
service_name: twirre_me
|
||||||
|
entrypoint: index.ts
|
||||||
|
port: 13013
|
||||||
|
git_ssh_key: "{{ vault_twirre_me_deploy_key | default('') }}"
|
||||||
|
env:
|
||||||
|
PORT: "13013"
|
||||||
|
|
||||||
|
gitea:
|
||||||
|
service_user: gitea
|
||||||
|
service_group: docker
|
||||||
|
path: /srv/gitea
|
||||||
|
compose_project_name: gitea
|
||||||
|
domain: git.twirre.io
|
||||||
|
http_bind_address: 127.0.0.1
|
||||||
|
http_port: 3000
|
||||||
|
ssh_port: 2222
|
||||||
|
image: docker.gitea.com/gitea:1.25.4
|
||||||
|
data_dir: /srv/gitea/data
|
||||||
|
|
||||||
|
mailserver:
|
||||||
|
service_user: mailstack
|
||||||
|
service_group: docker
|
||||||
|
path: /srv/mail
|
||||||
|
compose_project_name: mailserver
|
||||||
|
image: ghcr.io/docker-mailserver/docker-mailserver:latest
|
||||||
|
hostname: mail.twirre.io
|
||||||
|
env:
|
||||||
|
ENABLE_SPAMASSASSIN: "0"
|
||||||
|
ENABLE_FAIL2BAN: "1"
|
||||||
|
SSL_TYPE: letsencrypt
|
||||||
|
PERMIT_DOCKER: host
|
||||||
|
tls_root_path: /etc/letsencrypt
|
||||||
|
|
||||||
|
nginx_sites:
|
||||||
|
- name: twirre.me
|
||||||
|
server_names:
|
||||||
|
- twirre.me
|
||||||
|
default_server: true
|
||||||
|
acme_managed: true
|
||||||
|
upstream_host: 127.0.0.1
|
||||||
|
upstream_port: 13013
|
||||||
|
- name: twirre.io
|
||||||
|
server_names:
|
||||||
|
- twirre.io
|
||||||
|
acme_managed: true
|
||||||
|
upstream_host: 127.0.0.1
|
||||||
|
upstream_port: 14014
|
||||||
|
static_locations:
|
||||||
|
- path: /files/
|
||||||
|
alias: "{{ twirre_io_files.visible_dir }}/"
|
||||||
|
autoindex: true
|
||||||
|
- path: /hfiles/
|
||||||
|
alias: "{{ twirre_io_files.hidden_dir }}/"
|
||||||
|
- name: git.twirre.io
|
||||||
|
server_names:
|
||||||
|
- git.twirre.io
|
||||||
|
acme_managed: true
|
||||||
|
upstream_host: 127.0.0.1
|
||||||
|
upstream_port: 3000
|
||||||
|
- name: lagrange.meulenbelt.nl
|
||||||
|
server_names:
|
||||||
|
- lagrange.meulenbelt.nl
|
||||||
|
acme_managed: true
|
||||||
|
static_root: /srv/lagrange
|
||||||
|
- name: map.twirre.io
|
||||||
|
server_names:
|
||||||
|
- map.twirre.io
|
||||||
|
acme_managed: true
|
||||||
|
upstream_host: 10.0.0.2
|
||||||
|
upstream_port: 8123
|
||||||
|
- name: chat.twirre.io
|
||||||
|
server_names:
|
||||||
|
- chat.twirre.io
|
||||||
|
acme_managed: true
|
||||||
|
websocket: true
|
||||||
|
upstream_host: 10.0.0.2
|
||||||
|
upstream_port: 14607
|
||||||
|
- name: overleaf.twirre.io
|
||||||
|
server_names:
|
||||||
|
- overleaf.twirre.io
|
||||||
|
acme_managed: true
|
||||||
|
upstream_host: 10.0.0.2
|
||||||
|
upstream_port: 18009
|
||||||
|
- name: mail.twirre.io
|
||||||
|
server_names:
|
||||||
|
- mail.twirre.io
|
||||||
|
acme_only: true
|
||||||
|
|
||||||
|
static_sites:
|
||||||
|
- name: lagrange
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
path: /srv/lagrange
|
||||||
|
files:
|
||||||
|
- path: index.html
|
||||||
|
content: |
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="color-scheme" content="light dark"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>In aanbouw</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
|
|
||||||
|
height: 100dvh;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>In aanbouw</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
group_vars/all/vault.example.yml
Normal file
26
group_vars/all/vault.example.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
vault_wireguard_private_key: REPLACE_ME
|
||||||
|
vault_wireguard_bob_preshared_key: REPLACE_ME
|
||||||
|
vault_wireguard_ben_preshared_key: REPLACE_ME
|
||||||
|
vault_wireguard_iPhone_preshared_key: REPLACE_ME
|
||||||
|
vault_wireguard_iPad_preshared_key: REPLACE_ME
|
||||||
|
vault_wireguard_alternate1_preshared_key: REPLACE_ME
|
||||||
|
vault_wireguard_alternate2_preshared_key: REPLACE_ME
|
||||||
|
vault_wireguard_alternate3_preshared_key: REPLACE_ME
|
||||||
|
vault_wireguard_alternate4_preshared_key: REPLACE_ME
|
||||||
|
vault_wireguard_alternate5_preshared_key: REPLACE_ME
|
||||||
|
vault_wireguard_bill_preshared_key: REPLACE_ME
|
||||||
|
|
||||||
|
vault_gitea_secret_key: REPLACE_ME
|
||||||
|
vault_gitea_internal_token: REPLACE_ME
|
||||||
|
vault_gitea_lfs_jwt_secret: REPLACE_ME
|
||||||
|
|
||||||
|
vault_mailserver_accounts: |
|
||||||
|
# account@example.com|supersecret
|
||||||
|
|
||||||
|
vault_twirre_io_env: {}
|
||||||
|
vault_twirre_me_env: {}
|
||||||
|
vault_twirre_io_deploy_key: |
|
||||||
|
REPLACE_ME
|
||||||
|
vault_twirre_me_deploy_key: |
|
||||||
|
REPLACE_ME
|
||||||
8
inventory/hosts.yml
Normal file
8
inventory/hosts.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
all:
|
||||||
|
children:
|
||||||
|
twirre_servers:
|
||||||
|
hosts:
|
||||||
|
suno.twirre.dev:
|
||||||
|
ansible_host: 203.0.113.10
|
||||||
|
ansible_user: root
|
||||||
5
roles/backupagent/handlers/main.yml
Normal file
5
roles/backupagent/handlers/main.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: Restart ssh after backupagent change
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: ssh
|
||||||
|
state: restarted
|
||||||
44
roles/backupagent/tasks/main.yml
Normal file
44
roles/backupagent/tasks/main.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
- name: Ensure backupagent user exists
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ backupagent.name }}"
|
||||||
|
shell: "{{ backupagent.shell }}"
|
||||||
|
create_home: true
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Ensure backupagent SSH directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "/home/{{ backupagent.name }}/.ssh"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ backupagent.name }}"
|
||||||
|
group: "{{ backupagent.name }}"
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Install backupagent authorized keys
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "/home/{{ backupagent.name }}/.ssh/authorized_keys"
|
||||||
|
content: |
|
||||||
|
{% for key in backupagent.authorized_keys %}
|
||||||
|
{{ key }}
|
||||||
|
{% endfor %}
|
||||||
|
owner: "{{ backupagent.name }}"
|
||||||
|
group: "{{ backupagent.name }}"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Allow passwordless sudo for backup rsync
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: backupagent-sudoers.j2
|
||||||
|
dest: /etc/sudoers.d/backupagent-rsync
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0440"
|
||||||
|
validate: /usr/sbin/visudo -cf %s
|
||||||
|
|
||||||
|
- name: Restrict SSH settings for backupagent
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: backupagent-sshd-match.conf.j2
|
||||||
|
dest: /etc/ssh/sshd_config.d/60-backupagent.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Restart ssh after backupagent change
|
||||||
15
roles/backupagent/templates/backupagent-sshd-match.conf.j2
Normal file
15
roles/backupagent/templates/backupagent-sshd-match.conf.j2
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Match User {{ backupagent.name }}
|
||||||
|
AuthenticationMethods publickey
|
||||||
|
PasswordAuthentication no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
PermitTTY no
|
||||||
|
X11Forwarding no
|
||||||
|
AllowAgentForwarding no
|
||||||
|
AllowTcpForwarding no
|
||||||
|
AllowStreamLocalForwarding no
|
||||||
|
PermitTunnel no
|
||||||
|
GatewayPorts no
|
||||||
|
PermitUserEnvironment no
|
||||||
|
PermitUserRC no
|
||||||
|
PermitOpen none
|
||||||
1
roles/backupagent/templates/backupagent-sudoers.j2
Normal file
1
roles/backupagent/templates/backupagent-sudoers.j2
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{{ backupagent.name }} ALL=(root) NOPASSWD: {{ backupagent.sudo_commands | join(', ') }}
|
||||||
30
roles/base/tasks/main.yml
Normal file
30
roles/base/tasks/main.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
- name: Set server hostname
|
||||||
|
ansible.builtin.hostname:
|
||||||
|
name: "{{ inventory_hostname_short }}"
|
||||||
|
|
||||||
|
- name: Ensure hostname resolves in /etc/hosts
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/hosts
|
||||||
|
regexp: '^127\.0\.1\.1\s'
|
||||||
|
line: "127.0.1.1 {{ inventory_hostname }} {{ inventory_hostname_short }}"
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Set server timezone
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: "timedatectl set-timezone {{ timezone }}"
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Install base packages
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: "{{ base_packages_common }}"
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Ensure srv root exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ srv_root }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
32
roles/bun/tasks/main.yml
Normal file
32
roles/bun/tasks/main.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
- name: Resolve Bun architecture
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
bun_arch: "{{ bun_arch_map[ansible_facts.architecture] }}"
|
||||||
|
|
||||||
|
- name: Create Bun installation root
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ bun_install_root }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Download Bun release archive
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: "https://github.com/oven-sh/bun/releases/download/bun-v{{ bun_version }}/bun-linux-{{ bun_arch }}.zip"
|
||||||
|
dest: "/tmp/bun-linux-{{ bun_arch }}.zip"
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Unpack Bun release
|
||||||
|
ansible.builtin.unarchive:
|
||||||
|
src: "/tmp/bun-linux-{{ bun_arch }}.zip"
|
||||||
|
dest: "{{ bun_install_root }}"
|
||||||
|
remote_src: true
|
||||||
|
creates: "{{ bun_install_root }}/bun-linux-{{ bun_arch }}/bun"
|
||||||
|
|
||||||
|
- name: Symlink Bun binary into PATH
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: "{{ bun_install_root }}/bun-linux-{{ bun_arch }}/bun"
|
||||||
|
dest: "{{ bun_bin_path }}"
|
||||||
|
state: link
|
||||||
|
force: true
|
||||||
110
roles/bun_app/tasks/main.yml
Normal file
110
roles/bun_app/tasks/main.yml
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
- name: Ensure Bun app group exists
|
||||||
|
ansible.builtin.group:
|
||||||
|
name: "{{ bun_app.deploy_group }}"
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Ensure Bun app user exists
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ bun_app.deploy_user }}"
|
||||||
|
group: "{{ bun_app.deploy_group }}"
|
||||||
|
system: true
|
||||||
|
shell: /usr/sbin/nologin
|
||||||
|
create_home: true
|
||||||
|
|
||||||
|
- name: Ensure Bun app directories exist
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ bun_app.deploy_user }}"
|
||||||
|
group: "{{ bun_app.deploy_group }}"
|
||||||
|
mode: "0755"
|
||||||
|
loop:
|
||||||
|
- "{{ bun_app.path }}"
|
||||||
|
- "/var/lib/{{ bun_app.name }}"
|
||||||
|
- "/etc/{{ bun_app.name }}"
|
||||||
|
|
||||||
|
- name: Ensure Bun app extra directories exist
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ item.owner | default(bun_app.deploy_user) }}"
|
||||||
|
group: "{{ item.group | default(bun_app.deploy_group) }}"
|
||||||
|
mode: "{{ item.mode | default('0755') }}"
|
||||||
|
loop: "{{ bun_app.extra_directories | default([]) }}"
|
||||||
|
|
||||||
|
- name: Install Bun app deploy key when provided
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "/etc/{{ bun_app.name }}/deploy_key"
|
||||||
|
content: "{{ bun_app.git_ssh_key }}"
|
||||||
|
owner: "{{ bun_app.deploy_user }}"
|
||||||
|
group: "{{ bun_app.deploy_group }}"
|
||||||
|
mode: "0600"
|
||||||
|
when:
|
||||||
|
- bun_app.git_ssh_key is defined
|
||||||
|
- bun_app.git_ssh_key | length > 0
|
||||||
|
|
||||||
|
- name: Deploy Bun app checkout
|
||||||
|
ansible.builtin.git:
|
||||||
|
repo: "{{ bun_app.repo }}"
|
||||||
|
version: "{{ bun_app.version }}"
|
||||||
|
dest: "{{ bun_app.path }}"
|
||||||
|
accept_hostkey: true
|
||||||
|
key_file: "{{ '/etc/' ~ bun_app.name ~ '/deploy_key' if (bun_app.git_ssh_key is defined and bun_app.git_ssh_key | length > 0) else omit }}"
|
||||||
|
update: true
|
||||||
|
become_user: "{{ bun_app.deploy_user }}"
|
||||||
|
register: bun_app_checkout
|
||||||
|
|
||||||
|
- name: Check whether Bun app has package metadata
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ bun_app.path }}/package.json"
|
||||||
|
register: bun_app_package_json
|
||||||
|
|
||||||
|
- name: Check whether Bun app dependencies are installed
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ bun_app.path }}/node_modules"
|
||||||
|
register: bun_app_node_modules
|
||||||
|
|
||||||
|
- name: Install Bun app dependencies
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: "{{ bun_bin_path }} install"
|
||||||
|
chdir: "{{ bun_app.path }}"
|
||||||
|
become_user: "{{ bun_app.deploy_user }}"
|
||||||
|
when:
|
||||||
|
- bun_app_package_json.stat.exists
|
||||||
|
- bun_app_checkout.changed or not bun_app_node_modules.stat.exists
|
||||||
|
register: bun_app_install
|
||||||
|
|
||||||
|
- name: Render Bun app environment file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: bun-app.env.j2
|
||||||
|
dest: "/etc/{{ bun_app.name }}/app.env"
|
||||||
|
owner: root
|
||||||
|
group: "{{ bun_app.deploy_group }}"
|
||||||
|
mode: "0640"
|
||||||
|
register: bun_app_env
|
||||||
|
|
||||||
|
- name: Install Bun app systemd unit
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: bun-app.service.j2
|
||||||
|
dest: "/etc/systemd/system/{{ bun_app.service_name }}.service"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
register: bun_app_unit
|
||||||
|
|
||||||
|
- name: Reload systemd for Bun app changes
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
daemon_reload: true
|
||||||
|
when: bun_app_unit.changed
|
||||||
|
|
||||||
|
- name: Ensure Bun app service is enabled and running
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: "{{ bun_app.service_name }}"
|
||||||
|
state: >-
|
||||||
|
{{
|
||||||
|
'restarted'
|
||||||
|
if (bun_app_checkout.changed or bun_app_env.changed or bun_app_unit.changed or (bun_app_install is defined and bun_app_install.changed))
|
||||||
|
else 'started'
|
||||||
|
}}
|
||||||
|
enabled: true
|
||||||
6
roles/bun_app/templates/bun-app.env.j2
Normal file
6
roles/bun_app/templates/bun-app.env.j2
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{% set app_vault_env = vars['vault_' + (bun_app.name | replace('-', '_')) + '_env'] | default({}) %}
|
||||||
|
{% set app_non_vault_env_keys = bun_app.non_vault_env_keys | default([]) %}
|
||||||
|
{% set app_filtered_vault_env = app_vault_env | dict2items | rejectattr('key', 'in', app_non_vault_env_keys) | items2dict %}
|
||||||
|
{% for key, value in (bun_app.env | combine(app_filtered_vault_env)) | dictsort %}
|
||||||
|
{{ key }}={{ value }}
|
||||||
|
{% endfor %}
|
||||||
17
roles/bun_app/templates/bun-app.service.j2
Normal file
17
roles/bun_app/templates/bun-app.service.j2
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description={{ bun_app.name }} Bun service
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User={{ bun_app.deploy_user }}
|
||||||
|
Group={{ bun_app.deploy_group }}
|
||||||
|
WorkingDirectory={{ bun_app.path }}
|
||||||
|
EnvironmentFile=/etc/{{ bun_app.name }}/app.env
|
||||||
|
ExecStart={{ bun_bin_path }} {{ bun_app.entrypoint }}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
35
roles/certbot/tasks/main.yml
Normal file
35
roles/certbot/tasks/main.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
- name: Install certbot packages
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: "{{ certbot_packages }}"
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Ensure ACME webroot exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /var/www/letsencrypt
|
||||||
|
state: directory
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Enable certbot timer
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: certbot.timer
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
||||||
|
- name: Request managed certificates
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: >-
|
||||||
|
certbot certonly --non-interactive --agree-tos
|
||||||
|
--email {{ certbot_email }}
|
||||||
|
--webroot -w /var/www/letsencrypt
|
||||||
|
--cert-name {{ item.name }}
|
||||||
|
{% for domain in item.domains %}-d {{ domain }} {% endfor %}
|
||||||
|
args:
|
||||||
|
creates: "/etc/letsencrypt/live/{{ item.name }}/fullchain.pem"
|
||||||
|
when: certbot_manage_certificates | bool
|
||||||
|
loop: "{{ certbot_certificates }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}"
|
||||||
17
roles/docker/tasks/main.yml
Normal file
17
roles/docker/tasks/main.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
- name: Install Docker packages
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: "{{ docker_packages }}"
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Ensure docker group exists
|
||||||
|
ansible.builtin.group:
|
||||||
|
name: docker
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Enable Docker service
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: docker
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
5
roles/fail2ban/handlers/main.yml
Normal file
5
roles/fail2ban/handlers/main.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: Restart fail2ban
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: fail2ban
|
||||||
|
state: restarted
|
||||||
21
roles/fail2ban/tasks/main.yml
Normal file
21
roles/fail2ban/tasks/main.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
- name: Install fail2ban package
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: fail2ban
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Configure fail2ban jail.local
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: jail.local.j2
|
||||||
|
dest: /etc/fail2ban/jail.local
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Restart fail2ban
|
||||||
|
|
||||||
|
- name: Ensure fail2ban service is enabled
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: fail2ban
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
9
roles/fail2ban/templates/jail.local.j2
Normal file
9
roles/fail2ban/templates/jail.local.j2
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[DEFAULT]
|
||||||
|
bantime = {{ fail2ban_bantime }}
|
||||||
|
findtime = {{ fail2ban_findtime }}
|
||||||
|
maxretry = {{ fail2ban_maxretry }}
|
||||||
|
bantime.increment = true
|
||||||
|
ignoreip = {{ fail2ban_ignoreip | join(' ') }}
|
||||||
|
|
||||||
|
[sshd]
|
||||||
|
enabled = true
|
||||||
65
roles/gitea/tasks/main.yml
Normal file
65
roles/gitea/tasks/main.yml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
- name: Ensure Gitea service user exists
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ gitea.service_user }}"
|
||||||
|
groups:
|
||||||
|
- "{{ gitea.service_group }}"
|
||||||
|
append: true
|
||||||
|
system: true
|
||||||
|
shell: /usr/sbin/nologin
|
||||||
|
create_home: false
|
||||||
|
|
||||||
|
- name: Look up Gitea service user account details
|
||||||
|
ansible.builtin.getent:
|
||||||
|
database: passwd
|
||||||
|
key: "{{ gitea.service_user }}"
|
||||||
|
|
||||||
|
- name: Look up Gitea service group details
|
||||||
|
ansible.builtin.getent:
|
||||||
|
database: group
|
||||||
|
key: "{{ gitea.service_group }}"
|
||||||
|
|
||||||
|
- name: Set Gitea runtime UID and GID from host account
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
gitea_runtime_uid: "{{ getent_passwd[gitea.service_user][1] }}"
|
||||||
|
gitea_runtime_gid: "{{ getent_group[gitea.service_group][1] }}"
|
||||||
|
|
||||||
|
- name: Ensure Gitea directories exist
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ gitea.service_user }}"
|
||||||
|
group: "{{ gitea.service_group }}"
|
||||||
|
mode: "0755"
|
||||||
|
loop:
|
||||||
|
- "{{ gitea.path }}"
|
||||||
|
- "{{ gitea.data_dir }}"
|
||||||
|
|
||||||
|
- name: Render Gitea compose file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: compose.yaml.j2
|
||||||
|
dest: "{{ gitea.path }}/compose.yaml"
|
||||||
|
owner: "{{ gitea.service_user }}"
|
||||||
|
group: "{{ gitea.service_group }}"
|
||||||
|
mode: "0640"
|
||||||
|
register: gitea_compose
|
||||||
|
|
||||||
|
- name: Install Gitea compose systemd unit
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: gitea-compose.service.j2
|
||||||
|
dest: /etc/systemd/system/gitea-compose.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
register: gitea_unit
|
||||||
|
|
||||||
|
- name: Reload systemd for Gitea unit changes
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
daemon_reload: true
|
||||||
|
when: gitea_unit.changed
|
||||||
|
|
||||||
|
- name: Enable Gitea compose stack
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: gitea-compose
|
||||||
|
state: "{{ 'restarted' if (gitea_compose.changed or gitea_unit.changed) else 'started' }}"
|
||||||
|
enabled: true
|
||||||
28
roles/gitea/templates/compose.yaml.j2
Normal file
28
roles/gitea/templates/compose.yaml.j2
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
server:
|
||||||
|
image: {{ gitea.image }}
|
||||||
|
container_name: gitea
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
USER_UID: "{{ gitea_runtime_uid }}"
|
||||||
|
USER_GID: "{{ gitea_runtime_gid }}"
|
||||||
|
GITEA__security__SECRET_KEY: "{{ vault_gitea_secret_key | default('change-me') }}"
|
||||||
|
GITEA__security__INTERNAL_TOKEN: "{{ vault_gitea_internal_token | default('change-me') }}"
|
||||||
|
GITEA__security__LFS_JWT_SECRET: "{{ vault_gitea_lfs_jwt_secret | default('change-me') }}"
|
||||||
|
GITEA__server__DOMAIN: "{{ gitea.domain }}"
|
||||||
|
GITEA__server__ROOT_URL: "https://{{ gitea.domain }}/"
|
||||||
|
GITEA__server__PROTOCOL: "http"
|
||||||
|
GITEA__server__SSH_DOMAIN: "{{ gitea.domain }}"
|
||||||
|
GITEA__server__SSH_PORT: "{{ gitea.ssh_port }}"
|
||||||
|
GITEA__server__HTTP_PORT: "{{ gitea.http_port }}"
|
||||||
|
ports:
|
||||||
|
- "{{ gitea.http_bind_address }}:{{ gitea.http_port }}:3000"
|
||||||
|
- "{{ gitea.ssh_port }}:22"
|
||||||
|
volumes:
|
||||||
|
- "{{ gitea.data_dir }}:/data"
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: {{ gitea.compose_project_name }}
|
||||||
16
roles/gitea/templates/gitea-compose.service.j2
Normal file
16
roles/gitea/templates/gitea-compose.service.j2
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Gitea Docker Compose stack
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
WorkingDirectory={{ gitea.path }}
|
||||||
|
ExecStart=/usr/bin/docker compose -f {{ gitea.path }}/compose.yaml up -d
|
||||||
|
ExecStop=/usr/bin/docker compose -f {{ gitea.path }}/compose.yaml down
|
||||||
|
TimeoutStartSec=0
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
77
roles/mailserver/tasks/main.yml
Normal file
77
roles/mailserver/tasks/main.yml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
- name: Ensure mail service user exists
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ mailserver.service_user }}"
|
||||||
|
groups:
|
||||||
|
- "{{ mailserver.service_group }}"
|
||||||
|
append: true
|
||||||
|
system: true
|
||||||
|
shell: /usr/sbin/nologin
|
||||||
|
create_home: false
|
||||||
|
|
||||||
|
- name: Ensure mailserver directories exist
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ mailserver.service_user }}"
|
||||||
|
group: "{{ mailserver.service_group }}"
|
||||||
|
mode: "0755"
|
||||||
|
loop:
|
||||||
|
- "{{ mailserver.path }}"
|
||||||
|
- "{{ mailserver.path }}/docker-data"
|
||||||
|
- "{{ mailserver.path }}/docker-data/dms/mail-data"
|
||||||
|
- "{{ mailserver.path }}/docker-data/dms/mail-state"
|
||||||
|
- "{{ mailserver.path }}/docker-data/dms/mail-logs"
|
||||||
|
- "{{ mailserver.path }}/docker-data/dms/config"
|
||||||
|
|
||||||
|
- name: Render mailserver environment file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: mailserver.env.j2
|
||||||
|
dest: "{{ mailserver.path }}/mailserver.env"
|
||||||
|
owner: "{{ mailserver.service_user }}"
|
||||||
|
group: "{{ mailserver.service_group }}"
|
||||||
|
mode: "0640"
|
||||||
|
register: mailserver_env
|
||||||
|
|
||||||
|
- name: Render mailserver accounts file
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ mailserver.path }}/docker-data/dms/config/postfix-accounts.cf"
|
||||||
|
content: "{{ vault_mailserver_accounts | default('# add mail accounts here\n') }}"
|
||||||
|
owner: "{{ mailserver.service_user }}"
|
||||||
|
group: "{{ mailserver.service_group }}"
|
||||||
|
mode: "0600"
|
||||||
|
register: mailserver_accounts
|
||||||
|
|
||||||
|
- name: Render mailserver compose file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: compose.yaml.j2
|
||||||
|
dest: "{{ mailserver.path }}/compose.yaml"
|
||||||
|
owner: "{{ mailserver.service_user }}"
|
||||||
|
group: "{{ mailserver.service_group }}"
|
||||||
|
mode: "0640"
|
||||||
|
register: mailserver_compose
|
||||||
|
|
||||||
|
- name: Install mailserver compose systemd unit
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: mailserver-compose.service.j2
|
||||||
|
dest: /etc/systemd/system/mailserver-compose.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
register: mailserver_unit
|
||||||
|
|
||||||
|
- name: Reload systemd for mailserver unit changes
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
daemon_reload: true
|
||||||
|
when: mailserver_unit.changed
|
||||||
|
|
||||||
|
- name: Enable mailserver compose stack
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: mailserver-compose
|
||||||
|
state: >-
|
||||||
|
{{
|
||||||
|
'restarted'
|
||||||
|
if (mailserver_env.changed or mailserver_accounts.changed or mailserver_compose.changed or mailserver_unit.changed)
|
||||||
|
else 'started'
|
||||||
|
}}
|
||||||
|
enabled: true
|
||||||
28
roles/mailserver/templates/compose.yaml.j2
Normal file
28
roles/mailserver/templates/compose.yaml.j2
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
mailserver:
|
||||||
|
image: {{ mailserver.image }}
|
||||||
|
container_name: mailserver
|
||||||
|
hostname: {{ mailserver.hostname }}
|
||||||
|
env_file: {{ mailserver.path }}/mailserver.env
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 1m
|
||||||
|
ports:
|
||||||
|
- "25:25"
|
||||||
|
- "143:143"
|
||||||
|
- "465:465"
|
||||||
|
- "587:587"
|
||||||
|
- "993:993"
|
||||||
|
volumes:
|
||||||
|
- {{ mailserver.path }}/docker-data/dms/mail-data:/var/mail
|
||||||
|
- {{ mailserver.path }}/docker-data/dms/mail-state:/var/mail-state
|
||||||
|
- {{ mailserver.path }}/docker-data/dms/mail-logs:/var/log/mail
|
||||||
|
- {{ mailserver.path }}/docker-data/dms/config:/tmp/docker-mailserver
|
||||||
|
- {{ mailserver.tls_root_path }}:/etc/letsencrypt:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- SYS_PTRACE
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: {{ mailserver.compose_project_name }}
|
||||||
16
roles/mailserver/templates/mailserver-compose.service.j2
Normal file
16
roles/mailserver/templates/mailserver-compose.service.j2
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Docker Mailserver Compose stack
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
WorkingDirectory={{ mailserver.path }}
|
||||||
|
ExecStart=/usr/bin/docker compose -f {{ mailserver.path }}/compose.yaml up -d
|
||||||
|
ExecStop=/usr/bin/docker compose -f {{ mailserver.path }}/compose.yaml down
|
||||||
|
TimeoutStartSec=0
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
5
roles/mailserver/templates/mailserver.env.j2
Normal file
5
roles/mailserver/templates/mailserver.env.j2
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
OVERRIDE_HOSTNAME={{ mailserver.hostname }}
|
||||||
|
POSTMASTER_ADDRESS={{ certbot_email }}
|
||||||
|
{% for key, value in mailserver.env.items() %}
|
||||||
|
{{ key }}={{ value }}
|
||||||
|
{% endfor %}
|
||||||
5
roles/nginx/handlers/main.yml
Normal file
5
roles/nginx/handlers/main.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: Reload nginx
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: nginx
|
||||||
|
state: reloaded
|
||||||
94
roles/nginx/tasks/main.yml
Normal file
94
roles/nginx/tasks/main.yml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
- name: Install nginx package
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: nginx
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Ensure ACME webroot exists for nginx
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /var/www/letsencrypt
|
||||||
|
state: directory
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Ensure static site directories exist
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ item.owner }}"
|
||||||
|
group: "{{ item.group }}"
|
||||||
|
mode: "0755"
|
||||||
|
loop: "{{ static_sites | default([]) }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.path }}"
|
||||||
|
|
||||||
|
- name: Publish static placeholder files
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ item.0.path }}/{{ item.1.path }}"
|
||||||
|
content: "{{ item.1.content }}"
|
||||||
|
owner: "{{ item.0.owner }}"
|
||||||
|
group: "{{ item.0.group }}"
|
||||||
|
mode: "0644"
|
||||||
|
loop: "{{ (static_sites | default([])) | subelements('files', skip_missing=True) }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.0.name }}/{{ item.1.path }}"
|
||||||
|
|
||||||
|
- name: Remove default nginx site
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/nginx/sites-enabled/default
|
||||||
|
state: absent
|
||||||
|
notify: Reload nginx
|
||||||
|
|
||||||
|
- name: Check which ACME certificates already exist
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "/etc/letsencrypt/live/{{ item.certificate_name | default(item.server_names[0]) }}/fullchain.pem"
|
||||||
|
loop: "{{ nginx_sites | selectattr('acme_managed', 'defined') | selectattr('acme_managed') | list }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}"
|
||||||
|
register: nginx_site_cert_stats
|
||||||
|
|
||||||
|
- name: Build ACME certificate availability map
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
nginx_acme_certificates_available: >-
|
||||||
|
{{
|
||||||
|
dict(
|
||||||
|
nginx_site_cert_stats.results
|
||||||
|
| map(attribute='item.name')
|
||||||
|
| zip(nginx_site_cert_stats.results | map(attribute='stat.exists'))
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
|
||||||
|
- name: Render nginx site configurations
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: site.conf.j2
|
||||||
|
dest: "/etc/nginx/sites-available/{{ item.name }}.conf"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
loop: "{{ nginx_sites }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}"
|
||||||
|
notify: Reload nginx
|
||||||
|
|
||||||
|
- name: Enable nginx sites
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: "/etc/nginx/sites-available/{{ item.name }}.conf"
|
||||||
|
dest: "/etc/nginx/sites-enabled/{{ item.name }}.conf"
|
||||||
|
state: link
|
||||||
|
force: true
|
||||||
|
loop: "{{ nginx_sites }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}"
|
||||||
|
notify: Reload nginx
|
||||||
|
|
||||||
|
- name: Validate nginx configuration
|
||||||
|
ansible.builtin.command: nginx -t
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Ensure nginx service is enabled
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: nginx
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
76
roles/nginx/templates/site.conf.j2
Normal file
76
roles/nginx/templates/site.conf.j2
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
server {
|
||||||
|
listen 80{% if item.default_server | default(false) %} default_server{% endif %};
|
||||||
|
listen [::]:80{% if item.default_server | default(false) %} default_server{% endif %};
|
||||||
|
server_name {{ item.server_names | join(' ') }};
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/letsencrypt;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if item.acme_only | default(false) %}
|
||||||
|
location / {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
{% else %}
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if not (item.acme_only | default(false)) %}
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2{% if item.default_server | default(false) %} default_server{% endif %};
|
||||||
|
listen [::]:443 ssl http2{% if item.default_server | default(false) %} default_server{% endif %};
|
||||||
|
server_name {{ item.server_names | join(' ') }};
|
||||||
|
|
||||||
|
{% if item.acme_managed | default(true) %}
|
||||||
|
{% set certificate_name = item.certificate_name | default(item.server_names[0]) %}
|
||||||
|
{% set nginx_site_has_live_cert = nginx_acme_certificates_available[item.name] | default(false) %}
|
||||||
|
{% if nginx_site_has_live_cert %}
|
||||||
|
ssl_certificate /etc/letsencrypt/live/{{ certificate_name }}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/{{ certificate_name }}/privkey.pem;
|
||||||
|
{% else %}
|
||||||
|
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
{% if item.static_root is defined %}
|
||||||
|
root {{ item.static_root }};
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
{% else %}
|
||||||
|
{% for location in item.static_locations | default([]) %}
|
||||||
|
{% if location.path.endswith('/') %}
|
||||||
|
location = {{ location.path[:-1] }} {
|
||||||
|
return 301 {{ location.path }};
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
location ^~ {{ location.path }} {
|
||||||
|
alias {{ location.alias }};
|
||||||
|
{% if location.autoindex | default(false) %}
|
||||||
|
autoindex on;
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
|
{% endfor %}
|
||||||
|
location / {
|
||||||
|
proxy_pass http://{{ item.upstream_host }}:{{ item.upstream_port }};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
{% if item.websocket | default(false) %}
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
5
roles/ssh/handlers/main.yml
Normal file
5
roles/ssh/handlers/main.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: Restart ssh
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: ssh
|
||||||
|
state: restarted
|
||||||
58
roles/ssh/tasks/main.yml
Normal file
58
roles/ssh/tasks/main.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
- name: Install SSH packages
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: "{{ ssh_packages }}"
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Ensure admin groups exist
|
||||||
|
ansible.builtin.group:
|
||||||
|
name: "{{ item }}"
|
||||||
|
state: present
|
||||||
|
loop: "{{ ssh_admin_groups }}"
|
||||||
|
|
||||||
|
- name: Ensure admin users exist
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ item.name }}"
|
||||||
|
shell: "{{ item.shell | default('/bin/bash') }}"
|
||||||
|
groups: "{{ item.groups | default([]) }}"
|
||||||
|
append: true
|
||||||
|
create_home: true
|
||||||
|
state: present
|
||||||
|
loop: "{{ ssh_admin_users }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}"
|
||||||
|
|
||||||
|
- name: Ensure .ssh directories exist
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "/home/{{ item.name }}/.ssh"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ item.name }}"
|
||||||
|
group: "{{ item.name }}"
|
||||||
|
mode: "0700"
|
||||||
|
loop: "{{ ssh_admin_users }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}"
|
||||||
|
|
||||||
|
- name: Install SSH authorized key files
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "/home/{{ item.name }}/.ssh/authorized_keys"
|
||||||
|
content: |
|
||||||
|
{% for key in item.authorized_keys | default([]) %}
|
||||||
|
{{ key }}
|
||||||
|
{% endfor %}
|
||||||
|
owner: "{{ item.name }}"
|
||||||
|
group: "{{ item.name }}"
|
||||||
|
mode: "0600"
|
||||||
|
loop: "{{ ssh_admin_users }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.name }}"
|
||||||
|
|
||||||
|
- name: Harden sshd configuration
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: sshd_config.j2
|
||||||
|
dest: /etc/ssh/sshd_config.d/99-twirre.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Restart ssh
|
||||||
5
roles/ssh/templates/sshd_config.j2
Normal file
5
roles/ssh/templates/sshd_config.j2
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
PasswordAuthentication no
|
||||||
|
PermitRootLogin prohibit-password
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
UsePAM yes
|
||||||
5
roles/wireguard/handlers/main.yml
Normal file
5
roles/wireguard/handlers/main.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: Restart WireGuard
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: "wg-quick@{{ wireguard_interface.name }}"
|
||||||
|
state: restarted
|
||||||
31
roles/wireguard/tasks/main.yml
Normal file
31
roles/wireguard/tasks/main.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
- name: Install WireGuard packages
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- wireguard
|
||||||
|
- wireguard-tools
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Ensure WireGuard configuration directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/wireguard
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Render WireGuard interface configuration
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: wg0.conf.j2
|
||||||
|
dest: "/etc/wireguard/{{ wireguard_interface.name }}.conf"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
notify: Restart WireGuard
|
||||||
|
|
||||||
|
- name: Enable WireGuard interface
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: "wg-quick@{{ wireguard_interface.name }}"
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
21
roles/wireguard/templates/wg0.conf.j2
Normal file
21
roles/wireguard/templates/wg0.conf.j2
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[Interface]
|
||||||
|
Address = {{ wireguard_interface.address | join(', ') }}
|
||||||
|
ListenPort = {{ wireguard_interface.listen_port }}
|
||||||
|
PrivateKey = {{ wireguard_interface.private_key }}
|
||||||
|
|
||||||
|
{% for peer in wireguard_interface.peers %}
|
||||||
|
# {{ peer.name }}
|
||||||
|
[Peer]
|
||||||
|
PublicKey = {{ peer.public_key }}
|
||||||
|
{% if peer.preshared_key is defined and peer.preshared_key | length > 0 %}
|
||||||
|
PresharedKey = {{ peer.preshared_key }}
|
||||||
|
{% endif %}
|
||||||
|
AllowedIPs = {{ peer.allowed_ips | join(', ') }}
|
||||||
|
{% if peer.endpoint is defined %}
|
||||||
|
Endpoint = {{ peer.endpoint }}
|
||||||
|
{% endif %}
|
||||||
|
{% if peer.persistent_keepalive is defined %}
|
||||||
|
PersistentKeepalive = {{ peer.persistent_keepalive }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
49
site.yml
Normal file
49
site.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
- name: Provision Twirre server
|
||||||
|
hosts: twirre_servers
|
||||||
|
become: true
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- name: Validate Bun application definitions
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- bun_apps is iterable
|
||||||
|
- bun_apps | length > 0
|
||||||
|
fail_msg: Define at least one Bun application in bun_apps.
|
||||||
|
when: bun_enabled | bool
|
||||||
|
|
||||||
|
- name: Validate WireGuard configuration when enabled
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- wireguard_interface.address | length > 0
|
||||||
|
- wireguard_interface.private_key | length > 0
|
||||||
|
fail_msg: WireGuard is enabled but the interface address or private key is missing.
|
||||||
|
when: wireguard_enabled | bool
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- role: base
|
||||||
|
- role: ssh
|
||||||
|
- role: backupagent
|
||||||
|
when: backupagent_enabled | bool
|
||||||
|
- role: docker
|
||||||
|
when: docker_enabled | bool
|
||||||
|
- role: bun
|
||||||
|
when: bun_enabled | bool
|
||||||
|
- role: fail2ban
|
||||||
|
when: fail2ban_enabled | bool
|
||||||
|
- role: wireguard
|
||||||
|
when: wireguard_enabled | bool
|
||||||
|
- role: gitea
|
||||||
|
when: gitea_enabled | bool
|
||||||
|
- role: mailserver
|
||||||
|
when: mailserver_enabled | bool
|
||||||
|
- role: bun_app
|
||||||
|
loop: "{{ bun_apps }}"
|
||||||
|
loop_control:
|
||||||
|
loop_var: bun_app
|
||||||
|
label: "{{ bun_app.name }}"
|
||||||
|
when: bun_enabled | bool
|
||||||
|
- role: nginx
|
||||||
|
when: nginx_enabled | bool
|
||||||
|
- role: certbot
|
||||||
|
when: certbot_enabled | bool
|
||||||
Reference in New Issue
Block a user