diff options
Diffstat (limited to 'roles/jails/01_ingress/templates')
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 %} +} |
