ergo already defends this bug class deliberately -- it adopts .env settings on
a re-run and dies with a precise remedy for ERGO_DOMAIN, HISTORY, PLAINTEXT
and a floating ERGO_TAG. NETWORK_NAME and ACME_EMAIL were gaps in that
defense, not design choices: both are absent from EXPLICIT and from the
re-run writeback loop, though the header promises it "writes back settings you
passed explicitly on this run".
A plain re-run stays safe, since both are adopted from .env as prompt
defaults. The hole is a value the operator actively supplies. NETWORK_NAME
then splits three ways: the generated docs under $STACK_DIR/docs and (when
exported) the Caddy landing page get the NEW name, ircd.yaml keeps the OLD one
because render_ircd_yaml never rewrites an existing file, and .env is never
updated so the next run reverts the docs too. The summary prints the new name.
Clients keep seeing the old one in NETWORK=.
Add yaml_network_name beside yaml_server_name and die with a remedy pointing
at `ergoctl edit` + `ergoctl rehash`, matching how ERGO_DOMAIN is handled --
a rename ircd.yaml cannot absorb should stop the run, not half-apply. Add
ACME_EMAIL to EXPLICIT and to the writeback loop, since unlike the network
name it genuinely can change on a re-run.
ergolib.sh is embedded, so the payload was regenerated with build.sh; that
rebuild also carries the env_set repair from 947c899.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
745 lines
34 KiB
Bash
745 lines
34 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# ergolib.sh -- shared helpers for the Ergo stack. Sourced by deploy.sh (from the
|
|
# extracted archive), update.sh and ergoctl (from $STACK_DIR). Not run directly.
|
|
#
|
|
# Everything that touches the running stack lives here so the three scripts stay
|
|
# consistent: .env access, the compose wrapper, health + IRC probes over the
|
|
# loopback listener, the throwaway-container config validator, Caddy->Ergo cert
|
|
# sync, oper edits in ircd.yaml, and ntfy notifications.
|
|
#
|
|
# Conventions: functions return non-zero instead of exiting (callers decide),
|
|
# and everything is portable to busybox (Alpine) -- POSIX awk, no GNU date -d,
|
|
# no sort -V, no grep -P.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging (reuse the caller's if defined)
|
|
# ---------------------------------------------------------------------------
|
|
if ! declare -f log >/dev/null 2>&1; then
|
|
log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
|
|
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
|
|
die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config file (/etc/ergo-update.conf): KEY=VALUE defaults for the scheduled
|
|
# jobs. Environment wins over the file, the file over built-ins.
|
|
# ---------------------------------------------------------------------------
|
|
: "${ERGO_UPDATE_CONF:=/etc/ergo-update.conf}"
|
|
load_conf() {
|
|
[[ -r "$ERGO_UPDATE_CONF" ]] || return 0
|
|
local _line _k _v
|
|
while IFS= read -r _line || [[ -n "$_line" ]]; do
|
|
[[ "$_line" =~ ^[[:space:]]*# || -z "${_line//[[:space:]]/}" ]] && continue
|
|
_k="${_line%%=*}"; _v="${_line#*=}"; _k="${_k//[[:space:]]/}"
|
|
[[ "$_k" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
|
[[ -n "${!_k:-}" ]] && continue
|
|
_v="${_v%\"}"; _v="${_v#\"}"
|
|
printf -v "$_k" '%s' "$_v"
|
|
done < "$ERGO_UPDATE_CONF"
|
|
return 0
|
|
}
|
|
|
|
# Derived paths. Call after STACK_DIR is final.
|
|
ergo_set_paths() {
|
|
: "${STACK_DIR:=/srv/ergo}"
|
|
ENV_FILE="$STACK_DIR/.env"
|
|
IRCD_DIR="$STACK_DIR/ircd"
|
|
CADDY_DIR="$STACK_DIR/caddy"
|
|
SECRETS_DIR="$STACK_DIR/secrets"
|
|
BACKUP_DIR="$STACK_DIR/backups"
|
|
TEMPLATES_DIR="$STACK_DIR/templates"
|
|
STATE_DIR="$STACK_DIR/.state"
|
|
ADMIN_PASS_FILE="$SECRETS_DIR/admin.pass"
|
|
: "${GH_REPO:=ergochat/ergo}"
|
|
: "${NOTIFY:=1}"
|
|
: "${SSH_NOTIFY_CONF:=/etc/ssh-notify.conf}"
|
|
: "${DRY_RUN:=0}"
|
|
}
|
|
|
|
osfam() {
|
|
local id="" like=""
|
|
if [[ -r /etc/os-release ]]; then
|
|
id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")"
|
|
like="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")"
|
|
fi
|
|
case " $id $like " in
|
|
*" alpine "*) echo alpine ;;
|
|
*" debian "*|*" ubuntu "*) echo debian ;;
|
|
*" rhel "*|*" fedora "*|*" centos "*) echo rhel ;;
|
|
*) echo "${id:-unknown}" ;;
|
|
esac
|
|
}
|
|
|
|
# fetch a URL to stdout (curl or wget); rc 1 on failure.
|
|
fetch() {
|
|
if command -v curl >/dev/null 2>&1; then curl -fsSL -m 20 "$1" 2>/dev/null
|
|
elif command -v wget >/dev/null 2>&1; then wget -qO- -T 20 "$1" 2>/dev/null
|
|
else return 1
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Writing into ircd/ safely
|
|
#
|
|
# ircd/ is owned by the unprivileged container uid, but several things that
|
|
# write there run as ROOT (the 15-minute cert sync, every ergoctl config edit).
|
|
# A plain `cp`, `install` or `>` follows a symlink at the destination, so code
|
|
# execution inside Ergo could point one of those writes at any root-owned file
|
|
# and win the host. rename(2) is the one operation that REPLACES a symlink
|
|
# instead of following it, so every root write into ircd/ goes: create a temp
|
|
# under $STACK_DIR (root-owned 0700, same filesystem) -> set mode/owner -> mv.
|
|
# ---------------------------------------------------------------------------
|
|
# A temp file that an unprivileged uid cannot reach or pre-empt.
|
|
stage_tmp() { mktemp "$STACK_DIR/.stage.XXXXXX"; }
|
|
|
|
# safe_replace <tmpfile> <target> [mode] [uid] [gid]
|
|
safe_replace() {
|
|
local tmp="$1" target="$2" mode="${3:-0600}" uid="${4:-}" gid="${5:-}"
|
|
chmod "$mode" "$tmp" 2>/dev/null || true
|
|
[[ -n "$uid" ]] && chown "${uid}:${gid:-$uid}" "$tmp" 2>/dev/null
|
|
mv -f "$tmp" "$target"
|
|
}
|
|
|
|
# Refuse to read a path under ircd/ that the container uid has turned into a
|
|
# symlink (a root reader would otherwise copy out whatever it points at).
|
|
refuse_symlink() { # <path> [what]
|
|
[[ -L "$1" ]] || return 0
|
|
warn "${2:-$1} is a symlink; refusing to touch it as root (see README: Security model)."
|
|
return 1
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# .env access
|
|
# ---------------------------------------------------------------------------
|
|
env_get() { [[ -f "$ENV_FILE" ]] && grep -E "^$1=" "$ENV_FILE" | head -n1 | cut -d= -f2- || true; }
|
|
env_set() { # <KEY> <value>: update KEY in .env, or append if absent
|
|
# The value goes through the ENVIRONMENT, never interpolated into a sed
|
|
# script. Interpolating it corrupts any value containing & (sed expands it to
|
|
# the whole match) and aborts the run on one containing the s||| delimiter --
|
|
# which is reachable for an OIDC secret, a password or a URL query string.
|
|
local key="$1" val="$2" tmp
|
|
if [[ ! -f "$ENV_FILE" ]]; then
|
|
printf '%s=%s
|
|
' "$key" "$val" >> "$ENV_FILE"
|
|
return 0
|
|
fi
|
|
tmp="$(mktemp)"
|
|
_SE_KEY="$key" _SE_VAL="$val" awk '
|
|
BEGIN { k = ENVIRON["_SE_KEY"]; v = ENVIRON["_SE_VAL"]; seen = 0 }
|
|
!seen && index($0, k "=") == 1 { print k "=" v; seen = 1; next }
|
|
{ print }
|
|
END { if (!seen) print k "=" v }
|
|
' "$ENV_FILE" > "$tmp"
|
|
cat "$tmp" > "$ENV_FILE" # rewrite in place: keeps the original mode/owner
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
ergo_domain() { env_get ERGO_DOMAIN | tr 'A-Z' 'a-z' || true; }
|
|
|
|
# The compose invocation must include the postgres overlay whenever history runs
|
|
# on PostgreSQL. deploy.sh writes COMPOSE_FILE into .env, which docker compose
|
|
# reads by itself -- these helpers are for code that needs to know.
|
|
pg_enabled() { [[ -f "$STACK_DIR/docker-compose.postgres.yml" ]] && grep -q '^COMPOSE_FILE=.*postgres' "$ENV_FILE" 2>/dev/null; }
|
|
|
|
# Is PostgreSQL up and accepting our credentials? rc 0 ok, 1 not.
|
|
PG_CHECK_MSG=""
|
|
pg_check() {
|
|
PG_CHECK_MSG=""
|
|
if ! pg_enabled; then PG_CHECK_MSG="postgres not in use"; return 0; fi
|
|
if [[ "$(svc_state postgres)" != running* ]]; then PG_CHECK_MSG="the postgres container is not running"; return 1; fi
|
|
local db user
|
|
db="$(env_get POSTGRES_DB)"; user="$(env_get POSTGRES_USER)"
|
|
if dc exec -T postgres pg_isready -U "${user:-ergo}" -d "${db:-ergo_history}" >/dev/null 2>&1; then
|
|
PG_CHECK_MSG="postgres ready (${db:-ergo_history})"; return 0
|
|
fi
|
|
PG_CHECK_MSG="postgres is running but not accepting connections yet"
|
|
return 1
|
|
}
|
|
|
|
# Fill the named variables from .env when they are not already set in the
|
|
# environment. Used by update.sh/ergoctl so the knobs .env documents really are
|
|
# the ones the scheduled jobs obey (environment > /etc/ergo-update.conf > .env).
|
|
env_defaults() { # env_defaults <VAR>...
|
|
local k v
|
|
for k in "$@"; do
|
|
[[ -n "${!k:-}" ]] && continue
|
|
v="$(env_get "$k")"
|
|
[[ -n "$v" ]] && printf -v "$k" '%s' "$v"
|
|
done
|
|
return 0
|
|
}
|
|
ergo_uid() { local v; v="$(env_get ERGO_UID)"; printf '%s' "${v:-1000}"; }
|
|
ergo_gid() { local v; v="$(env_get ERGO_GID)"; printf '%s' "${v:-1000}"; }
|
|
ergo_tag() { local v; v="$(env_get ERGO_TAG)"; printf '%s' "${v:-stable}"; }
|
|
ergo_image() { local i; i="$(env_get ERGO_IMAGE)"; printf '%s:%s' "${i:-ghcr.io/ergochat/ergo}" "$(ergo_tag)"; }
|
|
|
|
# docker compose, scoped to the stack dir (so ./ircd, ./caddy resolve and .env
|
|
# is auto-loaded).
|
|
dc() { ( cd "$STACK_DIR" && docker compose "$@" ); }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Version helpers (pure bash; busybox sort has no -V)
|
|
# ---------------------------------------------------------------------------
|
|
# NOTE the trailing `|| true` here and in the cert/state helpers below: these are
|
|
# assigned in `set -euo pipefail` scripts, where a no-match grep would abort the
|
|
# caller instead of yielding "". Callers test for an empty result.
|
|
normver() { printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n1 || true; }
|
|
ver_cmp() { # <a> <b> -> prints -1 / 0 / 1
|
|
local a b i x y
|
|
IFS=. read -r -a a <<< "$(normver "$1")"
|
|
IFS=. read -r -a b <<< "$(normver "$2")"
|
|
for i in 0 1 2; do
|
|
x="${a[$i]:-0}"; y="${b[$i]:-0}"
|
|
if (( x > y )); then echo 1; return; fi
|
|
if (( x < y )); then echo -1; return; fi
|
|
done
|
|
echo 0
|
|
}
|
|
ver_gt() { [[ "$(ver_cmp "$1" "$2")" == "1" ]]; }
|
|
ver_ge() { [[ "$(ver_cmp "$1" "$2")" != "-1" ]]; }
|
|
ver_le() { [[ "$(ver_cmp "$1" "$2")" != "1" ]]; }
|
|
|
|
# Running version as reported by the binary ('ergo-v2.19.1' or 'ergo-2.19.1[-hash]');
|
|
# falls back to the pinned tag when the container is down.
|
|
ergo_version_running() {
|
|
local v=""
|
|
if ergo_running; then
|
|
v="$(dc exec -T ergo /ircd-bin/ergo --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)"
|
|
fi
|
|
[[ -n "$v" ]] || v="$(normver "$(ergo_tag)")"
|
|
printf '%s' "$v"
|
|
}
|
|
|
|
# Newest non-prerelease tag from GitHub (e.g. v2.19.1). rc 1 if unreachable.
|
|
ergo_latest_tag() {
|
|
local t
|
|
t="$(fetch "https://api.github.com/repos/${GH_REPO}/releases/latest" \
|
|
| grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n1 \
|
|
| sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')"
|
|
[[ -n "$t" ]] || return 1
|
|
printf '%s' "$t"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Container state
|
|
# ---------------------------------------------------------------------------
|
|
# "<state> <health>" for one compose service, e.g. "running healthy", "exited ".
|
|
# Empty when the container does not exist or the docker daemon is unreachable.
|
|
svc_state() {
|
|
dc ps -a --format '{{.Service}} {{.State}} {{.Health}}' 2>/dev/null | awk -v s="$1" '$1==s {print $2" "$3; exit}' || true
|
|
}
|
|
ergo_running() { [[ "$(svc_state ergo)" == running* ]]; }
|
|
|
|
# Wait for a service to be "running healthy"; rc 1 on timeout. Exited/restarting
|
|
# containers never count (docker compose ps hides them without -a).
|
|
wait_healthy() { # <service> [timeout-seconds]
|
|
local svc="$1" deadline st
|
|
deadline=$(( $(date +%s) + ${2:-120} ))
|
|
while (( $(date +%s) < deadline )); do
|
|
st="$(svc_state "$svc")"
|
|
[[ "$st" == "running healthy" ]] && return 0
|
|
sleep 3
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Talking IRC to the loopback listener (127.0.0.1:6667) with bash /dev/tcp.
|
|
#
|
|
# irc_raw [--oper] [--quiet SECS] -- <line>...
|
|
#
|
|
# Registers a throwaway nick, optionally opers up as 'admin' (password from
|
|
# $ADMIN_PASS_FILE or $ERGO_OPER_PASS), sends each line, prints the server's
|
|
# replies (control characters stripped) until the server has been quiet for
|
|
# --quiet seconds, then QUITs. IRC_LAST holds the raw transcript, but only when
|
|
# irc_raw is called directly -- inside $(...) it stays in the subshell, so parse
|
|
# the printed output instead.
|
|
#
|
|
# rc: 0 ok | 3 OPER refused | 4 connect failed | 5 registration failed
|
|
# Loopback is exempt from ip-limits/bans, but fakelag applies until opered:
|
|
# NICK+USER+OPER is 3 of the 5-line burst, so keep pre-oper payloads short.
|
|
# ---------------------------------------------------------------------------
|
|
IRC_LAST=""
|
|
# Strip CR/LF/NUL before framing: an argument containing a newline (e.g.
|
|
# `ergoctl announce "$(cat file)"`) would otherwise terminate this line and make
|
|
# the rest a second command, executed with the admin oper's full privileges.
|
|
_irc_send() {
|
|
local l="${1//$'\r'/}"; l="${l//$'\n'/ }"; l="${l//$'\0'/}"
|
|
printf '%s\r\n' "$l" >&3 2>/dev/null
|
|
}
|
|
_irc_cmd() { # echo the command/numeric token of a server line
|
|
local l="$1"
|
|
if [[ "$l" == :* ]]; then l="${l#* }"; fi
|
|
printf '%s' "${l%% *}"
|
|
}
|
|
irc_raw() {
|
|
local oper=0 quiet=2 line cmd nick tries=0 pass="" registered=0 opered=0 out=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--oper) oper=1; shift ;;
|
|
--quiet) quiet="$2"; shift 2 ;;
|
|
--) shift; break ;;
|
|
*) break ;;
|
|
esac
|
|
done
|
|
IRC_LAST=""
|
|
if [[ "$oper" == 1 ]]; then
|
|
pass="${ERGO_OPER_PASS:-}"
|
|
[[ -n "$pass" ]] || pass="$(head -n1 "$ADMIN_PASS_FILE" 2>/dev/null || true)"
|
|
[[ -n "$pass" ]] || { warn "No admin oper password (expected $ADMIN_PASS_FILE)."; return 3; }
|
|
fi
|
|
if ! { exec 3<>/dev/tcp/127.0.0.1/6667; } 2>/dev/null; then
|
|
return 4
|
|
fi
|
|
nick="ergoctl$RANDOM"
|
|
_irc_send "NICK $nick"
|
|
_irc_send "USER ergoctl 0 * :ergoctl"
|
|
while IFS= read -r -t 15 -u 3 line; do
|
|
line="${line%$'\r'}"
|
|
IRC_LAST+="$line"$'\n'
|
|
cmd="$(_irc_cmd "$line")"
|
|
case "$cmd" in
|
|
PING) _irc_send "PONG ${line#PING }" ;;
|
|
376|422) registered=1; break ;;
|
|
433) # nick in use -- pick another (up to 3 times)
|
|
tries=$((tries + 1)); (( tries > 3 )) && break
|
|
nick="ergoctl$RANDOM"; _irc_send "NICK $nick" ;;
|
|
432|464|465|ERROR|FAIL) break ;;
|
|
esac
|
|
done
|
|
if [[ "$registered" != 1 ]]; then
|
|
exec 3>&- 2>/dev/null; return 5
|
|
fi
|
|
if [[ "$oper" == 1 ]]; then
|
|
_irc_send "OPER admin $pass"
|
|
while IFS= read -r -t 15 -u 3 line; do
|
|
line="${line%$'\r'}"
|
|
IRC_LAST+="$line"$'\n'
|
|
cmd="$(_irc_cmd "$line")"
|
|
case "$cmd" in
|
|
PING) _irc_send "PONG ${line#PING }" ;;
|
|
381) opered=1; break ;;
|
|
491|464|ERROR) break ;;
|
|
esac
|
|
done
|
|
if [[ "$opered" != 1 ]]; then
|
|
_irc_send "QUIT :ergoctl"; exec 3>&- 2>/dev/null; return 3
|
|
fi
|
|
fi
|
|
for line in "$@"; do _irc_send "$line"; done
|
|
while IFS= read -r -t "$quiet" -u 3 line; do
|
|
line="${line%$'\r'}"
|
|
IRC_LAST+="$line"$'\n'
|
|
cmd="$(_irc_cmd "$line")"
|
|
case "$cmd" in
|
|
PING) _irc_send "PONG ${line#PING }"; continue ;;
|
|
ERROR) out+="$line"$'\n'; break ;;
|
|
esac
|
|
out+="$line"$'\n'
|
|
done
|
|
_irc_send "QUIT :ergoctl"
|
|
exec 3>&- 2>/dev/null
|
|
# Never let user-controlled IRC text drive the terminal: drop control chars
|
|
# (this also strips IRC formatting bytes 0x02/0x03/0x1d-0x1f).
|
|
[[ -n "$out" ]] && printf '%s' "$out" | tr -d '\000-\010\013\014\016-\037\177'
|
|
return 0
|
|
}
|
|
|
|
# Registration-only probe (no oper): proves the config loaded and the server
|
|
# completes a handshake. rc 0/1.
|
|
irc_probe() { irc_raw --quiet 1 -- >/dev/null 2>&1; }
|
|
|
|
# REHASH as the admin oper; sets REHASH_MSG. rc 0 = "Rehash complete",
|
|
# 1 = server refused (message in REHASH_MSG), 2 = could not connect/oper.
|
|
REHASH_MSG=""
|
|
irc_rehash() {
|
|
local out rc
|
|
REHASH_MSG=""
|
|
out="$(irc_raw --oper --quiet 3 -- REHASH)"; rc=$?
|
|
if (( rc != 0 )); then
|
|
REHASH_MSG="could not reach Ergo as oper (rc=$rc)"; return 2
|
|
fi
|
|
# $out holds every reply after the REHASH was sent (the $() subshell keeps
|
|
# IRC_LAST from us, so parse the printed transcript).
|
|
if printf '%s' "$out" | grep -qi 'Rehash complete'; then return 0; fi
|
|
REHASH_MSG="$(printf '%s\n' "$out" | awk '$2=="400" {sub(/^[^ ]+ 400 [^ ]+ REHASH :?/,""); print; exit}')"
|
|
[[ -n "$REHASH_MSG" ]] || REHASH_MSG="no confirmation from the server: $(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
|
|
return 1
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper containers: never on a network, never privileged, run as the ergo uid.
|
|
# ---------------------------------------------------------------------------
|
|
ergo_helper() { # ergo_helper [docker-run-opts...] -- <image> <args...>
|
|
local opts=()
|
|
while [[ $# -gt 0 && "$1" != "--" ]]; do opts+=("$1"); shift; done
|
|
[[ "${1:-}" == "--" ]] && shift
|
|
docker run --rm --network none --cap-drop ALL --security-opt no-new-privileges \
|
|
--user "$(ergo_uid):$(ergo_gid)" "${opts[@]}" "$@"
|
|
}
|
|
|
|
# The image's own default config (already patched by Ergo's Dockerfile: public
|
|
# ":6667", no [::1]). Writes to <dest>; rc 1 if nothing came out.
|
|
ergo_extract_template() { # <image> <dest>
|
|
local image="$1" dest="$2"
|
|
ergo_helper --entrypoint cat -- "$image" /ircd-bin/default.yaml > "$dest" 2>/dev/null || true
|
|
if ! grep -q '^server:' "$dest" 2>/dev/null; then
|
|
ergo_helper --entrypoint /ircd-bin/ergo -- "$image" defaultconfig > "$dest" 2>/dev/null || true
|
|
fi
|
|
grep -q '^server:' "$dest" 2>/dev/null
|
|
}
|
|
|
|
# bcrypt hash for a password (stdin -> stdout, one line). rc 1 if it doesn't
|
|
# look like a bcrypt hash.
|
|
ergo_genpasswd() { # <image> <password>
|
|
local h
|
|
h="$(printf '%s\n' "$2" | ergo_helper -i --entrypoint /ircd-bin/ergo -- "$1" genpasswd 2>/dev/null | tr -d '\r\n')"
|
|
[[ "$h" == \$2a\$* || "$h" == \$2b\$* ]] || return 1
|
|
printf '%s' "$h"
|
|
}
|
|
|
|
# Random password from [A-Za-z0-9_-]: never trips Ergo's passphrase checks.
|
|
ergo_random_password() { head -c 256 /dev/urandom | tr -dc '_A-Za-z0-9-' | head -c "${1:-24}"; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config validation: `ergo run --smoke` in a throwaway container against a COPY
|
|
# of ./ircd. Its own network namespace means the wildcard listeners bind fine,
|
|
# the copied ircd.db/lock are disposable, and a pending schema upgrade is
|
|
# dry-run on the copy. rc 0 = config (and certs) load; output in $VALIDATE_LOG.
|
|
# Ergo has no offline config checker and --smoke on the live dir would fight
|
|
# the running server for the flock and the ports.
|
|
# ---------------------------------------------------------------------------
|
|
VALIDATE_LOG=""
|
|
ergo_validate_config() { # [image]
|
|
local image="${1:-$(ergo_image)}" tmp rc
|
|
tmp="$(mktemp -d "$STACK_DIR/.validate.XXXXXX")" || return 1
|
|
VALIDATE_LOG="$(mktemp)"
|
|
if ! cp -a "$IRCD_DIR"/. "$tmp"/ 2>>"$VALIDATE_LOG"; then rm -rf "$tmp"; return 1; fi
|
|
rm -f "$tmp/ircd.lock"
|
|
# The validator has no network (so it cannot fight the live server for ports),
|
|
# which means it cannot reach a PostgreSQL history backend either. Neutralise
|
|
# that one setting in the COPY so everything else -- listeners, opers, MOTD,
|
|
# certs, and any pending DB schema upgrade -- is still really validated.
|
|
# PostgreSQL reachability is checked separately (see pg_check in update.sh).
|
|
if [[ "$(yaml_get_nested "$tmp/ircd.yaml" datastore postgresql enabled)" == "true" ]]; then
|
|
yaml_set_nested "$tmp/ircd.yaml" datastore postgresql enabled false || true
|
|
yaml_set_nested "$tmp/ircd.yaml" history persistent enabled false || true
|
|
fi
|
|
chown -R "$(ergo_uid):$(ergo_gid)" "$tmp"
|
|
ergo_helper -v "$tmp:/ircd" -w /ircd --entrypoint /bin/sh -- "$image" \
|
|
-c '/ircd-bin/ergo mkcerts >/dev/null 2>&1; exec /ircd-bin/ergo run --smoke' >>"$VALIDATE_LOG" 2>&1
|
|
rc=$?
|
|
rm -rf "$tmp"
|
|
return $rc
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ircd.yaml oper block edits (POSIX awk). The opers block is
|
|
# opers:
|
|
# <name>:
|
|
# class: ...
|
|
# password: "..."
|
|
# Each helper rewrites in place via a temp file and verifies the result.
|
|
# ---------------------------------------------------------------------------
|
|
yaml_server_name() {
|
|
awk '/^[a-z]/ {top=$1} top=="server:" && /^ name:/ {sub(/^ name:[ \t]*/,""); gsub(/["\047]/,""); print; exit}' "$1"
|
|
}
|
|
|
|
yaml_network_name() {
|
|
awk '/^[a-z]/ {top=$1} top=="network:" && /^ name:/ {sub(/^ name:[ \t]*/,""); gsub(/["\047]/,""); print; exit}' "$1"
|
|
}
|
|
|
|
yaml_oper_list() { # <file> -> "name class" per oper
|
|
awk '
|
|
/^[a-z]/ { top=$1 }
|
|
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { if (name!="") print name, cls; name=$1; sub(/:$/,"",name); cls="?" }
|
|
top=="opers:" && name!="" && /^ class:/ { cls=$2; gsub(/"/,"",cls) }
|
|
END { if (name!="") print name, cls }' "$1"
|
|
}
|
|
|
|
yaml_oper_has() { yaml_oper_list "$1" | awk -v n="$2" '$1==n {f=1} END {exit !f}'; }
|
|
|
|
yaml_oper_set_password() { # <file> <name> <hash>
|
|
local tmp; tmp="$(mktemp)"
|
|
NAME="$2" HASH="$3" awk '
|
|
/^[a-z]/ { top=$1; cur="" }
|
|
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { cur=$1; sub(/:$/,"",cur) }
|
|
top=="opers:" && cur==ENVIRON["NAME"] && /^ password:/ { print " password: \"" ENVIRON["HASH"] "\""; done=1; next }
|
|
{ print }
|
|
END { exit done ? 0 : 1 }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
|
|
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
|
|
}
|
|
|
|
# Read one field from an oper's block ("" when absent). Commented-out template
|
|
# lines (#certfp:) do not count -- only a real setting.
|
|
yaml_oper_get_field() { # <file> <name> <key>
|
|
NAME="$2" KEY="$3" awk '
|
|
/^[a-z]/ { top=$1; cur="" }
|
|
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { cur=$1; sub(/:$/,"",cur) }
|
|
top=="opers:" && cur==ENVIRON["NAME"] && $0 ~ "^ " ENVIRON["KEY"] ":" {
|
|
sub(/^ [a-z0-9-]+:[ \t]*/,""); gsub(/"/,""); print; exit }' "$1"
|
|
}
|
|
|
|
# Set a field inside ONE oper's block (certfp, auto, vhost, modes...). Replaces
|
|
# the line when it is already there, otherwise inserts it after that oper's
|
|
# `class:` line so it lands inside the right block.
|
|
yaml_oper_set_field() { # <file> <name> <key> <value>
|
|
local tmp; tmp="$(mktemp)"
|
|
if NAME="$2" KEY="$3" VAL="$4" awk '
|
|
/^[a-z]/ { top=$1; cur="" }
|
|
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { cur=$1; sub(/:$/,"",cur) }
|
|
top=="opers:" && cur==ENVIRON["NAME"] && $0 ~ "^ " ENVIRON["KEY"] ":" {
|
|
print " " ENVIRON["KEY"] ": " ENVIRON["VAL"]; done=1; next }
|
|
{ print }
|
|
END { exit done ? 0 : 1 }' "$1" > "$tmp"; then
|
|
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
|
|
return 0
|
|
fi
|
|
NAME="$2" KEY="$3" VAL="$4" awk '
|
|
/^[a-z]/ { top=$1; cur="" }
|
|
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { cur=$1; sub(/:$/,"",cur) }
|
|
{ print }
|
|
top=="opers:" && cur==ENVIRON["NAME"] && /^ class:/ && !done {
|
|
print " " ENVIRON["KEY"] ": " ENVIRON["VAL"]; done=1 }
|
|
END { exit done ? 0 : 1 }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
|
|
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
|
|
}
|
|
|
|
yaml_oper_rm_field() { # <file> <name> <key>
|
|
local tmp; tmp="$(mktemp)"
|
|
NAME="$2" KEY="$3" awk '
|
|
/^[a-z]/ { top=$1; cur="" }
|
|
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { cur=$1; sub(/:$/,"",cur) }
|
|
top=="opers:" && cur==ENVIRON["NAME"] && $0 ~ "^ " ENVIRON["KEY"] ":" { done=1; next }
|
|
{ print }
|
|
END { exit done ? 0 : 1 }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
|
|
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
|
|
}
|
|
|
|
# A SHA-256 certfp as Ergo stores it: 64 lowercase hex digits, no colons.
|
|
normalize_certfp() {
|
|
local fp; fp="$(printf '%s' "$1" | tr -d ': \t\r\n' | tr 'A-Z' 'a-z')"
|
|
[[ "$fp" =~ ^[0-9a-f]{64}$ ]] || return 1
|
|
printf '%s' "$fp"
|
|
}
|
|
|
|
yaml_oper_add() { # <file> <name> <class> <hash> [whois-line]
|
|
local tmp; tmp="$(mktemp)"
|
|
local block
|
|
block=" $2:
|
|
class: \"$3\"
|
|
hidden: true
|
|
whois-line: ${5:-is a network operator}
|
|
password: \"$4\"
|
|
"
|
|
BLOCK="$block" awk '
|
|
/^[a-z]/ { if (inopers && !done) { printf "%s\n", ENVIRON["BLOCK"]; done=1 } inopers=($1=="opers:") }
|
|
{ print }
|
|
END { if (inopers && !done) printf "%s", ENVIRON["BLOCK"] }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
|
|
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
|
|
yaml_oper_has "$1" "$2"
|
|
}
|
|
|
|
yaml_oper_rm() { # <file> <name>
|
|
local tmp; tmp="$(mktemp)"
|
|
NAME="$2" awk '
|
|
/^[a-z]/ { top=$1; skip=0 }
|
|
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { n=$1; sub(/:$/,"",n); skip=(n==ENVIRON["NAME"]); if (skip) next }
|
|
top=="opers:" && skip && (/^ / || /^[ \t]*$/) { next }
|
|
top=="opers:" && skip { skip=0 }
|
|
{ print }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
|
|
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
|
|
! yaml_oper_has "$1" "$2"
|
|
}
|
|
|
|
# Set a key nested as <top>:/<4-space sub>:/<8-space key>. Only the key inside
|
|
# THAT block is rewritten -- datastore's mysql/postgresql/sqlite sub-blocks all
|
|
# have an `enabled` key, so a plain sed would hit the wrong one. A key that is
|
|
# not present is an error (rc 1), never a silent no-op.
|
|
yaml_set_nested() { # <file> <top> <sub> <key> <value>
|
|
local tmp; tmp="$(mktemp)"
|
|
TOP="$2" SUB="$3" KEY="$4" VAL="$5" awk '
|
|
/^[a-z][a-z-]*:/ { t=$0; sub(/:.*/,"",t); cur_top=t; cur_sub="" }
|
|
/^ [a-z][a-z0-9-]*:/ { s=$0; sub(/^ /,"",s); sub(/:.*/,"",s); cur_sub=s }
|
|
cur_top==ENVIRON["TOP"] && cur_sub==ENVIRON["SUB"] && $0 ~ "^ " ENVIRON["KEY"] ":" {
|
|
print " " ENVIRON["KEY"] ": " ENVIRON["VAL"]; done=1; next
|
|
}
|
|
{ print }
|
|
END { exit done ? 0 : 1 }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
|
|
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
|
|
}
|
|
|
|
# Read a nested key back (for status/asserts); empty when absent.
|
|
yaml_get_nested() { # <file> <top> <sub> <key>
|
|
TOP="$2" SUB="$3" KEY="$4" awk '
|
|
/^[a-z][a-z-]*:/ { t=$0; sub(/:.*/,"",t); cur_top=t; cur_sub="" }
|
|
/^ [a-z][a-z0-9-]*:/ { s=$0; sub(/^ /,"",s); sub(/:.*/,"",s); cur_sub=s }
|
|
cur_top==ENVIRON["TOP"] && cur_sub==ENVIRON["SUB"] && $0 ~ "^ " ENVIRON["KEY"] ":" {
|
|
sub(/^ [a-z0-9-]+:[ \t]*/,""); gsub(/"/,""); print; exit
|
|
}' "$1"
|
|
}
|
|
|
|
# Which persistent-history backend the deployed ircd.yaml actually uses:
|
|
# prints sqlite | postgres | off. Reads the config, not .env, so it reports what
|
|
# Ergo is really doing after a hand edit.
|
|
history_backend() { # [ircd.yaml]
|
|
local f="${1:-$IRCD_DIR/ircd.yaml}"
|
|
[[ -f "$f" ]] || { printf 'off'; return 0; }
|
|
if [[ "$(yaml_get_nested "$f" history persistent enabled)" != "true" ]]; then printf 'off'; return 0; fi
|
|
if [[ "$(yaml_get_nested "$f" datastore postgresql enabled)" == "true" ]]; then printf 'postgres'; return 0; fi
|
|
if [[ "$(yaml_get_nested "$f" datastore sqlite enabled)" == "true" ]]; then printf 'sqlite'; return 0; fi
|
|
printf 'off'
|
|
}
|
|
|
|
# The first logging block's level (info <-> debug), for `ergoctl debug on|off`.
|
|
yaml_set_log_level() { # <file> <level>
|
|
local tmp; tmp="$(mktemp)"
|
|
LEVEL="$2" awk '
|
|
/^[a-z]/ { top=$1 }
|
|
top=="logging:" && !done && /^ level:/ { print " level: " ENVIRON["LEVEL"]; done=1; next }
|
|
{ print }
|
|
END { exit done ? 0 : 1 }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
|
|
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TLS: Caddy's cert -> ./ircd/{fullchain,privkey}.pem -> SIGHUP (rehash)
|
|
# ---------------------------------------------------------------------------
|
|
# Newest <domain>.crt across issuer dirs (Let's Encrypt, ZeroSSL fallback, ...):
|
|
# certmagic writes .key, then .crt, then .json per issuance, each atomically.
|
|
ergo_cert_source() {
|
|
local d best="" f
|
|
d="$(ergo_domain)"
|
|
for f in "$CADDY_DIR"/data/caddy/certificates/*/"$d"/"$d".crt; do
|
|
[[ -f "$f" ]] || continue
|
|
if [[ -z "$best" || "$f" -nt "$best" ]]; then best="$f"; fi
|
|
done
|
|
[[ -n "$best" ]] || return 1
|
|
printf '%s' "$best"
|
|
}
|
|
|
|
cert_fingerprint() { openssl x509 -in "$1" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2 || true; }
|
|
cert_enddate() { openssl x509 -in "$1" -noout -enddate 2>/dev/null | cut -d= -f2 || true; }
|
|
cert_subject_cn() { openssl x509 -in "$1" -noout -subject 2>/dev/null | sed -E 's/.*CN ?= ?([^,\/]+).*/\1/' || true; }
|
|
cert_expires_within() { ! openssl x509 -in "$1" -noout -checkend "$2" >/dev/null 2>&1; } # <file> <seconds>
|
|
cert_is_selfsigned() {
|
|
[[ "$(openssl x509 -in "$1" -noout -issuer 2>/dev/null | cut -d= -f2-)" == "$(openssl x509 -in "$1" -noout -subject 2>/dev/null | cut -d= -f2-)" ]]
|
|
}
|
|
served_fingerprint() { # what :6697 currently presents ("" if the handshake fails)
|
|
local d; d="$(ergo_domain)"
|
|
timeout 10 openssl s_client -connect 127.0.0.1:6697 -servername "$d" </dev/null 2>/dev/null \
|
|
| openssl x509 -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2 || true
|
|
}
|
|
|
|
# Sync Caddy's cert into ./ircd if it changed. Pairwise + atomic: both files are
|
|
# verified to match (pubkey) and staged on the same filesystem before two mv's,
|
|
# so Ergo can never see a half-renewed pair (which is fatal at the next start).
|
|
# rc: 0 synced or nothing to do | 2 no cert from Caddy yet | 1 failed (caller notifies)
|
|
CERTSYNC_MSG=""
|
|
ergo_certsync() {
|
|
local src_crt src_key src_json dst_crt dst_key tmpc tmpk want got i uid gid
|
|
CERTSYNC_MSG=""
|
|
command -v openssl >/dev/null 2>&1 || { CERTSYNC_MSG="openssl missing on the host"; return 1; }
|
|
src_crt="$(ergo_cert_source)" || { CERTSYNC_MSG="no certificate from Caddy yet"; return 2; }
|
|
src_key="${src_crt%.crt}.key"; src_json="${src_crt%.crt}.json"
|
|
[[ -f "$src_key" ]] || { CERTSYNC_MSG="renewal in progress (key missing)"; return 0; }
|
|
# .json is written last: if it is older than .crt/.key a renewal is mid-flight.
|
|
if [[ -f "$src_json" ]] && { [[ "$src_crt" -nt "$src_json" ]] || [[ "$src_key" -nt "$src_json" ]]; }; then
|
|
CERTSYNC_MSG="renewal in progress; retrying later"; return 0
|
|
fi
|
|
dst_crt="$IRCD_DIR/fullchain.pem"; dst_key="$IRCD_DIR/privkey.pem"
|
|
if [[ -f "$dst_crt" && -f "$dst_key" ]] && cmp -s "$src_crt" "$dst_crt" && cmp -s "$src_key" "$dst_key"; then
|
|
CERTSYNC_MSG="up to date"; return 0
|
|
fi
|
|
# cert and key must belong together
|
|
want="$(openssl x509 -in "$src_crt" -noout -pubkey 2>/dev/null | sha256sum | cut -d' ' -f1)"
|
|
got="$(openssl pkey -in "$src_key" -pubout 2>/dev/null | sha256sum | cut -d' ' -f1)"
|
|
if [[ -z "$want" || "$want" != "$got" ]]; then
|
|
CERTSYNC_MSG="cert/key mismatch in Caddy storage (renewal in progress?); retrying later"; return 0
|
|
fi
|
|
uid="$(ergo_uid)"; gid="$(ergo_gid)"
|
|
# Stage and keep the rollback copies OUTSIDE ircd/: that directory is
|
|
# writable by the container uid, and `install`/`cp` would follow a symlink
|
|
# planted there and write as root wherever it points. Only `mv` (rename(2),
|
|
# which replaces a symlink rather than following it) touches ircd/.
|
|
local prevdir="$STACK_DIR/.certsync"
|
|
install -d -m 0700 "$prevdir" || { CERTSYNC_MSG="could not create $prevdir"; return 1; }
|
|
tmpc="$prevdir/fullchain.pem.new"; tmpk="$prevdir/privkey.pem.new"
|
|
if ! install -m 0600 -o "$uid" -g "$gid" "$src_crt" "$tmpc" || ! install -m 0600 -o "$uid" -g "$gid" "$src_key" "$tmpk"; then
|
|
rm -f "$tmpc" "$tmpk"; CERTSYNC_MSG="could not stage the new pair in $prevdir"; return 1
|
|
fi
|
|
# source must not have moved under us mid-copy
|
|
if ! cmp -s "$src_crt" "$tmpc" || ! cmp -s "$src_key" "$tmpk"; then
|
|
rm -f "$tmpc" "$tmpk"; CERTSYNC_MSG="source changed during copy; retrying later"; return 0
|
|
fi
|
|
# Roll-back copies, also outside ircd/. Read the live pair only if it is a
|
|
# real file -- a symlink there is the container trying to exfiltrate.
|
|
rm -f "$prevdir/fullchain.pem.prev" "$prevdir/privkey.pem.prev"
|
|
if ! refuse_symlink "$dst_crt" "ircd/fullchain.pem" || ! refuse_symlink "$dst_key" "ircd/privkey.pem"; then
|
|
rm -f "$tmpc" "$tmpk"; CERTSYNC_MSG="ircd/ TLS path is a symlink; refusing to sync"; return 1
|
|
fi
|
|
[[ -f "$dst_crt" ]] && cp -p "$dst_crt" "$prevdir/fullchain.pem.prev"
|
|
[[ -f "$dst_key" ]] && cp -p "$dst_key" "$prevdir/privkey.pem.prev"
|
|
mv -f "$tmpc" "$dst_crt"; mv -f "$tmpk" "$dst_key"
|
|
if ergo_running; then
|
|
dc kill -s HUP ergo >/dev/null 2>&1 || true
|
|
want="$(cert_fingerprint "$dst_crt")"; got=""
|
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
|
got="$(served_fingerprint)"
|
|
[[ -n "$got" && "$got" == "$want" ]] && break
|
|
sleep 1
|
|
done
|
|
if [[ "$got" != "$want" ]]; then
|
|
# Ergo kept the previous tls config (a failed rehash leaves the old one
|
|
# active). Put the old pair back so the next start is not fatal.
|
|
if [[ -f "$prevdir/fullchain.pem.prev" && -f "$prevdir/privkey.pem.prev" ]]; then
|
|
mv -f "$prevdir/fullchain.pem.prev" "$dst_crt"; mv -f "$prevdir/privkey.pem.prev" "$dst_key"
|
|
dc kill -s HUP ergo >/dev/null 2>&1 || true
|
|
fi
|
|
CERTSYNC_MSG="Ergo did not present the new cert after SIGHUP (see: docker compose logs ergo); previous pair restored"
|
|
return 1
|
|
fi
|
|
fi
|
|
rm -f "$prevdir/fullchain.pem.prev" "$prevdir/privkey.pem.prev"
|
|
CERTSYNC_MSG="installed cert for $(cert_subject_cn "$dst_crt") (expires $(cert_enddate "$dst_crt")) from ${src_crt#"$CADDY_DIR"/data/caddy/certificates/}"
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Small persistent state for de-duplicating notifications (.state/<key>)
|
|
# ---------------------------------------------------------------------------
|
|
state_get() { cat "$STATE_DIR/$1" 2>/dev/null || true; }
|
|
state_set() { install -d -m 0700 "$STATE_DIR" 2>/dev/null; printf '%s\n' "$2" > "$STATE_DIR/$1"; }
|
|
state_rm() { rm -f "$STATE_DIR/$1"; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Notify (reuse the login-notifier's ntfy config, like scripts/auto-update.sh)
|
|
# ---------------------------------------------------------------------------
|
|
send_notice() { # <title> <priority> <body>
|
|
[[ "${NOTIFY:-1}" == "1" ]] || return 0
|
|
[[ -r "$SSH_NOTIFY_CONF" ]] || return 0
|
|
# shellcheck disable=SC1090
|
|
. "$SSH_NOTIFY_CONF"
|
|
[[ -n "${NTFY_URL:-}" ]] || return 0
|
|
command -v curl >/dev/null 2>&1 || return 0
|
|
local title="$1" prio="$2" body="$3" host
|
|
host="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo unknown)"
|
|
set -- -fsS -m 5 -H "X-Title: ${title}" -H "X-Priority: ${prio}"
|
|
[[ -n "${NTFY_EMAIL:-}" ]] && set -- "$@" -H "X-Email: ${NTFY_EMAIL}"
|
|
local t="ergo"; [[ -n "${NTFY_REGION:-}" ]] && t="${t},${NTFY_REGION}"
|
|
set -- "$@" -H "X-Tags: ${t}"
|
|
if [[ "${DRY_RUN:-0}" == "1" ]]; then
|
|
echo "DRY: curl ntfy ($prio): $body"
|
|
elif [[ -n "${NTFY_TOKEN:-}" ]]; then
|
|
# The token goes in on stdin, never in argv: /proc/<pid>/cmdline is
|
|
# world-readable, and every local process is inside this stack's trust
|
|
# boundary already (see README: Security model).
|
|
printf 'header = "Authorization: Bearer %s"\n' "$NTFY_TOKEN" \
|
|
| curl "$@" --config - -d "${body} [${host}]" "$NTFY_URL" >/dev/null 2>&1 || true
|
|
else
|
|
curl "$@" -d "${body} [${host}]" "$NTFY_URL" >/dev/null 2>&1 || true
|
|
fi
|
|
return 0
|
|
}
|