diff --git a/.gitignore b/.gitignore index 48c3b5a..ab496a5 100644 --- a/.gitignore +++ b/.gitignore @@ -125,6 +125,9 @@ celerybeat.pid .venv env/ venv/ + +# Written by install.sh into the install directory +.instance-name ENV/ env.bak/ venv.bak/ diff --git a/INSTALL.md b/INSTALL.md index 9ff1bda..82c3603 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -6,6 +6,22 @@ curl -fsSL https://raw.githubusercontent.com/smswithoutborders/RelaySMS-Publisher/main/install.sh | sudo bash ``` +Installs to `/opt/relaysms/relaysms-publisher` by default. Pass `--install-dir PATH` for a different location, or run without it and you'll be prompted. + +### Running Multiple Instances + +To run a second, independent copy of Publisher on the same host, give it its own install directory and `--instance-name`: + +```bash +sudo ./install.sh --install-dir /srv/relaysms-acme --instance-name acme +``` + +This namespaces the systemd units (`relaysms-publisher-acme.target`, `relaysms-publisher-acme-rest.service`, ...) so they don't collide with the default instance's, and `manage.sh` inside each install directory manages only its own instance. You still need to give each instance distinct `PORT`/`GRPC_PORT` values in its `.env`: two instances can't share a port on the same host, and `install.sh` doesn't assign this for you. + +Re-running `install.sh` against an existing named instance doesn't require repeating `--instance-name`; it's remembered automatically. Passing a *different* `--instance-name` than the instance was set up with is rejected. + +Run `install.sh --help` for the full flag list, including `--force-deps` to reinstall system dependencies even if already marked done. + ## Manual Installation ### System Dependencies @@ -253,6 +269,16 @@ sudo ./scripts/setup-postgres.sh --db-name relaysms --db-user relaysms Add `--db-password PASS` to set a specific password instead of a generated one. Both scripts write the resulting `DATABASE_DIALECT` and connection details into `.env` for you; the sections below are for manual configuration instead (e.g. pointing at a database server on another host). +**Already have a database?** Add `--db-existing` (plus `--db-host`, `--db-port` if not local) to connect to it instead of installing/provisioning a new one: + +```bash +sudo ./install.sh --setup-db postgres --db-existing \ + --db-host db.example.com --db-port 5432 \ + --db-name relaysms --db-user relaysms --db-password 'your-existing-password' +``` + +This only validates the connection and writes it to `.env` — it never creates, alters, or drops anything on that server. If the connection or credentials are wrong, the error message tells you to re-run without `--db-existing` to create a new local database instead. Run interactively (no `--setup-db`) and you'll be prompted for "existing" or "new" instead of needing the flags. + **SQLite (default):** ```bash @@ -318,6 +344,16 @@ sudo ./scripts/setup-rabbitmq.sh --broker-vhost relaysms --broker-user relaysms Add `--broker-password PASS` to set a specific password instead of a generated one. The script writes the resulting `CELERY_BROKER_TYPE` and `CELERY_RABBITMQ_URL` into `.env` for you; the block below is for manual configuration instead (e.g. pointing at a broker on another host). +**Already have a broker?** Add `--broker-existing` (plus `--broker-host` if not local) to connect to it instead: + +```bash +sudo ./install.sh --setup-broker rabbitmq --broker-existing \ + --broker-host mq.example.com \ + --broker-vhost relaysms --broker-user relaysms --broker-password 'your-existing-password' +``` + +This validates the credentials against the broker's management API (default port `15672`, override with `--broker-mgmt-port`) rather than `rabbitmqctl`, since that only talks to a local node. It never creates or modifies anything on the broker. A failed check tells you to re-run without `--broker-existing` to create a new local broker instead. + ```bash # Broker/backend type: sqlite | redis | rabbitmq CELERY_BROKER_TYPE=sqlite diff --git a/README.md b/README.md index 8da8fed..b2f4803 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ Add `--setup-observability` to also stand up self-hosted tracing/metrics/uptime Run with no flags at all and the installer walks you through each of these choices interactively instead. +Add `--install-dir PATH` to install somewhere other than `/opt/relaysms/relaysms-publisher`, and `--instance-name NAME` to run a second, independent copy on the same host. See [Running Multiple Instances](INSTALL.md#running-multiple-instances). + Manage services: ```bash diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index b82c1ce..e56f1a9 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -2,7 +2,6 @@ set -Ee -# Catches failures not already wrapped in error(), with line context. on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; } trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR diff --git a/gateway-clients.sh b/gateway-clients.sh index 3fc79b8..2102d08 100755 --- a/gateway-clients.sh +++ b/gateway-clients.sh @@ -1,33 +1,12 @@ #!/bin/bash # SPDX-License-Identifier: GPL-3.0-only -# -# Wrapper around `python3 -m gateway_clients.cli` that removes the guesswork -# of running the gateway clients CLI correctly: it resolves the install -# directory, loads .env, runs as the correct service user (so file -# ownership never drifts), and uses the project venv automatically. set -Eeuo pipefail -DEFAULT_INSTALL_DIR="/opt/relaysms/relaysms-publisher" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/scripts/lib.sh" -log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*"; } -error() { - echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2 - exit 1 -} -# Catches failures not already wrapped in error(), with line context. -on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; } -trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR - -# Resolve INSTALL_DIR: prefer the production install path if it exists and -# looks like a real install, otherwise fall back to this script's own -# directory (development checkout). -if [ -f "$DEFAULT_INSTALL_DIR/gateway_clients/cli.py" ]; then - INSTALL_DIR="$DEFAULT_INSTALL_DIR" -else - INSTALL_DIR="$SCRIPT_DIR" -fi +INSTALL_DIR="$SCRIPT_DIR" [ -f "$INSTALL_DIR/gateway_clients/cli.py" ] || error "gateway_clients/cli.py not found under $INSTALL_DIR. Is RelaySMS Publisher installed there?" @@ -39,20 +18,7 @@ VENV_DIR="$INSTALL_DIR/venv" [ -x "$VENV_DIR/bin/python3" ] || error "Virtualenv not found at $VENV_DIR. Run install.sh or 'make build-setup' first." -# Resolve the service user: prefer the User= set in the installed systemd -# unit (source of truth after install.sh), fall back to the .env owner, -# then to whoever is running this script. -detect_service_user() { - local unit="/etc/systemd/system/relaysms-publisher-rest.service" - if [ -f "$unit" ]; then - grep -E "^User=" "$unit" | head -1 | cut -d= -f2 && return - fi - if [ -f "$ENV_FILE" ]; then - stat -c '%U' "$ENV_FILE" 2>/dev/null && return - fi - id -un -} - +INSTANCE_NAME="$(read_instance_name)" SERVICE_USER="$(detect_service_user)" CURRENT_USER="$(id -un)" @@ -100,12 +66,6 @@ Examples: EOF } -read_env_var() { - local key="$1" - grep -E "^${key}[[:space:]]*=" "$ENV_FILE" 2>/dev/null | tail -1 | - sed 's/^[^=]*=//;s/^[[:space:]]*//;s/[[:space:]]*$//' -} - cmd_env() { echo "Install dir : $INSTALL_DIR" echo "Env file : $ENV_FILE" @@ -113,31 +73,7 @@ cmd_env() { echo "Current user : $CURRENT_USER" echo "Venv : $VENV_DIR" echo - echo "GATEWAY_CLIENTS_REGISTRY_FILE = $(read_env_var GATEWAY_CLIENTS_REGISTRY_FILE)" -} - -# Runs a command line as SERVICE_USER, in INSTALL_DIR, with .env loaded and -# the venv on PATH. Works whether this script is invoked as root, via sudo, -# or directly as the service user (no unnecessary sudo prompt in that case). -run_as_service_user() { - local inner_cmd="$1" - local run_cmd=" - set -a - # shellcheck disable=SC1090 - . '$ENV_FILE' - set +a - cd '$INSTALL_DIR' - export PATH=\"$VENV_DIR/bin:$PATH\" - $inner_cmd - " - - if [ "$CURRENT_USER" = "$SERVICE_USER" ]; then - bash -c "$run_cmd" - elif [ "$EUID" -eq 0 ]; then - sudo -u "$SERVICE_USER" bash -c "$run_cmd" - else - error "Must run as '$SERVICE_USER' or with sudo (current user: $CURRENT_USER)." - fi + echo "GATEWAY_CLIENTS_REGISTRY_FILE = $(read_env_var GATEWAY_CLIENTS_REGISTRY_FILE "$ENV_FILE")" } main() { diff --git a/grpc_services/v3/exchange_oauth2_code.py b/grpc_services/v3/exchange_oauth2_code.py index 451a1b0..610feaa 100644 --- a/grpc_services/v3/exchange_oauth2_code.py +++ b/grpc_services/v3/exchange_oauth2_code.py @@ -62,11 +62,15 @@ def ExchangeOAuth2CodeAndStore(self, request, context): ) if pipe.get("error"): + logger.error( + "Adapter error for platform %r: %s", request.platform, pipe["error"] + ) return self.handle_create_grpc_error_response( context, response, pipe["error"], - grpc.StatusCode.INVALID_ARGUMENT, + grpc.StatusCode.INTERNAL, + user_msg="Oops! Something went wrong. Please try again later.", error_type="UNKNOWN", ) diff --git a/grpc_services/v3/exchange_pnba_code.py b/grpc_services/v3/exchange_pnba_code.py index f30ac2b..8db8580 100644 --- a/grpc_services/v3/exchange_pnba_code.py +++ b/grpc_services/v3/exchange_pnba_code.py @@ -78,11 +78,15 @@ def ExchangePNBACodeAndStore(self, request, context): ) if pipe.get("error"): + logger.error( + "Adapter error for platform %r: %s", request.platform, pipe["error"] + ) return self.handle_create_grpc_error_response( context, response, pipe["error"], - grpc.StatusCode.INVALID_ARGUMENT, + grpc.StatusCode.INTERNAL, + user_msg="Oops! Something went wrong. Please try again later.", error_type="UNKNOWN", ) diff --git a/grpc_services/v3/get_oauth2_auth_url.py b/grpc_services/v3/get_oauth2_auth_url.py index 36f46de..1a9e8ab 100644 --- a/grpc_services/v3/get_oauth2_auth_url.py +++ b/grpc_services/v3/get_oauth2_auth_url.py @@ -43,11 +43,15 @@ def GetOAuth2AuthorizationUrl(self, request, context): ) if pipe.get("error"): + logger.error( + "Adapter error for platform %r: %s", request.platform, pipe["error"] + ) return self.handle_create_grpc_error_response( context, response, pipe["error"], - grpc.StatusCode.INVALID_ARGUMENT, + grpc.StatusCode.INTERNAL, + user_msg="Oops! Something went wrong. Please try again later.", error_type="UNKNOWN", ) diff --git a/grpc_services/v3/get_pnba_code.py b/grpc_services/v3/get_pnba_code.py index 4cb5aa3..4ea7dca 100644 --- a/grpc_services/v3/get_pnba_code.py +++ b/grpc_services/v3/get_pnba_code.py @@ -41,11 +41,15 @@ def GetPNBACode(self, request, context): ) if pipe.get("error"): + logger.error( + "Adapter error for platform %r: %s", request.platform, pipe["error"] + ) return self.handle_create_grpc_error_response( context, response, pipe["error"], - grpc.StatusCode.INVALID_ARGUMENT, + grpc.StatusCode.INTERNAL, + user_msg="Oops! Something went wrong. Please try again later.", error_type="UNKNOWN", ) diff --git a/install.sh b/install.sh index 957f2c8..f293d21 100755 --- a/install.sh +++ b/install.sh @@ -2,7 +2,19 @@ set -Eeuo pipefail -INSTALL_DIR="/opt/relaysms/relaysms-publisher" +DEFAULT_INSTALL_DIR="/opt/relaysms/relaysms-publisher" +if [ -n "${INSTALL_DIR:-}" ]; then + INSTALL_DIR_SET=1 +else + INSTALL_DIR_SET=0 +fi +INSTALL_DIR="${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" +if [ -n "${INSTANCE_NAME:-}" ]; then + INSTANCE_NAME_SET=1 +else + INSTANCE_NAME_SET=0 +fi +INSTANCE_NAME="${INSTANCE_NAME:-}" REPO_URL="https://github.com/smswithoutborders/RelaySMS-Publisher.git" BRANCH="${BRANCH:-main}" CARGO_BIN="$HOME/.cargo/bin" @@ -11,11 +23,29 @@ NGINX_CONF_TEMPLATE="relaysms-publisher-nginx.conf.template" SITE_NAME="${SITE_NAME:-}" LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL:-}" SKIP_NGINX="${SKIP_NGINX:-0}" +FORCE_DEPS="${FORCE_DEPS:-0}" SETUP_DB="${SETUP_DB:-}" +if [ -n "${DB_EXISTING:-}" ]; then + DB_EXISTING_SET=1 +else + DB_EXISTING_SET=0 +fi +DB_EXISTING="${DB_EXISTING:-0}" +DB_HOST="${DB_HOST:-}" +DB_PORT="${DB_PORT:-}" DB_NAME="${DB_NAME:-}" DB_USER="${DB_USER:-}" DB_PASSWORD="${DB_PASSWORD:-}" SETUP_BROKER="${SETUP_BROKER:-}" +if [ -n "${BROKER_EXISTING:-}" ]; then + BROKER_EXISTING_SET=1 +else + BROKER_EXISTING_SET=0 +fi +BROKER_EXISTING="${BROKER_EXISTING:-0}" +BROKER_HOST="${BROKER_HOST:-}" +BROKER_PORT="${BROKER_PORT:-}" +BROKER_MGMT_PORT="${BROKER_MGMT_PORT:-}" BROKER_VHOST="${BROKER_VHOST:-}" BROKER_USER="${BROKER_USER:-}" BROKER_PASSWORD="${BROKER_PASSWORD:-}" @@ -23,18 +53,25 @@ SETUP_OBSERVABILITY="${SETUP_OBSERVABILITY:-}" OBSERVABILITY_SITE_NAME="${OBSERVABILITY_SITE_NAME:-}" OBSERVABILITY_LETSENCRYPT_EMAIL="${OBSERVABILITY_LETSENCRYPT_EMAIL:-}" -TARGET_UNIT="relaysms-publisher.target" -SERVICE_UNITS=( +TARGET_UNIT_TEMPLATE="relaysms-publisher.target" +SERVICE_UNIT_TEMPLATES=( relaysms-publisher-rest.service relaysms-publisher-grpc.service relaysms-publisher-worker.service relaysms-publisher-beat.service relaysms-publisher-smtp.service ) -ALL_UNITS=("$TARGET_UNIT" "${SERVICE_UNITS[@]}") +ALL_UNIT_TEMPLATES=("$TARGET_UNIT_TEMPLATE" "${SERVICE_UNIT_TEMPLATES[@]}") + +unit_name_for() { + local template="$1" + if [ -z "$INSTANCE_NAME" ]; then + echo "$template" + else + echo "$template" | sed -E "s/^relaysms-publisher/relaysms-publisher-$INSTANCE_NAME/" + fi +} -# Runtime files are owned by the invoking user if run via sudo, otherwise -# a dedicated 'relaysms' user. The build itself still runs as root. if [ -n "${SUDO_USER:-}" ] && id "$SUDO_USER" &>/dev/null; then SERVICE_USER="$SUDO_USER" else @@ -46,7 +83,6 @@ error() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2 exit 1 } -# Catches failures not already wrapped in error(), with line context. on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; } trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR @@ -54,18 +90,29 @@ usage() { cat <<'EOF' Usage: install.sh [OPTIONS] + --install-dir PATH Installation directory (default: /opt/relaysms/relaysms-publisher) + --instance-name NAME Namespace this install's systemd units so a second instance + can coexist on the same host (default: unnamed/single instance) --branch BRANCH Git branch to install (default: main) --site-name DOMAIN Domain to front with nginx + Let's Encrypt --letsencrypt-email EMAIL Email for Let's Encrypt renewal notices --skip-nginx Skip the nginx/TLS setup entirely - --setup-db {mysql|postgres} Install and provision a database server + --force-deps Reinstall system dependencies even if already marked done + --setup-db {mysql|postgres} Install and provision a database server, or connect to an existing one + --db-existing Use an already-running database server instead of installing one locally + --db-host HOST Database host (only valid with --db-existing) + --db-port PORT Database port (only valid with --db-existing) --db-name NAME Database name (default: relaysms) --db-user USER Database user (default: relaysms) - --db-password PASS Database password (default: randomly generated) - --setup-broker {rabbitmq} Install and provision a message broker for Celery + --db-password PASS Database password (default: randomly generated; required with --db-existing) + --setup-broker {rabbitmq} Install and provision a message broker for Celery, or connect to an existing one + --broker-existing Use an already-running broker instead of installing one locally + --broker-host HOST Broker host (only valid with --broker-existing) + --broker-port PORT Broker AMQP port (only valid with --broker-existing) + --broker-mgmt-port PORT Broker management API port, used to verify --broker-existing credentials (default: 15672) --broker-vhost NAME RabbitMQ vhost (default: relaysms) --broker-user USER RabbitMQ user (default: relaysms) - --broker-password PASS RabbitMQ password (default: randomly generated) + --broker-password PASS RabbitMQ password (default: randomly generated; required with --broker-existing) --setup-observability Install and start SigNoz + Uptime Kuma --observability-site-name DOMAIN Domain for the observability reverse proxy --observability-letsencrypt-email EMAIL Email for its Let's Encrypt renewal notices @@ -79,6 +126,26 @@ EOF parse_args() { while [ $# -gt 0 ]; do case "$1" in + --install-dir) + INSTALL_DIR="$2" + INSTALL_DIR_SET=1 + shift 2 + ;; + --install-dir=*) + INSTALL_DIR="${1#*=}" + INSTALL_DIR_SET=1 + shift + ;; + --instance-name) + INSTANCE_NAME="$2" + INSTANCE_NAME_SET=1 + shift 2 + ;; + --instance-name=*) + INSTANCE_NAME="${1#*=}" + INSTANCE_NAME_SET=1 + shift + ;; --branch) BRANCH="$2" shift 2 @@ -107,6 +174,10 @@ parse_args() { SKIP_NGINX=1 shift ;; + --force-deps) + FORCE_DEPS=1 + shift + ;; --setup-db) SETUP_DB="$2" shift 2 @@ -115,6 +186,27 @@ parse_args() { SETUP_DB="${1#*=}" shift ;; + --db-existing) + DB_EXISTING=1 + DB_EXISTING_SET=1 + shift + ;; + --db-host) + DB_HOST="$2" + shift 2 + ;; + --db-host=*) + DB_HOST="${1#*=}" + shift + ;; + --db-port) + DB_PORT="$2" + shift 2 + ;; + --db-port=*) + DB_PORT="${1#*=}" + shift + ;; --db-name) DB_NAME="$2" shift 2 @@ -147,6 +239,35 @@ parse_args() { SETUP_BROKER="${1#*=}" shift ;; + --broker-existing) + BROKER_EXISTING=1 + BROKER_EXISTING_SET=1 + shift + ;; + --broker-host) + BROKER_HOST="$2" + shift 2 + ;; + --broker-host=*) + BROKER_HOST="${1#*=}" + shift + ;; + --broker-port) + BROKER_PORT="$2" + shift 2 + ;; + --broker-port=*) + BROKER_PORT="${1#*=}" + shift + ;; + --broker-mgmt-port) + BROKER_MGMT_PORT="$2" + shift 2 + ;; + --broker-mgmt-port=*) + BROKER_MGMT_PORT="${1#*=}" + shift + ;; --broker-vhost) BROKER_VHOST="$2" shift 2 @@ -205,8 +326,66 @@ parse_args() { check_root() { [ "$EUID" -eq 0 ] || error "Run with sudo"; } -# Reads KEY=value from a file without sourcing it. `|| true` on the grep -# stops a no-match from tripping pipefail. +configure_install_dir() { + if [ "$INSTALL_DIR_SET" != "1" ]; then + prompt INSTALL_DIR "Installation directory [$INSTALL_DIR]: " "$INSTALL_DIR" + fi + + if [ -n "$INSTANCE_NAME" ]; then + [[ "$INSTANCE_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$ ]] || + error "--instance-name '$INSTANCE_NAME' must start with a letter/digit and contain only letters, digits, underscores, and hyphens (max 32 chars)" + fi + + # Blocks top-level dirs (so a later `rm -rf "$INSTALL_DIR"` can't wipe a + # system directory) and sed-special characters (which would corrupt the + # unit-file templating in install_services()). + [[ "$INSTALL_DIR" =~ ^(/[A-Za-z0-9_.-]+){2,}/?$ ]] || + error "$INSTALL_DIR is not a safe install location; use an absolute path with at least two segments, letters/digits/._- only (e.g. /opt/relaysms/relaysms-publisher)" + + if [ -e "$INSTALL_DIR" ] && [ ! -d "$INSTALL_DIR" ]; then + error "$INSTALL_DIR exists and is not a directory" + fi + + if [ -d "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then + if [ -d "$INSTALL_DIR/.git" ] && + git -C "$INSTALL_DIR" remote get-url origin 2>/dev/null | grep -q "RelaySMS-Publisher"; then + if [ -f "$INSTALL_DIR/.env" ]; then + local existing_owner + existing_owner=$(stat -c '%G' "$INSTALL_DIR/.env") + if [ "$existing_owner" != "$SERVICE_USER" ]; then + error "$INSTALL_DIR is an existing install owned by service user '$existing_owner', but this run resolved to '$SERVICE_USER'. Re-run install.sh with sudo as '$existing_owner' (SERVICE_USER follows the invoking sudo user), or use --install-dir to target a different directory." + fi + fi + # manage.sh has no --instance-name flag, so persist it here for reuse. + local existing_instance="" + [ -f "$INSTALL_DIR/.instance-name" ] && existing_instance=$(<"$INSTALL_DIR/.instance-name") + if [ -n "$existing_instance" ]; then + if [ "$INSTANCE_NAME_SET" = "1" ] && [ "$INSTANCE_NAME" != "$existing_instance" ]; then + error "$INSTALL_DIR was previously configured as instance '$existing_instance'. Pass --instance-name $existing_instance (or omit the flag) to reuse it, or use --install-dir to target a different directory for a new instance." + fi + INSTANCE_NAME="$existing_instance" + fi + log "Installation directory: $INSTALL_DIR (existing install for service user '$SERVICE_USER', will update)" + else + error "$INSTALL_DIR already exists and is not empty; choose an empty or non-existent directory with --install-dir, or point it at an existing RelaySMS Publisher checkout" + fi + else + log "Installation directory: $INSTALL_DIR" + fi + + TARGET_UNIT=$(unit_name_for "$TARGET_UNIT_TEMPLATE") + SERVICE_UNITS=() + local template + for template in "${SERVICE_UNIT_TEMPLATES[@]}"; do + SERVICE_UNITS+=("$(unit_name_for "$template")") + done + ALL_UNITS=("$TARGET_UNIT" "${SERVICE_UNITS[@]}") + if [ -n "$INSTANCE_NAME" ]; then + log "Instance: $INSTANCE_NAME (target unit: $TARGET_UNIT)" + fi +} + +# `|| true` on the grep stops a no-match from tripping pipefail. read_env_var() { local key="$1" file="$2" val val=$( (grep -E "^(export[[:space:]]+)?${key}[[:space:]]*=" "$file" 2>/dev/null || true) | @@ -218,20 +397,34 @@ read_env_var() { echo "$val" } -# Prompts on the controlling terminal even when install.sh is piped via -# `curl | sudo bash`, where stdin is the script itself, not a tty. Falls -# back to $default with no prompt when no tty is reachable at all. +# Reads from the controlling terminal even when piped via `curl | sudo +# bash` (stdin is the script itself there). Falls back to $default if no +# tty is reachable. prompt() { - local __resultvar="$1" question="$2" default="${3:-}" reply="" + local __resultvar="$1" question="> $2" default="${3:-}" reply="" if [ -t 0 ]; then read -r -p "$question" reply elif [ -r /dev/tty ]; then - read -r -p "$question" reply "$INSTALL_DIR/.instance-name" } setup_virtualenv() { @@ -349,8 +545,6 @@ setup_env() { chmod 640 .env } -# Shared by create_app_directories and install_services so both stay in -# sync with .env. resolve_app_directories() { local envfile="$INSTALL_DIR/.env" [ -f "$envfile" ] || error ".env not found" @@ -422,14 +616,32 @@ install_services() { ) [ -n "$rw_paths" ] || error "No application directories resolved for ReadWritePaths" - local svc - for svc in "${ALL_UNITS[@]}"; do - [ -f "$svc" ] || error "Service file not found: $svc" - # # as delimiter, a resolved path could contain a pipe. + # Only matches unit-name references (PartOf=, WantedBy=, ...), never + # Description=/Documentation=: those read "RelaySMS Publisher" (space, + # capitalized), not this lowercase-hyphenated pattern. + local instance_sed_args=() + if [ -n "$INSTANCE_NAME" ]; then + instance_sed_args+=(-e "s/relaysms-publisher\.target/$TARGET_UNIT/g") + local svc_name + for svc_name in rest grpc worker beat smtp; do + instance_sed_args+=( + -e "s/relaysms-publisher-$svc_name\.service/relaysms-publisher-$INSTANCE_NAME-$svc_name.service/g" + -e "s/relaysms-publisher-$svc_name\$/relaysms-publisher-$INSTANCE_NAME-$svc_name/g" + ) + done + fi + + local template dest + for template in "${ALL_UNIT_TEMPLATES[@]}"; do + [ -f "$template" ] || error "Service file not found: $template" + dest=$(unit_name_for "$template") + # rw_paths/INSTALL_DIR are absolute paths, so / can't be the sed delimiter. sed \ -e "s/User=relaysms/User=$SERVICE_USER/" \ + -e "s#/opt/relaysms/relaysms-publisher#$INSTALL_DIR#g" \ -e "s#__RW_PATHS__#$rw_paths#" \ - "$svc" >"/etc/systemd/system/$svc" + "${instance_sed_args[@]}" \ + "$template" >"/etc/systemd/system/$dest" done systemctl daemon-reload @@ -440,9 +652,6 @@ install_services() { systemctl start "$TARGET_UNIT" } -# Opt-in. Skipped outright with SKIP_NGINX=1. Non-interactive automation -# sets SITE_NAME (and optionally LETSENCRYPT_EMAIL) up front; interactive -# runs get prompted for both instead. configure_nginx() { if [ "${SKIP_NGINX:-0}" = "1" ]; then log "Skipping nginx setup (SKIP_NGINX=1)" @@ -467,6 +676,10 @@ configure_nginx() { return } fi + # Rejects path separators (path traversal into conf_dest below) and + # anything that could be read as a certbot flag instead of a domain. + [[ "$site" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]] || + error "'$site' is not a valid hostname" if ! command -v nginx &>/dev/null || ! command -v certbot &>/dev/null; then log "Installing nginx and certbot" @@ -550,10 +763,32 @@ configure_database() { *) error "--setup-db must be 'mysql' or 'postgres', got '$dialect'" ;; esac + if [ "$DB_EXISTING_SET" != "1" ]; then + local choice="" + prompt choice "Use an existing $dialect server, or create a new local one? [existing/new, default: new] " "new" + case "$choice" in + existing | Existing | EXISTING | e | E) DB_EXISTING=1 ;; + *) DB_EXISTING=0 ;; + esac + fi + local args=(--install-dir "$INSTALL_DIR") [ -n "$DB_NAME" ] && args+=(--db-name "$DB_NAME") - [ -n "$DB_USER" ] && args+=(--db-user "$DB_USER") - [ -n "$DB_PASSWORD" ] && args+=(--db-password "$DB_PASSWORD") + + if [ "$DB_EXISTING" = "1" ]; then + args+=(--existing) + [ -n "$DB_HOST" ] || prompt DB_HOST "Existing $dialect host: " "" + [ -n "$DB_HOST" ] || error "A host is required to use an existing database" + [ -n "$DB_USER" ] || prompt DB_USER "Existing $dialect user: " "" + [ -n "$DB_USER" ] || error "A user is required to use an existing database" + [ -n "$DB_PASSWORD" ] || prompt_secret DB_PASSWORD "Existing $dialect password: " + [ -n "$DB_PASSWORD" ] || error "A password is required to use an existing database" + args+=(--db-host "$DB_HOST" --db-user "$DB_USER" --db-password "$DB_PASSWORD") + [ -n "$DB_PORT" ] && args+=(--db-port "$DB_PORT") + else + [ -n "$DB_USER" ] && args+=(--db-user "$DB_USER") + [ -n "$DB_PASSWORD" ] && args+=(--db-password "$DB_PASSWORD") + fi log "Setting up $dialect" "$INSTALL_DIR/scripts/setup-$dialect.sh" "${args[@]}" @@ -578,10 +813,33 @@ configure_broker() { *) error "--setup-broker must be 'rabbitmq', got '$broker'" ;; esac + if [ "$BROKER_EXISTING_SET" != "1" ]; then + local choice="" + prompt choice "Use an existing RabbitMQ server, or create a new local one? [existing/new, default: new] " "new" + case "$choice" in + existing | Existing | EXISTING | e | E) BROKER_EXISTING=1 ;; + *) BROKER_EXISTING=0 ;; + esac + fi + local args=(--install-dir "$INSTALL_DIR") [ -n "$BROKER_VHOST" ] && args+=(--broker-vhost "$BROKER_VHOST") - [ -n "$BROKER_USER" ] && args+=(--broker-user "$BROKER_USER") - [ -n "$BROKER_PASSWORD" ] && args+=(--broker-password "$BROKER_PASSWORD") + + if [ "$BROKER_EXISTING" = "1" ]; then + args+=(--existing) + [ -n "$BROKER_HOST" ] || prompt BROKER_HOST "Existing RabbitMQ host: " "" + [ -n "$BROKER_HOST" ] || error "A host is required to use an existing broker" + [ -n "$BROKER_USER" ] || prompt BROKER_USER "Existing RabbitMQ user: " "" + [ -n "$BROKER_USER" ] || error "A user is required to use an existing broker" + [ -n "$BROKER_PASSWORD" ] || prompt_secret BROKER_PASSWORD "Existing RabbitMQ password: " + [ -n "$BROKER_PASSWORD" ] || error "A password is required to use an existing broker" + args+=(--broker-host "$BROKER_HOST" --broker-user "$BROKER_USER" --broker-password "$BROKER_PASSWORD") + [ -n "$BROKER_PORT" ] && args+=(--broker-port "$BROKER_PORT") + [ -n "$BROKER_MGMT_PORT" ] && args+=(--broker-mgmt-port "$BROKER_MGMT_PORT") + else + [ -n "$BROKER_USER" ] && args+=(--broker-user "$BROKER_USER") + [ -n "$BROKER_PASSWORD" ] && args+=(--broker-password "$BROKER_PASSWORD") + fi log "Setting up $broker" "$INSTALL_DIR/scripts/setup-$broker.sh" "${args[@]}" @@ -617,8 +875,11 @@ configure_observability() { main() { parse_args "$@" check_root + git check-ref-format --branch "$BRANCH" &>/dev/null || + error "--branch '$BRANCH' is not a valid branch name" log "Installing RelaySMS Publisher (service user: $SERVICE_USER)" + configure_install_dir install_system_deps install_rust setup_service_user diff --git a/manage.sh b/manage.sh index 217ee3d..c0fd381 100755 --- a/manage.sh +++ b/manage.sh @@ -2,58 +2,46 @@ set -Eeuo pipefail -INSTALL_DIR="/opt/relaysms/relaysms-publisher" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/scripts/lib.sh" + +INSTALL_DIR="$SCRIPT_DIR" CARGO_BIN="$HOME/.cargo/bin" -TARGET_UNIT="relaysms-publisher.target" -SERVICE_UNITS=( - relaysms-publisher-rest.service - relaysms-publisher-grpc.service - relaysms-publisher-worker.service - relaysms-publisher-beat.service - relaysms-publisher-smtp.service -) +INSTANCE_NAME="$(read_instance_name)" +TARGET_UNIT="$(unit_name_for "$TARGET_UNIT_TEMPLATE")" +SERVICE_UNITS=() +for _template in "${SERVICE_UNIT_TEMPLATES[@]}"; do + SERVICE_UNITS+=("$(unit_name_for "$_template")") +done +unset _template ALL_UNITS=("$TARGET_UNIT" "${SERVICE_UNITS[@]}") -log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*"; } -error() { - echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2 - exit 1 -} -# Catches failures not already wrapped in error(), with line context. -on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; } -trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR - check_sudo() { [ "$EUID" -eq 0 ] || error "Run with sudo"; } +# Only targets an already-installed service, so no fallback beyond the unit file. detect_service_user() { - local unit="/etc/systemd/system/relaysms-publisher-rest.service" + local unit="/etc/systemd/system/$(unit_name_for "relaysms-publisher-rest.service")" grep -E "^User=" "$unit" 2>/dev/null | head -1 | cut -d= -f2 } -read_env_var() { - local key="$1" file="$INSTALL_DIR/.env" - grep -E "^${key}[[:space:]]*=" "$file" 2>/dev/null | tail -1 | - sed 's/^[^=]*=//;s/^[[:space:]]*//;s/[[:space:]]*$//' -} - -# git pull doesn't fix ownership for directories .env references that were -# added since the last install.sh run. Re-apply it here too. +# git pull doesn't fix ownership for directories .env added since the last run. sync_app_directories() { local service_user service_user="$(detect_service_user)" [ -n "$service_user" ] || return + local envfile="$INSTALL_DIR/.env" local dirs=( - "$(dirname "$(read_env_var SQLITE_DATABASE_PATH)")" - "$(dirname "$(read_env_var CELERY_BROKER_DB_PATH)")" - "$(dirname "$(read_env_var CELERY_RESULT_DB_PATH)")" - "$(dirname "$(read_env_var CELERY_BEAT_SCHEDULE_PATH)")" - "$(read_env_var PLATFORMS_ADAPTERS_DIR)" - "$(read_env_var PLATFORMS_ADAPTERS_VENV_DIR)" - "$(read_env_var PLATFORMS_ADAPTERS_ASSETS_DIR)" - "$(dirname "$(read_env_var PLATFORMS_REGISTRY_FILE)")" - "$(dirname "$(read_env_var GATEWAY_CLIENTS_REGISTRY_FILE)")" + "$(dirname "$(read_env_var SQLITE_DATABASE_PATH "$envfile")")" + "$(dirname "$(read_env_var CELERY_BROKER_DB_PATH "$envfile")")" + "$(dirname "$(read_env_var CELERY_RESULT_DB_PATH "$envfile")")" + "$(dirname "$(read_env_var CELERY_BEAT_SCHEDULE_PATH "$envfile")")" + "$(read_env_var PLATFORMS_ADAPTERS_DIR "$envfile")" + "$(read_env_var PLATFORMS_ADAPTERS_VENV_DIR "$envfile")" + "$(read_env_var PLATFORMS_ADAPTERS_ASSETS_DIR "$envfile")" + "$(dirname "$(read_env_var PLATFORMS_REGISTRY_FILE "$envfile")")" + "$(dirname "$(read_env_var GATEWAY_CLIENTS_REGISTRY_FILE "$envfile")")" ) local dir @@ -252,8 +240,7 @@ cmd_update() { venv/bin/pip install --quiet --upgrade pip venv/bin/pip install --quiet -r requirements.txt - # Keep observability deps current too, but only if they were opted into - # (see observability/README.md); don't install them for everyone. + # Only update observability deps if they were opted into in the first place. if venv/bin/pip show opentelemetry-sdk &>/dev/null; then venv/bin/pip install --quiet -r requirements-observability.txt fi @@ -294,6 +281,9 @@ cmd_uninstall() { done systemctl daemon-reload + # Belt-and-suspenders against a top-level directory, even though + # INSTALL_DIR is always self-derived from this script's own location. + [[ "$INSTALL_DIR" =~ ^(/[^/]+){2,}/?$ ]] || error "Refusing to remove '$INSTALL_DIR': not a safe path" rm -rf "$INSTALL_DIR" log "Uninstall complete" } diff --git a/platforms.sh b/platforms.sh index 5ca1b26..8b02dc1 100755 --- a/platforms.sh +++ b/platforms.sh @@ -1,33 +1,12 @@ #!/bin/bash # SPDX-License-Identifier: GPL-3.0-only -# -# Wrapper around `python3 -m platforms.cli` that removes the guesswork of -# running the platform adapter CLI correctly: it resolves the install -# directory, loads .env, runs as the correct service user (so file -# ownership never drifts), and uses the project venv automatically. set -Eeuo pipefail -DEFAULT_INSTALL_DIR="/opt/relaysms/relaysms-publisher" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/scripts/lib.sh" -log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*"; } -error() { - echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2 - exit 1 -} -# Catches failures not already wrapped in error(), with line context. -on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; } -trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR - -# Resolve INSTALL_DIR: prefer the production install path if it exists and -# looks like a real install, otherwise fall back to this script's own -# directory (development checkout). -if [ -f "$DEFAULT_INSTALL_DIR/platforms/cli.py" ]; then - INSTALL_DIR="$DEFAULT_INSTALL_DIR" -else - INSTALL_DIR="$SCRIPT_DIR" -fi +INSTALL_DIR="$SCRIPT_DIR" [ -f "$INSTALL_DIR/platforms/cli.py" ] || error "platforms/cli.py not found under $INSTALL_DIR. Is RelaySMS Publisher installed there?" @@ -39,20 +18,7 @@ VENV_DIR="$INSTALL_DIR/venv" [ -x "$VENV_DIR/bin/python3" ] || error "Virtualenv not found at $VENV_DIR. Run install.sh or 'make build-setup' first." -# Resolve the service user: prefer the User= set in the installed systemd -# unit (source of truth after install.sh), fall back to the .env owner, -# then to whoever is running this script. -detect_service_user() { - local unit="/etc/systemd/system/relaysms-publisher-rest.service" - if [ -f "$unit" ]; then - grep -E "^User=" "$unit" | head -1 | cut -d= -f2 && return - fi - if [ -f "$ENV_FILE" ]; then - stat -c '%U' "$ENV_FILE" 2>/dev/null && return - fi - id -un -} - +INSTANCE_NAME="$(read_instance_name)" SERVICE_USER="$(detect_service_user)" CURRENT_USER="$(id -un)" @@ -96,12 +62,6 @@ Examples: EOF } -read_env_var() { - local key="$1" - grep -E "^${key}[[:space:]]*=" "$ENV_FILE" 2>/dev/null | tail -1 | - sed 's/^[^=]*=//;s/^[[:space:]]*//;s/[[:space:]]*$//' -} - cmd_env() { echo "Install dir : $INSTALL_DIR" echo "Env file : $ENV_FILE" @@ -109,34 +69,10 @@ cmd_env() { echo "Current user : $CURRENT_USER" echo "Venv : $VENV_DIR" echo - echo "PLATFORMS_ADAPTERS_DIR = $(read_env_var PLATFORMS_ADAPTERS_DIR)" - echo "PLATFORMS_ADAPTERS_VENV_DIR = $(read_env_var PLATFORMS_ADAPTERS_VENV_DIR)" - echo "PLATFORMS_ADAPTERS_ASSETS_DIR = $(read_env_var PLATFORMS_ADAPTERS_ASSETS_DIR)" - echo "PLATFORMS_REGISTRY_FILE = $(read_env_var PLATFORMS_REGISTRY_FILE)" -} - -# Runs a command line as SERVICE_USER, in INSTALL_DIR, with .env loaded and -# the venv on PATH. Works whether this script is invoked as root, via sudo, -# or directly as the service user (no unnecessary sudo prompt in that case). -run_as_service_user() { - local inner_cmd="$1" - local run_cmd=" - set -a - # shellcheck disable=SC1090 - . '$ENV_FILE' - set +a - cd '$INSTALL_DIR' - export PATH=\"$VENV_DIR/bin:$PATH\" - $inner_cmd - " - - if [ "$CURRENT_USER" = "$SERVICE_USER" ]; then - bash -c "$run_cmd" - elif [ "$EUID" -eq 0 ]; then - sudo -u "$SERVICE_USER" bash -c "$run_cmd" - else - error "Must run as '$SERVICE_USER' or with sudo (current user: $CURRENT_USER)." - fi + echo "PLATFORMS_ADAPTERS_DIR = $(read_env_var PLATFORMS_ADAPTERS_DIR "$ENV_FILE")" + echo "PLATFORMS_ADAPTERS_VENV_DIR = $(read_env_var PLATFORMS_ADAPTERS_VENV_DIR "$ENV_FILE")" + echo "PLATFORMS_ADAPTERS_ASSETS_DIR = $(read_env_var PLATFORMS_ADAPTERS_ASSETS_DIR "$ENV_FILE")" + echo "PLATFORMS_REGISTRY_FILE = $(read_env_var PLATFORMS_REGISTRY_FILE "$ENV_FILE")" } main() { diff --git a/relaysms-publisher-nginx.conf.template b/relaysms-publisher-nginx.conf.template index 7e1122d..5a8e3be 100644 --- a/relaysms-publisher-nginx.conf.template +++ b/relaysms-publisher-nginx.conf.template @@ -73,6 +73,49 @@ server { location / { proxy_pass http://publisher_rest; + + error_page 500 = /_errors/500.json; + error_page 502 = /_errors/502.json; + error_page 503 = /_errors/503.json; + error_page 504 = /_errors/504.json; + error_page 413 = /_errors/413.json; + error_page 494 = /_errors/494.json; + } + + location = /_errors/500.json { + internal; + default_type application/json; + return 500 '{"error": "Something went wrong. Please try again later."}'; + } + + location = /_errors/502.json { + internal; + default_type application/json; + return 502 '{"error": "Service is temporarily unavailable. Please try again shortly."}'; + } + + location = /_errors/503.json { + internal; + default_type application/json; + return 503 '{"error": "Service is temporarily unavailable. Please try again shortly."}'; + } + + location = /_errors/504.json { + internal; + default_type application/json; + return 504 '{"error": "The service took too long to respond. Please try again shortly."}'; + } + + location = /_errors/413.json { + internal; + default_type application/json; + return 413 '{"error": "Request is too large."}'; + } + + location = /_errors/494.json { + internal; + default_type application/json; + return 494 '{"error": "Request headers are too large."}'; } # proxy_set_header above doesn't apply to grpc_pass; repeated here via diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100644 index 0000000..ebfedc6 --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0-only +# Shared helpers sourced by the other scripts in this repo. + +log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*"; } +error() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2 + exit 1 +} +on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; } +trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR + +# Keeps output like generated credentials from getting lost in the log. +highlight() { + local line + echo + echo "################################################################" + for line in "$@"; do + echo "# $line" + done + echo "################################################################" + echo +} + +# Excludes characters unsafe in SQL/AMQP, and can't start with - (CLI flag). +validate_identifier() { + local name="$1" value="$2" + [[ "$value" =~ ^[A-Za-z_][A-Za-z0-9_]{0,63}$ ]] || + error "$name must contain only letters, digits, and underscores, and start with a letter or underscore (got: '$value')" +} + +# Excludes characters that could break out of SQL/sed/amqp:// contexts. +validate_secret() { + local name="$1" value="$2" + [[ "$value" =~ ^[A-Za-z0-9_.,!?+=~^-]+$ ]] || + error "$name contains unsupported characters (letters, digits, and _.,!?+=~^- only)" +} + +# Rejects slashes (path traversal into nginx conf paths) and a leading - +# (could be mistaken for a certbot flag). +validate_hostname() { + local name="$1" value="$2" + [[ "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]] || + error "$name must be a valid hostname (got: '$value')" +} + +# Mirrors install.sh's own copy, which can't source this file (must also +# run standalone via curl | sudo bash). +TARGET_UNIT_TEMPLATE="relaysms-publisher.target" +SERVICE_UNIT_TEMPLATES=( + relaysms-publisher-rest.service + relaysms-publisher-grpc.service + relaysms-publisher-worker.service + relaysms-publisher-beat.service + relaysms-publisher-smtp.service +) +ALL_UNIT_TEMPLATES=("$TARGET_UNIT_TEMPLATE" "${SERVICE_UNIT_TEMPLATES[@]}") + +# Expects INSTANCE_NAME to already be set by the caller (empty is fine). +unit_name_for() { + local template="$1" + if [ -z "${INSTANCE_NAME:-}" ]; then + echo "$template" + else + echo "$template" | sed -E "s/^relaysms-publisher/relaysms-publisher-$INSTANCE_NAME/" + fi +} + +# Expects INSTALL_DIR to already be set by the caller. +read_instance_name() { + [ -f "$INSTALL_DIR/.instance-name" ] && cat "$INSTALL_DIR/.instance-name" || true +} + +# `|| true` on the grep stops a no-match from tripping pipefail. +read_env_var() { + local key="$1" file="$2" val + val=$( (grep -E "^(export[[:space:]]+)?${key}[[:space:]]*=" "$file" 2>/dev/null || true) | + tail -1 | sed -E 's/^(export[[:space:]]+)?[^=]*=//; s/^[[:space:]]*//; s/[[:space:]]*$//') + val="${val%\"}" + val="${val#\"}" + val="${val%\'}" + val="${val#\'}" + echo "$val" +} + +# Prefers the installed unit's User=, then .env's owner, then whoever is +# running the script. Expects ENV_FILE and INSTANCE_NAME to already be set. +detect_service_user() { + local unit="/etc/systemd/system/$(unit_name_for "relaysms-publisher-rest.service")" + if [ -f "$unit" ]; then + grep -E "^User=" "$unit" | head -1 | cut -d= -f2 && return + fi + if [ -f "$ENV_FILE" ]; then + stat -c '%U' "$ENV_FILE" 2>/dev/null && return + fi + id -un +} + +# Runs a command as SERVICE_USER, in INSTALL_DIR, with .env loaded and the +# venv on PATH. Expects INSTALL_DIR, ENV_FILE, VENV_DIR, SERVICE_USER, and +# CURRENT_USER to already be set by the caller. +run_as_service_user() { + local inner_cmd="$1" + local run_cmd=" + set -a + # shellcheck disable=SC1090 + . '$ENV_FILE' + set +a + cd '$INSTALL_DIR' + export PATH=\"$VENV_DIR/bin:$PATH\" + $inner_cmd + " + + if [ "$CURRENT_USER" = "$SERVICE_USER" ]; then + bash -c "$run_cmd" + elif [ "$EUID" -eq 0 ]; then + sudo -u "$SERVICE_USER" bash -c "$run_cmd" + else + error "Must run as '$SERVICE_USER' or with sudo (current user: $CURRENT_USER)." + fi +} diff --git a/scripts/otel-wrap.sh b/scripts/otel-wrap.sh index e3d3feb..97c9273 100755 --- a/scripts/otel-wrap.sh +++ b/scripts/otel-wrap.sh @@ -1,8 +1,5 @@ #!/usr/bin/env bash # SPDX-License-Identifier: GPL-3.0-only -# Runs the given command through OpenTelemetry auto-instrumentation if -# OTEL_EXPORTER_OTLP_ENDPOINT is set, otherwise runs it plain. -# See observability/README.md. set -euo pipefail if [ -n "${OTEL_EXPORTER_OTLP_ENDPOINT:-}" ]; then diff --git a/scripts/run.sh b/scripts/run.sh index 4897547..4be1c19 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -12,7 +12,6 @@ log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] [$1] $2" } -# Catches failures not already wrapped in error(), with line context. on_err() { log ERROR "aborted at line $1 (last command: $2)"; } trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR diff --git a/scripts/setup-mysql.sh b/scripts/setup-mysql.sh index e8da7a0..4e3e47b 100755 --- a/scripts/setup-mysql.sh +++ b/scripts/setup-mysql.sh @@ -1,31 +1,29 @@ #!/usr/bin/env bash # SPDX-License-Identifier: GPL-3.0-only -# Installs MySQL if missing, creates a dedicated database/user, and writes -# the connection details into .env. Re-runnable: an existing install or -# database/user is left as-is; only missing pieces are created. +# Re-runnable: an existing install or database/user is left as-is. set -Eeuo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + INSTALL_DIR="/opt/relaysms/relaysms-publisher" +DB_EXISTING=0 +DB_HOST="127.0.0.1" +DB_PORT="3306" DB_NAME="relaysms" DB_USER="relaysms" DB_PASSWORD="" -log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*"; } -error() { - echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2 - exit 1 -} -on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; } -trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR - usage() { cat <<'EOF' Usage: setup-mysql.sh [OPTIONS] --install-dir DIR Publisher install directory (default: /opt/relaysms/relaysms-publisher) + --existing Use an already-running MySQL server instead of installing one locally + --db-host HOST Database host (default: 127.0.0.1; only valid with --existing) + --db-port PORT Database port (default: 3306; only valid with --existing) --db-name NAME Database name (default: relaysms) --db-user USER Database user (default: relaysms) - --db-password PASS Database password (default: randomly generated) + --db-password PASS Database password (default: randomly generated; required with --existing) -h, --help Show this help and exit EOF } @@ -36,6 +34,18 @@ while [ $# -gt 0 ]; do INSTALL_DIR="$2" shift 2 ;; + --existing) + DB_EXISTING=1 + shift + ;; + --db-host) + DB_HOST="$2" + shift 2 + ;; + --db-port) + DB_PORT="$2" + shift 2 + ;; --db-name) DB_NAME="$2" shift 2 @@ -61,17 +71,33 @@ done [ "$EUID" -eq 0 ] || error "Run with sudo" [ -f "$INSTALL_DIR/.env" ] || error "$INSTALL_DIR/.env not found, run install.sh first" -[ -n "$DB_PASSWORD" ] || DB_PASSWORD=$(openssl rand -hex 24) - -if ! command -v mysql &>/dev/null; then - log "Installing MySQL server" - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends mysql-server +validate_identifier "--db-name" "$DB_NAME" +validate_identifier "--db-user" "$DB_USER" +if [ "$DB_EXISTING" != "1" ] && { [ "$DB_HOST" != "127.0.0.1" ] || [ "$DB_PORT" != "3306" ]; }; then + error "--db-host/--db-port only apply with --existing; a new local install always uses 127.0.0.1:3306" fi -systemctl enable mysql &>/dev/null || true -systemctl start mysql -log "Creating database '$DB_NAME' and user '$DB_USER'" -mysql -u root <&1) || error "Could not connect to existing MySQL database '$DB_NAME' at $DB_HOST:$DB_PORT as '$DB_USER': $db_err +Re-run without --existing (or choose 'new' at the prompt) to create a new local database instead." + log "Connected successfully" +else + [ -n "$DB_PASSWORD" ] || DB_PASSWORD=$(openssl rand -hex 24) + validate_secret "--db-password" "$DB_PASSWORD" + + if ! command -v mysql &>/dev/null; then + log "Installing MySQL server" + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends mysql-server + fi + systemctl enable mysql &>/dev/null || true + systemctl start mysql + + log "Creating database '$DB_NAME' and user '$DB_USER'" + mysql -u root <&2 - exit 1 -} -on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; } -trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR - usage() { cat <<'EOF' Usage: setup-observability.sh [OPTIONS] @@ -67,6 +59,7 @@ done [ "$EUID" -eq 0 ] || error "Run with sudo" [ -d "$INSTALL_DIR/observability" ] || error "$INSTALL_DIR/observability not found, run install.sh first" +[ -z "$SITE_NAME" ] || validate_hostname "--site-name" "$SITE_NAME" cd "$INSTALL_DIR" if ! command -v docker &>/dev/null; then @@ -130,8 +123,11 @@ if [ "$SKIP_NGINX" != "1" ] && [ -n "$SITE_NAME" ]; then else htpasswd_password=$(openssl rand -hex 16) htpasswd -cb "$htpasswd_file" admin "$htpasswd_password" - log "Created $htpasswd_file. User: admin, password: $htpasswd_password" - log "Record the password now, it's not stored anywhere else" + highlight \ + "Observability reverse proxy credentials" \ + "User : admin" \ + "Password : $htpasswd_password" \ + "Written to $htpasswd_file -- not stored anywhere else, save it now." fi nginx -t || error "nginx config test failed" diff --git a/scripts/setup-postgres.sh b/scripts/setup-postgres.sh index d81b3a6..4d958e7 100755 --- a/scripts/setup-postgres.sh +++ b/scripts/setup-postgres.sh @@ -1,31 +1,29 @@ #!/usr/bin/env bash # SPDX-License-Identifier: GPL-3.0-only -# Installs PostgreSQL if missing, creates a dedicated database/role, and -# writes the connection details into .env. Re-runnable: an existing install -# or database/role is left as-is; only missing pieces are created. +# Re-runnable: an existing install or database/role is left as-is. set -Eeuo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + INSTALL_DIR="/opt/relaysms/relaysms-publisher" +DB_EXISTING=0 +DB_HOST="127.0.0.1" +DB_PORT="5432" DB_NAME="relaysms" DB_USER="relaysms" DB_PASSWORD="" -log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*"; } -error() { - echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2 - exit 1 -} -on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; } -trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR - usage() { cat <<'EOF' Usage: setup-postgres.sh [OPTIONS] --install-dir DIR Publisher install directory (default: /opt/relaysms/relaysms-publisher) + --existing Use an already-running PostgreSQL server instead of installing one locally + --db-host HOST Database host (default: 127.0.0.1; only valid with --existing) + --db-port PORT Database port (default: 5432; only valid with --existing) --db-name NAME Database name (default: relaysms) --db-user USER Database user (default: relaysms) - --db-password PASS Database password (default: randomly generated) + --db-password PASS Database password (default: randomly generated; required with --existing) -h, --help Show this help and exit EOF } @@ -36,6 +34,18 @@ while [ $# -gt 0 ]; do INSTALL_DIR="$2" shift 2 ;; + --existing) + DB_EXISTING=1 + shift + ;; + --db-host) + DB_HOST="$2" + shift 2 + ;; + --db-port) + DB_PORT="$2" + shift 2 + ;; --db-name) DB_NAME="$2" shift 2 @@ -61,17 +71,33 @@ done [ "$EUID" -eq 0 ] || error "Run with sudo" [ -f "$INSTALL_DIR/.env" ] || error "$INSTALL_DIR/.env not found, run install.sh first" -[ -n "$DB_PASSWORD" ] || DB_PASSWORD=$(openssl rand -hex 24) - -if ! command -v psql &>/dev/null; then - log "Installing PostgreSQL server" - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql +validate_identifier "--db-name" "$DB_NAME" +validate_identifier "--db-user" "$DB_USER" +if [ "$DB_EXISTING" != "1" ] && { [ "$DB_HOST" != "127.0.0.1" ] || [ "$DB_PORT" != "5432" ]; }; then + error "--db-host/--db-port only apply with --existing; a new local install always uses 127.0.0.1:5432" fi -systemctl enable postgresql &>/dev/null || true -systemctl start postgresql -log "Creating role '$DB_USER'" -sudo -u postgres psql -v ON_ERROR_STOP=1 -c " +if [ "$DB_EXISTING" = "1" ]; then + [ -n "$DB_PASSWORD" ] || error "--db-password is required with --existing" + validate_secret "--db-password" "$DB_PASSWORD" + + log "Checking connection to existing PostgreSQL server at $DB_HOST:$DB_PORT" + db_err=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT 1;" 2>&1) || error "Could not connect to existing PostgreSQL database '$DB_NAME' at $DB_HOST:$DB_PORT as '$DB_USER': $db_err +Re-run without --existing (or choose 'new' at the prompt) to create a new local database instead." + log "Connected successfully" +else + [ -n "$DB_PASSWORD" ] || DB_PASSWORD=$(openssl rand -hex 24) + validate_secret "--db-password" "$DB_PASSWORD" + + if ! command -v psql &>/dev/null; then + log "Installing PostgreSQL server" + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql + fi + systemctl enable postgresql &>/dev/null || true + systemctl start postgresql + + log "Creating role '$DB_USER'" + sudo -u postgres psql -v ON_ERROR_STOP=1 -c " DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$DB_USER') THEN @@ -83,26 +109,30 @@ END \$\$; " -log "Creating database '$DB_NAME'" -db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'") -if [ "$db_exists" != "1" ]; then - sudo -u postgres psql -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$DB_NAME\" OWNER \"$DB_USER\";" -else - sudo -u postgres psql -v ON_ERROR_STOP=1 -c "ALTER DATABASE \"$DB_NAME\" OWNER TO \"$DB_USER\";" -fi + log "Creating database '$DB_NAME'" + db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'") + if [ "$db_exists" != "1" ]; then + sudo -u postgres psql -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$DB_NAME\" OWNER \"$DB_USER\";" + else + sudo -u postgres psql -v ON_ERROR_STOP=1 -c "ALTER DATABASE \"$DB_NAME\" OWNER TO \"$DB_USER\";" + fi -# Belt-and-suspenders for pre-15 PostgreSQL: since 15, the database owner -# already gets CREATE on the public schema via pg_database_owner, but -# older versions need it granted explicitly or migrations fail with -# "permission denied for schema public". -sudo -u postgres psql -v ON_ERROR_STOP=1 -d "$DB_NAME" -c "GRANT ALL ON SCHEMA public TO \"$DB_USER\";" + # Needed on Postgres < 15: owner doesn't get public-schema CREATE by + # default there, and migrations fail with "permission denied for schema public". + sudo -u postgres psql -v ON_ERROR_STOP=1 -d "$DB_NAME" -c "GRANT ALL ON SCHEMA public TO \"$DB_USER\";" +fi sed -i "s|^DATABASE_DIALECT=.*|DATABASE_DIALECT=postgres|" "$INSTALL_DIR/.env" -sed -i "s|^POSTGRES_HOST=.*|POSTGRES_HOST=127.0.0.1|" "$INSTALL_DIR/.env" -sed -i "s|^POSTGRES_PORT=.*|POSTGRES_PORT=5432|" "$INSTALL_DIR/.env" +sed -i "s|^POSTGRES_HOST=.*|POSTGRES_HOST=$DB_HOST|" "$INSTALL_DIR/.env" +sed -i "s|^POSTGRES_PORT=.*|POSTGRES_PORT=$DB_PORT|" "$INSTALL_DIR/.env" sed -i "s|^POSTGRES_USER=.*|POSTGRES_USER=$DB_USER|" "$INSTALL_DIR/.env" sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$DB_PASSWORD|" "$INSTALL_DIR/.env" sed -i "s|^POSTGRES_DATABASE=.*|POSTGRES_DATABASE=$DB_NAME|" "$INSTALL_DIR/.env" -log "Postgres ready. Database: $DB_NAME, user: $DB_USER, password: $DB_PASSWORD" -log "Credentials are written to $INSTALL_DIR/.env, record the password now if you need it elsewhere" +highlight \ + "Postgres ready" \ + "Host : $DB_HOST:$DB_PORT" \ + "Database : $DB_NAME" \ + "User : $DB_USER" \ + "Password : $DB_PASSWORD" \ + "Written to $INSTALL_DIR/.env -- not stored anywhere else, save it now." diff --git a/scripts/setup-rabbitmq.sh b/scripts/setup-rabbitmq.sh index 4963001..9698d30 100755 --- a/scripts/setup-rabbitmq.sh +++ b/scripts/setup-rabbitmq.sh @@ -1,32 +1,32 @@ #!/usr/bin/env bash # SPDX-License-Identifier: GPL-3.0-only -# Installs RabbitMQ if missing, creates a dedicated vhost/user, and writes -# the connection details into .env. Re-runnable: an existing install or -# vhost/user is left as-is; only missing pieces are created. +# Re-runnable: an existing install or vhost/user is left as-is. set -Eeuo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + INSTALL_DIR="/opt/relaysms/relaysms-publisher" +BROKER_EXISTING=0 +BROKER_HOST="127.0.0.1" +BROKER_PORT="5672" +BROKER_MGMT_PORT="15672" BROKER_VHOST="relaysms" BROKER_USER="relaysms" BROKER_PASSWORD="" -log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*"; } -error() { - echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2 - exit 1 -} -on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; } -trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR - usage() { cat <<'EOF' Usage: setup-rabbitmq.sh [OPTIONS] - --install-dir DIR Publisher install directory (default: /opt/relaysms/relaysms-publisher) - --broker-vhost NAME RabbitMQ vhost (default: relaysms) - --broker-user USER RabbitMQ user (default: relaysms) - --broker-password PASS RabbitMQ password (default: randomly generated) - -h, --help Show this help and exit + --install-dir DIR Publisher install directory (default: /opt/relaysms/relaysms-publisher) + --existing Use an already-running RabbitMQ server instead of installing one locally + --broker-host HOST Broker host (default: 127.0.0.1; only valid with --existing) + --broker-port PORT Broker AMQP port (default: 5672; only valid with --existing) + --broker-mgmt-port PORT Broker management API port, used to verify --existing credentials (default: 15672) + --broker-vhost NAME RabbitMQ vhost (default: relaysms) + --broker-user USER RabbitMQ user (default: relaysms) + --broker-password PASS RabbitMQ password (default: randomly generated; required with --existing) + -h, --help Show this help and exit EOF } @@ -36,6 +36,22 @@ while [ $# -gt 0 ]; do INSTALL_DIR="$2" shift 2 ;; + --existing) + BROKER_EXISTING=1 + shift + ;; + --broker-host) + BROKER_HOST="$2" + shift 2 + ;; + --broker-port) + BROKER_PORT="$2" + shift 2 + ;; + --broker-mgmt-port) + BROKER_MGMT_PORT="$2" + shift 2 + ;; --broker-vhost) BROKER_VHOST="$2" shift 2 @@ -61,30 +77,71 @@ done [ "$EUID" -eq 0 ] || error "Run with sudo" [ -f "$INSTALL_DIR/.env" ] || error "$INSTALL_DIR/.env not found, run install.sh first" -[ -n "$BROKER_PASSWORD" ] || BROKER_PASSWORD=$(openssl rand -hex 24) - -if ! command -v rabbitmqctl &>/dev/null; then - log "Installing RabbitMQ server" - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends rabbitmq-server +validate_identifier "--broker-vhost" "$BROKER_VHOST" +validate_identifier "--broker-user" "$BROKER_USER" +if [ "$BROKER_EXISTING" != "1" ] && { [ "$BROKER_HOST" != "127.0.0.1" ] || [ "$BROKER_PORT" != "5672" ]; }; then + error "--broker-host/--broker-port only apply with --existing; a new local install always uses 127.0.0.1:5672" fi -systemctl enable rabbitmq-server &>/dev/null || true -systemctl start rabbitmq-server -log "Creating vhost '$BROKER_VHOST'" -if ! rabbitmqctl -q list_vhosts | grep -qx "$BROKER_VHOST"; then - rabbitmqctl add_vhost "$BROKER_VHOST" -fi +if [ "$BROKER_EXISTING" = "1" ]; then + [ -n "$BROKER_PASSWORD" ] || error "--broker-password is required with --existing" + validate_secret "--broker-password" "$BROKER_PASSWORD" -log "Creating user '$BROKER_USER'" -if rabbitmqctl -q list_users | awk '{print $1}' | grep -qx "$BROKER_USER"; then - rabbitmqctl change_password "$BROKER_USER" "$BROKER_PASSWORD" + # rabbitmqctl only works locally, so this checks the management HTTP API + # instead. /api/vhosts/ 401s for anyone without the administrator + # tag, so /api/exchanges/ is used to work for a plain vhost user. + log "Checking connection to existing RabbitMQ server at $BROKER_HOST:$BROKER_MGMT_PORT" + http_code=$(curl -s -o /dev/null -w '%{http_code}' -u "$BROKER_USER:$BROKER_PASSWORD" \ + "http://$BROKER_HOST:$BROKER_MGMT_PORT/api/exchanges/$BROKER_VHOST") || http_code="000" + case "$http_code" in + 200) + log "Connected successfully" + ;; + 401 | 403) + error "Authentication failed for RabbitMQ user '$BROKER_USER' at $BROKER_HOST. +Re-run without --existing (or choose 'new' at the prompt) to create a new local broker instead." + ;; + 404) + error "Vhost '$BROKER_VHOST' not found on $BROKER_HOST. +Check --broker-vhost, or re-run without --existing to create a new local broker instead." + ;; + *) + error "Could not reach the RabbitMQ management API at $BROKER_HOST:$BROKER_MGMT_PORT (HTTP $http_code). +Check --broker-host/--broker-mgmt-port and that the management plugin is enabled, or re-run without --existing to create a new local broker instead." + ;; + esac else - rabbitmqctl add_user "$BROKER_USER" "$BROKER_PASSWORD" + [ -n "$BROKER_PASSWORD" ] || BROKER_PASSWORD=$(openssl rand -hex 24) + validate_secret "--broker-password" "$BROKER_PASSWORD" + + if ! command -v rabbitmqctl &>/dev/null; then + log "Installing RabbitMQ server" + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends rabbitmq-server + fi + systemctl enable rabbitmq-server &>/dev/null || true + systemctl start rabbitmq-server + + log "Creating vhost '$BROKER_VHOST'" + if ! rabbitmqctl -q list_vhosts | grep -qx "$BROKER_VHOST"; then + rabbitmqctl add_vhost "$BROKER_VHOST" + fi + + log "Creating user '$BROKER_USER'" + if rabbitmqctl -q list_users | awk '{print $1}' | grep -qx "$BROKER_USER"; then + rabbitmqctl change_password "$BROKER_USER" "$BROKER_PASSWORD" + else + rabbitmqctl add_user "$BROKER_USER" "$BROKER_PASSWORD" + fi + rabbitmqctl set_permissions -p "$BROKER_VHOST" "$BROKER_USER" ".*" ".*" ".*" fi -rabbitmqctl set_permissions -p "$BROKER_VHOST" "$BROKER_USER" ".*" ".*" ".*" sed -i "s|^CELERY_BROKER_TYPE=.*|CELERY_BROKER_TYPE=rabbitmq|" "$INSTALL_DIR/.env" -sed -i "s|^#\?[[:space:]]*CELERY_RABBITMQ_URL=.*|CELERY_RABBITMQ_URL=amqp://$BROKER_USER:$BROKER_PASSWORD@localhost:5672/$BROKER_VHOST|" "$INSTALL_DIR/.env" +sed -i "s|^#\?[[:space:]]*CELERY_RABBITMQ_URL=.*|CELERY_RABBITMQ_URL=amqp://$BROKER_USER:$BROKER_PASSWORD@$BROKER_HOST:$BROKER_PORT/$BROKER_VHOST|" "$INSTALL_DIR/.env" -log "RabbitMQ ready. Vhost: $BROKER_VHOST, user: $BROKER_USER, password: $BROKER_PASSWORD" -log "Credentials are written to $INSTALL_DIR/.env, record the password now if you need it elsewhere" +highlight \ + "RabbitMQ ready" \ + "Host : $BROKER_HOST:$BROKER_PORT" \ + "Vhost : $BROKER_VHOST" \ + "User : $BROKER_USER" \ + "Password : $BROKER_PASSWORD" \ + "Written to $INSTALL_DIR/.env -- not stored anywhere else, save it now." diff --git a/sync-env.sh b/sync-env.sh index aaa121d..01c82f2 100755 --- a/sync-env.sh +++ b/sync-env.sh @@ -1,19 +1,13 @@ #!/bin/bash # SPDX-License-Identifier: GPL-3.0-only -# -# Adds variables from template.env that are missing from .env, without -# touching any existing value. Safe to re-run any time template.env gains -# new fields. -# -# Each missing variable is inserted right after its section's comment -# header if that header already exists in .env; otherwise the variable -# (with its header, if any) is appended as a new block at the end. -# # Usage: ./sync-env.sh [env-file] [template-file] +# +# Adds template.env variables missing from .env, without touching existing +# values. Each is inserted after its section header if that header already +# exists in .env, otherwise appended as a new block. set -Eeuo pipefail -# Catches failures not already wrapped in error(), with line context. on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; } trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR