aboutsummaryrefslogtreecommitdiffstats
path: root/roles/jails
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/jails
parent4715a28fdcd87440400d17154bfa361d99db29cc (diff)
Rework
Diffstat (limited to 'roles/jails')
-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
54 files changed, 950 insertions, 0 deletions
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