The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.
Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.
Three places needed judgement rather than substitution:
db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.
instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.
peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.
Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
10 KiB
Bash
228 lines
10 KiB
Bash
#!/bin/bash
|
|
|
|
# Notifications — `libreportal notify <sub>`
|
|
# ---------------------------------------------------------------------------
|
|
# The outbound half of "hands-off background work": when a task the user is
|
|
# not watching fails (an automatic update, a scheduled backup), this is what
|
|
# tells them, on channels configured in configs/general/general_notifications.
|
|
#
|
|
# send <severity> <title> <body...> send one message now (severity: info|high)
|
|
# task <task-id> notify about a finished task, applying the
|
|
# CFG_NOTIFY_EVENTS policy (the task
|
|
# processor calls this after every task)
|
|
# test send a test message to every enabled channel
|
|
#
|
|
# Design rules:
|
|
# * Fire-and-forget, never load-bearing: a dead webhook must not fail a task
|
|
# or block the processor, so every channel send has hard curl timeouts and
|
|
# the caller backgrounds the whole invocation.
|
|
# * The POLICY lives here, not in the processor: the processor reports every
|
|
# terminal task and this command decides (per CFG_NOTIFY_EVENTS) whether
|
|
# anyone hears about it. The daemon never needs the notify config.
|
|
# * Cancelled tasks never notify — the user did that themselves.
|
|
|
|
cliHandleNotifyCommands()
|
|
{
|
|
local sub="$initial_command2"
|
|
|
|
case "$sub" in
|
|
"send")
|
|
local sev="$initial_command3"
|
|
local title="$initial_command4"
|
|
local body="$initial_command5"
|
|
if [[ -z "$title" ]]; then isError "Usage: libreportal notify send <info|high> <title> <body>"; return 1; fi
|
|
lpNotifySend "${sev:-info}" "$title" "$body"
|
|
;;
|
|
"task")
|
|
lpNotifyTaskResult "$initial_command3"
|
|
;;
|
|
"test")
|
|
isHeader "Sending a test notification"
|
|
if ! lpNotifyAnyChannelEnabled; then
|
|
isNotice "No notification channels are enabled — turn one on under Settings → Notifications first."
|
|
return 1
|
|
fi
|
|
lpNotifySend "info" "LibrePortal test notification" \
|
|
"Notifications are working on $(hostname 2>/dev/null || echo this-host). Sent $(date '+%Y-%m-%d %H:%M')."
|
|
;;
|
|
*)
|
|
cliShowNotifyHelp
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# True when at least one channel checkbox is on.
|
|
lpNotifyAnyChannelEnabled()
|
|
{
|
|
[[ "$CFG_NOTIFY_EMAIL" == "true" || "$CFG_NOTIFY_NTFY" == "true" \
|
|
|| "$CFG_NOTIFY_GOTIFY" == "true" || "$CFG_NOTIFY_DISCORD" == "true" \
|
|
|| "$CFG_NOTIFY_SLACK" == "true" || "$CFG_NOTIFY_TELEGRAM" == "true" \
|
|
|| "$CFG_NOTIFY_PUSHOVER" == "true" ]]
|
|
}
|
|
|
|
# JSON string escaper for the webhook payloads. jq is used when present (it
|
|
# handles every edge); this fallback covers the two characters that break a
|
|
# JSON string plus newlines, which is sufficient for our own message text.
|
|
_lpNotifyJson()
|
|
{
|
|
if command -v jq >/dev/null 2>&1; then
|
|
jq -rn --arg s "$1" '$s|tojson'
|
|
else
|
|
local s="${1//\\/\\\\}"; s="${s//\"/\\\"}"; s="${s//$'\n'/\\n}"
|
|
printf '"%s"' "$s"
|
|
fi
|
|
}
|
|
|
|
# Send one message to every enabled channel. $1 severity (info|high), $2 title,
|
|
# $3 body. Reports per-channel results; returns 0 when every enabled channel
|
|
# accepted the message (still 0 when none are enabled — nothing to fail).
|
|
lpNotifySend()
|
|
{
|
|
local sev="$1" title="$2" body="$3"
|
|
local curl_opts=(-sS --connect-timeout 5 --max-time 8 -o /dev/null -w '%{http_code}')
|
|
local failures=0 sent=0
|
|
|
|
if ! command -v curl >/dev/null 2>&1; then
|
|
isError "notify: curl is required to send notifications."
|
|
return 1
|
|
fi
|
|
|
|
# One channel = one _try call: runs the curl, classifies the HTTP code,
|
|
# logs per-channel. 2xx = delivered; anything else (or curl error) = fail.
|
|
_try() {
|
|
local name="$1"; shift
|
|
local code; code="$(curl "${curl_opts[@]}" "$@" 2>/dev/null)"
|
|
if [[ "$code" =~ ^2[0-9][0-9]$ ]]; then
|
|
isSuccessful "notify: $name ok"
|
|
sent=$((sent + 1))
|
|
else
|
|
isError "notify: $name failed (HTTP ${code:-none})"
|
|
failures=$((failures + 1))
|
|
fi
|
|
}
|
|
|
|
if [[ "$CFG_NOTIFY_NTFY" == "true" && -n "$CFG_NOTIFY_NTFY_TOPIC" ]]; then
|
|
local ntfy_args=(-H "Title: $title" -H "Priority: $([[ "$sev" == "high" ]] && echo high || echo default)")
|
|
[[ -n "$CFG_NOTIFY_NTFY_TOKEN" ]] && ntfy_args+=(-H "Authorization: Bearer $CFG_NOTIFY_NTFY_TOKEN")
|
|
_try ntfy "${ntfy_args[@]}" -d "$body" "${CFG_NOTIFY_NTFY_URL%/}/$CFG_NOTIFY_NTFY_TOPIC"
|
|
fi
|
|
|
|
if [[ "$CFG_NOTIFY_GOTIFY" == "true" && -n "$CFG_NOTIFY_GOTIFY_URL" && -n "$CFG_NOTIFY_GOTIFY_TOKEN" ]]; then
|
|
_try gotify -F "title=$title" -F "message=$body" \
|
|
-F "priority=$([[ "$sev" == "high" ]] && echo 8 || echo 4)" \
|
|
"${CFG_NOTIFY_GOTIFY_URL%/}/message?token=$CFG_NOTIFY_GOTIFY_TOKEN"
|
|
fi
|
|
|
|
if [[ "$CFG_NOTIFY_DISCORD" == "true" && -n "$CFG_NOTIFY_DISCORD_WEBHOOK" ]]; then
|
|
_try discord -H 'Content-Type: application/json' \
|
|
-d "{\"content\": $(_lpNotifyJson "**${title}**"$'\n'"${body}")}" \
|
|
"$CFG_NOTIFY_DISCORD_WEBHOOK"
|
|
fi
|
|
|
|
if [[ "$CFG_NOTIFY_SLACK" == "true" && -n "$CFG_NOTIFY_SLACK_WEBHOOK" ]]; then
|
|
_try slack -H 'Content-Type: application/json' \
|
|
-d "{\"text\": $(_lpNotifyJson "*${title}*"$'\n'"${body}")}" \
|
|
"$CFG_NOTIFY_SLACK_WEBHOOK"
|
|
fi
|
|
|
|
if [[ "$CFG_NOTIFY_TELEGRAM" == "true" && -n "$CFG_NOTIFY_TELEGRAM_TOKEN" && -n "$CFG_NOTIFY_TELEGRAM_CHAT_ID" ]]; then
|
|
_try telegram --data-urlencode "chat_id=$CFG_NOTIFY_TELEGRAM_CHAT_ID" \
|
|
--data-urlencode "text=${title}"$'\n'"${body}" \
|
|
"https://api.telegram.org/bot${CFG_NOTIFY_TELEGRAM_TOKEN}/sendMessage"
|
|
fi
|
|
|
|
if [[ "$CFG_NOTIFY_PUSHOVER" == "true" && -n "$CFG_NOTIFY_PUSHOVER_TOKEN" && -n "$CFG_NOTIFY_PUSHOVER_USER" ]]; then
|
|
_try pushover -F "token=$CFG_NOTIFY_PUSHOVER_TOKEN" -F "user=$CFG_NOTIFY_PUSHOVER_USER" \
|
|
-F "title=$title" -F "message=$body" \
|
|
-F "priority=$([[ "$sev" == "high" ]] && echo 1 || echo 0)" \
|
|
"https://api.pushover.net/1/messages.json"
|
|
fi
|
|
|
|
# Email rides the existing Mail settings (host/creds/from) — the notify
|
|
# config only adds the on/off switch and the recipient. curl speaks SMTP
|
|
# natively: smtps:// for implicit TLS (465), --ssl-reqd for STARTTLS (587).
|
|
if [[ "$CFG_NOTIFY_EMAIL" == "true" && -n "$CFG_NOTIFY_EMAIL_TO" ]]; then
|
|
if [[ "$CFG_MAIL_ENABLED" != "true" ]]; then
|
|
isError "notify: email is enabled but Mail settings are off (CFG_MAIL_ENABLED) — skipping."
|
|
failures=$((failures + 1))
|
|
else
|
|
local proto="smtp" mail_args=(--mail-from "$CFG_MAIL_FROM" --mail-rcpt "$CFG_NOTIFY_EMAIL_TO")
|
|
case "$CFG_MAIL_SECURE" in
|
|
ssl) proto="smtps" ;;
|
|
tls) mail_args+=(--ssl-reqd) ;;
|
|
esac
|
|
[[ -n "$CFG_MAIL_USERNAME" ]] && mail_args+=(--user "$CFG_MAIL_USERNAME:$CFG_MAIL_PASSWORD")
|
|
local msg; msg="$(printf 'From: LibrePortal <%s>\r\nTo: %s\r\nSubject: %s\r\n\r\n%s\r\n' \
|
|
"$CFG_MAIL_FROM" "$CFG_NOTIFY_EMAIL_TO" "$title" "$body")"
|
|
local ecode; ecode="$(printf '%s' "$msg" | curl -sS --connect-timeout 5 --max-time 15 \
|
|
-o /dev/null -w '%{http_code}' "${mail_args[@]}" -T - \
|
|
"${proto}://${CFG_MAIL_HOST}:${CFG_MAIL_PORT}" 2>/dev/null; echo "rc=$?")"
|
|
# SMTP via curl reports no meaningful http_code; trust the exit code.
|
|
if [[ "$ecode" == *"rc=0"* ]]; then
|
|
isSuccessful "notify: email ok"
|
|
sent=$((sent + 1))
|
|
else
|
|
isError "notify: email failed (${ecode##*rc=})"
|
|
failures=$((failures + 1))
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if (( sent == 0 && failures == 0 )); then
|
|
isNotice "notify: no channels enabled — nothing sent."
|
|
return 0
|
|
fi
|
|
(( failures == 0 ))
|
|
}
|
|
|
|
# Notify about one finished task, applying the events policy. Called by the
|
|
# task processor after EVERY terminal task; this is where "should anyone hear
|
|
# about it" is decided, so the daemon stays policy-free.
|
|
lpNotifyTaskResult()
|
|
{
|
|
local task_id="$1"
|
|
[[ "$task_id" =~ ^task_[0-9]+_[A-Za-z0-9]+$ ]] || return 0
|
|
local events="${CFG_NOTIFY_EVENTS:-failures}"
|
|
[[ "$events" == "off" ]] && return 0
|
|
lpNotifyAnyChannelEnabled || return 0
|
|
|
|
local f="$(webuiDir)/frontend/data/tasks/${task_id}.json"
|
|
[[ -f "$f" ]] || return 0
|
|
local status type app exit_code
|
|
if command -v jq >/dev/null 2>&1; then
|
|
read -r status type app exit_code < <(jq -r '[.status, .type // "task", .app // "", (.exit_code // 0 | tostring)] | join(" ")' "$f" 2>/dev/null)
|
|
else
|
|
status=$(grep -oE '"status"[[:space:]]*:[[:space:]]*"[^"]+"' "$f" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
type=$(grep -oE '"type"[[:space:]]*:[[:space:]]*"[^"]+"' "$f" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
app=$(grep -oE '"app"[[:space:]]*:[[:space:]]*"[^"]+"' "$f" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
|
exit_code=""
|
|
fi
|
|
[[ "$status" == "cancelled" ]] && return 0 # the user did that
|
|
[[ "$status" == "failed" || "$events" == "all" ]] || return 0
|
|
|
|
# Human copy per task type — the failure lines say what ALREADY happened
|
|
# (rollback, retained snapshot), so the reader knows the box is safe before
|
|
# they even open the WebUI.
|
|
local host; host="$(hostname 2>/dev/null || echo LibrePortal)"
|
|
local label="${type:-task}"; local extra=""
|
|
case "$type" in
|
|
updater_apply|updater_apply_all)
|
|
label="Update${app:+ of $app}"
|
|
extra=" The app was rolled back to its pre-update snapshot and this build will not be retried automatically — retry from the Updates page when ready." ;;
|
|
updater_rollback) label="Rollback${app:+ of $app}" ;;
|
|
backup) label="Backup${app:+ of $app}"
|
|
extra=" Check the Backups page — the previous snapshots are unaffected." ;;
|
|
restore) label="Restore${app:+ of $app}" ;;
|
|
install) label="Install${app:+ of $app}" ;;
|
|
*) label="Task ${type:-?}${app:+ ($app)}" ;;
|
|
esac
|
|
|
|
if [[ "$status" == "failed" ]]; then
|
|
lpNotifySend "high" "$host: ${label} failed" \
|
|
"${label} failed (exit ${exit_code:-?}) at $(date '+%H:%M').${extra} Full log: WebUI → Tasks → ${task_id}."
|
|
else
|
|
lpNotifySend "info" "$host: ${label} completed" \
|
|
"${label} finished successfully at $(date '+%H:%M')."
|
|
fi
|
|
}
|