diff options
| author | Jan Tuomi <jan@jantuomi.fi> | 2025-10-11 15:36:54 +0300 |
|---|---|---|
| committer | Jan Tuomi <jan@jantuomi.fi> | 2025-10-11 15:36:54 +0300 |
| commit | 3117429d1eb122382e78e6cb27a07dd878f7c0e9 (patch) | |
| tree | 9678c0a90c36f869a5152fc2d79571e23106428b | |
| parent | e4faedce00a921ab3b58b816eda987feaa2f0e9e (diff) | |
Improve ingress
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | playbook.yml | 40 | ||||
| -rw-r--r-- | tasks/jail_ingress.yml | 16 | ||||
| -rw-r--r-- | tasks/network.yml | 16 | ||||
| -rw-r--r-- | templates/etc_crontab.j2 | 3 | ||||
| -rw-r--r-- | templates/ingress/etc_crontab.j2 | 5 | ||||
| -rw-r--r-- | templates/ingress/usr_local_bin_hetzner_ddns.sh.j2 | 151 | ||||
| -rw-r--r-- | templates/ingress/usr_local_etc_hetzner_auth.j2 (renamed from templates/usr_local_etc_hetzner_auth.j2) | 0 | ||||
| -rw-r--r-- | templates/ingress/usr_local_etc_nginx_nginx.conf.j2 | 19 | ||||
| -rw-r--r-- | templates/usr_local_bin_hetzner_ddns.sh.j2 | 119 |
10 files changed, 203 insertions, 167 deletions
@@ -1 +1,2 @@ .envrc +secrets.yml diff --git a/playbook.yml b/playbook.yml index 81de8ca..095e058 100644 --- a/playbook.yml +++ b/playbook.yml @@ -3,36 +3,18 @@ hosts: pursotin pre_tasks: - - name: Fail if direnv wasn't loaded and SMTP_USER is missing + - name: Fail if secrets.yml wasn't loaded and smtp_user is missing assert: that: - - lookup('env', 'SMTP_USER') != '' + - smtp_user != '' fail_msg: > - Required environment variables are missing. Did you forget to run `direnv allow` or load `.envrc`? + Required variables are missing. Did you forget to create a secrets.yml file? vars: ansible_assert_no_log: true + vars_files: + - secrets.yml vars: - smtp_user: "{{ lookup('env', 'SMTP_USER') }}" - smtp_password: "{{ lookup('env', 'SMTP_PASSWORD') }}" - smtp_host: "{{ lookup('env', 'SMTP_HOST') }}" - smtp_port: "{{ lookup('env', 'SMTP_PORT') }}" - dma_mail_hostname: "{{ lookup('env', 'DMA_MAILNAME') }}" - dma_to_address: "{{ lookup('env', 'DMA_TO_ADDRESS') }}" - hetzner_zone_id: "{{ lookup('env', 'HETZNER_ZONE_ID') }}" - hetzner_record_id: "{{ lookup('env', 'HETZNER_RECORD_ID') }}" - hetzner_pat: "{{ lookup('env', 'HETZNER_PAT') }}" - backup_ssh_host: "{{ lookup('env', 'BACKUP_SSH_HOST') }}" - backup_ssh_user: "{{ lookup('env', 'BACKUP_SSH_USER') }}" - backup_ssh_privkey_file: "{{ lookup('env', 'BACKUP_SSH_PRIVKEY_FILE') }}" - backup_ssh_pubkey_file: "{{ lookup('env', 'BACKUP_SSH_PUBKEY_FILE') }}" - backup_zfs_dataset: zroot - diddle_email_host_user: "{{ lookup('env', 'DIDDLE_EMAIL_HOST_USER') }}" - diddle_email_host_password: "{{ lookup('env', 'DIDDLE_EMAIL_HOST_PASSWORD') }}" - hommabot_telegram_bot_token: "{{ lookup('env', 'HOMMABOT_TELEGRAM_BOT_TOKEN') }}" - hommabot_g_sa_json_b64: "{{ lookup('env', 'HOMMABOT_G_SA_JSON_B64') }}" - hommabot_sheets_spreadsheet_id: "{{ lookup('env', 'HOMMABOT_SHEETS_SPREADSHEET_ID') }}" - hommabot_sheets_range: "{{ lookup('env', 'HOMMABOT_SHEETS_RANGE') }}" lan_ipv4_cidr: 192.168.0.10/16 lan_ipv4_gateway: 192.168.0.1 lan_search_domain: local.jan.systems @@ -43,20 +25,20 @@ - { name: postgres, num: 2, tls: false } - { name: irc_thelounge, num: 3, tls: false } - { name: taulubot, num: 4, tls: false } - - { name: veetibot, num: 5, tls: false } + #- { name: veetibot, num: 5, tls: false } - { name: hommabot, num: 6, tls: false } - - { name: aggro, num: 7, tls: false } + - { name: aggro, num: 7, tls: true, host: aggro.jan.systems } - { name: diddle, num: 8, tls: true, host: diddle.jan.systems } - - { name: gallery_sakari, num: 9, tls: false } - - { name: gallery_leo, num: 10, tls: false } + #- { name: gallery_sakari, num: 9, tls: false } + #- { name: gallery_leo, num: 10, tls: false } - { name: spliit, num: 11, tls: true, host: spliit.jan.systems } - - { name: stirling-pdf, num: 12, tls: false } + #- { name: stirling-pdf, num: 12, tls: false } - { name: ente, num: 13, tls: false } - { name: freshrss, num: 14, tls: true, host: freshrss.jan.systems } static_sites: - { site: "homepage", host: "jan.systems", tls: true } static_site_dirs: - homepage: "{{ lookup('env', 'JAN_SYSTEMS_HTML_DIR') }}" + homepage: "{{ jan_systems_html_dir }}" tasks: - name: Run general tasks diff --git a/tasks/jail_ingress.yml b/tasks/jail_ingress.yml index 78eb3e6..ff6e7d3 100644 --- a/tasks/jail_ingress.yml +++ b/tasks/jail_ingress.yml @@ -65,6 +65,22 @@ service -j ingress nginx onestart when: ingress_nginx_enabled.rc != 0 +- name: Install hetzner_ddns.sh + template: + src: ingress/usr_local_bin_hetzner_ddns.sh.j2 + dest: /usr/local/jails/containers/ingress/usr/local/bin/hetzner_ddns.sh + owner: root + group: wheel + mode: "0755" + +- name: Set up hetzner_auth + template: + src: ingress/usr_local_etc_hetzner_auth.j2 + dest: /usr/local/jails/containers/ingress/usr/local/etc/hetzner_auth + owner: root + group: wheel + mode: "0600" + - name: Set up crontab template: src: ingress/etc_crontab.j2 diff --git a/tasks/network.yml b/tasks/network.yml index e9143bd..3b95380 100644 --- a/tasks/network.yml +++ b/tasks/network.yml @@ -67,19 +67,3 @@ - name: Reload pf rules shell: pfctl -f /etc/pf.conf when: pf_conf.changed - -- name: Install hetzner_ddns.sh - template: - src: usr_local_bin_hetzner_ddns.sh.j2 - dest: /usr/local/bin/hetzner_ddns.sh - owner: root - group: wheel - mode: "0755" - -- name: Set up hetzner_auth - template: - src: usr_local_etc_hetzner_auth.j2 - dest: /usr/local/etc/hetzner_auth - owner: root - group: wheel - mode: "0600" diff --git a/templates/etc_crontab.j2 b/templates/etc_crontab.j2 index debc65e..affdf7d 100644 --- a/templates/etc_crontab.j2 +++ b/templates/etc_crontab.j2 @@ -21,9 +21,6 @@ PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin # UTC time. See adjkerntz(8) for details. 1,31 0-5 * * * root adjkerntz -a -# Update dynamic DNS (Hetzner) -* * * * * root logto /var/log/hetzner_ddns /usr/local/bin/hetzner_ddns.sh - # 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 diff --git a/templates/ingress/etc_crontab.j2 b/templates/ingress/etc_crontab.j2 index 0ec694d..8fd559e 100644 --- a/templates/ingress/etc_crontab.j2 +++ b/templates/ingress/etc_crontab.j2 @@ -10,3 +10,8 @@ PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin {% for item in items %} {{ loop.index0 }} 2 * * * root certbot certonly --nginx -n -m jan@jantuomi.fi --agree-tos -d {{ item }} {% endfor %} + +# Update dynamic DNS (Hetzner) +{% for elem in hetzner_zone_record_ids %} +* * * * * root /usr/local/bin/hetzner_ddns.sh --zone-id '{{ elem.zone_id }}' --record-id '{{ elem.record_id }}' --record-name '{{ elem.name }}' --record-type '{{ elem.type }}' --ttl '{{ elem.ttl }}' --iface-cmd 'ifconfig epw1b' >>/var/log/hetzner_ddns.log 2>&1 +{% endfor %} diff --git a/templates/ingress/usr_local_bin_hetzner_ddns.sh.j2 b/templates/ingress/usr_local_bin_hetzner_ddns.sh.j2 new file mode 100644 index 0000000..3b23ea5 --- /dev/null +++ b/templates/ingress/usr_local_bin_hetzner_ddns.sh.j2 @@ -0,0 +1,151 @@ +#!/bin/sh +# +# Hetzner DNS record updater (one-shot for cron) +# +# Adds named CLI args: +# --zone-id --record-id --record-name +# --record-type --ttl --iface-cmd +# +# Env overrides (with sensible defaults): +# API_TOKEN_FILE (default: /usr/local/etc/hetzner_auth) +# API_BASE (default: https://dns.hetzner.com/api/v1) +# +# Exit codes: +# 0 = Updated or already up-to-date +# 1 = Transient issue (no local IP, API/network) +# 2 = Config error (bad/missing args, token) +# +# Requirements: curl, awk + +set -eu + +# ---------------------------- Defaults --------------------------------------- +# Env-overridable: +: "${API_TOKEN_FILE:=/usr/local/etc/hetzner_auth}" +: "${API_BASE:=https://dns.hetzner.com/api/v1}" +# ----------------------------------------------------------------------------- + + +usage() { + cat <<'USAGE' >&2 +Usage: hetzner_ddns.sh [OPTIONS] + +Options (named): + --zone-id ID Hetzner zone ID + --record-id ID Record ID to update + --record-name NAME Record name (e.g., "host" part) + --record-type TYPE Record type (e.g., A, AAAA, TXT) + --ttl SECONDS TTL in seconds + --iface-cmd CMD Command that prints interface info (for IP discovery) + -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 DNS API base URL + (default: https://dns.hetzner.com/api/v1) + +Examples: + API_TOKEN_FILE=/secret/token \ + ./hetzner_ddns.sh \ + --zone-id ZONE123 --record-id REC456 \ + --record-name example --record-type A \ + --ttl 300 --iface-cmd "ifconfig em0" +USAGE +} + +# ----------------------------- Arg parsing ----------------------------------- +# Accept both `--key value` and `--key=value` +while [ $# -gt 0 ]; do + case "$1" in + --zone-id=*) ZONE_ID=${1#*=} ;; + --zone-id) ZONE_ID=$2; shift ;; + --record-id=*) RECORD_ID=${1#*=} ;; + --record-id) RECORD_ID=$2; shift ;; + --record-name=*) RECORD_NAME=${1#*=} ;; + --record-name) RECORD_NAME=$2; shift ;; + --record-type=*) RECORD_TYPE=${1#*=} ;; + --record-type) RECORD_TYPE=$2; shift ;; + --ttl=*) TTL=${1#*=} ;; + --ttl) TTL=$2; shift ;; + --iface-cmd=*) IFACE_CMD=${1#*=} ;; + --iface-cmd) IFACE_CMD=$2; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + -*) + echo "ERROR: Unknown option: $1" >&2 + usage + exit 2 + ;; + *) + echo "ERROR: Unexpected positional argument: $1" >&2 + usage + exit 2 + ;; + esac + shift +done + +fail() { echo "ERROR: $*" >&2; exit 2; } +warn() { echo "WARN: $*" >&2; } +info() { echo "INFO: $*"; } + +# ----------------------------- Validation ------------------------------------ +[ -n "${ZONE_ID}" ] || fail "Missing --zone-id" +[ -n "${RECORD_ID}" ] || fail "Missing --record-id" +[ -n "${RECORD_NAME}" ] || fail "Missing --record-name" +[ -n "${RECORD_TYPE}" ] || fail "Missing --record-type" +[ -n "${TTL}" ] || fail "Missing --ttl" +case "${TTL}" in (*[!0-9]*|'') fail "--ttl must be an integer";; esac +[ -n "${IFACE_CMD}" ] || fail "Missing --iface-cmd" + +# ------------------------------ Auth ----------------------------------------- +if [ ! -r "${API_TOKEN_FILE}" ]; then + echo "ERROR: Token file missing or unreadable: ${API_TOKEN_FILE}" >&2 + exit 2 +fi +API_TOKEN="$(cat "${API_TOKEN_FILE}" | tr -d '[:space:]')" +[ -n "${API_TOKEN}" ] || { echo "ERROR: API token is empty" >&2; exit 2; } + +# ------------------------------ 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 '{"type":"%s","name":"%s","value":"%s","zone_id":"%s","ttl":%s}' \ + "${RECORD_TYPE}" "${RECORD_NAME}" "${IFACE_IP}" "${ZONE_ID}" "${TTL}") + +HTTP_CODE=$( + curl -sS -o /dev/null -w "%{http_code}" -X PUT \ + -H "Content-Type: application/json" \ + -H "Auth-API-Token: ${API_TOKEN}" \ + --data "${BODY}" \ + "${API_BASE}/records/${RECORD_ID}" +) + +case "${HTTP_CODE}" in + 200) + info "Updated zone ${ZONE_ID} record ${RECORD_ID} -> ${RECORD_TYPE} ${RECORD_NAME} ${IFACE_IP} with TTL ${TTL}" + exit 0 + ;; + 4*) + echo "ERROR: Client error from API (HTTP ${HTTP_CODE}) - check IDs/token/body" >&2 + 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/templates/usr_local_etc_hetzner_auth.j2 b/templates/ingress/usr_local_etc_hetzner_auth.j2 index 129dccf..129dccf 100644 --- a/templates/usr_local_etc_hetzner_auth.j2 +++ b/templates/ingress/usr_local_etc_hetzner_auth.j2 diff --git a/templates/ingress/usr_local_etc_nginx_nginx.conf.j2 b/templates/ingress/usr_local_etc_nginx_nginx.conf.j2 index 9f20a5f..04c43db 100644 --- a/templates/ingress/usr_local_etc_nginx_nginx.conf.j2 +++ b/templates/ingress/usr_local_etc_nginx_nginx.conf.j2 @@ -76,6 +76,25 @@ http { } {% endif -%} + {# TODO: parameterize this properly -#} + server { + server_name jantuomi.fi; + 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; + + ssl_certificate /usr/local/etc/letsencrypt/live/jantuomi.fi/fullchain.pem; + ssl_certificate_key /usr/local/etc/letsencrypt/live/jantuomi.fi/privkey.pem; + include /usr/local/etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /usr/local/etc/letsencrypt/ssl-dhparams.pem; + + return 307 https://jan.systems$request_uri; + } + {% endfor -%} {% for jail in jails -%} server { diff --git a/templates/usr_local_bin_hetzner_ddns.sh.j2 b/templates/usr_local_bin_hetzner_ddns.sh.j2 deleted file mode 100644 index 7fed440..0000000 --- a/templates/usr_local_bin_hetzner_ddns.sh.j2 +++ /dev/null @@ -1,119 +0,0 @@ -#!/bin/sh -# -# Hetzner DNS A record updater (one-shot for cron) -# -# Intended to be run every minute via cron wrapped in: -# logto /var/log/hetzner_ddns /usr/local/bin/hetzner_ddns.sh -# -# Responsibilities: -# 1. Determine current IPv4 address from a specified interface (command). -# 2. Fetch existing DNS record value from Hetzner. -# 3. If different, issue PUT to update record; otherwise exit quietly. -# -# Exits: -# 0 = Successfully updated or already up-to-date. -# 1 = Transient issue (e.g., no local IP yet, API/network error). -# 2 = Permanent-ish configuration problem (e.g., missing token). -# -# Requirements: -# - curl -# - A file /usr/local/etc/hetzner_auth containing ONLY the API token. -# -# If you ever need to change the interface command, expose a variable -# and adapt IFACE_CMD below in the template. - -set -eu - -# ---------------------------- Configuration --------------------------------- -API_TOKEN_FILE="/usr/local/etc/hetzner_auth" -API_BASE="https://dns.hetzner.com/api/v1" -ZONE_ID="{{ hetzner_zone_id }}" -RECORD_ID="{{ hetzner_record_id }}" -RECORD_NAME="pursotin" -RECORD_TYPE="A" -TTL="300" -IFACE_CMD="jexec ingress ifconfig epw1b" -# --------------------------------------------------------------------------- - -fail() { - echo "ERROR: $*" >&2 - exit 1 -} - -warn() { - echo "WARN: $*" >&2 -} - -info() { - echo "INFO: $*" -} - -# Read token -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" - -# Get current IPv4 from interface output -get_local_ip() { - # Extract the first 'inet ' IPv4 (ignoring inet6) - sh -c "${IFACE_CMD}" 2>/dev/null | awk '/inet[[:space:]]/ {print $2; exit}' -} - -LOCAL_IP="$(get_local_ip || true)" -if [ -z "${LOCAL_IP}" ]; then - warn "No IPv4 address found via '${IFACE_CMD}' (interface not ready?)" - exit 1 -fi - -# Fetch current DNS record value -fetch_record_ip() { - # We ONLY need the "value" field. Avoid jq dependency; use awk. - # Response shape (simplified): - # {"record":{"id":"...","value":"198.51.100.7", ...}} - curl -sS -H "Auth-API-Token: ${API_TOKEN}" \ - "${API_BASE}/records/${RECORD_ID}" \ - | awk -F'"' '/"value":"/ {print $4; exit}' -} - -REMOTE_IP="$(fetch_record_ip || true)" -if [ -z "${REMOTE_IP}" ]; then - warn "Failed to parse remote record value (network/API issue?)" - exit 1 -fi - -if [ "${REMOTE_IP}" = "${LOCAL_IP}" ]; then - info "No change (${LOCAL_IP})" - exit 0 -fi - -# Prepare JSON body (avoid subshell harm by using printf for escaping basics) -BODY=$(printf '{"type":"%s","name":"%s","value":"%s","zone_id":"%s","ttl":%s}' \ - "${RECORD_TYPE}" "${RECORD_NAME}" "${LOCAL_IP}" "${ZONE_ID}" "${TTL}") - -HTTP_CODE=$( - curl -sS -o /dev/null -w "%{http_code}" -X PUT \ - -H "Content-Type: application/json" \ - -H "Auth-API-Token: ${API_TOKEN}" \ - --data "${BODY}" \ - "${API_BASE}/records/${RECORD_ID}" -) - -case "${HTTP_CODE}" in - 200) - info "Updated ${RECORD_NAME} ${REMOTE_IP} -> ${LOCAL_IP}" - exit 0 - ;; - 4*) - fail "Client error from API (HTTP ${HTTP_CODE}) - check IDs/token/body" - ;; - 5*) - warn "Server error from API (HTTP ${HTTP_CODE})" - exit 1 - ;; - *) - warn "Unexpected HTTP status ${HTTP_CODE}" - exit 1 - ;; -esac |
