diff --git a/.env.example b/.env.example index c2153911..a358ebb3 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,18 @@ WEBMENTION_SENDER_ORIGIN= # Example: http://127.0.0.1:3000/webmention-sender WEBMENTION_SENDER_ENDPOINT= +# Wait up to this many seconds for endpoint readiness before first poll +WEBMENTION_SENDER_READY_TIMEOUT=60 + +# Graceful stop timeout for webmention poller during shutdown (seconds) +WEBMENTION_SENDER_STOP_TIMEOUT=5 + +# Graceful stop timeout for Indiekit process during shutdown (seconds) +INDIEKIT_STOP_TIMEOUT=20 + +# If parent process is FreeBSD daemon(8), terminate it during shutdown (1/0) +KILL_DAEMON_PARENT_ON_SHUTDOWN=1 + # Optional webmentions proxy endpoint settings # Default mount path in indiekit.config.mjs is /webmentions-api WEBMENTIONS_PROXY_MOUNT_PATH=/webmentions-api diff --git a/README.md b/README.md index 73a79767..30e754e4 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,17 @@ - Use `start.example.sh` as the tracked template and keep real credentials in environment variables (or `.env` on the server). - Startup scripts parse `.env` with the `dotenv` parser (not shell `source`), so values containing spaces are handled safely. - `start.example.sh` includes an optional background webmention sender polling loop for bare-metal deployments (including FreeBSD jails). +- For FreeBSD service management, use `indiekit.rcd.example` as a template for `/usr/local/etc/rc.d/indiekit`. +- Important: do not use `daemon -r` in the rc.d command args. Let `service indiekit restart` control restart behavior; `-r` can keep the supervisor alive during stop/restart. +- The rc.d template uses daemon supervisor pidfile `-P` (and child pidfile `-p`) and supports `indiekit_stop_timeout` in `rc.conf` (default `20` seconds). +- FreeBSD rc.d install example: + +```sh +install -m 0555 /usr/local/indiekit/indiekit.rcd.example /usr/local/etc/rc.d/indiekit +sysrc indiekit_enable=YES +service indiekit restart +``` + - FreeBSD jail env example for auto-send polling: ```sh diff --git a/indiekit.rcd.example b/indiekit.rcd.example new file mode 100644 index 00000000..447cf915 --- /dev/null +++ b/indiekit.rcd.example @@ -0,0 +1,73 @@ +#!/bin/sh +# +# PROVIDE: indiekit +# REQUIRE: NETWORKING LOGIN +# KEYWORD: shutdown + +. /etc/rc.subr + +name="indiekit" +rcvar="${name}_enable" + +pidfile="/var/run/${name}.pid" +child_pidfile="/var/run/${name}.child.pid" +logfile="/var/log/${name}.log" + +command="/usr/sbin/daemon" +procname="/usr/sbin/daemon" +required_files="/usr/local/indiekit/start.sh" + +# Important: do not use daemon -r here. rc.d should own restart behavior. +command_args="-P ${pidfile} -p ${child_pidfile} -o ${logfile} -u indiekit /usr/local/indiekit/start.sh" + +extra_commands="reload" +sig_reload="HUP" + +start_precmd="${name}_prestart" +stop_cmd="${name}_stop" + +indiekit_prestart() +{ + touch "${logfile}" + chown indiekit:indiekit "${logfile}" 2>/dev/null || true +} + +indiekit_stop() +{ + if [ ! -r "${pidfile}" ]; then + echo "${name} not running?" + return 0 + fi + + _pid="$(cat "${pidfile}" 2>/dev/null || true)" + if [ -z "${_pid}" ] || ! kill -0 "${_pid}" 2>/dev/null; then + rm -f "${pidfile}" "${child_pidfile}" + return 0 + fi + + _timeout="${indiekit_stop_timeout:-20}" + case "${_timeout}" in + ''|*[!0-9]*) _timeout=20 ;; + esac + + echo "Stopping ${name}." + kill -TERM "${_pid}" 2>/dev/null || true + + while kill -0 "${_pid}" 2>/dev/null; do + if [ "${_timeout}" -le 0 ]; then + echo "${name} stop timeout; forcing kill" >&2 + kill -KILL "${_pid}" 2>/dev/null || true + break + fi + + sleep 1 + _timeout=$((_timeout - 1)) + done + + rm -f "${pidfile}" "${child_pidfile}" +} + +load_rc_config "${name}" +: ${indiekit_enable:="NO"} + +run_rc_command "$1" diff --git a/start.example.sh b/start.example.sh index 2ba8ff3b..75af3f08 100644 --- a/start.example.sh +++ b/start.example.sh @@ -3,6 +3,233 @@ set -eu cd /usr/local/indiekit NODE_BIN="${NODE_BIN:-/usr/local/bin/node}" +WEBMENTION_POLL_PID="" +INDIEKIT_PID="" +SHUTDOWN_IN_PROGRESS=0 +WEBMENTION_STOP_TIMEOUT="${WEBMENTION_SENDER_STOP_TIMEOUT:-5}" +INDIEKIT_STOP_TIMEOUT="${INDIEKIT_STOP_TIMEOUT:-20}" +WEBMENTION_READY_TIMEOUT="${WEBMENTION_SENDER_READY_TIMEOUT:-60}" +KILL_DAEMON_PARENT_ON_SHUTDOWN="${KILL_DAEMON_PARENT_ON_SHUTDOWN:-1}" + +case "$WEBMENTION_STOP_TIMEOUT" in + ''|*[!0-9]*) WEBMENTION_STOP_TIMEOUT=5 ;; +esac + +case "$INDIEKIT_STOP_TIMEOUT" in + ''|*[!0-9]*) INDIEKIT_STOP_TIMEOUT=20 ;; +esac + +case "$WEBMENTION_READY_TIMEOUT" in + ''|*[!0-9]*) WEBMENTION_READY_TIMEOUT=60 ;; +esac + +case "$KILL_DAEMON_PARENT_ON_SHUTDOWN" in + ''|*[!0-9]*) KILL_DAEMON_PARENT_ON_SHUTDOWN=1 ;; +esac + +is_pid_alive() { + _pid="$1" + + if [ -z "$_pid" ] || ! kill -0 "$_pid" 2>/dev/null; then + return 1 + fi + + # FreeBSD can report zombies as existing PIDs; exclude them from "alive". + if command -v ps >/dev/null 2>&1; then + _state="$(ps -o stat= -p "$_pid" 2>/dev/null || true)" + case "$_state" in + *Z*) return 1 ;; + esac + fi + + return 0 +} + +wait_for_pid_exit() { + _pid="$1" + _timeout="$2" + _elapsed=0 + + while is_pid_alive "$_pid"; do + if [ "$_elapsed" -ge "$_timeout" ]; then + return 1 + fi + + sleep 1 + _elapsed=$((_elapsed + 1)) + done + + wait "$_pid" 2>/dev/null || true + return 0 +} + +stop_webmention_poller() { + if [ -n "${WEBMENTION_POLL_PID}" ] && is_pid_alive "${WEBMENTION_POLL_PID}"; then + kill "${WEBMENTION_POLL_PID}" 2>/dev/null || true + + if ! wait_for_pid_exit "${WEBMENTION_POLL_PID}" "${WEBMENTION_STOP_TIMEOUT}"; then + kill -9 "${WEBMENTION_POLL_PID}" 2>/dev/null || true + wait "${WEBMENTION_POLL_PID}" 2>/dev/null || true + fi + fi + + WEBMENTION_POLL_PID="" +} + +stop_indiekit_server() { + if [ -n "${INDIEKIT_PID}" ] && is_pid_alive "${INDIEKIT_PID}"; then + kill "${INDIEKIT_PID}" 2>/dev/null || true + + if ! wait_for_pid_exit "${INDIEKIT_PID}" "${INDIEKIT_STOP_TIMEOUT}"; then + echo "[indiekit] Shutdown timeout after ${INDIEKIT_STOP_TIMEOUT}s; forcing kill" >&2 + kill -9 "${INDIEKIT_PID}" 2>/dev/null || true + wait "${INDIEKIT_PID}" 2>/dev/null || true + fi + fi + + INDIEKIT_PID="" +} + +stop_daemon_parent() { + if [ "$KILL_DAEMON_PARENT_ON_SHUTDOWN" != "1" ]; then + return + fi + + if ! command -v ps >/dev/null 2>&1; then + return + fi + + _ppid="${PPID:-}" + if [ -z "$_ppid" ] || ! kill -0 "$_ppid" 2>/dev/null; then + return + fi + + _parent_cmd="$(ps -o command= -p "$_ppid" 2>/dev/null || true)" + + case "$_parent_cmd" in + daemon:\ *\(daemon\)*|*/daemon\ *) + kill "$_ppid" 2>/dev/null || true + ;; + esac +} + +start_webmention_poller() { + if [ "${WEBMENTION_SENDER_AUTO_POLL:-1}" != "1" ]; then + return + fi + + WEBMENTION_SENDER_HOST="${WEBMENTION_SENDER_HOST:-127.0.0.1}" + WEBMENTION_SENDER_PORT="${WEBMENTION_SENDER_PORT:-${PORT:-3000}}" + WEBMENTION_SENDER_PATH="${WEBMENTION_SENDER_MOUNT_PATH:-/webmention-sender}" + WEBMENTION_SENDER_ORIGIN="${WEBMENTION_SENDER_ORIGIN:-${PUBLICATION_URL:-${SITE_URL:-}}}" + WEBMENTION_SENDER_INTERVAL="${WEBMENTION_SENDER_POLL_INTERVAL:-300}" + + case "$WEBMENTION_SENDER_PATH" in + /*) ;; + *) WEBMENTION_SENDER_PATH="/$WEBMENTION_SENDER_PATH" ;; + esac + + case "$WEBMENTION_SENDER_INTERVAL" in + ''|*[!0-9]*) WEBMENTION_SENDER_INTERVAL=300 ;; + esac + + WEBMENTION_SENDER_ORIGIN="${WEBMENTION_SENDER_ORIGIN%/}" + + if ! command -v curl >/dev/null 2>&1; then + echo "[webmention] curl not found; skipping auto-send polling" >&2 + return + fi + + if [ -z "$WEBMENTION_SENDER_ORIGIN" ]; then + echo "[webmention] SITE_URL/PUBLICATION_URL missing; skipping auto-send polling" >&2 + return + fi + + WEBMENTION_SENDER_ENDPOINT="${WEBMENTION_SENDER_ENDPOINT:-http://${WEBMENTION_SENDER_HOST}:${WEBMENTION_SENDER_PORT}${WEBMENTION_SENDER_PATH}}" + + # Wait for the local endpoint to answer (any HTTP status) before polling. + WEBMENTION_READY_ELAPSED=0 + while true; do + if ! is_pid_alive "${INDIEKIT_PID}"; then + echo "[webmention] Indiekit exited before poller startup; skipping" >&2 + return + fi + + WEBMENTION_READY_CODE="$( + curl -sS -o /dev/null -m 2 -w '%{http_code}' "${WEBMENTION_SENDER_ENDPOINT}" 2>/dev/null || true + )" + + case "$WEBMENTION_READY_CODE" in + ''|000) ;; + *) break ;; + esac + + if [ "$WEBMENTION_READY_ELAPSED" -ge "$WEBMENTION_READY_TIMEOUT" ]; then + echo "[webmention] Startup readiness timeout after ${WEBMENTION_READY_TIMEOUT}s; starting poller anyway" >&2 + break + fi + + sleep 1 + WEBMENTION_READY_ELAPSED=$((WEBMENTION_READY_ELAPSED + 1)) + done + + ( + echo "[webmention] Starting auto-send polling every ${WEBMENTION_SENDER_INTERVAL}s (${WEBMENTION_SENDER_ENDPOINT})" + + while true; do + if ! is_pid_alive "${INDIEKIT_PID}"; then + echo "[webmention] Indiekit stopped; exiting poller" + break + fi + + TOKEN="$({ + WEBMENTION_ORIGIN="$WEBMENTION_SENDER_ORIGIN" \ + WEBMENTION_SECRET="$SECRET" \ + "$NODE_BIN" -e ' + const jwt = require("jsonwebtoken"); + const me = process.env.WEBMENTION_ORIGIN; + const secret = process.env.WEBMENTION_SECRET; + if (!me || !secret) process.exit(1); + process.stdout.write( + jwt.sign({ me, scope: "update" }, secret, { expiresIn: "5m" }), + ); + ' 2>/dev/null; + } || true)" + + if [ -n "$TOKEN" ]; then + RESULT="$(curl -sS -X POST "${WEBMENTION_SENDER_ENDPOINT}?token=${TOKEN}" 2>&1 || true)" + + if [ -n "$RESULT" ]; then + echo "[webmention] $(date '+%Y-%m-%d %H:%M:%S') - $RESULT" + else + echo "[webmention] $(date '+%Y-%m-%d %H:%M:%S') - ok" + fi + else + echo "[webmention] $(date '+%Y-%m-%d %H:%M:%S') - token generation failed" + fi + + sleep "$WEBMENTION_SENDER_INTERVAL" + done + ) & + + WEBMENTION_POLL_PID="$!" +} + +shutdown() { + if [ "${SHUTDOWN_IN_PROGRESS}" = "1" ]; then + return + fi + + SHUTDOWN_IN_PROGRESS=1 + trap '' INT TERM HUP + + # Stop poller first so shutdown does not generate connection-refused spam. + stop_webmention_poller + stop_indiekit_server + stop_daemon_parent +} + +trap 'shutdown; exit 0' INT TERM HUP # Optional: load environment from local .env file # (dotenv syntax, supports spaces in values). @@ -71,66 +298,23 @@ unset DEBUG "${NODE_BIN}" scripts/patch-indieauth-devmode-guard.mjs "${NODE_BIN}" scripts/patch-listening-endpoint-runtime-guards.mjs -# Optional: poll the webmention sender endpoint in the background. -if [ "${WEBMENTION_SENDER_AUTO_POLL:-1}" = "1" ]; then - WEBMENTION_SENDER_HOST="${WEBMENTION_SENDER_HOST:-127.0.0.1}" - WEBMENTION_SENDER_PORT="${WEBMENTION_SENDER_PORT:-${PORT:-3000}}" - WEBMENTION_SENDER_PATH="${WEBMENTION_SENDER_MOUNT_PATH:-/webmention-sender}" - WEBMENTION_SENDER_ORIGIN="${WEBMENTION_SENDER_ORIGIN:-${PUBLICATION_URL:-${SITE_URL:-}}}" - WEBMENTION_SENDER_INTERVAL="${WEBMENTION_SENDER_POLL_INTERVAL:-300}" +"${NODE_BIN}" node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs & +INDIEKIT_PID="$!" - case "$WEBMENTION_SENDER_PATH" in - /*) ;; - *) WEBMENTION_SENDER_PATH="/$WEBMENTION_SENDER_PATH" ;; - esac +start_webmention_poller - case "$WEBMENTION_SENDER_INTERVAL" in - ''|*[!0-9]*) WEBMENTION_SENDER_INTERVAL=300 ;; - esac +# Keep the parent shell responsive to TERM/HUP from rc(8)/daemon while the +# Node process runs. A blocking wait can delay trap execution on some shells. +INDIEKIT_EXIT_CODE=0 - WEBMENTION_SENDER_ORIGIN="${WEBMENTION_SENDER_ORIGIN%/}" +while is_pid_alive "${INDIEKIT_PID}"; do + sleep 1 +done - if ! command -v curl >/dev/null 2>&1; then - echo "[webmention] curl not found; skipping auto-send polling" >&2 - elif [ -z "$WEBMENTION_SENDER_ORIGIN" ]; then - echo "[webmention] SITE_URL/PUBLICATION_URL missing; skipping auto-send polling" >&2 - else - WEBMENTION_SENDER_ENDPOINT="${WEBMENTION_SENDER_ENDPOINT:-http://${WEBMENTION_SENDER_HOST}:${WEBMENTION_SENDER_PORT}${WEBMENTION_SENDER_PATH}}" +set +e +wait "${INDIEKIT_PID}" +INDIEKIT_EXIT_CODE="$?" +set -e - ( - echo "[webmention] Starting auto-send polling every ${WEBMENTION_SENDER_INTERVAL}s (${WEBMENTION_SENDER_ENDPOINT})" - - while true; do - TOKEN="$({ - WEBMENTION_ORIGIN="$WEBMENTION_SENDER_ORIGIN" \ - WEBMENTION_SECRET="$SECRET" \ - "$NODE_BIN" -e ' - const jwt = require("jsonwebtoken"); - const me = process.env.WEBMENTION_ORIGIN; - const secret = process.env.WEBMENTION_SECRET; - if (!me || !secret) process.exit(1); - process.stdout.write( - jwt.sign({ me, scope: "update" }, secret, { expiresIn: "5m" }), - ); - ' 2>/dev/null; - } || true)" - - if [ -n "$TOKEN" ]; then - RESULT="$(curl -sS -X POST "${WEBMENTION_SENDER_ENDPOINT}?token=${TOKEN}" 2>&1 || true)" - - if [ -n "$RESULT" ]; then - echo "[webmention] $(date '+%Y-%m-%d %H:%M:%S') - $RESULT" - else - echo "[webmention] $(date '+%Y-%m-%d %H:%M:%S') - ok" - fi - else - echo "[webmention] $(date '+%Y-%m-%d %H:%M:%S') - token generation failed" - fi - - sleep "$WEBMENTION_SENDER_INTERVAL" - done - ) & - fi -fi - -exec "${NODE_BIN}" node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs +stop_webmention_poller +exit "${INDIEKIT_EXIT_CODE}"