aboutsummaryrefslogtreecommitdiffstats
path: root/roles
diff options
context:
space:
mode:
authorJan Tuomi <jan@jantuomi.fi>2026-05-13 00:13:57 +0300
committerJan Tuomi <jan@jantuomi.fi>2026-05-16 18:42:27 +0300
commitb5860daf11ac353049cb1654b9414a129e5cfb96 (patch)
tree87ed89711e4f0e85ace0a97fa123199152c67302 /roles
parent4715a28fdcd87440400d17154bfa361d99db29cc (diff)
Rework
Diffstat (limited to 'roles')
-rw-r--r--roles/host/files/root_bashrc7
-rw-r--r--roles/host/handlers/main.yml9
-rw-r--r--roles/host/tasks/main.yml217
-rw-r--r--roles/host/tasks/userland.yml59
-rw-r--r--roles/host/templates/devfs.rules.j269
-rw-r--r--roles/host/templates/etc_crontab.j232
-rw-r--r--roles/host/templates/etc_periodic.conf.j24
-rw-r--r--roles/host/templates/etc_rc.conf.j250
-rw-r--r--roles/host/templates/etc_resolv.conf.j22
-rw-r--r--roles/host/templates/etc_ssh_sshd_config.j2121
-rw-r--r--roles/host/templates/jail.conf.j21
-rw-r--r--roles/host/templates/usr_local_bin_logto.sh.j247
-rw-r--r--roles/host/templates/usr_local_bin_pylogsentinel-batch-email.sh.j214
-rw-r--r--roles/host/templates/usr_local_etc_pylogsentinel.conf.j222
-rw-r--r--roles/host/templates/usr_local_etc_rc.d_zpool_gpt_labels.j233
-rw-r--r--roles/host/templates/usr_local_etc_smartd.conf.j24
-rw-r--r--roles/host_prod/handlers/main.yml13
-rw-r--r--roles/host_prod/tasks/main.yml153
-rw-r--r--roles/host_prod/templates/etc_aliases.j21
-rw-r--r--roles/host_prod/templates/etc_dma_auth.conf.j21
-rw-r--r--roles/host_prod/templates/etc_dma_dma.conf.j28
-rw-r--r--roles/host_prod/templates/etc_fstab.j27
-rw-r--r--roles/host_prod/templates/root_ssh_config.j24
-rw-r--r--roles/host_prod/templates/usr_local_bin_backup.sh.j2365
-rw-r--r--roles/host_prod/templates/usr_local_bin_safepf.sh36
-rw-r--r--roles/host_test/handlers/main.yml2
-rw-r--r--roles/host_test/tasks/main.yml13
-rw-r--r--roles/jail/handlers/main.yml5
-rw-r--r--roles/jail/tasks/main.yml175
-rw-r--r--roles/jail/templates/jail_conf.j256
-rw-r--r--roles/jails/01_ingress/defaults/main.yml92
-rw-r--r--roles/jails/01_ingress/tasks/main.yml49
-rwxr-xr-xroles/jails/01_ingress/templates/acme-dns-auth.py170
-rw-r--r--roles/jails/01_ingress/templates/etc_crontab.j218
-rw-r--r--roles/jails/01_ingress/templates/etc_pf.conf.j227
-rw-r--r--roles/jails/01_ingress/templates/usr_local_bin_gen_goaccess.sh.j28
-rw-r--r--roles/jails/01_ingress/templates/usr_local_bin_hetzner_ddns.sh.j2130
-rw-r--r--roles/jails/01_ingress/templates/usr_local_etc_hetzner_auth.j21
-rw-r--r--roles/jails/01_ingress/templates/usr_local_etc_nginx_nginx.conf.j2123
-rw-r--r--roles/jails/02_postgres/defaults/main.yml9
-rw-r--r--roles/jails/02_postgres/tasks/main.yml2
-rw-r--r--roles/jails/03_irc_thelounge/defaults/main.yml1
-rw-r--r--roles/jails/03_irc_thelounge/tasks/main.yml2
-rw-r--r--roles/jails/04_taulubot/defaults/main.yml1
-rw-r--r--roles/jails/04_taulubot/tasks/main.yml2
-rw-r--r--roles/jails/05_homepage/defaults/main.yml18
-rw-r--r--roles/jails/05_homepage/tasks/main.yml2
-rw-r--r--roles/jails/05_homepage/templates/etc_crontab.j29
-rw-r--r--roles/jails/05_homepage/templates/usr_local_etc_nginx_nginx.conf.j255
-rw-r--r--roles/jails/06_hommabot/defaults/main.yml14
-rw-r--r--roles/jails/06_hommabot/tasks/main.yml2
-rw-r--r--roles/jails/06_hommabot/templates/etc_crontab.j210
-rw-r--r--roles/jails/06_hommabot/templates/root_hommabot_deps.sh14
-rw-r--r--roles/jails/06_hommabot/templates/root_hommabot_env.j24
-rw-r--r--roles/jails/07_aggro/defaults/main.yml1
-rw-r--r--roles/jails/07_aggro/tasks/main.yml2
-rw-r--r--roles/jails/08_diddle/defaults/main.yml23
-rw-r--r--roles/jails/08_diddle/tasks/main.yml2
-rw-r--r--roles/jails/08_diddle/templates/root_clone.sh21
-rw-r--r--roles/jails/08_diddle/templates/root_diddle_env.j29
-rw-r--r--roles/jails/08_diddle/templates/usr_local_bin_diddle17
-rw-r--r--roles/jails/08_diddle/templates/usr_local_etc_rc.d_diddle43
-rw-r--r--roles/jails/09_redis/defaults/main.yml1
-rw-r--r--roles/jails/09_redis/tasks/main.yml2
-rw-r--r--roles/jails/10_samba/defaults/main.yml5
-rw-r--r--roles/jails/10_samba/tasks/main.yml2
-rw-r--r--roles/jails/11_spliit/defaults/main.yml1
-rw-r--r--roles/jails/11_spliit/tasks/main.yml2
-rw-r--r--roles/jails/12_goaccess/defaults/main.yml5
-rw-r--r--roles/jails/12_goaccess/tasks/main.yml2
-rw-r--r--roles/jails/13_plex/defaults/main.yml5
-rw-r--r--roles/jails/13_plex/tasks/main.yml2
-rw-r--r--roles/jails/14_freshrss/defaults/main.yml1
-rw-r--r--roles/jails/14_freshrss/tasks/main.yml2
-rw-r--r--roles/jails/15_paste/defaults/main.yml1
-rw-r--r--roles/jails/15_paste/tasks/main.yml2
-rw-r--r--roles/jails/16_dl/defaults/main.yml11
-rw-r--r--roles/jails/16_dl/tasks/main.yml2
-rw-r--r--roles/jails/17_syncthing/defaults/main.yml11
-rw-r--r--roles/jails/17_syncthing/tasks/main.yml2
-rw-r--r--roles/jails/18_komga/defaults/main.yml5
-rw-r--r--roles/jails/18_komga/tasks/main.yml2
-rw-r--r--roles/jails/19_leolalla_fi/defaults/main.yml1
-rw-r--r--roles/jails/19_leolalla_fi/tasks/main.yml2
84 files changed, 2480 insertions, 0 deletions
diff --git a/roles/host/files/root_bashrc b/roles/host/files/root_bashrc
new file mode 100644
index 0000000..f580db6
--- /dev/null
+++ b/roles/host/files/root_bashrc
@@ -0,0 +1,7 @@
+HISTCONTROL=ignoredups:erasedups # no duplicate entries
+HISTSIZE=100000 # big big history
+HISTFILESIZE=100000 # big big history
+shopt -s histappend # append to history, don't overwrite it
+
+# Save and reload the history after each command finishes
+PROMPT_COMMAND="history -a; history -c; history -r; $PROMPT_COMMAND"
diff --git a/roles/host/handlers/main.yml b/roles/host/handlers/main.yml
new file mode 100644
index 0000000..5d0a990
--- /dev/null
+++ b/roles/host/handlers/main.yml
@@ -0,0 +1,9 @@
+- name: Restart sshd
+ service:
+ name: sshd
+ state: restarted
+
+- name: Restart cron
+ service:
+ name: cron
+ state: restarted
diff --git a/roles/host/tasks/main.yml b/roles/host/tasks/main.yml
new file mode 100644
index 0000000..ed9b476
--- /dev/null
+++ b/roles/host/tasks/main.yml
@@ -0,0 +1,217 @@
+- name: Disable resolvconf
+ copy:
+ content: "resolvconf=NO\n"
+ dest: /etc/resolvconf.conf
+ owner: root
+ group: wheel
+ mode: "0644"
+
+- name: Set up resolv.conf
+ template:
+ src: etc_resolv.conf.j2
+ dest: /etc/resolv.conf
+ owner: root
+ group: wheel
+ mode: "0644"
+
+- name: Install packages
+ package:
+ name: "{{ item }}"
+ state: present
+ loop:
+ - rsync
+ - dma
+ - jq
+ - curl
+ - bash
+ - python
+ - py311-pip
+ - fastfetch
+
+- name: Set up sshd
+ template:
+ src: etc_ssh_sshd_config.j2
+ dest: /etc/ssh/sshd_config
+ owner: root
+ group: wheel
+ mode: "0644"
+ notify: Restart sshd
+
+- name: Start sshd
+ service:
+ name: sshd
+ state: started
+
+- name: Deploy logto script
+ template:
+ src: usr_local_bin_logto.sh.j2
+ dest: /usr/local/bin/logto
+ owner: root
+ group: wheel
+ mode: "0755"
+
+- name: Check if pylogsentinel is installed
+ shell: pip show pylogsentinel
+ register: pylogsentinel_check
+ failed_when: false
+ changed_when: false
+
+- name: Install pylogsentinel
+ shell: pip install pylogsentinel==0.3.0 --force --no-input
+ when: pylogsentinel_check.rc != 0
+
+- name: Deploy pylogsentinel.conf
+ template:
+ src: usr_local_etc_pylogsentinel.conf.j2
+ dest: /usr/local/etc/pylogsentinel.conf
+ owner: root
+ group: wheel
+ mode: "0644"
+
+- name: Deploy pylogsentinel-batch-email.sh
+ template:
+ src: usr_local_bin_pylogsentinel-batch-email.sh.j2
+ dest: /usr/local/bin/pylogsentinel-batch-email.sh
+ owner: root
+ group: wheel
+ mode: "0755"
+
+- name: Start syslogd
+ service:
+ name: syslogd
+ state: started
+
+- name: Start auditd
+ service:
+ name: auditd
+ state: started
+
+- name: Set up periodic.conf
+ template:
+ src: etc_periodic.conf.j2
+ dest: /etc/periodic.conf
+ owner: root
+ group: wheel
+ mode: "0644"
+
+- name: Set up crontab
+ template:
+ src: etc_crontab.j2
+ dest: /etc/crontab
+ owner: root
+ group: wheel
+ mode: "0644"
+ notify: Restart cron
+
+- name: Install .bashrc
+ copy:
+ src: root_bashrc
+ dest: /root/.bashrc
+ owner: root
+ group: wheel
+ mode: "0644"
+
+# Jail infrastructure
+- name: Discover jail directories
+ find:
+ paths: "{{ playbook_dir }}/roles/jails"
+ patterns: "main.yml"
+ recurse: true
+ delegate_to: localhost
+ register: _jail_specs
+
+- name: Load jail definitions
+ set_fact:
+ jail_defs: "{{ jail_defs | default([]) + [_content | combine({'num': _num, 'name': _name})] }}"
+ vars:
+ _content: "{{ lookup('file', item.path) | from_yaml }}"
+ _num: "{{ item.path | regex_replace('.*/jails/([^/]+)/.*', '\\1') | split('_') | first | int }}"
+ _name: "{{ item.path | regex_replace('.*/jails/([^/]+)/.*', '\\1') | regex_replace('^[0-9]+_', '') }}"
+ loop: "{{ _jail_specs.files | sort(attribute='path') }}"
+ loop_control:
+ label: "{{ item.path | regex_replace('.*/jails/([^/]+)/.*', '\\1') }}"
+ when: "'/defaults/' in item.path"
+
+- name: Collect unique userlands
+ set_fact:
+ jail_userlands: "{{ jail_defs | map(attribute='userland') | unique | list }}"
+
+- name: Create base ZFS datasets
+ community.general.zfs:
+ name: "{{ item.name }}"
+ state: present
+ extra_zfs_properties:
+ mountpoint: "{{ item.mountpoint | default(omit) }}"
+ loop:
+ - { name: "zroot/jails", mountpoint: "/usr/local/jails" }
+ - { name: "zroot/jails/media" }
+ - { name: "zroot/jails/templates" }
+ - { name: "zroot/jails/containers" }
+ - { name: "zroot/jails/volumes", mountpoint: "none" }
+ - { name: "zroot/jails/volumes/goaccess_www", mountpoint: "/usr/local/jails/volumes/goaccess_www" }
+ - { name: "zroot/jails/volumes/postgres_data", mountpoint: "/usr/local/jails/containers/postgres/var/db/postgres" }
+ - { name: "zroot/jails/volumes/irc_thelounge_logs", mountpoint: "/usr/local/jails/containers/irc_thelounge/root/.thelounge/logs" }
+ - { name: "zroot/jails/volumes/irc_thelounge_uploads", mountpoint: "/usr/local/jails/containers/irc_thelounge/root/.thelounge/uploads" }
+ - { name: "zroot/jails/volumes/komga_data", mountpoint: "/usr/local/jails/containers/komga/root/.komga" }
+ - { name: "zroot/storage", mountpoint: "/usr/local/jails/volumes/storage" }
+ loop_control:
+ label: "{{ item.name }}"
+
+- name: Create storage group
+ group:
+ name: storage
+ gid: 1001
+
+- name: Create storage user
+ user:
+ name: storage
+ uid: 1001
+ group: storage
+ home: /nonexistent
+ shell: /usr/sbin/nologin
+ create_home: false
+
+- name: Set storage volume permissions
+ file:
+ path: /usr/local/jails/volumes/storage
+ state: directory
+ owner: "1001"
+ group: "1001"
+ mode: "0777"
+
+- name: Create storage directories
+ file:
+ path: "/usr/local/jails/volumes/storage/{{ item.name }}"
+ state: directory
+ owner: "1001"
+ group: "1001"
+ mode: "{{ item.mode }}"
+ loop:
+ - { name: media, mode: "0777" }
+ - { name: docs, mode: "0775" }
+ - { name: downloads, mode: "0777" }
+ - { name: projects-ableton, mode: "0755" }
+ - { name: vault, mode: "0755" }
+ - { name: jan-systems-2025-content, mode: "0755" }
+
+- name: Set up userland templates
+ include_tasks: userland.yml
+ loop: "{{ jail_userlands }}"
+ loop_control:
+ loop_var: userland
+
+- name: Deploy /etc/jail.conf
+ template:
+ src: jail.conf.j2
+ dest: /etc/jail.conf
+ owner: root
+ group: wheel
+ mode: "0644"
+
+- name: Deploy devfs.rules
+ template:
+ src: devfs.rules.j2
+ dest: /etc/devfs.rules
+ owner: root
+ group: wheel
+ mode: "0644"
diff --git a/roles/host/tasks/userland.yml b/roles/host/tasks/userland.yml
new file mode 100644
index 0000000..59de0dd
--- /dev/null
+++ b/roles/host/tasks/userland.yml
@@ -0,0 +1,59 @@
+- name: "Create template dataset for {{ userland }}"
+ community.general.zfs:
+ name: "zroot/jails/templates/{{ userland }}"
+ state: present
+
+- name: "Check if {{ userland }} snapshot exists"
+ shell: zfs list -t snapshot -o name | grep -Fxq "zroot/jails/templates/{{ userland }}@base"
+ failed_when: false
+ changed_when: false
+ register: userland_snap
+
+- name: "Set up {{ userland }} template"
+ when: userland_snap.rc != 0
+ block:
+ - name: Download userland
+ get_url:
+ url: "https://download.freebsd.org/ftp/releases/{{ arch }}/{{ userland }}/base.txz"
+ dest: "/usr/local/jails/media/{{ userland }}-base.txz"
+ owner: root
+ group: wheel
+ mode: "0644"
+
+ - name: Unarchive userland
+ shell: "chflags -R noschg /usr/local/jails/templates/{{ userland }} 2>/dev/null; tar -xzf /usr/local/jails/media/{{ userland }}-base.txz -C /usr/local/jails/templates/{{ userland }}"
+
+ - name: Copy localtime
+ copy:
+ remote_src: true
+ src: /etc/localtime
+ dest: "/usr/local/jails/templates/{{ userland }}/etc/localtime"
+
+ - name: Deploy resolv.conf
+ template:
+ src: etc_resolv.conf.j2
+ dest: "/usr/local/jails/templates/{{ userland }}/etc/resolv.conf"
+ owner: root
+ group: wheel
+ mode: "0644"
+
+ - name: Disable resolvconf
+ lineinfile:
+ path: "/usr/local/jails/templates/{{ userland }}/etc/resolvconf.conf"
+ line: "resolvconf=NO"
+ create: true
+
+ - name: Update userland
+ shell: "freebsd-update -b /usr/local/jails/templates/{{ userland }}/ fetch install"
+
+ - name: Disable syslogd
+ lineinfile:
+ path: "/usr/local/jails/templates/{{ userland }}/etc/rc.conf"
+ line: 'syslogd_enable="NO"'
+ create: true
+
+ - name: Install packages in template
+ shell: "ASSUME_ALWAYS_YES=yes pkg -c /usr/local/jails/templates/{{ userland }} install -y python3 bash"
+
+ - name: Create snapshot
+ shell: "zfs snapshot zroot/jails/templates/{{ userland }}@base"
diff --git a/roles/host/templates/devfs.rules.j2 b/roles/host/templates/devfs.rules.j2
new file mode 100644
index 0000000..9fc0bd1
--- /dev/null
+++ b/roles/host/templates/devfs.rules.j2
@@ -0,0 +1,69 @@
+[devfsrules_hide_all=1]
+add hide
+
+[devfsrules_unhide_basic=2]
+add path null unhide
+add path zero unhide
+add path crypto unhide
+add path random unhide
+add path urandom unhide
+
+[devfsrules_unhide_login=3]
+add path 'ptyp*' unhide
+add path 'ptyq*' unhide
+add path 'ptyr*' unhide
+add path 'ptys*' unhide
+add path 'ptyP*' unhide
+add path 'ptyQ*' unhide
+add path 'ptyR*' unhide
+add path 'ptyS*' unhide
+add path 'ptyl*' unhide
+add path 'ptym*' unhide
+add path 'ptyn*' unhide
+add path 'ptyo*' unhide
+add path 'ptyL*' unhide
+add path 'ptyM*' unhide
+add path 'ptyN*' unhide
+add path 'ptyO*' unhide
+add path 'ttyp*' unhide
+add path 'ttyq*' unhide
+add path 'ttyr*' unhide
+add path 'ttys*' unhide
+add path 'ttyP*' unhide
+add path 'ttyQ*' unhide
+add path 'ttyR*' unhide
+add path 'ttyS*' unhide
+add path 'ttyl*' unhide
+add path 'ttym*' unhide
+add path 'ttyn*' unhide
+add path 'ttyo*' unhide
+add path 'ttyL*' unhide
+add path 'ttyM*' unhide
+add path 'ttyN*' unhide
+add path 'ttyO*' unhide
+add path ptmx unhide
+add path pts unhide
+add path 'pts/*' unhide
+add path fd unhide
+add path 'fd/*' unhide
+add path stdin unhide
+add path stdout unhide
+add path stderr unhide
+add path 'drm' unhide
+add path 'drm/*' unhide
+add path 'dri' unhide
+add path 'dri/*' unhide
+
+[devfsrules_jail=4]
+add include $devfsrules_hide_all
+add include $devfsrules_unhide_basic
+add include $devfsrules_unhide_login
+
+[devfsrules_jail_postgres=5]
+add include $devfsrules_jail
+add path 'bpf*' unhide
+
+[devfsrules_jail_ingress=6]
+add include $devfsrules_jail
+add path 'bpf*' unhide
+add path 'pf*' unhide
diff --git a/roles/host/templates/etc_crontab.j2 b/roles/host/templates/etc_crontab.j2
new file mode 100644
index 0000000..de1f3c6
--- /dev/null
+++ b/roles/host/templates/etc_crontab.j2
@@ -0,0 +1,32 @@
+# /etc/crontab - root's crontab for FreeBSD
+#
+#
+SHELL=/bin/sh
+PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
+#
+#minute hour mday month wday who command
+#
+# Save some entropy so that /dev/random can re-seed on boot.
+*/11 * * * * operator /usr/libexec/save-entropy
+#
+# Rotate log files every hour, if necessary.
+0 * * * * root newsyslog
+#
+# Perform daily/weekly/monthly maintenance.
+1 3 * * * root periodic daily
+15 4 * * 6 root periodic weekly
+30 5 1 * * root periodic monthly
+#
+# Adjust the time zone if the CMOS clock keeps local time, as opposed to
+# UTC time. See adjkerntz(8) for details.
+1,31 0-5 * * * root adjkerntz -a
+
+# Take snapshots every day at 3:05 AM
+5 3 * * * root logto /var/log/backup /usr/local/bin/backup snapshot prune-local
+# Send backup snapshots to remote every third day at 4:05 AM
+5 4 */3 * * root logto /var/log/backup /usr/local/bin/backup send-to-remote prune-remote notify
+
+# Run pylogmonitor
+*/10 * * * * root python -m pylogsentinel
+# Run pylogsentinel batch job once a day
+5 6 * * * root /usr/local/bin/pylogsentinel-batch-email.sh
diff --git a/roles/host/templates/etc_periodic.conf.j2 b/roles/host/templates/etc_periodic.conf.j2
new file mode 100644
index 0000000..176b55d
--- /dev/null
+++ b/roles/host/templates/etc_periodic.conf.j2
@@ -0,0 +1,4 @@
+daily_status_smart_enable="YES"
+daily_status_security_inline="YES"
+daily_status_zfs_enable="YES"
+weekly_certbot_enable="YES"
diff --git a/roles/host/templates/etc_rc.conf.j2 b/roles/host/templates/etc_rc.conf.j2
new file mode 100644
index 0000000..7cdf65e
--- /dev/null
+++ b/roles/host/templates/etc_rc.conf.j2
@@ -0,0 +1,50 @@
+hostname="pursotin"
+keymap="fi.kbd"
+sshd_enable="YES"
+ntpd_enable="YES"
+ntpd_sync_on_start="YES"
+powerd_enable="YES"
+moused_nondefault_enable="NO"
+dumpdev="AUTO"
+zfs_enable="YES"
+defaultrouter="{{ lan_ipv4_gateway }}"
+cloned_interfaces="bridge0 bridge1"
+{% if is_prod %}
+ifconfig_{{ nic_lan }}_name="lan0"
+ifconfig_{{ nic_wan }}_name="wan0"
+ifconfig_bridge0_name="brlan0"
+ifconfig_bridge1_name="brwan0"
+ifconfig_lan0="inet {{ lan_ipv4_cidr }}"
+ifconfig_lan0_ipv6="inet6 accept_rtadv"
+ifconfig_wan0="up"
+ifconfig_brlan0="addm lan0 up"
+ifconfig_brwan0="addm wan0 up"
+{% else %}
+ifconfig_bridge0_name="brlan0"
+ifconfig_bridge1_name="brwan0"
+ifconfig_vtnet0="inet {{ lan_ipv4_cidr }}"
+ifconfig_vtnet0_ipv6="inet6 accept_rtadv"
+ifconfig_brlan0="addm vtnet0 up"
+ifconfig_brwan0="up"
+{% endif %}
+zpool_gpt_labels_enable="YES"
+smartd_enable="YES"
+pf_enable="YES"
+syslogd_enable="YES"
+syslogd_flags="-s"
+jail_enable="YES"
+jail_parallel_start="YES"
+jail_reverse_stop="YES"
+clear_tmp_enable="YES"
+cleanvar_enable="YES"
+auditd_enable="YES"
+devd_enable="YES"
+{% if is_prod %}
+vm_enable="YES"
+vm_dir="zfs:zroot/vm"
+vm_list="alpine0"
+vm_delay="5"
+kld_list="pf if_wg i915kms"
+{% else %}
+kld_list="pf"
+{% endif %}
diff --git a/roles/host/templates/etc_resolv.conf.j2 b/roles/host/templates/etc_resolv.conf.j2
new file mode 100644
index 0000000..b382ecf
--- /dev/null
+++ b/roles/host/templates/etc_resolv.conf.j2
@@ -0,0 +1,2 @@
+search {{ lan_search_domain }}
+nameserver {{ dns_nameserver | default(lan_ipv4_gateway) }}
diff --git a/roles/host/templates/etc_ssh_sshd_config.j2 b/roles/host/templates/etc_ssh_sshd_config.j2
new file mode 100644
index 0000000..06179ce
--- /dev/null
+++ b/roles/host/templates/etc_ssh_sshd_config.j2
@@ -0,0 +1,121 @@
+# $OpenBSD: sshd_config,v 1.104 2021/07/02 05:11:21 dtucker Exp $
+
+# This is the sshd server system-wide configuration file. See
+# sshd_config(5) for more information.
+
+# This sshd was compiled with PATH=/usr/bin:/bin:/usr/sbin:/sbin
+
+# The strategy used for options in the default sshd_config shipped with
+# OpenSSH is to specify options with their default value where
+# possible, but leave them commented. Uncommented options override the
+# default value.
+
+# Note that some of FreeBSD's defaults differ from OpenBSD's, and
+# FreeBSD has a few additional options.
+
+#Port 22
+#AddressFamily any
+#ListenAddress 0.0.0.0
+#ListenAddress ::
+
+#HostKey /etc/ssh/ssh_host_rsa_key
+#HostKey /etc/ssh/ssh_host_ecdsa_key
+#HostKey /etc/ssh/ssh_host_ed25519_key
+
+# Ciphers and keying
+#RekeyLimit default none
+
+# Logging
+#SyslogFacility AUTH
+#LogLevel INFO
+
+# Authentication:
+
+#LoginGraceTime 2m
+PermitRootLogin prohibit-password
+#StrictModes yes
+#MaxAuthTries 6
+#MaxSessions 10
+
+#PubkeyAuthentication yes
+
+# The default is to check both .ssh/authorized_keys and .ssh/authorized_keys2
+# but this is overridden so installations will only check .ssh/authorized_keys
+AuthorizedKeysFile .ssh/authorized_keys
+
+#AuthorizedPrincipalsFile none
+
+#AuthorizedKeysCommand none
+#AuthorizedKeysCommandUser nobody
+
+# For this to work you will also need host keys in /etc/ssh/ssh_known_hosts
+#HostbasedAuthentication no
+# Change to yes if you don't trust ~/.ssh/known_hosts for
+# HostbasedAuthentication
+#IgnoreUserKnownHosts no
+# Don't read the user's ~/.rhosts and ~/.shosts files
+#IgnoreRhosts yes
+
+# Change to yes to enable built-in password authentication.
+# Note that passwords may also be accepted via KbdInteractiveAuthentication.
+PasswordAuthentication no
+#PermitEmptyPasswords no
+
+# Change to no to disable PAM authentication
+#KbdInteractiveAuthentication yes
+
+# Kerberos options
+#KerberosAuthentication no
+#KerberosOrLocalPasswd yes
+#KerberosTicketCleanup yes
+#KerberosGetAFSToken no
+
+# GSSAPI options
+#GSSAPIAuthentication no
+#GSSAPICleanupCredentials yes
+
+# Set this to 'no' to disable PAM authentication, account processing,
+# and session processing. If this is enabled, PAM authentication will
+# be allowed through the KbdInteractiveAuthentication and
+# PasswordAuthentication. Depending on your PAM configuration,
+# PAM authentication via KbdInteractiveAuthentication may bypass
+# the setting of "PermitRootLogin prohibit-password".
+# If you just want the PAM account and session checks to run without
+# PAM authentication, then enable this but set PasswordAuthentication
+# and KbdInteractiveAuthentication to 'no'.
+#UsePAM yes
+
+#AllowAgentForwarding yes
+#AllowTcpForwarding yes
+#GatewayPorts no
+#X11Forwarding no
+#X11DisplayOffset 10
+#X11UseLocalhost yes
+#PermitTTY yes
+#PrintMotd yes
+#PrintLastLog yes
+#TCPKeepAlive yes
+#PermitUserEnvironment no
+#Compression delayed
+#ClientAliveInterval 0
+#ClientAliveCountMax 3
+#UseDNS yes
+#PidFile /var/run/sshd.pid
+#MaxStartups 10:30:100
+#PermitTunnel no
+#ChrootDirectory none
+#UseBlacklist no
+#VersionAddendum FreeBSD-20250219
+
+# no default banner path
+#Banner none
+
+# override default of no subsystems
+Subsystem sftp /usr/libexec/sftp-server
+
+# Example of overriding settings on a per-user basis
+#Match User anoncvs
+# X11Forwarding no
+# AllowTcpForwarding no
+# PermitTTY no
+# ForceCommand cvs server
diff --git a/roles/host/templates/jail.conf.j2 b/roles/host/templates/jail.conf.j2
new file mode 100644
index 0000000..81bfbad
--- /dev/null
+++ b/roles/host/templates/jail.conf.j2
@@ -0,0 +1 @@
+.include "/etc/jail.conf.d/*.conf";
diff --git a/roles/host/templates/usr_local_bin_logto.sh.j2 b/roles/host/templates/usr_local_bin_logto.sh.j2
new file mode 100644
index 0000000..9bb5aa1
--- /dev/null
+++ b/roles/host/templates/usr_local_bin_logto.sh.j2
@@ -0,0 +1,47 @@
+#!/bin/sh
+
+set -ue
+
+# Wrapper for logging to file and prepending a timestamp.
+# By default writes both stdout and stderr to the log file.
+
+usage() {
+ echo "Usage: logto [-s|-e] <log_file> <command>"
+ echo "Flags:"
+ echo " -s: Write only stdout to the log file."
+ echo " -e: Write only stderr to the log file."
+ echo ""
+ echo "Example usage:"
+ echo " logto /var/log/my.log run some command"
+ exit 1
+}
+
+mode="all"
+
+while getopts "se" opt; do
+ case $opt in
+ s) mode="stdout" ;;
+ e) mode="stderr" ;;
+ *) usage ;;
+ esac
+done
+shift $((OPTIND-1))
+
+if [ $# -lt 2 ]; then
+ usage
+fi
+
+log_file="$1"
+shift
+
+if [ "$mode" = "stdout" ]; then
+ out=$(2>/dev/null $@)
+elif [ "$mode" = "stderr" ]; then
+ out=$(2>&1 >/dev/null $@)
+else
+ out=$(2>&1 $@)
+fi
+
+if [ ! -z "$out" ]; then
+ echo "$(date +"%Y-%m-%dT%H:%M:%S%z")" "$out" >>"$log_file"
+fi
diff --git a/roles/host/templates/usr_local_bin_pylogsentinel-batch-email.sh.j2 b/roles/host/templates/usr_local_bin_pylogsentinel-batch-email.sh.j2
new file mode 100644
index 0000000..d28efd9
--- /dev/null
+++ b/roles/host/templates/usr_local_bin_pylogsentinel-batch-email.sh.j2
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+FILE=/tmp/pylogsentinel.daily
+
+if [ ! -f "$FILE" ]; then
+ echo "Nothing to do"
+ exit 0
+fi
+
+echo "Sending email"
+cat "$FILE" | mail -s "pylogsentinel alert batch" root
+
+echo "Removing batch file"
+rm "$FILE"
diff --git a/roles/host/templates/usr_local_etc_pylogsentinel.conf.j2 b/roles/host/templates/usr_local_etc_pylogsentinel.conf.j2
new file mode 100644
index 0000000..cb80742
--- /dev/null
+++ b/roles/host/templates/usr_local_etc_pylogsentinel.conf.j2
@@ -0,0 +1,22 @@
+[system]
+state_dir = /var/run/pylogsentinel
+max_block_size = 10M
+
+[logs.standard]
+cmd = find / -type f -path '*/var/log/*' ! -name '*access.log' ! -name '*.bz2' ! -name 'dmesg*' ! -name 'syncthing*'
+
+[logs.access]
+paths = /usr/local/jails/containers/ingress/var/log/nginx/access.log
+
+[action.default]
+cmd = echo -e "---------\nMatched $RULE_ID in $FILE at line $LINE, context:\n\n$CONTEXT\n" >> /tmp/pylogsentinel.daily
+
+[rule.error]
+description = Error-like conditions
+pattern = /(error|fatal|exception|killed)/i
+logs = standard
+
+[rule.access]
+description = HTTP code >=500 in access log
+pattern = /HTTP\/[0-9].[0-9]" 5[0-9][0-9]/
+logs = access
diff --git a/roles/host/templates/usr_local_etc_rc.d_zpool_gpt_labels.j2 b/roles/host/templates/usr_local_etc_rc.d_zpool_gpt_labels.j2
new file mode 100644
index 0000000..36bc715
--- /dev/null
+++ b/roles/host/templates/usr_local_etc_rc.d_zpool_gpt_labels.j2
@@ -0,0 +1,33 @@
+#!/bin/sh
+
+# PROVIDE: zpool_gpt_labels
+# REQUIRE: zfs
+# BEFORE: LOGIN
+# KEYWORD: nojail
+
+. /etc/rc.subr
+
+name="zpool_gpt_labels"
+rcvar="zpool_gpt_labels_enable"
+start_cmd="zpool_gpt_labels_start"
+
+zpool_gpt_labels_start() {
+ logger "Setting zpool vdev paths to GPT labels"
+ local zpool_status="$(zpool status)"
+ for i in 0 1 2 3; do
+ local raw="nda${i}p4.eli"
+ local path="/dev/gpt/zfs${i}.eli"
+ case "$zpool_status" in
+ *"$raw"*)
+ zpool set "path=${path}" zroot "${raw}"
+ logger "Setting ${raw} -> ${path}"
+ ;;
+ *)
+ logger "Skipping ${raw}..."
+ ;;
+ esac
+ done
+}
+
+load_rc_config $name
+run_rc_command "$1"
diff --git a/roles/host/templates/usr_local_etc_smartd.conf.j2 b/roles/host/templates/usr_local_etc_smartd.conf.j2
new file mode 100644
index 0000000..bdcbfd0
--- /dev/null
+++ b/roles/host/templates/usr_local_etc_smartd.conf.j2
@@ -0,0 +1,4 @@
+/dev/nvme0 -a -d nvme
+/dev/nvme1 -a -d nvme
+/dev/nvme2 -a -d nvme
+/dev/nvme3 -a -d nvme
diff --git a/roles/host_prod/handlers/main.yml b/roles/host_prod/handlers/main.yml
new file mode 100644
index 0000000..76ec23e
--- /dev/null
+++ b/roles/host_prod/handlers/main.yml
@@ -0,0 +1,13 @@
+- name: Apply rc.conf
+ shell: service kld start; service netif restart && service routing restart
+
+- name: Mount filesystems
+ shell: mount -a
+
+- name: Restart smartd
+ service:
+ name: smartd
+ state: restarted
+
+- name: Run newaliases
+ shell: newaliases
diff --git a/roles/host_prod/tasks/main.yml b/roles/host_prod/tasks/main.yml
new file mode 100644
index 0000000..ec635ce
--- /dev/null
+++ b/roles/host_prod/tasks/main.yml
@@ -0,0 +1,153 @@
+- name: Set up /etc/rc.conf
+ template:
+ src: "{{ playbook_dir }}/roles/host/templates/etc_rc.conf.j2"
+ dest: /etc/rc.conf
+ owner: root
+ group: wheel
+ mode: "0644"
+ vars:
+ is_prod: true
+ notify: Apply rc.conf
+
+- name: Flush handlers
+ meta: flush_handlers
+
+- name: Install hardware packages
+ package:
+ name: "{{ item }}"
+ state: present
+ loop:
+ - vm-bhyve
+ - drm-kmod
+
+- name: Set up fstab
+ template:
+ src: etc_fstab.j2
+ dest: /etc/fstab
+ owner: root
+ group: wheel
+ mode: "0644"
+ notify: Mount filesystems
+
+- name: Set up bhyve
+ block:
+ - name: Create vm dataset
+ community.general.zfs:
+ name: zroot/vm
+ state: present
+
+ - name: Check if vm-bhyve is initialized
+ stat:
+ path: /zroot/vm/.config
+ register: vm_init_check
+
+ - name: Run vm init
+ shell: vm init
+ when: not vm_init_check.stat.exists
+
+- name: Copy backup SSH private key
+ copy:
+ src: "{{ backup_ssh_privkey_file }}"
+ dest: /root/.ssh/backup
+ owner: root
+ group: wheel
+ mode: "0600"
+
+- name: Copy backup SSH public key
+ copy:
+ src: "{{ backup_ssh_pubkey_file }}"
+ dest: /root/.ssh/backup.pub
+ owner: root
+ group: wheel
+ mode: "0644"
+
+- name: Copy SSH config
+ template:
+ src: root_ssh_config.j2
+ dest: /root/.ssh/config
+ owner: root
+ group: wheel
+ mode: "0644"
+
+- name: Copy backup script
+ template:
+ src: usr_local_bin_backup.sh.j2
+ dest: /usr/local/bin/backup
+ owner: root
+ group: wheel
+ mode: "0755"
+
+- name: Create dma config directory
+ file:
+ path: /etc/dma
+ state: directory
+ owner: root
+ group: wheel
+ mode: "0755"
+
+- name: Configure dma.conf
+ template:
+ src: etc_dma_dma.conf.j2
+ dest: /etc/dma/dma.conf
+ owner: root
+ group: wheel
+ mode: "0644"
+
+- name: Configure dma auth.conf
+ template:
+ src: etc_dma_auth.conf.j2
+ dest: /etc/dma/auth.conf
+ owner: root
+ group: mail
+ mode: "0640"
+
+- name: Configure mail aliases
+ template:
+ src: etc_aliases.j2
+ dest: /etc/aliases
+ owner: root
+ group: wheel
+ mode: "0644"
+ notify: Run newaliases
+
+- name: Install smartmontools
+ package:
+ name: smartmontools
+ state: present
+
+- name: Deploy smartd.conf
+ template:
+ src: "{{ playbook_dir }}/roles/host/templates/usr_local_etc_smartd.conf.j2"
+ dest: /usr/local/etc/smartd.conf
+ owner: root
+ group: wheel
+ mode: "0644"
+ notify: Restart smartd
+
+- name: Start smartd
+ service:
+ name: smartd
+ state: started
+
+- name: Deploy zpool_gpt_labels rc.d script
+ template:
+ src: "{{ playbook_dir }}/roles/host/templates/usr_local_etc_rc.d_zpool_gpt_labels.j2"
+ dest: /usr/local/etc/rc.d/zpool_gpt_labels
+ owner: root
+ group: wheel
+ mode: "0755"
+ register: zpool_gpt_labels_script
+
+- name: Run zpool_gpt_labels
+ service:
+ name: zpool_gpt_labels
+ state: started
+ when: zpool_gpt_labels_script is changed
+
+- name: Deploy safepf script
+ copy:
+ src: "{{ playbook_dir }}/roles/host_prod/templates/usr_local_bin_safepf.sh"
+ dest: /usr/local/bin/safepf
+ owner: root
+ group: wheel
+ mode: "0755"
diff --git a/roles/host_prod/templates/etc_aliases.j2 b/roles/host_prod/templates/etc_aliases.j2
new file mode 100644
index 0000000..4fe3e7f
--- /dev/null
+++ b/roles/host_prod/templates/etc_aliases.j2
@@ -0,0 +1 @@
+*: {{ dma_to_address }}
diff --git a/roles/host_prod/templates/etc_dma_auth.conf.j2 b/roles/host_prod/templates/etc_dma_auth.conf.j2
new file mode 100644
index 0000000..a1000ea
--- /dev/null
+++ b/roles/host_prod/templates/etc_dma_auth.conf.j2
@@ -0,0 +1 @@
+{{ smtp_user }}|{{ smtp_host }}:{{ smtp_password }}
diff --git a/roles/host_prod/templates/etc_dma_dma.conf.j2 b/roles/host_prod/templates/etc_dma_dma.conf.j2
new file mode 100644
index 0000000..dfaa04f
--- /dev/null
+++ b/roles/host_prod/templates/etc_dma_dma.conf.j2
@@ -0,0 +1,8 @@
+SMARTHOST {{ smtp_host }}
+PORT {{ smtp_port }}
+AUTHPATH /etc/dma/auth.conf
+SECURETRANSFER
+STARTTLS
+MAILNAME {{ dma_mail_hostname }}
+MASQUERADE {{ ansible_hostname }}@{{ dma_mail_hostname }}
+#NULLCLIENT
diff --git a/roles/host_prod/templates/etc_fstab.j2 b/roles/host_prod/templates/etc_fstab.j2
new file mode 100644
index 0000000..95d3fa0
--- /dev/null
+++ b/roles/host_prod/templates/etc_fstab.j2
@@ -0,0 +1,7 @@
+# Device Mountpoint FStype Options Dump Pass#
+/dev/gpt/efiboot0 /boot/efi msdosfs rw 2 2
+/dev/nda0p3.eli none swap sw 0 0
+/dev/nda1p3.eli none swap sw 0 0
+/dev/nda2p3.eli none swap sw 0 0
+/dev/nda3p3.eli none swap sw 0 0
+tmpfs /tmp tmpfs rw,mode=777,size=2g 0 0
diff --git a/roles/host_prod/templates/root_ssh_config.j2 b/roles/host_prod/templates/root_ssh_config.j2
new file mode 100644
index 0000000..96f78f2
--- /dev/null
+++ b/roles/host_prod/templates/root_ssh_config.j2
@@ -0,0 +1,4 @@
+Host backup
+ HostName {{ backup_ssh_host }}
+ User {{ backup_ssh_user }}
+ IdentityFile /root/.ssh/backup
diff --git a/roles/host_prod/templates/usr_local_bin_backup.sh.j2 b/roles/host_prod/templates/usr_local_bin_backup.sh.j2
new file mode 100644
index 0000000..f6a0e2c
--- /dev/null
+++ b/roles/host_prod/templates/usr_local_bin_backup.sh.j2
@@ -0,0 +1,365 @@
+{% raw %}#!/usr/bin/env bash
+set -euo pipefail
+
+# ===== CONFIG (filled by Ansible) =====
+KEEP_LOCAL=30
+KEEP_REMOTE=10
+KEYFILE="/root/.ssh/backup"
+HOST_DIR="backup"
+DATASET="{% endraw %}{{ backup_zfs_dataset }}{% raw %}"
+BACKUP_EXCLUDE_PROP="{% endraw %}{{ backup_zfs_exclude_property | default('com.pursotin:backup') }}{% raw %}"
+USER="{% endraw %}{{ backup_ssh_user }}{% raw %}"
+HOST="{% endraw %}{{ backup_ssh_host }}{% raw %}"
+EMAIL_TO=root
+# =====================================
+
+# ----- Globals for notification -----
+STARTED_AT="$(date '+%Y-%m-%dT%H:%M:%S%z')"
+FINISHED_AT=""
+MESSAGE_LOG=""
+BACKUP_NAME="" # e.g. zroot@2025-09-30-12-00-00-0300.enc
+BACKUP_SIZE_BYTES="" # numeric bytes
+
+# ----- Helpers -----
+die() { echo "Error: $*" >&2; exit 1; }
+
+require_cmds() {
+ local cmds=("$@")
+ for c in "${cmds[@]}"; do command -v "$c" >/dev/null 2>&1 || die "Missing command: $c"; done
+}
+
+log_note() {
+ # Echo to console and append to message buffer
+ local msg="$1"
+ echo "$msg"
+ MESSAGE_LOG+="$msg"$'\n'
+}
+
+timestamp() {
+ # Replace '+' with '-' so timezone is filename-safe and lexicographically sortable within TZ.
+ date +%Y-%m-%d-%H-%M-%S%z | tr '+' '-'
+}
+
+humanize_bytes() {
+ local bytes="$1"
+ if [[ "${bytes}" =~ ^[0-9]+$ ]]; then
+ awk -v b="${bytes}" '
+ BEGIN {
+ unit_count = split("B KiB MiB GiB TiB PiB EiB", units, " ")
+ i = 1
+ while (b >= 1024 && i < unit_count) {
+ b = b / 1024
+ i++
+ }
+ if (i == 1) {
+ printf "%.0f %s\n", b, units[i]
+ } else {
+ printf "%.2f %s\n", b, units[i]
+ }
+ }
+ '
+ else
+ echo "unknown"
+ fi
+}
+
+latest_snapshot_for_dataset() {
+ # Latest snapshot on the TOP dataset only (newest first). Returns e.g. zroot@2025-09-30-...
+ zfs list -t snapshot -o name -S creation "$DATASET" 2>/dev/null \
+ | awk 'NR==2{print; exit}'
+}
+
+list_top_snapshots_newest_first() {
+ # Only list snapshots on the top dataset (not children), newest first.
+ zfs list -t snapshot -o name -S creation "$DATASET" 2>/dev/null \
+ | awk 'NR>1{print $1}'
+}
+
+list_excluded_datasets() {
+ # List datasets (including inherited values) where backup property is explicitly false.
+ zfs get -r -H -o name,value "${BACKUP_EXCLUDE_PROP}" "${DATASET}" 2>/dev/null \
+ | awk 'tolower($2)=="false"{print $1}'
+}
+
+list_remote_backups_sorted() {
+ # We store files under ${HOST_DIR}/<dataset>@<timestamp>.enc
+ echo "ls ${HOST_DIR}" \
+ | sftp -q -i "${KEYFILE}" "${USER}@${HOST}" 2>/dev/null \
+ | tail -n +2 | sort
+}
+
+# Upload a local file to the storage box path "${HOST_DIR}/<remote_name>"
+sftp_put() {
+ local local_file="$1"
+ local remote_name="$2" # just the filename, no directory
+ sftp -i "${KEYFILE}" "${USER}@${HOST}" <<EOF
+put ${local_file} ${HOST_DIR}/${remote_name}
+EOF
+}
+
+# ----- Subcommands -----
+cmd_init_remote() {
+ echo ""
+ echo "[init-remote]"
+ require_cmds sftp
+
+ echo "Checking for remote directory: ${HOST_DIR}"
+ if echo "ls ${HOST_DIR}" | sftp -i "${KEYFILE}" "${USER}@${HOST}" >/dev/null 2>&1; then
+ echo "Remote directory already exists: ${HOST_DIR}"
+ log_note "init-remote: verified remote directory '${HOST_DIR}'"
+ return 0
+ fi
+
+ echo "Creating remote directory: ${HOST_DIR}"
+ set +e
+ sftp -i "${KEYFILE}" "${USER}@${HOST}" <<EOF
+mkdir ${HOST_DIR}
+EOF
+ rc=$?
+ set -e
+ if [[ $rc -ne 0 ]]; then
+ die "Failed to create remote directory ${HOST_DIR}"
+ fi
+
+ # Re-check
+ if echo "ls ${HOST_DIR}" | sftp -q -i "${KEYFILE}" "${USER}@${HOST}" >/dev/null 2>&1; then
+ echo "Remote directory created: ${HOST_DIR}"
+ log_note "init-remote: created remote directory '${HOST_DIR}'"
+ else
+ die "Remote directory ${HOST_DIR} not found after creation"
+ fi
+}
+
+cmd_snapshot() {
+ echo ""
+ echo "[snapshot]"
+ require_cmds zfs date tr
+ local ts snap
+ ts="$(timestamp)"
+ snap="${DATASET}@${ts}"
+ echo "Taking recursive snapshot \"${snap}\""
+ (set -x; zfs snapshot -r "${snap}")
+ echo "Recursive snapshot \"${snap}\" created"
+ log_note "snapshot: created recursive snapshot '${snap}'"
+}
+
+cmd_send_to_remote() {
+ echo ""
+ echo "[send-to-remote]"
+ require_cmds zfs age sftp mktemp stat awk sort
+
+ local snap base target tmp size_bytes ds parent skip
+ local -a send_args excluded_raw excluded
+ snap="$(latest_snapshot_for_dataset)"
+ [[ -n "${snap}" ]] || die "No snapshot found to send. Run 'snapshot' first or ensure the dataset has snapshots."
+
+ base="$(basename "${snap}")" # e.g., zroot@2025-09-30-12-00-00-0300
+ target="${base}.enc"
+ tmp="$(mktemp -t backup_send.XXXXXX)"
+ trap 'rm -f "${tmp}"' EXIT
+
+ send_args=(-Rvc)
+ mapfile -t excluded_raw < <(list_excluded_datasets | sort)
+ excluded=()
+ for ds in "${excluded_raw[@]}"; do
+ skip=0
+ [[ -n "${ds}" ]] || continue
+ for parent in "${excluded[@]}"; do
+ if [[ "${ds}" == "${parent}" || "${ds}" == "${parent}/"* ]]; then
+ skip=1
+ break
+ fi
+ done
+ [[ "${skip}" -eq 1 ]] || excluded+=("${ds}")
+ done
+
+ if [[ "${#excluded[@]}" -gt 0 ]]; then
+ echo "Excluding datasets where ${BACKUP_EXCLUDE_PROP}=false:"
+ for ds in "${excluded[@]}"; do
+ if [[ "${ds}" == "${DATASET}" ]]; then
+ die "Top dataset ${DATASET} is excluded via ${BACKUP_EXCLUDE_PROP}=false; refusing to create an empty backup stream."
+ fi
+ echo " - ${ds}"
+ send_args+=(-X "${ds}")
+ done
+ log_note "send-to-remote: excluded ${#excluded[@]} dataset(s) using ${BACKUP_EXCLUDE_PROP}=false"
+ fi
+
+ echo "Creating encrypted replication stream to temp file: ${tmp}"
+ # -R: recursive hierarchy, -v: progress to stderr, -c: send compressed (keeps on-disk compression)
+ (set -x; zfs send "${send_args[@]}" "${snap}" | age -e -i "${KEYFILE}" > "${tmp}")
+
+ size_bytes="$(stat -f %z "${tmp}" 2>/dev/null || stat -c %s "${tmp}" 2>/dev/null || echo "unknown")"
+ echo "Local stream size: ${size_bytes} bytes"
+
+ echo "Uploading via SFTP to ${HOST}:${HOST_DIR}/${target}"
+ if sftp_put "${tmp}" "${target}"; then
+ echo "Upload complete"
+ else
+ die "SFTP upload failed (does the remote directory '${HOST_DIR}' exist? Run 'init-remote')"
+ fi
+
+ # Set globals for notify()
+ BACKUP_NAME="${target}"
+ BACKUP_SIZE_BYTES="${size_bytes}"
+
+ log_note "send-to-remote: uploaded '${snap}' as '${target}' (${size_bytes} bytes) to ${HOST}:${HOST_DIR}"
+ rm -f "${tmp}"
+ trap - EXIT
+}
+
+cmd_prune_remote() {
+ echo ""
+ echo "[prune-remote]"
+ require_cmds sftp awk sort wc
+
+ echo "Fetching remote backup listing from sftp://${HOST}/${HOST_DIR}"
+ BACKUPS="$(list_remote_backups_sorted || true)"
+ mapfile -t BACKUP_ARR < <(printf "%s\n" "${BACKUPS}")
+ local count="${#BACKUP_ARR[@]}"
+
+ if [[ "${count}" -le "${KEEP_REMOTE}" ]]; then
+ echo "Remote backups (${count}) <= KEEP_REMOTE (${KEEP_REMOTE}); nothing to prune."
+ log_note "prune-remote: kept ${count} (<= ${KEEP_REMOTE}); no deletions"
+ return 0
+ fi
+
+ echo "Pruning remote backups, keeping latest ${KEEP_REMOTE} (will delete $(("${count}" - "${KEEP_REMOTE}")))"
+ local to_delete_count=$((count - KEEP_REMOTE))
+ local deleted=0
+ for ((i=0; i<to_delete_count; i++)); do
+ b="${BACKUP_ARR[$i]}"
+ [[ -n "${b}" ]] || continue
+ echo "Deleting remote: ${b}"
+ set +e
+ echo "rm ${b}" | sftp -i "${KEYFILE}" "${USER}@${HOST}"
+ rc=$?
+ set -e
+ if [[ $rc -ne 0 ]]; then
+ die "Failed to delete remote file: ${b}"
+ fi
+ deleted=$((deleted+1))
+ done
+ echo "Remote prune done. Deleted: ${deleted}"
+ log_note "prune-remote: deleted ${deleted}, kept ${KEEP_REMOTE}"
+}
+
+cmd_prune_local() {
+ echo ""
+ echo "[prune-local]"
+ require_cmds zfs awk
+
+ echo "Pruning local snapshots on dataset: ${DATASET}; keeping latest ${KEEP_LOCAL}"
+ mapfile -t snaps < <(list_top_snapshots_newest_first)
+ local total="${#snaps[@]}"
+ if [[ "${total}" -le "${KEEP_LOCAL}" ]]; then
+ echo "Local snapshots (${total}) <= KEEP_LOCAL (${KEEP_LOCAL}); nothing to prune."
+ log_note "prune-local: kept ${total} (<= ${KEEP_LOCAL}); no deletions"
+ return 0
+ fi
+
+ local deleted=0
+ # List is newest-first; skip first KEEP_LOCAL and destroy the rest RECURSIVELY across the tree
+ for ((i=KEEP_LOCAL; i<total; i++)); do
+ s="${snaps[$i]}" # e.g., zroot@2025-09-30-...
+ echo "Destroying recursive snapshot: ${s}"
+ (set -x; zfs destroy -r "${s}")
+ deleted=$((deleted+1))
+ done
+ echo "Local prune done. Deleted: ${deleted}"
+ log_note "prune-local: deleted ${deleted}, kept ${KEEP_LOCAL}"
+}
+
+cmd_notify() {
+ echo ""
+ echo "[notify]"
+ require_cmds mail date awk
+ local backup_size_human
+ FINISHED_AT="$(date '+%Y-%m-%dT%H:%M:%S%z')"
+
+ # Derive snapshot name if not set yet (best-effort)
+ if [[ -z "${BACKUP_NAME}" ]]; then
+ # Try to infer from latest snapshot
+ latest="$(latest_snapshot_for_dataset || true)"
+ if [[ -n "${latest}" ]]; then
+ BACKUP_NAME="$(basename "${latest}").enc"
+ else
+ BACKUP_NAME="unknown"
+ fi
+ fi
+
+ backup_size_human="$(humanize_bytes "${BACKUP_SIZE_BYTES:-unknown}")"
+
+ # Compose subject and body
+ local subject="Backup completed: ${BACKUP_NAME}"
+ local body=""
+ body+="Backup run summary"$'\n'
+ body+="Started: ${STARTED_AT}"$'\n'
+ body+="Finished: ${FINISHED_AT}"$'\n'
+ body+="Dataset: ${DATASET}"$'\n'
+ body+="Remote: ${USER}@${HOST}:${HOST_DIR}"$'\n'
+ body+="Name: ${BACKUP_NAME}"$'\n'
+ body+="Size: ${backup_size_human} (${BACKUP_SIZE_BYTES:-unknown} bytes)"$'\n'
+ body+=$'\n'
+ body+="Steps:"$'\n'
+ body+="${MESSAGE_LOG:-<no steps recorded>}"$'\n'
+
+ echo "Sending notification email..."
+ printf "%s\n" "$body" | mail -s "$subject" "${EMAIL_TO}"
+ log_note "notify: email sent to ${EMAIL_TO}"
+}
+
+usage() {
+ cat <<EOF
+Usage: $(basename "$0") [subcommand [subcommand ...]]
+
+Subcommands (executed in order):
+ init-remote Create the remote backup directory (\$HOST_DIR). Succeeds if it already exists.
+ snapshot Create a new **recursive** ZFS snapshot for \$DATASET
+ send-to-remote Create encrypted replication stream to a temp file, then upload via SFTP
+ prune-remote Keep latest \$KEEP_REMOTE backups on remote (default ${KEEP_REMOTE})
+ prune-local Keep latest \$KEEP_LOCAL local snapshots on the top dataset (default ${KEEP_LOCAL})
+ notify Send notification email (includes steps, timestamps, backup name & size)
+
+Notes:
+ - Run 'init-remote' once before the first upload, or anytime after changing \$HOST_DIR.
+ - Local pruning destroys older snapshots **recursively** to maintain consistency across descendants.
+ - Set \${BACKUP_EXCLUDE_PROP}=false on a dataset to exclude it (and inherited descendants) from send-to-remote.
+ Example: zfs set com.pursotin:backup=false zroot/some/heavy-dataset
+
+Examples:
+ $(basename "$0") init-remote snapshot send-to-remote prune-remote prune-local notify
+EOF
+}
+
+# ----- Main -----
+main() {
+ if [[ $# -eq 0 ]]; then
+ usage
+ exit 1
+ fi
+
+ # sanity
+ [[ -n "${DATASET}" ]] || die "DATASET not set"
+ [[ -n "${USER}" && -n "${HOST}" ]] || die "USER/HOST not set"
+ [[ -r "${KEYFILE}" ]] || die "KEYFILE not readable: ${KEYFILE}"
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ init-remote) cmd_init_remote ;;
+ snapshot) cmd_snapshot ;;
+ send-to-remote) cmd_send_to_remote ;;
+ prune-remote) cmd_prune_remote ;;
+ prune-local) cmd_prune_local ;;
+ notify) cmd_notify ;;
+ -h|--help|help) usage; exit 0 ;;
+ *) die "Unknown subcommand: $1" ;;
+ esac
+ shift
+ done
+
+ echo "Done."
+}
+
+main "$@"
+{% endraw %}
diff --git a/roles/host_prod/templates/usr_local_bin_safepf.sh b/roles/host_prod/templates/usr_local_bin_safepf.sh
new file mode 100644
index 0000000..1b6efee
--- /dev/null
+++ b/roles/host_prod/templates/usr_local_bin_safepf.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+
+set -eu
+
+cmd="pfctl -f /etc/pf.conf"
+read -p "Run this command (y/n)? $cmd " answer
+if [ "$answer" = "y" ]; then
+ (set -x; $cmd)
+else
+ exit 0
+fi
+
+cmd="service pf restart"
+read -p "Run this command (y/n)? $cmd " answer
+if [ "$answer" = "y" ]; then
+ (set -x; $cmd)
+else
+ exit 0
+fi
+
+timeout=60
+echo "Running safety timeout ($timeout seconds). Press CTRL-C if everything is working."
+while [ $timeout -gt 0 ]
+do
+ sleep 1
+ timeout=$((timeout - 1))
+ echo -n "."
+done
+
+echo "Timeout reached. Enabling empty pf rules"
+
+set -x
+mv /etc/pf.conf /etc/pf.conf.locked_out
+echo "" > /etc/pf.conf
+pfctl -f /etc/pf.conf
+service pf restart
diff --git a/roles/host_test/handlers/main.yml b/roles/host_test/handlers/main.yml
new file mode 100644
index 0000000..46fcbde
--- /dev/null
+++ b/roles/host_test/handlers/main.yml
@@ -0,0 +1,2 @@
+- name: Apply rc.conf
+ shell: service kld start; service netif restart && service routing restart
diff --git a/roles/host_test/tasks/main.yml b/roles/host_test/tasks/main.yml
new file mode 100644
index 0000000..1246073
--- /dev/null
+++ b/roles/host_test/tasks/main.yml
@@ -0,0 +1,13 @@
+- name: Set up /etc/rc.conf
+ template:
+ src: "{{ playbook_dir }}/roles/host/templates/etc_rc.conf.j2"
+ dest: /etc/rc.conf
+ owner: root
+ group: wheel
+ mode: "0644"
+ vars:
+ is_prod: false
+ notify: Apply rc.conf
+
+- name: Flush handlers
+ meta: flush_handlers
diff --git a/roles/jail/handlers/main.yml b/roles/jail/handlers/main.yml
new file mode 100644
index 0000000..5375cb8
--- /dev/null
+++ b/roles/jail/handlers/main.yml
@@ -0,0 +1,5 @@
+- name: Restart jail services
+ service:
+ name: "{{ item }}"
+ state: restarted
+ loop: "{{ services | default([]) }}"
diff --git a/roles/jail/tasks/main.yml b/roles/jail/tasks/main.yml
new file mode 100644
index 0000000..be72e3b
--- /dev/null
+++ b/roles/jail/tasks/main.yml
@@ -0,0 +1,175 @@
+# Host-side setup (runs on the jail host via SSH)
+- name: "Check if {{ jail_name }} container exists"
+ shell: "zfs list -o name | grep -Fxq 'zroot/jails/containers/{{ jail_name }}'"
+ failed_when: false
+ changed_when: false
+ register: jail_exists
+ delegate_to: "{{ jail_delegate_host }}"
+
+- name: "Check {{ jail_name }} userland version"
+ shell: "zfs get -H -o value origin zroot/jails/containers/{{ jail_name }}"
+ register: jail_origin
+ changed_when: false
+ when: jail_exists.rc == 0
+ delegate_to: "{{ jail_delegate_host }}"
+
+- name: "*** MIGRATION REQUIRED: {{ jail_name }} ***"
+ pause:
+ prompt: |
+
+ ════════════════════════════════════════════════════════════════
+ JAIL USERLAND MIGRATION: {{ jail_name }}
+ ════════════════════════════════════════════════════════════════
+ Current: {{ jail_origin.stdout | trim }}
+ Target: zroot/jails/templates/{{ userland }}@base
+
+ This will:
+ 1. Stop the jail
+ 2. Rename existing dataset to *.old.<timestamp>
+ 3. Clone fresh from {{ userland }}
+ ════════════════════════════════════════════════════════════════
+
+ Press Enter to continue or Ctrl+C to abort
+ when:
+ - jail_exists.rc == 0
+ - "userland + '@base' not in jail_origin.stdout"
+
+- name: "Migrate {{ jail_name }} to {{ userland }}"
+ shell: |
+ service jail stop {{ jail_name }} || true
+ zfs rename zroot/jails/containers/{{ jail_name }} zroot/jails/containers/{{ jail_name }}.old.$(date +%s)
+ when:
+ - jail_exists.rc == 0
+ - "userland + '@base' not in jail_origin.stdout"
+ delegate_to: "{{ jail_delegate_host }}"
+
+- name: "Clone {{ jail_name }} from template"
+ shell: "zfs clone zroot/jails/templates/{{ userland }}@base zroot/jails/containers/{{ jail_name }}"
+ when: jail_exists.rc != 0 or (jail_origin.stdout is defined and userland + '@base' not in jail_origin.stdout)
+ delegate_to: "{{ jail_delegate_host }}"
+
+- name: "Create directories for {{ jail_name }}"
+ file:
+ path: "/usr/local/jails/containers/{{ jail_name }}{{ item }}"
+ state: directory
+ owner: root
+ group: wheel
+ mode: "0755"
+ loop: "{{ dirs | default([]) }}"
+ delegate_to: "{{ jail_delegate_host }}"
+
+- name: "Create mount point sources for {{ jail_name }}"
+ file:
+ path: "{{ item.src }}"
+ state: directory
+ owner: root
+ group: wheel
+ mode: "0755"
+ loop: "{{ nullfs | default([]) }}"
+ loop_control:
+ label: "{{ item.src }}"
+ delegate_to: "{{ jail_delegate_host }}"
+
+- name: "Create mount point destinations for {{ jail_name }}"
+ file:
+ path: "/usr/local/jails/containers/{{ jail_name }}{{ item.dst }}"
+ state: directory
+ owner: root
+ group: wheel
+ mode: "0755"
+ loop: "{{ nullfs | default([]) }}"
+ loop_control:
+ label: "{{ item.dst }}"
+ delegate_to: "{{ jail_delegate_host }}"
+
+- name: "Deploy jail.conf.d/{{ jail_name }}.conf"
+ template:
+ src: jail_conf.j2
+ dest: "/etc/jail.conf.d/{{ jail_name }}.conf"
+ owner: root
+ group: wheel
+ mode: "0644"
+ vars:
+ jail:
+ name: "{{ jail_name }}"
+ num: "{{ jail_num }}"
+ ip: "{{ jail_lan_cidr | ipv4_nth_cidr(jail_num | int + jail_lan_offset | int) }}"
+ devfs_ruleset: "{{ devfs_ruleset | default(4) }}"
+ options: "{{ jail_conf_options | default([]) }}"
+ default_route: "{{ not no_default_route | default(false) }}"
+ exec_prestart: "{{ exec_prestart | default([]) }}"
+ exec_start: "{{ exec_start | default([]) }}"
+ exec_poststart: "{{ exec_poststart | default([]) }}"
+ exec_prestop: "{{ exec_prestop | default([]) }}"
+ exec_stop: "{{ exec_stop | default([]) }}"
+ exec_poststop: "{{ exec_poststop | default([]) }}"
+ mounts: "{{ nullfs | default([]) }}"
+ delegate_to: "{{ jail_delegate_host }}"
+
+- name: "Start {{ jail_name }} jail"
+ shell: "service jail start {{ jail_name }}"
+ register: jail_start
+ failed_when: "jail_start.rc != 0 and 'already exists' not in jail_start.stdout"
+ changed_when: "'already exists' not in jail_start.stdout"
+ delegate_to: "{{ jail_delegate_host }}"
+
+# In-jail provisioning (runs inside the jail via jailexec)
+- name: Install packages
+ shell: "pkg install -y {{ pkg | join(' ') }}"
+ environment:
+ ASSUME_ALWAYS_YES: "yes"
+ when: pkg is defined and pkg | length > 0
+ register: pkg_result
+ changed_when: "'Number of packages to be installed' in pkg_result.stdout"
+
+- name: Create parent directories for files
+ file:
+ path: "{{ item.dest | dirname }}"
+ state: directory
+ owner: root
+ group: wheel
+ mode: "0755"
+ loop: "{{ files | default([]) }}"
+ loop_control:
+ label: "{{ item.dest | dirname }}"
+ when: files is defined
+
+- name: Deploy files
+ template:
+ src: "{{ jail_role_dir }}/templates/{{ item.src }}"
+ dest: "{{ item.dest }}"
+ owner: "{{ item.owner | default('root') }}"
+ group: "{{ item.group | default('wheel') }}"
+ mode: "{{ item.mode | default('0644') }}"
+ loop: "{{ files | default([]) }}"
+ loop_control:
+ label: "{{ item.dest }}"
+ when: files is defined
+ notify: Restart jail services
+
+- name: Enable services
+ community.general.sysrc:
+ name: "{{ item }}_enable"
+ value: "YES"
+ loop: "{{ services | default([]) }}"
+
+- name: Start services
+ service:
+ name: "{{ item }}"
+ state: started
+ loop: "{{ services | default([]) }}"
+
+- name: Set sysctl values
+ sysctl:
+ name: "{{ item.name }}"
+ value: "{{ item.value }}"
+ state: present
+ loop: "{{ sysctl | default([]) }}"
+ when: sysctl is defined
+
+- name: Set sysrc values
+ community.general.sysrc:
+ name: "{{ item.name }}"
+ value: "{{ item.value }}"
+ loop: "{{ sysrc | default([]) }}"
+ when: sysrc is defined
diff --git a/roles/jail/templates/jail_conf.j2 b/roles/jail/templates/jail_conf.j2
new file mode 100644
index 0000000..2a0ea27
--- /dev/null
+++ b/roles/jail/templates/jail_conf.j2
@@ -0,0 +1,56 @@
+{{ jail.name }} {
+ vnet;
+ persist;
+ exec.clean;
+ allow.raw_sockets;
+ mount.devfs;
+{% for opt in jail.options %}
+ {{ opt }};
+{% endfor %}
+
+ devfs_ruleset = {{ jail.devfs_ruleset }};
+ host.hostname = "{{ jail.name }}";
+ path = "/usr/local/jails/containers/${name}";
+
+ exec.start = "/bin/sh /etc/rc";
+ exec.stop = "/bin/sh /etc/rc.shutdown";
+
+ # LAN epair
+ exec.prestart += "ifconfig epl{{ jail.num }}a destroy 2>/dev/null || true";
+ exec.prestart += "ifconfig epair{{ jail.num }}000 create";
+ exec.prestart += "ifconfig epair{{ jail.num }}000a name epl{{ jail.num }}a";
+ exec.prestart += "ifconfig epair{{ jail.num }}000b name epl{{ jail.num }}b";
+ exec.prestart += "ifconfig epl{{ jail.num }}b ether random";
+ exec.prestart += "ifconfig brlan0 addm epl{{ jail.num }}a";
+ exec.poststart += "ifconfig epl{{ jail.num }}b vnet ${name}";
+ exec.poststart += "ifconfig epl{{ jail.num }}a up";
+ exec.poststart += "jexec ${name} ifconfig epl{{ jail.num }}b up";
+ exec.poststart += "jexec ${name} ifconfig epl{{ jail.num }}b {{ jail.ip }}";
+ exec.poststart += "jexec ${name} route delete default || true";
+{% if jail.default_route %}
+ exec.poststart += "jexec ${name} route add default {{ ingress_ip }} || true";
+{% endif %}
+ exec.poststop += "ifconfig epl{{ jail.num }}a destroy 2>/dev/null || true";
+{% for cmd in jail.exec_prestart %}
+ exec.prestart += "{{ cmd }}";
+{% endfor %}
+{% for cmd in jail.exec_start %}
+ exec.start += "{{ cmd }}";
+{% endfor %}
+{% for cmd in jail.exec_poststart %}
+ exec.poststart += "{{ cmd }}";
+{% endfor %}
+{% for cmd in jail.exec_prestop %}
+ exec.prestop += "{{ cmd }}";
+{% endfor %}
+{% for cmd in jail.exec_stop %}
+ exec.stop += "{{ cmd }}";
+{% endfor %}
+{% for cmd in jail.exec_poststop %}
+ exec.poststop += "{{ cmd }}";
+{% endfor %}
+{% for mount in jail.mounts %}
+ exec.prestart += "mount -t nullfs {{ mount.src }} /usr/local/jails/containers/{{ jail.name }}{{ mount.dst }} || true";
+ exec.poststop += "umount /usr/local/jails/containers/{{ jail.name }}{{ mount.dst }} || true";
+{% endfor %}
+}
diff --git a/roles/jails/01_ingress/defaults/main.yml b/roles/jails/01_ingress/defaults/main.yml
new file mode 100644
index 0000000..f6d5aa4
--- /dev/null
+++ b/roles/jails/01_ingress/defaults/main.yml
@@ -0,0 +1,92 @@
+userland: "15.0-RELEASE"
+devfs_ruleset: 6
+no_default_route: true
+
+ingress_routes:
+ - { host: jan.systems, jail: homepage }
+ - { host: jantuomi.fi, redirect: jan.systems }
+ - { host: aggro.jan.systems, jail: aggro }
+ - { host: diddle.jan.systems, jail: diddle }
+ - { host: spliit.jan.systems, jail: spliit }
+ - { host: freshrss.jan.systems, jail: freshrss }
+ - { host: irc.jan.systems, jail: irc_thelounge, presets: [websocket] }
+ - { host: paste.jan.systems, jail: paste }
+ - { host: leolalla.fi, jail: leolalla_fi }
+ - { host: immich.jan.systems, ip: 192.168.3.3, port: 2283 }
+ - { host: plex.jan.systems, jail: plex, port: 32400, presets: [streaming] }
+ - { host: komga.jan.systems, jail: komga, port: 25600 }
+
+cert_domains:
+ - "jan.systems"
+ - "*.jan.systems"
+ - "jantuomi.fi"
+ - "*.jantuomi.fi"
+ - "leolalla.fi"
+ - "*.leolalla.fi"
+
+cert_name: "{{ cert_domains[0] }}"
+contact_email: jan@jantuomi.fi
+
+jail_conf_options:
+ - "allow.raw_sockets"
+
+nullfs:
+ - src: /usr/local/jails/volumes/goaccess_www
+ dst: /mnt/www_goaccess
+
+pkg:
+ - nginx
+ - py311-certbot
+ - py311-certbot-nginx
+ - goaccess
+
+files:
+ - src: etc_pf.conf.j2
+ dest: /etc/pf.conf
+ - src: usr_local_etc_nginx_nginx.conf.j2
+ dest: /usr/local/etc/nginx/nginx.conf
+ - src: acme-dns-auth.py
+ dest: /usr/local/bin/acme-dns-auth.py
+ mode: "0755"
+ - src: usr_local_bin_hetzner_ddns.sh.j2
+ dest: /usr/local/bin/hetzner_ddns.sh
+ mode: "0755"
+ - src: usr_local_etc_hetzner_auth.j2
+ dest: /usr/local/etc/hetzner_auth
+ mode: "0600"
+ - src: usr_local_bin_gen_goaccess.sh.j2
+ dest: /usr/local/bin/gen_goaccess.sh
+ mode: "0755"
+ - src: etc_crontab.j2
+ dest: /etc/crontab
+
+services:
+ - nginx
+ - pf
+
+sysctl:
+ - name: net.inet.ip.forwarding
+ value: "1"
+
+sysrc:
+ - name: gateway_enable
+ value: "YES"
+
+nginx_presets:
+ websocket:
+ - "proxy_http_version 1.1"
+ - 'proxy_set_header Connection "Upgrade"'
+ - "proxy_set_header Upgrade $http_upgrade"
+ - "proxy_read_timeout 1d"
+ - "proxy_send_timeout 1d"
+ - "proxy_buffering off"
+ - "proxy_request_buffering off"
+ - "client_max_body_size 100M"
+ streaming:
+ - "proxy_http_version 1.1"
+ - 'proxy_set_header Connection "Upgrade"'
+ - "proxy_set_header Upgrade $http_upgrade"
+ - "proxy_redirect off"
+ - "proxy_buffering off"
+ - "proxy_read_timeout 3600s"
+ - "proxy_send_timeout 3600s"
diff --git a/roles/jails/01_ingress/tasks/main.yml b/roles/jails/01_ingress/tasks/main.yml
new file mode 100644
index 0000000..5b56286
--- /dev/null
+++ b/roles/jails/01_ingress/tasks/main.yml
@@ -0,0 +1,49 @@
+- name: Set WAN hooks (test)
+ set_fact:
+ exec_prestart:
+ - "ifconfig epw{{ jail_num }}a destroy 2>/dev/null || true"
+ - "ifconfig epair{{ jail_num }}001 create"
+ - "ifconfig epair{{ jail_num }}001a name epw{{ jail_num }}a"
+ - "ifconfig epair{{ jail_num }}001b name epw{{ jail_num }}b"
+ - "ifconfig brlan0 addm epw{{ jail_num }}a"
+ exec_poststart:
+ - "ifconfig epw{{ jail_num }}b vnet {{ jail_name }}"
+ - "ifconfig epw{{ jail_num }}a up"
+ - "jexec {{ jail_name }} ifconfig epw{{ jail_num }}b up"
+ - "jexec {{ jail_name }} ifconfig epw{{ jail_num }}b inet {{ ingress_wan_static }}"
+ - "jexec {{ jail_name }} route add default {{ lan_ipv4_gateway }}"
+ exec_poststop:
+ - "ifconfig epw{{ jail_num }}a destroy 2>/dev/null || true"
+ when: not is_prod
+
+- name: Set WAN hooks (prod)
+ set_fact:
+ exec_prestart:
+ - "ifconfig epw{{ jail_num }}a destroy 2>/dev/null || true"
+ - "ifconfig epair{{ jail_num }}001 create"
+ - "ifconfig epair{{ jail_num }}001a name epw{{ jail_num }}a"
+ - "ifconfig epair{{ jail_num }}001b name epw{{ jail_num }}b"
+ - "ifconfig brwan0 addm epw{{ jail_num }}a"
+ exec_poststart:
+ - "ifconfig epw{{ jail_num }}b vnet {{ jail_name }}"
+ - "ifconfig epw{{ jail_num }}a up"
+ - "jexec {{ jail_name }} ifconfig epw{{ jail_num }}b up"
+ - "jexec {{ jail_name }} service dhclient restart epw{{ jail_num }}b"
+ - "jexec {{ jail_name }} route add 10.6.210.0/24 {{ lan_ipv4_gateway }} || true"
+ exec_poststop:
+ - "ifconfig epw{{ jail_num }}a destroy 2>/dev/null || true"
+ when: is_prod
+
+- import_role:
+ name: jail
+
+- name: Check if TLS certs exist
+ stat:
+ path: "/usr/local/etc/letsencrypt/live/{{ cert_name }}"
+ register: _certbot_certs
+ when: is_prod
+
+- name: Pause for manual certbot setup
+ pause:
+ prompt: "Run certbot manually in the ingress jail to obtain certs, then press Enter."
+ when: is_prod and not (_certbot_certs.stat.exists | default(true))
diff --git a/roles/jails/01_ingress/templates/acme-dns-auth.py b/roles/jails/01_ingress/templates/acme-dns-auth.py
new file mode 100755
index 0000000..77928e6
--- /dev/null
+++ b/roles/jails/01_ingress/templates/acme-dns-auth.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+#
+# Source: https://github.com/joohoi/acme-dns-certbot-joohoi/blob/master/acme-dns-auth.py
+# Some modifications to configuration values present.
+
+
+import json
+import os
+import sys
+
+import requests
+
+### EDIT THESE: Configuration values ###
+
+# URL to acme-dns instance
+ACMEDNS_URL = "https://auth.acme-dns.io"
+# Path for acme-dns credential storage
+STORAGE_PATH = "/usr/local/etc/letsencrypt/acmedns.json"
+# Whitelist for address ranges to allow the updates from
+# Example: ALLOW_FROM = ["192.168.10.0/24", "::1/128"]
+ALLOW_FROM = []
+# Force re-registration. Overwrites the already existing acme-dns accounts.
+FORCE_REGISTER = False
+
+### DO NOT EDIT BELOW THIS POINT ###
+### HERE BE DRAGONS ###
+
+DOMAIN = os.environ["CERTBOT_DOMAIN"]
+if DOMAIN.startswith("*."):
+ DOMAIN = DOMAIN[2:]
+VALIDATION_DOMAIN = "_acme-challenge." + DOMAIN
+VALIDATION_TOKEN = os.environ["CERTBOT_VALIDATION"]
+
+
+class AcmeDnsClient(object):
+ """
+ Handles the communication with ACME-DNS API
+ """
+
+ def __init__(self, acmedns_url):
+ self.acmedns_url = acmedns_url
+
+ def register_account(self, allowfrom):
+ """Registers a new ACME-DNS account"""
+
+ if allowfrom:
+ # Include whitelisted networks to the registration call
+ reg_data = {"allowfrom": allowfrom}
+ res = requests.post(
+ self.acmedns_url + "/register", data=json.dumps(reg_data)
+ )
+ else:
+ res = requests.post(self.acmedns_url + "/register")
+ if res.status_code == 201:
+ # The request was successful
+ return res.json()
+ else:
+ # Encountered an error
+ msg = (
+ "Encountered an error while trying to register a new acme-dns "
+ "account. HTTP status {}, Response body: {}"
+ )
+ print(msg.format(res.status_code, res.text))
+ sys.exit(1)
+
+ def update_txt_record(self, account, txt):
+ """Updates the TXT challenge record to ACME-DNS subdomain."""
+ update = {"subdomain": account["subdomain"], "txt": txt}
+ headers = {
+ "X-Api-User": account["username"],
+ "X-Api-Key": account["password"],
+ "Content-Type": "application/json",
+ }
+ res = requests.post(
+ self.acmedns_url + "/update", headers=headers, data=json.dumps(update)
+ )
+ if res.status_code == 200:
+ # Successful update
+ return
+ else:
+ msg = (
+ "Encountered an error while trying to update TXT record in "
+ "acme-dns. \n"
+ "------- Request headers:\n{}\n"
+ "------- Request body:\n{}\n"
+ "------- Response HTTP status: {}\n"
+ "------- Response body: {}"
+ )
+ s_headers = json.dumps(headers, indent=2, sort_keys=True)
+ s_update = json.dumps(update, indent=2, sort_keys=True)
+ s_body = json.dumps(res.json(), indent=2, sort_keys=True)
+ print(msg.format(s_headers, s_update, res.status_code, s_body))
+ sys.exit(1)
+
+
+class Storage(object):
+ def __init__(self, storagepath):
+ self.storagepath = storagepath
+ self._data = self.load()
+
+ def load(self):
+ """Reads the storage content from the disk to a dict structure"""
+ data = dict()
+ filedata = ""
+ try:
+ with open(self.storagepath, "r") as fh:
+ filedata = fh.read()
+ except IOError as e:
+ if os.path.isfile(self.storagepath):
+ # Only error out if file exists, but cannot be read
+ print("ERROR: Storage file exists but cannot be read")
+ sys.exit(1)
+ try:
+ data = json.loads(filedata)
+ except ValueError:
+ if len(filedata) > 0:
+ # Storage file is corrupted
+ print("ERROR: Storage JSON is corrupted")
+ sys.exit(1)
+ return data
+
+ def save(self):
+ """Saves the storage content to disk"""
+ serialized = json.dumps(self._data)
+ try:
+ with os.fdopen(
+ os.open(self.storagepath, os.O_WRONLY | os.O_CREAT, 0o600), "w"
+ ) as fh:
+ fh.truncate()
+ fh.write(serialized)
+ except IOError as e:
+ print("ERROR: Could not write storage file.")
+ sys.exit(1)
+
+ def put(self, key, value):
+ """Puts the configuration value to storage and sanitize it"""
+ # If wildcard domain, remove the wildcard part as this will use the
+ # same validation record name as the base domain
+ if key.startswith("*."):
+ key = key[2:]
+ self._data[key] = value
+
+ def fetch(self, key):
+ """Gets configuration value from storage"""
+ try:
+ return self._data[key]
+ except KeyError:
+ return None
+
+
+if __name__ == "__main__":
+ # Init
+ client = AcmeDnsClient(ACMEDNS_URL)
+ storage = Storage(STORAGE_PATH)
+
+ # Check if an account already exists in storage
+ account = storage.fetch(DOMAIN)
+ if FORCE_REGISTER or not account:
+ # Create and save the new account
+ account = client.register_account(ALLOW_FROM)
+ storage.put(DOMAIN, account)
+ storage.save()
+
+ # Display the notification for the user to update the main zone
+ msg = "Please add the following CNAME record to your main DNS zone:\n{}"
+ cname = "{} CNAME {}.".format(VALIDATION_DOMAIN, account["fulldomain"])
+ print(msg.format(cname))
+
+ # Update the TXT record in acme-dns instance
+ client.update_txt_record(account, VALIDATION_TOKEN)
diff --git a/roles/jails/01_ingress/templates/etc_crontab.j2 b/roles/jails/01_ingress/templates/etc_crontab.j2
new file mode 100644
index 0000000..6879766
--- /dev/null
+++ b/roles/jails/01_ingress/templates/etc_crontab.j2
@@ -0,0 +1,18 @@
+# /etc/crontab - root's crontab for FreeBSD
+#
+#
+SHELL=/bin/sh
+PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
+#
+#minute hour mday month wday who command
+
+# Update LetsEncrypt certificates every day after 2:00 AM
+0 2 * * * root certbot certonly -n -m {{ contact_email }} --agree-tos --manual --manual-auth-hook /usr/local/bin/acme-dns-auth.py --preferred-challenges dns --debug-challenges {% for domain in cert_domains %}-d '{{ domain }}' {% endfor %} || echo "ERROR: Failed to renew certs" >&2
+
+# Update dynamic DNS (Hetzner)
+{% for elem in hetzner_zone_record_ids %}
+* * * * * root /usr/local/bin/hetzner_ddns.sh --zone '{{ elem.zone }}' --rr-id '{{ elem.rr_id }}' --iface-cmd 'ifconfig epw1b' >>/var/log/hetzner_ddns.log 2>&1
+{% endfor %}
+
+# Update goaccess report HTML
+0 * * * * root /usr/local/bin/gen_goaccess.sh
diff --git a/roles/jails/01_ingress/templates/etc_pf.conf.j2 b/roles/jails/01_ingress/templates/etc_pf.conf.j2
new file mode 100644
index 0000000..c0528e1
--- /dev/null
+++ b/roles/jails/01_ingress/templates/etc_pf.conf.j2
@@ -0,0 +1,27 @@
+{% for jail in jails -%}
+{% if jail.name == 'ingress' -%}
+# Interfaces & nets
+lan = "epl{{ jail.num }}b"
+wan = "epw{{ jail.num }}b"
+lan_net = "{{ lan_ipv4_network }}"
+
+table <blocked> persist
+
+# Keep PF out of loopback, drop by default if you add blocks later
+set skip on lo0
+set block-policy drop
+
+# NAT: translate LAN traffic to the WAN interface address
+nat on $wan from $lan_net to any -> ($wan)
+
+# Block traffic from IPs in the blocked table
+block in quick from <blocked> to any
+
+# Allow all outbound traffic from the jail and LAN via both interfaces
+# NAT will be applied automatically when source is in $lan_net and going out $wan
+pass out on $wan all keep state
+pass out on $lan all keep state
+
+pass in on $wan inet proto {tcp, udp} from any to any port {80, 443} keep state
+{% endif %}
+{% endfor %}
diff --git a/roles/jails/01_ingress/templates/usr_local_bin_gen_goaccess.sh.j2 b/roles/jails/01_ingress/templates/usr_local_bin_gen_goaccess.sh.j2
new file mode 100644
index 0000000..2cfc93a
--- /dev/null
+++ b/roles/jails/01_ingress/templates/usr_local_bin_gen_goaccess.sh.j2
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+TARGET=/mnt/www_goaccess/index.html
+
+cat /var/log/nginx/access.log | awk '$8=$1$8' | /usr/local/bin/goaccess --log-format=VCOMBINED -j "$(($(nproc) + 1))" --keep-last=30 -a -o $TARGET --restore --persist
+chmod 644 $TARGET
+chown www $TARGET
+chgrp www $TARGET
diff --git a/roles/jails/01_ingress/templates/usr_local_bin_hetzner_ddns.sh.j2 b/roles/jails/01_ingress/templates/usr_local_bin_hetzner_ddns.sh.j2
new file mode 100644
index 0000000..a2f4430
--- /dev/null
+++ b/roles/jails/01_ingress/templates/usr_local_bin_hetzner_ddns.sh.j2
@@ -0,0 +1,130 @@
+#!/bin/sh
+#
+# Hetzner DNS record updater (one-shot for cron)
+# Requirements: curl, awk
+
+set -eu
+
+# ---------------------------- Defaults ---------------------------------------
+# Env-overridable:
+: "${API_TOKEN_FILE:=/usr/local/etc/hetzner_auth}"
+: "${API_BASE:=https://api.hetzner.cloud/v1}"
+# -----------------------------------------------------------------------------
+
+# Defaults
+TTL=300
+
+usage() {
+ cat <<'USAGE' >&2
+Usage: hetzner_ddns.sh [OPTIONS]
+
+Options (named):
+ --zone NAME_OR_ID Hetzner Zone name or ID (primary mode only)
+ --rr-id ID RRSet identifier in the form "rr-name/rr-type" (e.g., "host/A")
+ --iface-cmd CMD Command that prints interface info (for IP discovery)
+ --ttl TTL Time-to-live of the record (optional)
+ -h, --help Show this help
+
+Environment:
+ API_TOKEN_FILE Path to file containing ONLY the API token
+ (default: /usr/local/etc/hetzner_auth)
+ API_BASE Hetzner Cloud DNS API base URL
+ (default: https://api.hetzner.cloud/v1)
+
+Examples:
+ API_TOKEN_FILE=/secret/token \
+ ./hetzner_ddns.sh \
+ --zone example.com --rr-name host --rr-type A \
+ --iface-cmd "ifconfig em0"
+USAGE
+}
+
+ts() { date +"%Y-%m-%dT%H:%M:%S%z"; }
+fail() { echo "$(ts) ERROR: $*" >&2; exit 2; }
+error() { echo "$(ts) ERROR: $*" >&2; }
+warn() { echo "$(ts) WARN: $*" >&2; }
+info() { echo "$(ts) INFO: $*"; }
+
+# ----------------------------- Arg parsing -----------------------------------
+# Accept both `--key value` and `--key=value`
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --zone=*) ZONE=${1#*=} ;;
+ --zone) ZONE=$2; shift ;;
+ --rr-id=*) RR_ID=${1#*=} ;;
+ --rr-id) RR_ID=$2; shift ;;
+ --iface-cmd=*) IFACE_CMD=${1#*=} ;;
+ --iface-cmd) IFACE_CMD=$2; shift ;;
+ --ttl=*) TTL=${1#*=} ;;
+ --ttl) TTL=$2; shift ;;
+ -h|--help) usage; exit 0 ;;
+ --) shift; break ;;
+ -*)
+ error "Unknown option: $1"
+ usage
+ exit 2
+ ;;
+ *)
+ error "Unexpected positional argument: $1"
+ usage
+ exit 2
+ ;;
+ esac
+ shift
+done
+
+# ----------------------------- Validation ------------------------------------
+[ -n "${ZONE:-}" ] || fail "Missing --zone"
+[ -n "${RR_ID:-}" ] || fail "Missing --rr-id"
+[ -n "${IFACE_CMD:-}" ] || fail "Missing --iface-cmd"
+
+# ------------------------------ Auth -----------------------------------------
+if [ ! -r "${API_TOKEN_FILE}" ]; then
+ fail "Token file missing or unreadable: ${API_TOKEN_FILE}"
+fi
+API_TOKEN="$(cat "${API_TOKEN_FILE}" | tr -d '[:space:]')"
+[ -n "${API_TOKEN}" ] || fail "API token is empty"
+
+# ------------------------------ Helpers --------------------------------------
+get_ip() {
+ # Extract first IPv4 after 'inet ' (ignore inet6)
+ sh -c "${IFACE_CMD}" 2>/dev/null | awk '/(^|[[:space:]])inet[[:space:]]/ {print $2; exit}'
+}
+
+# ------------------------------ Main -----------------------------------------
+IFACE_IP="$(get_ip || true)"
+if [ -z "${IFACE_IP}" ]; then
+ warn "No IPv4 address found via '${IFACE_CMD}' (interface not ready?)"
+ exit 1
+fi
+
+BODY=$(printf '{"records":[{"value":"%s","comment":"Updated by hetzner_ddns.sh at %s","ttl":"%s"}]}' \
+ "${IFACE_IP}" "$(ts)" "${TTL}")
+
+HTTP_CODE=$(
+ curl -sS -o /dev/null -w "%{http_code}" -X POST \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer ${API_TOKEN}" \
+ --data "${BODY}" \
+ "${API_BASE}/zones/${ZONE}/rrsets/${RR_ID}/actions/set_records"
+)
+
+case "${HTTP_CODE}" in
+ 2*)
+ info "Updated zone ${ZONE} RRSet ${RR_ID} -> ${IFACE_IP}"
+ exit 0
+ ;;
+ 4*)
+ error "Client error from API (HTTP ${HTTP_CODE}) - check zone/rr params/token/body"
+ exit 2
+ ;;
+ 5*)
+ warn "Server error from API (HTTP ${HTTP_CODE})"
+ exit 1
+ ;;
+ *)
+ warn "Unexpected HTTP status ${HTTP_CODE}"
+ exit 1
+ ;;
+esac
diff --git a/roles/jails/01_ingress/templates/usr_local_etc_hetzner_auth.j2 b/roles/jails/01_ingress/templates/usr_local_etc_hetzner_auth.j2
new file mode 100644
index 0000000..129dccf
--- /dev/null
+++ b/roles/jails/01_ingress/templates/usr_local_etc_hetzner_auth.j2
@@ -0,0 +1 @@
+{{ hetzner_pat }}
diff --git a/roles/jails/01_ingress/templates/usr_local_etc_nginx_nginx.conf.j2 b/roles/jails/01_ingress/templates/usr_local_etc_nginx_nginx.conf.j2
new file mode 100644
index 0000000..3f853c5
--- /dev/null
+++ b/roles/jails/01_ingress/templates/usr_local_etc_nginx_nginx.conf.j2
@@ -0,0 +1,123 @@
+worker_processes auto;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include mime.types;
+ default_type application/octet-stream;
+
+ sendfile on;
+ keepalive_timeout 65;
+
+ log_format vcombined '$host:$server_port '
+ '$remote_addr - $remote_user [$time_local] '
+ '"$request" $status $body_bytes_sent '
+ '"$http_referer" "$http_user_agent"';
+
+ access_log /var/log/nginx/access.log vcombined;
+
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 512;
+ gzip_types
+ text/plain
+ text/css
+ application/json
+ application/javascript
+ application/xml
+ image/svg+xml;
+
+ server {
+ listen 80 default_server;
+ server_name _;
+
+ location / {
+ return 404;
+ }
+ }
+
+ {% for route in ingress_routes -%}
+ server {
+ listen 80;
+ listen [::]:80;
+ server_name {{ route.host }};
+
+{% if ssl_enabled %}
+ return 307 https://$host$request_uri;
+ }
+
+ server {
+ server_name {{ route.host }};
+ http2 on;
+
+ listen 443 ssl;
+ listen [::]:443 ssl;
+
+ # See https://ssl-config.mozilla.org/#server=nginx&version=1.28.0&config=intermediate&openssl=3.4.0&guideline=5.7
+ add_header Strict-Transport-Security "max-age=63072000" always;
+
+ # Common hardening headers
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options DENY always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Permissions-Policy interest-cohort=();
+
+ # Hide "Server: nginx/1.28.0" header
+ server_tokens off;
+
+ ssl_certificate /usr/local/etc/letsencrypt/live/{{ cert_name }}/fullchain.pem;
+ ssl_certificate_key /usr/local/etc/letsencrypt/live/{{ cert_name }}/privkey.pem;
+ include /usr/local/etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /usr/local/etc/letsencrypt/ssl-dhparams.pem;
+{% endif %}
+
+ {% if route.jail is defined -%}
+ {% for jail in jails if jail.name == route.jail -%}
+ location / {
+ proxy_pass http://{{ jail_lan_cidr | ipv4_nth(jail.num + jail_lan_offset | int) }}{% if route.port is defined %}:{{ route.port }}{% endif %};
+ 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 route.presets is defined -%}
+ {% set directives = [] -%}
+ {% for p in route.presets -%}
+ {% for d in nginx_presets[p] -%}
+ {% if d not in directives -%}{% set _ = directives.append(d) -%}{% endif -%}
+ {% endfor -%}
+ {% endfor -%}
+ {% for directive in directives -%}
+ {{ directive }};
+ {% endfor -%}
+ {% endif %}
+ }
+ {% endfor %}
+ {% elif route.redirect is defined -%}
+ return 307 https://{{ route.redirect }}$request_uri;
+ {% elif route.ip is defined -%}
+ location / {
+ proxy_pass http://{{ route.ip }}:{{ route.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;
+
+ # TODO: these shouldn't configured for all ip:port proxies but Immich needs them
+ proxy_http_version 1.1;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Upgrade $http_upgrade;
+
+ # by default nginx times out connections in one minute
+ proxy_read_timeout 1d;
+ proxy_send_timeout 1d;
+ proxy_buffering off;
+ proxy_request_buffering off;
+
+ client_max_body_size 10G;
+ }
+ {% endif %}
+ }
+ {% endfor %}
+}
diff --git a/roles/jails/02_postgres/defaults/main.yml b/roles/jails/02_postgres/defaults/main.yml
new file mode 100644
index 0000000..045ba4d
--- /dev/null
+++ b/roles/jails/02_postgres/defaults/main.yml
@@ -0,0 +1,9 @@
+userland: "14.3-RELEASE"
+devfs_ruleset: 5
+
+jail_conf_options:
+ - "allow.raw_sockets"
+ - "allow.sysvipc"
+
+pkg:
+ - postgresql18-server
diff --git a/roles/jails/02_postgres/tasks/main.yml b/roles/jails/02_postgres/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/02_postgres/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/03_irc_thelounge/defaults/main.yml b/roles/jails/03_irc_thelounge/defaults/main.yml
new file mode 100644
index 0000000..697ea79
--- /dev/null
+++ b/roles/jails/03_irc_thelounge/defaults/main.yml
@@ -0,0 +1 @@
+userland: "14.3-RELEASE"
diff --git a/roles/jails/03_irc_thelounge/tasks/main.yml b/roles/jails/03_irc_thelounge/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/03_irc_thelounge/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/04_taulubot/defaults/main.yml b/roles/jails/04_taulubot/defaults/main.yml
new file mode 100644
index 0000000..697ea79
--- /dev/null
+++ b/roles/jails/04_taulubot/defaults/main.yml
@@ -0,0 +1 @@
+userland: "14.3-RELEASE"
diff --git a/roles/jails/04_taulubot/tasks/main.yml b/roles/jails/04_taulubot/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/04_taulubot/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/05_homepage/defaults/main.yml b/roles/jails/05_homepage/defaults/main.yml
new file mode 100644
index 0000000..d48c9fa
--- /dev/null
+++ b/roles/jails/05_homepage/defaults/main.yml
@@ -0,0 +1,18 @@
+userland: "14.3-RELEASE"
+
+pkg:
+ - nginx
+ - rsync
+ - bash
+
+files:
+ - src: usr_local_etc_nginx_nginx.conf.j2
+ dest: /usr/local/etc/nginx/nginx.conf
+ - src: etc_crontab.j2
+ dest: /etc/crontab
+
+dirs:
+ - /var/www
+
+services:
+ - nginx
diff --git a/roles/jails/05_homepage/tasks/main.yml b/roles/jails/05_homepage/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/05_homepage/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/05_homepage/templates/etc_crontab.j2 b/roles/jails/05_homepage/templates/etc_crontab.j2
new file mode 100644
index 0000000..8541f57
--- /dev/null
+++ b/roles/jails/05_homepage/templates/etc_crontab.j2
@@ -0,0 +1,9 @@
+# /etc/crontab - root's crontab for FreeBSD
+#
+#
+SHELL=/bin/sh
+PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
+#
+#minute hour mday month wday who command
+
+# TODO: update linklog
diff --git a/roles/jails/05_homepage/templates/usr_local_etc_nginx_nginx.conf.j2 b/roles/jails/05_homepage/templates/usr_local_etc_nginx_nginx.conf.j2
new file mode 100644
index 0000000..ee45405
--- /dev/null
+++ b/roles/jails/05_homepage/templates/usr_local_etc_nginx_nginx.conf.j2
@@ -0,0 +1,55 @@
+worker_processes auto;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include mime.types;
+ default_type application/octet-stream;
+
+ sendfile on;
+ keepalive_timeout 65;
+
+ gzip on;
+ gzip_vary on;
+ gzip_proxied any;
+ gzip_comp_level 6;
+ gzip_min_length 256;
+
+ # Compress sensible, text-based response types (HTML is covered by default)
+ gzip_types
+ text/plain
+ text/css
+ text/xml
+ text/javascript
+ application/javascript
+ application/json
+ application/ld+json
+ application/xml
+ application/rss+xml
+ application/atom+xml
+ image/svg+xml;
+
+ server {
+ listen 80 default_server;
+ server_name _;
+
+ root /var/www;
+
+ index index.html;
+
+ # Migration redirects from legacy site
+ location ~ ^/archive(?:/(.*))?$ {
+ return 307 /posts/$1$is_args$args;
+ }
+ location = /feed.xml {
+ # Serve atom.xml content at the legacy feed.xml URL (no redirect)
+ rewrite ^ /atom.xml break;
+ }
+
+ location / {
+ try_files $uri $uri.html $uri/ =404;
+ }
+ }
+}
diff --git a/roles/jails/06_hommabot/defaults/main.yml b/roles/jails/06_hommabot/defaults/main.yml
new file mode 100644
index 0000000..d12d40f
--- /dev/null
+++ b/roles/jails/06_hommabot/defaults/main.yml
@@ -0,0 +1,14 @@
+userland: "14.3-RELEASE"
+
+pkg:
+ - npm
+
+files:
+ - src: root_hommabot_env.j2
+ dest: /root/hommabot/.env
+ mode: "0600"
+ - src: root_hommabot_deps.sh
+ dest: /root/hommabot/deps.sh
+ mode: "0755"
+ - src: etc_crontab.j2
+ dest: /etc/crontab
diff --git a/roles/jails/06_hommabot/tasks/main.yml b/roles/jails/06_hommabot/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/06_hommabot/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/06_hommabot/templates/etc_crontab.j2 b/roles/jails/06_hommabot/templates/etc_crontab.j2
new file mode 100644
index 0000000..556d12a
--- /dev/null
+++ b/roles/jails/06_hommabot/templates/etc_crontab.j2
@@ -0,0 +1,10 @@
+# /etc/crontab - root's crontab for FreeBSD
+#
+#
+SHELL=/bin/sh
+PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
+#
+#minute hour mday month wday who command
+
+# Run hommabot every Monday at 9:00 AM
+0 9 * * 1 root /bin/sh -c "cd /root/hommabot && node index.js"
diff --git a/roles/jails/06_hommabot/templates/root_hommabot_deps.sh b/roles/jails/06_hommabot/templates/root_hommabot_deps.sh
new file mode 100644
index 0000000..25c0e56
--- /dev/null
+++ b/roles/jails/06_hommabot/templates/root_hommabot_deps.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+set -eux
+cd /root/hommabot
+npm ci
+
+# We need to build the better-sqlite3.node file before running the app
+if [ ! -f build/better_sqlite3.node ]; then (
+ mkdir -p build
+ cd node_modules/better-sqlite3
+ npm run build-release
+ cp build/Release/better_sqlite3.node ../../build/better_sqlite3.node
+)
+fi
diff --git a/roles/jails/06_hommabot/templates/root_hommabot_env.j2 b/roles/jails/06_hommabot/templates/root_hommabot_env.j2
new file mode 100644
index 0000000..c66c666
--- /dev/null
+++ b/roles/jails/06_hommabot/templates/root_hommabot_env.j2
@@ -0,0 +1,4 @@
+TELEGRAM_BOT_TOKEN="{{ hommabot_telegram_bot_token }}"
+SHEETS_SPREADSHEET_ID="{{ hommabot_sheets_spreadsheet_id }}"
+SHEETS_RANGE="{{ hommabot_sheets_range }}"
+G_SA_JSON_B64="{{ hommabot_g_sa_json_b64 }}"
diff --git a/roles/jails/07_aggro/defaults/main.yml b/roles/jails/07_aggro/defaults/main.yml
new file mode 100644
index 0000000..697ea79
--- /dev/null
+++ b/roles/jails/07_aggro/defaults/main.yml
@@ -0,0 +1 @@
+userland: "14.3-RELEASE"
diff --git a/roles/jails/07_aggro/tasks/main.yml b/roles/jails/07_aggro/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/07_aggro/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/08_diddle/defaults/main.yml b/roles/jails/08_diddle/defaults/main.yml
new file mode 100644
index 0000000..9423bd6
--- /dev/null
+++ b/roles/jails/08_diddle/defaults/main.yml
@@ -0,0 +1,23 @@
+userland: "14.3-RELEASE"
+
+pkg:
+ - python311
+ - py311-sqlite3
+ - git
+
+files:
+ - src: root_clone.sh
+ dest: /root/clone.sh
+ mode: "0755"
+ - src: root_diddle_env.j2
+ dest: /root/diddle/.env
+ mode: "0600"
+ - src: usr_local_bin_diddle
+ dest: /usr/local/bin/diddle
+ mode: "0755"
+ - src: usr_local_etc_rc.d_diddle
+ dest: /usr/local/etc/rc.d/diddle
+ mode: "0755"
+
+services:
+ - diddle
diff --git a/roles/jails/08_diddle/tasks/main.yml b/roles/jails/08_diddle/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/08_diddle/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/08_diddle/templates/root_clone.sh b/roles/jails/08_diddle/templates/root_clone.sh
new file mode 100644
index 0000000..7f92c5f
--- /dev/null
+++ b/roles/jails/08_diddle/templates/root_clone.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+
+REPO="$1"
+TARGET="$2"
+
+set -eux
+
+# Exits with 100 if the directory was changed
+
+if [ ! -d "$TARGET" ]; then
+ git clone --depth=1 --branch main --single-branch "$REPO" "$TARGET"
+ exit 100
+fi
+
+cd "$TARGET"
+before=$(git rev-parse HEAD)
+git fetch --depth=1 --prune origin main
+git reset --hard origin/main
+after=$(git rev-parse HEAD)
+
+if [ "$before" != "$after" ]; then exit 100; fi
diff --git a/roles/jails/08_diddle/templates/root_diddle_env.j2 b/roles/jails/08_diddle/templates/root_diddle_env.j2
new file mode 100644
index 0000000..ca7db78
--- /dev/null
+++ b/roles/jails/08_diddle/templates/root_diddle_env.j2
@@ -0,0 +1,9 @@
+PYTHONUNBUFFERED="1"
+BASE_URL="https://diddle.jan.systems"
+DB_PATH="db.sqlite3"
+EMAIL_HOST="smtp.postmarkapp.com"
+EMAIL_PORT="587"
+EMAIL_HOST_USER="{{ diddle_email_host_user }}"
+EMAIL_HOST_PASSWORD="{{ diddle_email_host_password }}"
+EMAIL_USE_TLS="true"
+EMAIL_MESSAGE_FROM="diddle@jan.systems"
diff --git a/roles/jails/08_diddle/templates/usr_local_bin_diddle b/roles/jails/08_diddle/templates/usr_local_bin_diddle
new file mode 100644
index 0000000..d07cd5e
--- /dev/null
+++ b/roles/jails/08_diddle/templates/usr_local_bin_diddle
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+set -eux
+
+PATH=$PATH:/usr/local/bin
+
+cd /root/diddle
+
+if [ ! -f venv ]; then
+ python3.11 -m venv venv
+fi
+
+PATH=$PATH:/root/diddle/venv/bin
+
+pip install -r requirements.txt
+python apply_migrations.py
+gunicorn --bind "0.0.0.0:80" -w 4 app:app
diff --git a/roles/jails/08_diddle/templates/usr_local_etc_rc.d_diddle b/roles/jails/08_diddle/templates/usr_local_etc_rc.d_diddle
new file mode 100644
index 0000000..01deaad
--- /dev/null
+++ b/roles/jails/08_diddle/templates/usr_local_etc_rc.d_diddle
@@ -0,0 +1,43 @@
+#!/bin/sh
+#
+# PROVIDE: diddle
+# REQUIRE: NETWORKING
+# KEYWORD: shutdown
+#
+# Enable in /etc/rc.conf:
+# diddle_enable="YES"
+#
+. /etc/rc.subr
+
+name="diddle"
+rcvar=diddle_enable
+
+load_rc_config $name
+
+: ${diddle_command:=/usr/local/bin/diddle}
+: ${diddle_log:=/var/log/${name}.log}
+
+start_cmd="${name}_start"
+stop_cmd="${name}_stop"
+status_cmd="${name}_status"
+
+extra_commands="status"
+
+diddle_start() {
+ /usr/local/bin/logto ${diddle_log} ${diddle_command} &
+}
+
+diddle_status() {
+ if ps aux | grep diddle | grep -v grep | grep -v rc.d/diddle; then
+ echo "diddle is running"
+ else
+ echo "diddle is not running"
+ exit 1
+ fi
+}
+
+diddle_stop() {
+ ps aux | grep diddle | grep -v grep | grep -v rc.d/diddle | awk '{print $2}' | xargs kill -TERM
+}
+
+run_rc_command "$1"
diff --git a/roles/jails/09_redis/defaults/main.yml b/roles/jails/09_redis/defaults/main.yml
new file mode 100644
index 0000000..5416267
--- /dev/null
+++ b/roles/jails/09_redis/defaults/main.yml
@@ -0,0 +1 @@
+userland: "15.0-RELEASE"
diff --git a/roles/jails/09_redis/tasks/main.yml b/roles/jails/09_redis/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/09_redis/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/10_samba/defaults/main.yml b/roles/jails/10_samba/defaults/main.yml
new file mode 100644
index 0000000..99c53e1
--- /dev/null
+++ b/roles/jails/10_samba/defaults/main.yml
@@ -0,0 +1,5 @@
+userland: "15.0-RELEASE"
+
+nullfs:
+ - src: /usr/local/jails/volumes/storage
+ dst: /mnt/storage
diff --git a/roles/jails/10_samba/tasks/main.yml b/roles/jails/10_samba/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/10_samba/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/11_spliit/defaults/main.yml b/roles/jails/11_spliit/defaults/main.yml
new file mode 100644
index 0000000..697ea79
--- /dev/null
+++ b/roles/jails/11_spliit/defaults/main.yml
@@ -0,0 +1 @@
+userland: "14.3-RELEASE"
diff --git a/roles/jails/11_spliit/tasks/main.yml b/roles/jails/11_spliit/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/11_spliit/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/12_goaccess/defaults/main.yml b/roles/jails/12_goaccess/defaults/main.yml
new file mode 100644
index 0000000..0fa6078
--- /dev/null
+++ b/roles/jails/12_goaccess/defaults/main.yml
@@ -0,0 +1,5 @@
+userland: "15.0-RELEASE"
+
+nullfs:
+ - src: /usr/local/jails/volumes/goaccess_www
+ dst: /var/www/goaccess
diff --git a/roles/jails/12_goaccess/tasks/main.yml b/roles/jails/12_goaccess/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/12_goaccess/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/13_plex/defaults/main.yml b/roles/jails/13_plex/defaults/main.yml
new file mode 100644
index 0000000..91ea619
--- /dev/null
+++ b/roles/jails/13_plex/defaults/main.yml
@@ -0,0 +1,5 @@
+userland: "15.0-RELEASE"
+
+nullfs:
+ - src: /usr/local/jails/volumes/storage/media
+ dst: /mnt/media
diff --git a/roles/jails/13_plex/tasks/main.yml b/roles/jails/13_plex/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/13_plex/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/14_freshrss/defaults/main.yml b/roles/jails/14_freshrss/defaults/main.yml
new file mode 100644
index 0000000..697ea79
--- /dev/null
+++ b/roles/jails/14_freshrss/defaults/main.yml
@@ -0,0 +1 @@
+userland: "14.3-RELEASE"
diff --git a/roles/jails/14_freshrss/tasks/main.yml b/roles/jails/14_freshrss/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/14_freshrss/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/15_paste/defaults/main.yml b/roles/jails/15_paste/defaults/main.yml
new file mode 100644
index 0000000..697ea79
--- /dev/null
+++ b/roles/jails/15_paste/defaults/main.yml
@@ -0,0 +1 @@
+userland: "14.3-RELEASE"
diff --git a/roles/jails/15_paste/tasks/main.yml b/roles/jails/15_paste/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/15_paste/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/16_dl/defaults/main.yml b/roles/jails/16_dl/defaults/main.yml
new file mode 100644
index 0000000..c296953
--- /dev/null
+++ b/roles/jails/16_dl/defaults/main.yml
@@ -0,0 +1,11 @@
+userland: "15.0-RELEASE"
+devfs_ruleset: 4
+no_default_route: true
+
+jail_conf_options:
+ - "allow.raw_sockets"
+ - "allow.mlock"
+
+nullfs:
+ - src: /usr/local/jails/volumes/storage
+ dst: /mnt/storage
diff --git a/roles/jails/16_dl/tasks/main.yml b/roles/jails/16_dl/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/16_dl/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/17_syncthing/defaults/main.yml b/roles/jails/17_syncthing/defaults/main.yml
new file mode 100644
index 0000000..a2d7d8f
--- /dev/null
+++ b/roles/jails/17_syncthing/defaults/main.yml
@@ -0,0 +1,11 @@
+userland: "15.0-RELEASE"
+
+nullfs:
+ - src: /usr/local/jails/volumes/storage/docs
+ dst: /mnt/docs
+ - src: /usr/local/jails/volumes/storage/vault
+ dst: /mnt/vault
+ - src: /usr/local/jails/volumes/storage/jan-systems-2025-content
+ dst: /mnt/jan-systems-2025-content
+ - src: /usr/local/jails/volumes/storage/projects-ableton
+ dst: /mnt/projects-ableton
diff --git a/roles/jails/17_syncthing/tasks/main.yml b/roles/jails/17_syncthing/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/17_syncthing/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/18_komga/defaults/main.yml b/roles/jails/18_komga/defaults/main.yml
new file mode 100644
index 0000000..d69a731
--- /dev/null
+++ b/roles/jails/18_komga/defaults/main.yml
@@ -0,0 +1,5 @@
+userland: "15.0-RELEASE"
+
+nullfs:
+ - src: /usr/local/jails/volumes/storage/media/manga
+ dst: /mnt/manga
diff --git a/roles/jails/18_komga/tasks/main.yml b/roles/jails/18_komga/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/18_komga/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail
diff --git a/roles/jails/19_leolalla_fi/defaults/main.yml b/roles/jails/19_leolalla_fi/defaults/main.yml
new file mode 100644
index 0000000..5416267
--- /dev/null
+++ b/roles/jails/19_leolalla_fi/defaults/main.yml
@@ -0,0 +1 @@
+userland: "15.0-RELEASE"
diff --git a/roles/jails/19_leolalla_fi/tasks/main.yml b/roles/jails/19_leolalla_fi/tasks/main.yml
new file mode 100644
index 0000000..2abdaff
--- /dev/null
+++ b/roles/jails/19_leolalla_fi/tasks/main.yml
@@ -0,0 +1,2 @@
+- import_role:
+ name: jail