aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJan Tuomi <jan@jantuomi.fi>2025-10-11 15:36:54 +0300
committerJan Tuomi <jan@jantuomi.fi>2025-10-11 15:36:54 +0300
commit3117429d1eb122382e78e6cb27a07dd878f7c0e9 (patch)
tree9678c0a90c36f869a5152fc2d79571e23106428b
parente4faedce00a921ab3b58b816eda987feaa2f0e9e (diff)
Improve ingress
-rw-r--r--.gitignore1
-rw-r--r--playbook.yml40
-rw-r--r--tasks/jail_ingress.yml16
-rw-r--r--tasks/network.yml16
-rw-r--r--templates/etc_crontab.j23
-rw-r--r--templates/ingress/etc_crontab.j25
-rw-r--r--templates/ingress/usr_local_bin_hetzner_ddns.sh.j2151
-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.j219
-rw-r--r--templates/usr_local_bin_hetzner_ddns.sh.j2119
10 files changed, 203 insertions, 167 deletions
diff --git a/.gitignore b/.gitignore
index 7a6353d..a6b4302 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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