aboutsummaryrefslogtreecommitdiffstats
path: root/templates/ingress
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 /templates/ingress
parente4faedce00a921ab3b58b816eda987feaa2f0e9e (diff)
Improve ingress
Diffstat (limited to 'templates/ingress')
-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.j21
-rw-r--r--templates/ingress/usr_local_etc_nginx_nginx.conf.j219
4 files changed, 176 insertions, 0 deletions
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/ingress/usr_local_etc_hetzner_auth.j2 b/templates/ingress/usr_local_etc_hetzner_auth.j2
new file mode 100644
index 0000000..129dccf
--- /dev/null
+++ b/templates/ingress/usr_local_etc_hetzner_auth.j2
@@ -0,0 +1 @@
+{{ hetzner_pat }}
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 {