From 0d967909e791464ec5cd834df6f4687e52f15c94 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:22:58 +0200 Subject: [PATCH] feat: full untested ansible setup --- .gitignore | 6 + README.md | 64 +++- ansible.cfg | 12 + group_vars/all/main.yml | 316 ++++++++++++++++++ group_vars/all/vault.example.yml | 26 ++ inventory/hosts.yml | 8 + roles/backupagent/handlers/main.yml | 5 + roles/backupagent/tasks/main.yml | 44 +++ .../templates/backupagent-sshd-match.conf.j2 | 15 + .../templates/backupagent-sudoers.j2 | 1 + roles/base/tasks/main.yml | 30 ++ roles/bun/tasks/main.yml | 32 ++ roles/bun_app/tasks/main.yml | 110 ++++++ roles/bun_app/templates/bun-app.env.j2 | 6 + roles/bun_app/templates/bun-app.service.j2 | 17 + roles/certbot/tasks/main.yml | 35 ++ roles/docker/tasks/main.yml | 17 + roles/fail2ban/handlers/main.yml | 5 + roles/fail2ban/tasks/main.yml | 21 ++ roles/fail2ban/templates/jail.local.j2 | 9 + roles/gitea/tasks/main.yml | 65 ++++ roles/gitea/templates/compose.yaml.j2 | 28 ++ .../gitea/templates/gitea-compose.service.j2 | 16 + roles/mailserver/tasks/main.yml | 77 +++++ roles/mailserver/templates/compose.yaml.j2 | 28 ++ .../templates/mailserver-compose.service.j2 | 16 + roles/mailserver/templates/mailserver.env.j2 | 5 + roles/nginx/handlers/main.yml | 5 + roles/nginx/tasks/main.yml | 94 ++++++ roles/nginx/templates/site.conf.j2 | 76 +++++ roles/ssh/handlers/main.yml | 5 + roles/ssh/tasks/main.yml | 58 ++++ roles/ssh/templates/sshd_config.j2 | 5 + roles/wireguard/handlers/main.yml | 5 + roles/wireguard/tasks/main.yml | 31 ++ roles/wireguard/templates/wg0.conf.j2 | 21 ++ site.yml | 49 +++ 37 files changed, 1362 insertions(+), 1 deletion(-) create mode 100644 ansible.cfg create mode 100644 group_vars/all/main.yml create mode 100644 group_vars/all/vault.example.yml create mode 100644 inventory/hosts.yml create mode 100644 roles/backupagent/handlers/main.yml create mode 100644 roles/backupagent/tasks/main.yml create mode 100644 roles/backupagent/templates/backupagent-sshd-match.conf.j2 create mode 100644 roles/backupagent/templates/backupagent-sudoers.j2 create mode 100644 roles/base/tasks/main.yml create mode 100644 roles/bun/tasks/main.yml create mode 100644 roles/bun_app/tasks/main.yml create mode 100644 roles/bun_app/templates/bun-app.env.j2 create mode 100644 roles/bun_app/templates/bun-app.service.j2 create mode 100644 roles/certbot/tasks/main.yml create mode 100644 roles/docker/tasks/main.yml create mode 100644 roles/fail2ban/handlers/main.yml create mode 100644 roles/fail2ban/tasks/main.yml create mode 100644 roles/fail2ban/templates/jail.local.j2 create mode 100644 roles/gitea/tasks/main.yml create mode 100644 roles/gitea/templates/compose.yaml.j2 create mode 100644 roles/gitea/templates/gitea-compose.service.j2 create mode 100644 roles/mailserver/tasks/main.yml create mode 100644 roles/mailserver/templates/compose.yaml.j2 create mode 100644 roles/mailserver/templates/mailserver-compose.service.j2 create mode 100644 roles/mailserver/templates/mailserver.env.j2 create mode 100644 roles/nginx/handlers/main.yml create mode 100644 roles/nginx/tasks/main.yml create mode 100644 roles/nginx/templates/site.conf.j2 create mode 100644 roles/ssh/handlers/main.yml create mode 100644 roles/ssh/tasks/main.yml create mode 100644 roles/ssh/templates/sshd_config.j2 create mode 100644 roles/wireguard/handlers/main.yml create mode 100644 roles/wireguard/tasks/main.yml create mode 100644 roles/wireguard/templates/wg0.conf.j2 create mode 100644 site.yml diff --git a/.gitignore b/.gitignore index 505a3b1..34a0ba7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,9 @@ wheels/ # Virtual environments .venv + +# Ansible secrets +group_vars/all/vault.yml + +# OS-generated files +.DS_Store diff --git a/README.md b/README.md index e95f376..ba04f71 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..d73debb --- /dev/null +++ b/ansible.cfg @@ -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 diff --git a/group_vars/all/main.yml b/group_vars/all/main.yml new file mode 100644 index 0000000..487d732 --- /dev/null +++ b/group_vars/all/main.yml @@ -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: | + + +
+ + + +In aanbouw
+ + diff --git a/group_vars/all/vault.example.yml b/group_vars/all/vault.example.yml new file mode 100644 index 0000000..4e2546a --- /dev/null +++ b/group_vars/all/vault.example.yml @@ -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 diff --git a/inventory/hosts.yml b/inventory/hosts.yml new file mode 100644 index 0000000..bed6d42 --- /dev/null +++ b/inventory/hosts.yml @@ -0,0 +1,8 @@ +--- +all: + children: + twirre_servers: + hosts: + suno.twirre.dev: + ansible_host: 203.0.113.10 + ansible_user: root diff --git a/roles/backupagent/handlers/main.yml b/roles/backupagent/handlers/main.yml new file mode 100644 index 0000000..f280536 --- /dev/null +++ b/roles/backupagent/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart ssh after backupagent change + ansible.builtin.service: + name: ssh + state: restarted diff --git a/roles/backupagent/tasks/main.yml b/roles/backupagent/tasks/main.yml new file mode 100644 index 0000000..fd3effd --- /dev/null +++ b/roles/backupagent/tasks/main.yml @@ -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 diff --git a/roles/backupagent/templates/backupagent-sshd-match.conf.j2 b/roles/backupagent/templates/backupagent-sshd-match.conf.j2 new file mode 100644 index 0000000..59fb754 --- /dev/null +++ b/roles/backupagent/templates/backupagent-sshd-match.conf.j2 @@ -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 diff --git a/roles/backupagent/templates/backupagent-sudoers.j2 b/roles/backupagent/templates/backupagent-sudoers.j2 new file mode 100644 index 0000000..6175b53 --- /dev/null +++ b/roles/backupagent/templates/backupagent-sudoers.j2 @@ -0,0 +1 @@ +{{ backupagent.name }} ALL=(root) NOPASSWD: {{ backupagent.sudo_commands | join(', ') }} diff --git a/roles/base/tasks/main.yml b/roles/base/tasks/main.yml new file mode 100644 index 0000000..1abee1e --- /dev/null +++ b/roles/base/tasks/main.yml @@ -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" diff --git a/roles/bun/tasks/main.yml b/roles/bun/tasks/main.yml new file mode 100644 index 0000000..28cfdb7 --- /dev/null +++ b/roles/bun/tasks/main.yml @@ -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 diff --git a/roles/bun_app/tasks/main.yml b/roles/bun_app/tasks/main.yml new file mode 100644 index 0000000..d2a68d8 --- /dev/null +++ b/roles/bun_app/tasks/main.yml @@ -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 diff --git a/roles/bun_app/templates/bun-app.env.j2 b/roles/bun_app/templates/bun-app.env.j2 new file mode 100644 index 0000000..7b443b4 --- /dev/null +++ b/roles/bun_app/templates/bun-app.env.j2 @@ -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 %} diff --git a/roles/bun_app/templates/bun-app.service.j2 b/roles/bun_app/templates/bun-app.service.j2 new file mode 100644 index 0000000..eb808d7 --- /dev/null +++ b/roles/bun_app/templates/bun-app.service.j2 @@ -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 diff --git a/roles/certbot/tasks/main.yml b/roles/certbot/tasks/main.yml new file mode 100644 index 0000000..f6851cb --- /dev/null +++ b/roles/certbot/tasks/main.yml @@ -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 }}" diff --git a/roles/docker/tasks/main.yml b/roles/docker/tasks/main.yml new file mode 100644 index 0000000..77dc102 --- /dev/null +++ b/roles/docker/tasks/main.yml @@ -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 diff --git a/roles/fail2ban/handlers/main.yml b/roles/fail2ban/handlers/main.yml new file mode 100644 index 0000000..c86a3ee --- /dev/null +++ b/roles/fail2ban/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart fail2ban + ansible.builtin.service: + name: fail2ban + state: restarted diff --git a/roles/fail2ban/tasks/main.yml b/roles/fail2ban/tasks/main.yml new file mode 100644 index 0000000..8bf2d8a --- /dev/null +++ b/roles/fail2ban/tasks/main.yml @@ -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 diff --git a/roles/fail2ban/templates/jail.local.j2 b/roles/fail2ban/templates/jail.local.j2 new file mode 100644 index 0000000..8e40f1d --- /dev/null +++ b/roles/fail2ban/templates/jail.local.j2 @@ -0,0 +1,9 @@ +[DEFAULT] +bantime = {{ fail2ban_bantime }} +findtime = {{ fail2ban_findtime }} +maxretry = {{ fail2ban_maxretry }} +bantime.increment = true +ignoreip = {{ fail2ban_ignoreip | join(' ') }} + +[sshd] +enabled = true diff --git a/roles/gitea/tasks/main.yml b/roles/gitea/tasks/main.yml new file mode 100644 index 0000000..f19b129 --- /dev/null +++ b/roles/gitea/tasks/main.yml @@ -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 diff --git a/roles/gitea/templates/compose.yaml.j2 b/roles/gitea/templates/compose.yaml.j2 new file mode 100644 index 0000000..ca117d7 --- /dev/null +++ b/roles/gitea/templates/compose.yaml.j2 @@ -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 }} diff --git a/roles/gitea/templates/gitea-compose.service.j2 b/roles/gitea/templates/gitea-compose.service.j2 new file mode 100644 index 0000000..9c4578e --- /dev/null +++ b/roles/gitea/templates/gitea-compose.service.j2 @@ -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 diff --git a/roles/mailserver/tasks/main.yml b/roles/mailserver/tasks/main.yml new file mode 100644 index 0000000..b402e03 --- /dev/null +++ b/roles/mailserver/tasks/main.yml @@ -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 diff --git a/roles/mailserver/templates/compose.yaml.j2 b/roles/mailserver/templates/compose.yaml.j2 new file mode 100644 index 0000000..29ba1c4 --- /dev/null +++ b/roles/mailserver/templates/compose.yaml.j2 @@ -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 }} diff --git a/roles/mailserver/templates/mailserver-compose.service.j2 b/roles/mailserver/templates/mailserver-compose.service.j2 new file mode 100644 index 0000000..87be852 --- /dev/null +++ b/roles/mailserver/templates/mailserver-compose.service.j2 @@ -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 diff --git a/roles/mailserver/templates/mailserver.env.j2 b/roles/mailserver/templates/mailserver.env.j2 new file mode 100644 index 0000000..b80e88d --- /dev/null +++ b/roles/mailserver/templates/mailserver.env.j2 @@ -0,0 +1,5 @@ +OVERRIDE_HOSTNAME={{ mailserver.hostname }} +POSTMASTER_ADDRESS={{ certbot_email }} +{% for key, value in mailserver.env.items() %} +{{ key }}={{ value }} +{% endfor %} diff --git a/roles/nginx/handlers/main.yml b/roles/nginx/handlers/main.yml new file mode 100644 index 0000000..0f31453 --- /dev/null +++ b/roles/nginx/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Reload nginx + ansible.builtin.service: + name: nginx + state: reloaded diff --git a/roles/nginx/tasks/main.yml b/roles/nginx/tasks/main.yml new file mode 100644 index 0000000..d2e4552 --- /dev/null +++ b/roles/nginx/tasks/main.yml @@ -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 diff --git a/roles/nginx/templates/site.conf.j2 b/roles/nginx/templates/site.conf.j2 new file mode 100644 index 0000000..93e0694 --- /dev/null +++ b/roles/nginx/templates/site.conf.j2 @@ -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 %} diff --git a/roles/ssh/handlers/main.yml b/roles/ssh/handlers/main.yml new file mode 100644 index 0000000..6665708 --- /dev/null +++ b/roles/ssh/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart ssh + ansible.builtin.service: + name: ssh + state: restarted diff --git a/roles/ssh/tasks/main.yml b/roles/ssh/tasks/main.yml new file mode 100644 index 0000000..84bdbf6 --- /dev/null +++ b/roles/ssh/tasks/main.yml @@ -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 diff --git a/roles/ssh/templates/sshd_config.j2 b/roles/ssh/templates/sshd_config.j2 new file mode 100644 index 0000000..414b76a --- /dev/null +++ b/roles/ssh/templates/sshd_config.j2 @@ -0,0 +1,5 @@ +PasswordAuthentication no +PermitRootLogin prohibit-password +PubkeyAuthentication yes +KbdInteractiveAuthentication no +UsePAM yes diff --git a/roles/wireguard/handlers/main.yml b/roles/wireguard/handlers/main.yml new file mode 100644 index 0000000..40e26c1 --- /dev/null +++ b/roles/wireguard/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart WireGuard + ansible.builtin.service: + name: "wg-quick@{{ wireguard_interface.name }}" + state: restarted diff --git a/roles/wireguard/tasks/main.yml b/roles/wireguard/tasks/main.yml new file mode 100644 index 0000000..03000ee --- /dev/null +++ b/roles/wireguard/tasks/main.yml @@ -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 diff --git a/roles/wireguard/templates/wg0.conf.j2 b/roles/wireguard/templates/wg0.conf.j2 new file mode 100644 index 0000000..f7180f6 --- /dev/null +++ b/roles/wireguard/templates/wg0.conf.j2 @@ -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 %} diff --git a/site.yml b/site.yml new file mode 100644 index 0000000..89274e9 --- /dev/null +++ b/site.yml @@ -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