aboutsummaryrefslogtreecommitdiffstats
path: root/roles/jails/01_ingress/templates
diff options
context:
space:
mode:
Diffstat (limited to 'roles/jails/01_ingress/templates')
-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
7 files changed, 477 insertions, 0 deletions
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 %}
+}