feat: full untested ansible setup

This commit is contained in:
Twirre Meulenbelt
2026-04-22 12:22:58 +02:00
parent b1d9b2a857
commit 0d967909e7
37 changed files with 1362 additions and 1 deletions

6
.gitignore vendored
View File

@@ -8,3 +8,9 @@ wheels/
# Virtual environments
.venv
# Ansible secrets
group_vars/all/vault.yml
# OS-generated files
.DS_Store

View File

@@ -1,3 +1,65 @@
# 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
View 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
View 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>

View 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
View File

@@ -0,0 +1,8 @@
---
all:
children:
twirre_servers:
hosts:
suno.twirre.dev:
ansible_host: 203.0.113.10
ansible_user: root

View File

@@ -0,0 +1,5 @@
---
- name: Restart ssh after backupagent change
ansible.builtin.service:
name: ssh
state: restarted

View 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

View 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

View File

@@ -0,0 +1 @@
{{ backupagent.name }} ALL=(root) NOPASSWD: {{ backupagent.sudo_commands | join(', ') }}

30
roles/base/tasks/main.yml Normal file
View 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
View 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

View 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

View 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 %}

View 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

View 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 }}"

View 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

View File

@@ -0,0 +1,5 @@
---
- name: Restart fail2ban
ansible.builtin.service:
name: fail2ban
state: restarted

View 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

View 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

View 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

View 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 }}

View 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

View 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

View 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 }}

View 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

View File

@@ -0,0 +1,5 @@
OVERRIDE_HOSTNAME={{ mailserver.hostname }}
POSTMASTER_ADDRESS={{ certbot_email }}
{% for key, value in mailserver.env.items() %}
{{ key }}={{ value }}
{% endfor %}

View File

@@ -0,0 +1,5 @@
---
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded

View 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

View 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 %}

View File

@@ -0,0 +1,5 @@
---
- name: Restart ssh
ansible.builtin.service:
name: ssh
state: restarted

58
roles/ssh/tasks/main.yml Normal file
View 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

View File

@@ -0,0 +1,5 @@
PasswordAuthentication no
PermitRootLogin prohibit-password
PubkeyAuthentication yes
KbdInteractiveAuthentication no
UsePAM yes

View File

@@ -0,0 +1,5 @@
---
- name: Restart WireGuard
ansible.builtin.service:
name: "wg-quick@{{ wireguard_interface.name }}"
state: restarted

View 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

View 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
View 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