feat: menu doors for media-sync/backup/compose/schedule/vbox/download; firewall menu → stderr+/dev/tty mechanics
gates / consistency-and-conventions (push) Successful in 2m10s

This commit is contained in:
Your Name
2026-08-24 14:44:25 -04:00
parent 5b2a0304e1
commit 692cb6b362
13 changed files with 865 additions and 241 deletions
+122 -1
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: docker compose — Docker Compose service manager (ls/up/down/restart/logs/update/config)
# POS_SUBCMDS: ls installed up down restart logs update config
# POS_SUBCMDS: ls installed up down restart logs update config menu
# POS_CONFIG: compose | compose.env | TS_AUTHKEY=secret:Tailscale auth key for the sidecar | TZ=:Service timezone (default Europe/Amsterdam) | DNS_SERVER=:Custom DNS server (default 9.9.9.9) | SERVICES_BASE=:Deployment root (default /srv)
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
SCALE_DIR="${SCALE_DIR:-/usr/local/share/linux_post_install/scale-tail/services}"
CONFIG_ENV="${CONFIG_ENV:-${HOME}/.config/linux_post_install/compose.env}"
@@ -12,6 +13,9 @@ usage() {
cat <<EOF
Usage: pos docker compose <command> [args]
Bare \`pos docker compose\` on a terminal (or \`pos docker compose menu\`) opens
an interactive menu wrapping these commands; arguments stay scriptable.
Commands:
ls List all available ScaleTail service templates
installed List services already deployed on this machine
@@ -340,10 +344,127 @@ EOF
esac
}
###############################################################################
# Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh)
###############################################################################
menu_list_templates() { # available ScaleTail template names, one per line
local d
for d in "$SCALE_DIR"/*/; do
[ -d "$d" ] || continue
basename "$d"
done | LC_ALL=C sort
}
menu_list_deployed() { # deployed stack names under $SERVICES_BASE, one per line
local d
[ -d "$SERVICES_BASE" ] || return 0
for d in "$SERVICES_BASE"/*/; do
[ -d "$d" ] || continue
basename "$d"
done | LC_ALL=C sort
}
# $1 = prompt · $2 = listing function · $3 = empty-state hint → picked name
menu_pick_stack() {
local -a items=()
mapfile -t items < <("$2")
if [ ${#items[@]} -eq 0 ]; then
warn "$3"
return 1
fi
local idx
idx="$(menu_pick "$1" "${items[@]}")" || return 1
printf '%s\n' "${items[$((idx - 1))]}"
}
menu_up() {
local svc
svc="$(menu_pick_stack "Deploy / start which template?" menu_list_templates \
"No ScaleTail templates found at $SCALE_DIR — run install.sh to set them up")" || return 0
cmd_up "$svc" # first-deploy .env/TS_AUTHKEY prompts fold in here
}
menu_down() {
local svc
load_global_config
svc="$(menu_pick_stack "Stop and remove which stack?" menu_list_deployed \
"Nothing deployed under $SERVICES_BASE — deploy a template first ('up')")" || return 0
confirm "Stop and remove stack '$svc' (docker compose down)?" n || { log "Cancelled"; return 0; }
cmd_down "$svc"
}
menu_restart() {
local svc
load_global_config
svc="$(menu_pick_stack "Restart which stack?" menu_list_deployed \
"Nothing deployed under $SERVICES_BASE — deploy a template first ('up')")" || return 0
confirm "Restart stack '$svc'?" n || { log "Cancelled"; return 0; }
cmd_restart "$svc"
}
menu_logs() {
local svc
load_global_config
svc="$(menu_pick_stack "Logs of which stack?" menu_list_deployed \
"Nothing deployed under $SERVICES_BASE — deploy a template first ('up')")" || return 0
log "Following logs of '$svc' — Ctrl-C returns to the menu"
if ! cmd_logs "$svc" -f; then
log "Returned from logs of '$svc'"
fi
}
menu_update() {
load_global_config
confirm "Update ALL stacks: pull latest ScaleTail templates and overwrite deployed compose files under $SERVICES_BASE (.env preserved)?" n \
|| { log "Cancelled"; return 0; }
cmd_update
}
run_menu() {
menu_guard || exit 1
while true; do
local choice
choice="$(menu_run "Docker Compose (ScaleTail)" \
"List available service templates" \
"List deployed stacks (+ status)" \
"Deploy / start a stack (up)" \
"Stop and remove a deployed stack (down)" \
"Restart a deployed stack" \
"Follow a stack's logs (-f; Ctrl-C returns)" \
"Update templates + refresh deployed compose files" \
"Show global config" \
"Edit global config (\$EDITOR)")" || return 0
case "$choice" in
1) cmd_ls ;;
2) cmd_installed ;;
3) menu_up ;;
4) menu_down ;;
5) menu_restart ;;
6) menu_logs ;;
7) menu_update ;;
8) cmd_config show ;;
9) cmd_config edit ;;
esac
done
}
###############################################################################
# CLI dispatch
###############################################################################
# Menu door: explicit verb, or zero args on a terminal. Everything below —
# including zero args without a terminal — stays byte-compatible with the
# pre-menu CLI.
if [ "${1:-}" = "menu" ]; then
run_menu
exit 0
fi
if [ $# -eq 0 ] && [ -t 0 ]; then
run_menu
exit 0
fi
[ $# -eq 0 ] && usage
case "${1:-}" in
+104 -1
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: docker vbox — Disposable Docker-based VMs (create/enter/start/stop/rm/ls)
# POS_SUBCMDS: create enter stop start rm ls
# POS_SUBCMDS: create enter stop start rm ls menu
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
usage() {
cat <<EOF
@@ -16,6 +17,9 @@ Usage:
Manage disposable Docker containers as lightweight VMs.
Bare \`pos docker vbox\` on a terminal (or \`pos docker vbox menu\`) opens an
interactive menu wrapping these verbs; arguments stay scriptable.
Each container gets a bind-mounted host directory so files persist
on the host even after the container is removed.
@@ -37,6 +41,105 @@ EOF
exit 0
}
# ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ──
# Every item re-enters the existing CLI verbs below unchanged — the menu adds
# picks/prompts/confirms only (same self-invocation the tool itself uses for
# tmux handovers). 'enter' hands the terminal to the container shell and
# returns to the loop when the shell exits.
menu_self() { # run this tool's own verb as a child process
"$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" "$@"
}
menu_list_vms() { # vbox VM names, one per line (same label filter as 'ls')
docker ps -a --filter label=linux_post_install.vbox=true --format '{{.Names}}' | LC_ALL=C sort
}
menu_pick_vm() { # $1 = prompt → picked VM name on stdout · rc 1 = cancelled / none
local -a vms=()
mapfile -t vms < <(menu_list_vms 2>/dev/null)
if [ ${#vms[@]} -eq 0 ]; then
if docker info &>/dev/null; then
warn "no vbox VMs found — create one first ('pos docker vbox create <name>')"
else
warn "docker daemon not reachable — start Docker first"
fi
return 1
fi
local idx
idx="$(menu_pick "$1" "${vms[@]}")" || return 1
printf '%s\n' "${vms[$((idx - 1))]}"
}
menu_vbox_create() {
local name image dir
name="$(menu_ask_value "VM name")" || return 0
image="$(menu_ask_value "Image" "ubuntu:22.04")" || return 0
dir="$(menu_ask_value "Host directory (empty = ~/$name)")" || true
if [ -n "${dir:-}" ]; then
confirm "Create VM '$name' from $image (host dir: $dir)?" n \
|| { log "Cancelled"; return 0; }
menu_self create "$name" "$image" --dir "$dir"
else
confirm "Create VM '$name' from $image?" n || { log "Cancelled"; return 0; }
menu_self create "$name" "$image"
fi
}
menu_vbox_enter() {
local vm
vm="$(menu_pick_vm "Enter which VM?")" || return 0
log "Entering '$vm' — exit its shell to return to the menu"
menu_self enter "$vm"
}
menu_vbox_verb() { # $1 = start|stop — pick a VM, run the verb
local verb="$1" vm
vm="$(menu_pick_vm "${verb} which VM?")" || return 0
menu_self "$verb" "$vm"
}
menu_vbox_remove() {
local vm
vm="$(menu_pick_vm "Remove which VM?")" || return 0
confirm "Permanently remove VM '$vm' (docker rm -f; the host folder is kept)?" n \
|| { log "Cancelled — '$vm' kept"; return 0; }
menu_self rm "$vm"
}
run_menu() {
menu_guard || exit 1
while true; do
local choice
choice="$(menu_run "Docker VBox — disposable VMs" \
"List VMs" \
"Create a VM" \
"Enter a VM (interactive shell)" \
"Start a VM" \
"Stop a VM" \
"Remove a VM (permanent)")" || return 0
case "$choice" in
1) menu_self ls ;;
2) menu_vbox_create ;;
3) menu_vbox_enter ;;
4) menu_vbox_verb start ;;
5) menu_vbox_verb stop ;;
6) menu_vbox_remove ;;
esac
done
}
# Menu door: explicit verb, or zero args on a terminal. Everything below —
# including zero args without a terminal — stays byte-compatible with the
# pre-menu CLI.
if [ "${1:-}" = "menu" ]; then
run_menu
exit 0
fi
if [ $# -eq 0 ] && [ -t 0 ]; then
run_menu
exit 0
fi
case "${1:-}" in
-h|--help) usage ;;
esac
+146 -94
View File
@@ -2,10 +2,12 @@
set -euo pipefail
# POS: media sync — Incremental Music → USB sync (mp3/mp4, add/update only)
# POS_FLAGS: --mp3 --mp4 --source --dry-run
# POS_SUBCMDS: menu
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
source "$(dirname "$0")/../lib/usb-lib.sh" 2>/dev/null || source "$(dirname "$0")/usb-lib.sh"
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
load_system_env
@@ -29,6 +31,9 @@ USB storage is detected like \`pos system backup\` (lsblk TRAN + lsusb/by-id
corroboration; unmounted sticks get a mount offer first) and you pick which
one to sync to. The source tree is mirrored under <usb>/$DEST_DIR.
Bare \`pos media sync\` on a terminal (or \`pos media sync menu\`) opens an
interactive menu wrapping these same actions; flags stay scriptable.
Options:
--mp3 Sync only *.mp3 files
--mp4 Sync only *.mp4 files
@@ -53,6 +58,146 @@ EOF
exit 0
}
# ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ──
menu_change_source() {
local val
val="$(menu_ask_value "Source folder [current: $SRC]")" || return 0
if [ ! -d "$val" ]; then
warn "Not a folder: $val"
return 0
fi
SRC="$val"
log "Source set to $SRC"
}
run_menu() {
menu_guard || exit 1
while true; do
local choice
choice="$(menu_run "Music sync" \
"Sync now — mp3 + mp4" \
"Preview only (--dry-run)" \
"Sync mp3 only" \
"Sync mp4 only" \
"Change source folder (current: $SRC)")" || return 0
case "$choice" in
1) MP3=1; MP4=1; DRY_RUN=0; cmd_sync ;;
2) MP3=1; MP4=1; DRY_RUN=1; cmd_sync ;;
3) MP3=1; MP4=0; DRY_RUN=0; cmd_sync ;;
4) MP3=0; MP4=1; DRY_RUN=0; cmd_sync ;;
5) menu_change_source ;;
esac
done
}
cmd_sync() {
[ -d "$SRC" ] || err "Source not found: $SRC"
trap 'notify_send "Music sync FAILED"' ERR
section "Music sync"
echo "Source : $SRC"
if [ "$MP3" -eq 1 ] && [ "$MP4" -eq 1 ]; then
echo "Filter : mp3 + mp4"
find_expr=(-type f \( -iname '*.mp3' -o -iname '*.mp4' \))
elif [ "$MP3" -eq 1 ]; then
echo "Filter : mp3 only"
find_expr=(-type f -iname '*.mp3')
else
echo "Filter : mp4 only"
find_expr=(-type f -iname '*.mp4')
fi
usb_pick_root "Sync to" "$DEST_DIR" "no sync performed" || {
log "Skipped — no sync performed"
return 0
}
dest_root="${USB_ROOT%/}/$DEST_DIR"
echo "Target : $dest_root"
# Does the destination need this file copied? Missing, or size/mtime differs.
needs_copy() {
local src="$1" dst="$2" ss="" ds="" sm="" dm=""
[ -f "$dst" ] || return 0
ss="$(stat -c %s "$src" 2>/dev/null || printf 0)"
ds="$(stat -c %s "$dst" 2>/dev/null || printf 0)"
sm="$(stat -c %Y "$src" 2>/dev/null || printf 0)"
dm="$(stat -c %Y "$dst" 2>/dev/null || printf 0)"
[ "$ss" = "$ds" ] && [ "$sm" -le "$dm" ] && return 1
return 0
}
# Pre-flight: fail early on "won't fit" instead of a mid-copy ENOSPC that
# leaves a half-written target. Measures exactly what needs_copy would copy.
space_path="$USB_ROOT"
if [ "$DRY_RUN" -eq 0 ]; then
mkdir -p "$dest_root"
space_path="$dest_root"
fi
need_kb=0
while IFS= read -r f; do
rel="${f#"$SRC/"}"
if needs_copy "$f" "$dest_root/$rel"; then
sz="$(stat -c %s "$f" 2>/dev/null || printf 0)"
need_kb=$((need_kb + (sz + 1023) / 1024))
fi
done < <(find -H "$SRC" "${find_expr[@]}")
have_kb="$(df -Pk "$space_path" 2>/dev/null | awk 'NR==2 {print $4}')"
have_kb="${have_kb:-0}"
if [ "$need_kb" -gt "$have_kb" ]; then
msg="Not enough free space on ${USB_ROOT%/}: need ~${need_kb}K, have ${have_kb}K"
if [ "$DRY_RUN" -eq 1 ]; then
warn "$msg"
else
err "$msg"
fi
fi
added=0
updated=0
unchanged=0
while IFS= read -r f; do
rel="${f#"$SRC/"}"
dest="$dest_root/$rel"
if needs_copy "$f" "$dest"; then
if [ -f "$dest" ]; then
updated=$((updated + 1))
else
added=$((added + 1))
fi
if [ "$DRY_RUN" -eq 1 ]; then
log "would copy $rel"
else
mkdir -p "$(dirname "$dest")"
cp --preserve=timestamps "$f" "$dest"
echo " + $rel"
fi
else
unchanged=$((unchanged + 1))
fi
done < <(find -H "$SRC" "${find_expr[@]}" | sort)
if [ "$DRY_RUN" -eq 1 ]; then
echo "DRY RUN — nothing copied. Would sync: ${added} new, ${updated} updated, ${unchanged} unchanged → $dest_root"
else
ok "Sync complete: ${added} added, ${updated} updated, ${unchanged} unchanged → $dest_root"
notify_send "Music sync completed: ${added} added, ${updated} updated → $dest_root"
fi
}
# Menu door: explicit verb, or zero args on a terminal. Everything below —
# including zero args without a terminal — stays byte-compatible with the
# pre-menu CLI.
if [ "${1:-}" = "menu" ]; then
run_menu
exit 0
fi
if [ $# -eq 0 ] && [ -t 0 ]; then
run_menu
exit 0
fi
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
@@ -68,97 +213,4 @@ while [[ $# -gt 0 ]]; do
esac
done
[ "$MP3" -eq 1 ] || [ "$MP4" -eq 1 ] || { MP3=1; MP4=1; }
[ -d "$SRC" ] || err "Source not found: $SRC"
trap 'notify_send "Music sync FAILED"' ERR
section "Music sync"
echo "Source : $SRC"
if [ "$MP3" -eq 1 ] && [ "$MP4" -eq 1 ]; then
echo "Filter : mp3 + mp4"
find_expr=(-type f \( -iname '*.mp3' -o -iname '*.mp4' \))
elif [ "$MP3" -eq 1 ]; then
echo "Filter : mp3 only"
find_expr=(-type f -iname '*.mp3')
else
echo "Filter : mp4 only"
find_expr=(-type f -iname '*.mp4')
fi
usb_pick_root "Sync to" "$DEST_DIR" "no sync performed" || {
log "Skipped — no sync performed"
exit 0
}
dest_root="${USB_ROOT%/}/$DEST_DIR"
echo "Target : $dest_root"
# Does the destination need this file copied? Missing, or size/mtime differs.
needs_copy() {
local src="$1" dst="$2" ss="" ds="" sm="" dm=""
[ -f "$dst" ] || return 0
ss="$(stat -c %s "$src" 2>/dev/null || printf 0)"
ds="$(stat -c %s "$dst" 2>/dev/null || printf 0)"
sm="$(stat -c %Y "$src" 2>/dev/null || printf 0)"
dm="$(stat -c %Y "$dst" 2>/dev/null || printf 0)"
[ "$ss" = "$ds" ] && [ "$sm" -le "$dm" ] && return 1
return 0
}
# Pre-flight: fail early on "won't fit" instead of a mid-copy ENOSPC that
# leaves a half-written target. Measures exactly what needs_copy would copy.
space_path="$USB_ROOT"
if [ "$DRY_RUN" -eq 0 ]; then
mkdir -p "$dest_root"
space_path="$dest_root"
fi
need_kb=0
while IFS= read -r f; do
rel="${f#"$SRC/"}"
if needs_copy "$f" "$dest_root/$rel"; then
sz="$(stat -c %s "$f" 2>/dev/null || printf 0)"
need_kb=$((need_kb + (sz + 1023) / 1024))
fi
done < <(find -H "$SRC" "${find_expr[@]}")
have_kb="$(df -Pk "$space_path" 2>/dev/null | awk 'NR==2 {print $4}')"
have_kb="${have_kb:-0}"
if [ "$need_kb" -gt "$have_kb" ]; then
msg="Not enough free space on ${USB_ROOT%/}: need ~${need_kb}K, have ${have_kb}K"
if [ "$DRY_RUN" -eq 1 ]; then
warn "$msg"
else
err "$msg"
fi
fi
added=0
updated=0
unchanged=0
while IFS= read -r f; do
rel="${f#"$SRC/"}"
dest="$dest_root/$rel"
if needs_copy "$f" "$dest"; then
if [ -f "$dest" ]; then
updated=$((updated + 1))
else
added=$((added + 1))
fi
if [ "$DRY_RUN" -eq 1 ]; then
log "would copy $rel"
else
mkdir -p "$(dirname "$dest")"
cp --preserve=timestamps "$f" "$dest"
echo " + $rel"
fi
else
unchanged=$((unchanged + 1))
fi
done < <(find -H "$SRC" "${find_expr[@]}" | sort)
if [ "$DRY_RUN" -eq 1 ]; then
echo "DRY RUN — nothing copied. Would sync: ${added} new, ${updated} updated, ${unchanged} unchanged → $dest_root"
else
ok "Sync complete: ${added} added, ${updated} updated, ${unchanged} unchanged → $dest_root"
notify_send "Music sync completed: ${added} added, ${updated} updated → $dest_root"
fi
cmd_sync
+154 -1
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: network download — aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits)
# POS_SUBCMDS: start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace
# POS_SUBCMDS: start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace menu
# POS_FLAGS: --dir --out --split --seed --force --upload --gid --tmux
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
command -v aria2c &>/dev/null || err "aria2c not found (install aria2)"
command -v jq &>/dev/null || err "jq not found (install jq)"
@@ -45,6 +46,9 @@ Usage: pos network download <command> [args]
aria2 download daemon + queue control. Runs a persistent aria2c with JSON-RPC
(systemd user service on localhost:$RPC_PORT) and drives it via the RPC API.
Bare \`pos network download\` on a terminal (or \`pos network download menu\`)
opens an interactive menu wrapping these commands; arguments stay scriptable.
Commands:
start Start the daemon (installs the systemd user service, generates RPC secret)
stop Stop the daemon and remove the service
@@ -922,6 +926,142 @@ tmux_watch() {
log "tmux session '$sname' started — attach: tmux attach -t '$sname'"
}
# ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ──
# Top-verb map over the existing cmd_* implementations — picks/prompts/gates
# only, no new RPC logic. Queue views are gated on a non-fatal RPC liveness
# probe first: rpc() itself err-exits, so a dead daemon must be caught before
# it can end the menu; the graceful hint points at the start-daemon item.
# This tool deliberately stays OUTSIDE the dispatcher's INTERACTIVE_CMDS (its
# verbs are pipe-friendly one-shots that keep their tee logs), so every prompt
# here is a lib/menu-lib.sh primitive behind menu_guard's tty proof — no raw
# stdin-read helpers.
menu_ask_yn() { # $1 = question · rc 0 iff answered yes (default n; EOF cancels)
local ans
ans="$(menu_ask_value "$1" "N")" || return 1
[[ "$ans" =~ ^[Yy] ]]
}
menu_rpc_ok() { # cheap non-fatal liveness probe (same request shape as rpc())
curl -fsS --noproxy '*' -m 3 -H 'Content-Type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"aria2.getVersion\",\"params\":[\"token:${RPC_SECRET}\"]}" \
"$RPC_URL" >/dev/null 2>&1
}
menu_gate_daemon() { # rc 0 iff the RPC answers · else graceful pointer
if menu_rpc_ok; then return 0; fi
warn "aria2 RPC unreachable on $RPC_URL — start the daemon first (menu item below, or 'pos network download start')"
return 1
}
menu_list_gids() { # "full_gid<tab>status<tab>name" for active + waiting + stopped
{ rpc aria2.tellActive; rpc aria2.tellWaiting 0 200; rpc aria2.tellStopped 0 200; } \
| jq -r --arg na '?' '.result[]? |
[ .gid, .status,
(.bittorrent.info.name // (.files[0].path // $na | split("/") | last)) ] | @tsv'
}
menu_pick_gid_row() { # $1 = prompt → "<gid><tab>label" on stdout · rc 1 = cancelled / none
local -a rows=() gids=() labels=()
mapfile -t rows < <(menu_list_gids)
[ ${#rows[@]} -gt 0 ] || { warn "queue is empty — nothing to pick"; return 1; }
local r
for r in "${rows[@]}"; do
gids+=("${r%%$'\t'*}")
labels+=("$(printf '%s' "$r" | cut -f2- | tr '\t' ' ')")
done
local idx
idx="$(menu_pick "$1" "${labels[@]}")" || return 1
printf '%s\t%s\n' "${gids[$((idx - 1))]}" "${labels[$((idx - 1))]}"
}
menu_pick_gid() { # $1 = prompt → full gid on stdout · rc 1 = cancelled / none
local row
row="$(menu_pick_gid_row "$1")" || return 1
printf '%s\n' "${row%%$'\t'*}"
}
menu_gid_action() { # $1 = info|pause|resume|restart — pick a download, run the verb
local verb="$1" gid
menu_gate_daemon || return 0
gid="$(menu_pick_gid "$verb which download?")" || return 0
"cmd_$verb" "$gid"
}
menu_download_add() { # ask for a URL, hand cmd_add the existing flags
local url
url="$(menu_ask_value "URL to add (download dir: $DOWNLOAD_DIR)")" || return 0
[ -n "$url" ] || return 0
if menu_ask_yn "Hand live progress to a tmux session (--tmux)?"; then
cmd_add --tmux "$url"
else
log "(watch it later with: pos network download watch)"
cmd_add "$url"
fi
}
menu_download_remove() {
menu_gate_daemon || return 0
local row gid label name
row="$(menu_pick_gid_row "Remove which download?")" || return 0
gid="${row%%$'\t'*}"
label="${row#*$'\t'}"
name="${label#* }"
menu_ask_yn "Remove download '$name' (${gid:0:8})? Its progress is discarded." \
|| { log "Cancelled — kept"; return 0; }
cmd_remove "$gid"
}
menu_purge() {
menu_gate_daemon || return 0
warn "Purge clears ALL finished/error history from aria2."
local word
word="$(menu_ask_value "Type purge to clear finished/error history")" || return 0
[ "$word" = "purge" ] || { log "Cancelled — history kept"; return 0; }
cmd_purge
}
menu_daemon_stop() {
menu_ask_yn "Stop the aria2 daemon ($SERVICE)? Active downloads pause until it runs again." \
|| { log "Cancelled"; return 0; }
cmd_stop
}
run_menu() {
menu_guard || exit 1
while true; do
local choice
choice="$(menu_run "Downloads — aria2 RPC queue" \
"Daemon status" \
"Overview — status + queue snapshot" \
"List downloads" \
"Add a download URL" \
"Download details (info)" \
"Pause a download" \
"Resume a download" \
"Remove a download" \
"Re-queue / restart a download" \
"Purge finished/error history (type purge)" \
"Watch live progress (Ctrl-C leaves the menu)" \
"Start the daemon" \
"Stop the daemon")" || return 0
case "$choice" in
1) cmd_status ;;
2) menu_gate_daemon && overview ;;
3) menu_gate_daemon && cmd_list ;;
4) menu_download_add ;;
5) menu_gid_action info ;;
6) menu_gid_action pause ;;
7) menu_gid_action resume ;;
8) menu_download_remove ;;
9) menu_gid_action restart ;;
10) menu_purge ;;
11) menu_gate_daemon && cmd_watch ;;
12) cmd_start ;;
13) menu_daemon_stop ;;
esac
done
}
# ── Dispatch ───────────────────────────────────────────────────
overview() {
cmd_status
@@ -948,4 +1088,17 @@ main() {
esac
}
# Menu door: explicit verb, or zero args on a terminal. Everything below —
# including zero args without a terminal — stays byte-compatible with the
# pre-menu CLI; scripted verbs (and the healer timer's `retry … --once`)
# never enter the menu.
if [ "${1:-}" = "menu" ]; then
run_menu
exit 0
fi
if [ $# -eq 0 ] && [ -t 0 ]; then
run_menu
exit 0
fi
main "$@"
+132 -56
View File
@@ -2,11 +2,13 @@
set -euo pipefail
# POS: system backup — Encrypted (AES-256) folder snapshots (tar + gpg)
# POS_FLAGS: --service --no-encrypt
# POS_SUBCMDS: menu
# POS_CONFIG: notify | notify.env | NOTIFY_PLATFORM=:Comma-separated notify platforms (default telegram) — shared by backup, firewall, share nfs client/server
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
source "$(dirname "$0")/../lib/usb-lib.sh" 2>/dev/null || source "$(dirname "$0")/usb-lib.sh"
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
load_system_env
EFF_ROOTS="${BACKUP_SERVICE_ROOTS:-/srv $HOME/srv}"
@@ -29,6 +31,9 @@ Modes:
--no-encrypt Skip encryption (no password prompt, artifact stays .tar.gz).
--service List folders under /srv and ~/srv, pick one, back it up.
Bare \`pos system backup\` on a terminal (or \`pos system backup menu\`)
opens an interactive menu wrapping these modes; arguments stay scriptable.
The final artifact <name>_<date>.tar.gz[.gpg] is written to the current directory.
After it verifies, connected USB storage is offered: the copy lands in
<usb>/backups/ and is sha256-verified 100% before it is announced. A stick
@@ -112,17 +117,10 @@ usb_copy_offer() {
SERVICE=0
ENCRYPT=1
[ "${BACKUP_ENCRYPT:-1}" = "0" ] && ENCRYPT=0
for arg in "$@"; do
case "$arg" in
-h|--help) usage ;;
--service) SERVICE=1 ;;
--no-encrypt) ENCRYPT=0 ;;
*) FOLDER="$arg" ;;
esac
done
{ [ "$SERVICE" -eq 1 ] || [ -n "${FOLDER:-}" ]; } || err "Missing folder path (or use --service)"
if [ "$SERVICE" -eq 1 ]; then
# ── Folder picker over the --service roots ──────────────────────
# Sets $FOLDER; errors out when nothing can be offered (same as --service).
pick_service_folder() {
if [ -n "${BACKUP_SERVICE_ROOTS:-}" ]; then
read -r -a roots <<< "$BACKUP_SERVICE_ROOTS"
else
@@ -156,61 +154,139 @@ if [ "$SERVICE" -eq 1 ]; then
err "Invalid selection: $choice"
fi
FOLDER="${names[$choice]}"
fi
}
[ -d "$FOLDER" ] || err "Folder not found: $FOLDER"
# ── Backup flow ($FOLDER → timestamped archive, then USB copy offer) ──
run_backup() {
[ -d "$FOLDER" ] || err "Folder not found: $FOLDER"
NAME="$(basename "$FOLDER")"
DATE="$(date +%Y-%m-%d_%H-%M-%S)"
ARCHIVE="${NAME}_${DATE}.tar.gz"
NAME="$(basename "$FOLDER")"
DATE="$(date +%Y-%m-%d_%H-%M-%S)"
ARCHIVE="${NAME}_${DATE}.tar.gz"
echo
log "Creating backup..."
echo "Source : $FOLDER"
echo "Output : $ARCHIVE"
echo
log "Creating backup..."
echo "Source : $FOLDER"
echo "Output : $ARCHIVE"
sudo tar -czvf "$ARCHIVE" -C "$(dirname "$FOLDER")" "$NAME"
sudo tar -czvf "$ARCHIVE" -C "$(dirname "$FOLDER")" "$NAME"
log "Verifying archive..."
tar -tzf "$ARCHIVE" > /dev/null
log "Archive verified"
log "Verifying archive..."
tar -tzf "$ARCHIVE" > /dev/null
log "Archive verified"
if [ "$ENCRYPT" -eq 1 ]; then
command -v gpg &>/dev/null || err "gpg not found (install gnupg)"
if [ "$ENCRYPT" -eq 1 ]; then
command -v gpg &>/dev/null || err "gpg not found (install gnupg)"
while true; do
read -s -rp "Enter backup password: " PASS
echo
read -s -rp "Confirm backup password: " CONFIRM
echo
if [ -n "$PASS" ] && [ "$PASS" = "$CONFIRM" ]; then
break
fi
warn "Passwords are empty or do not match — try again"
done
unset CONFIRM
log "Encrypting backup..."
gpg --batch --yes --passphrase "$PASS" --symmetric --cipher-algo AES256 "$ARCHIVE"
rm -f "$ARCHIVE"
ARCHIVE="${ARCHIVE}.gpg"
chmod 600 "$ARCHIVE"
log "Verifying encrypted backup..."
gpg --batch --quiet --passphrase "$PASS" --decrypt "$ARCHIVE" | tar -tzf - > /dev/null
unset PASS
else
chmod 600 "$ARCHIVE"
log "No encryption requested — keeping $ARCHIVE"
fi
echo
log "Backup completed: $ARCHIVE"
notify_send "Backup completed: $ARCHIVE"
# Optional: detect a USB stick connected after the backup finished, offer to
# copy the archive to <usb>/backups/, and prove the transfer 100%. From here
# on a failure is a USB-copy problem, not a backup problem.
trap 'notify_send "USB copy FAILED: ${ARCHIVE:-unknown}"' ERR
usb_copy_offer "$ARCHIVE"
}
# ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ──
menu_backup_folder() {
local enc="$1" val prompt
val="$(menu_ask_value "Folder to back up")" || return 0
if [ ! -d "$val" ]; then
warn "Not a folder: $val"
return 0
fi
FOLDER="$val"
if [ "$enc" -eq 1 ]; then
prompt="Create ENCRYPTED backup of $FOLDER?"
else
prompt="Create UNENCRYPTED backup of $FOLDER (plain .tar.gz, no password)?"
fi
confirm "$prompt" n || { log "Cancelled"; return 0; }
ENCRYPT="$enc"
run_backup
}
menu_backup_service() {
if ! pick_service_folder; then
warn "Cancelled — no folder selected"
return 0
fi
confirm "Create ENCRYPTED backup of $FOLDER?" n || { log "Cancelled"; return 0; }
ENCRYPT=1
run_backup
}
run_menu() {
menu_guard || exit 1
while true; do
read -s -rp "Enter backup password: " PASS
echo
read -s -rp "Confirm backup password: " CONFIRM
echo
if [ -n "$PASS" ] && [ "$PASS" = "$CONFIRM" ]; then
break
fi
warn "Passwords are empty or do not match — try again"
local choice
choice="$(menu_run "System backup" \
"New encrypted backup (type/paste folder)" \
"New encrypted backup — pick from ${EFF_ROOTS}" \
"New backup WITHOUT encryption (type/paste folder)")" || return 0
case "$choice" in
1) menu_backup_folder 1 ;;
2) menu_backup_service ;;
3) menu_backup_folder 0 ;;
esac
done
unset CONFIRM
}
log "Encrypting backup..."
gpg --batch --yes --passphrase "$PASS" --symmetric --cipher-algo AES256 "$ARCHIVE"
rm -f "$ARCHIVE"
ARCHIVE="${ARCHIVE}.gpg"
chmod 600 "$ARCHIVE"
log "Verifying encrypted backup..."
gpg --batch --quiet --passphrase "$PASS" --decrypt "$ARCHIVE" | tar -tzf - > /dev/null
unset PASS
else
chmod 600 "$ARCHIVE"
log "No encryption requested — keeping $ARCHIVE"
# Menu door: explicit verb, or zero args on a terminal. Everything below —
# including zero args without a terminal — stays byte-compatible with the
# pre-menu CLI.
if [ "${1:-}" = "menu" ]; then
run_menu
exit 0
fi
echo
log "Backup completed: $ARCHIVE"
notify_send "Backup completed: $ARCHIVE"
# Optional: detect a USB stick connected after the backup finished, offer to
# copy the archive to <usb>/backups/, and prove the transfer 100%. From here
# on a failure is a USB-copy problem, not a backup problem.
trap 'notify_send "USB copy FAILED: ${ARCHIVE:-unknown}"' ERR
usb_copy_offer "$ARCHIVE"
for arg in "$@"; do
case "$arg" in
-h|--help) usage ;;
--service) SERVICE=1 ;;
--no-encrypt) ENCRYPT=0 ;;
*) FOLDER="$arg" ;;
esac
done
if [ $# -eq 0 ] && [ -t 0 ]; then
run_menu
exit 0
fi
{ [ "$SERVICE" -eq 1 ] || [ -n "${FOLDER:-}" ]; } || err "Missing folder path (or use --service)"
if [ "$SERVICE" -eq 1 ]; then
pick_service_folder
fi
run_backup
+59 -42
View File
@@ -39,10 +39,25 @@ log() { echo "[+] $*"; }
warn() { echo "[!] $*"; }
err() { echo "ERROR: $*" >&2; exit 1; }
# Interactive input seam: every prompt reads the controlling terminal
# (/dev/tty), so the menu survives stdout redirection / command substitution,
# and fails closed on EOF or a missing TTY — it prints a pointer to the CLI
# instead of hanging under cron/pipes (repo-standard menu mechanics; see
# lib/menu-lib.sh contracts).
tty_read() {
local prompt="$1"
shift
if ! read -rp "$prompt" "$@" < /dev/tty; then
printf '[!] Terminal closed or unavailable (EOF) — stopping; nothing more was executed.\n' >&2
printf '[!] Re-open interactively with: sudo pos system firewall (see --help)\n' >&2
exit 1
fi
}
run_cmd() {
local -a cmd=("$@")
printf "\n>>> %s\n" "${cmd[*]}"
read -rp "Execute this command? [y/N]: " confirm
tty_read "Execute this command? [y/N]: " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "(dry-run) skipping execution"
@@ -101,9 +116,9 @@ build_ufw_cmd() {
}
prompt_ipver() {
local ver
read -rp "IP version (4 / 6 / both): " ver
echo "$ver"
# Assigns `ipver` in the caller's scope (bash dynamic scoping); direct call
# instead of command substitution so an EOF exits the whole tool gracefully.
tty_read "IP version (4 / 6 / both): " ipver
}
apply_for_versions() {
@@ -111,7 +126,7 @@ apply_for_versions() {
local port="$6" onif="$7" logmode="$8" comment="$9"
local insert_pos="${10:-}"
local ipver
ipver=$(prompt_ipver)
prompt_ipver
case "$ipver" in
4) build_ufw_cmd "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment" "$insert_pos" "" ;;
@@ -130,26 +145,26 @@ add_rule() {
echo "1) Port/service (eg: port 8080 or 'ssh')"
echo "2) IP-based (from X to Y)"
echo "3) Directional port rule (in/out to any port ...)"
read -rp "Choice: " rtype
tty_read "Choice: " rtype
case "$rtype" in
1)
read -rp "Action (allow/deny/reject/limit) [allow]: " action
tty_read "Action (allow/deny/reject/limit) [allow]: " action
action=${action:-allow}
read -rp "Enter port number or service name (eg 'ssh' or '8080'): " port_or_svc
tty_read "Enter port number or service name (eg 'ssh' or '8080'): " port_or_svc
if [[ "$port_or_svc" =~ ^[0-9]+$ ]]; then
read -rp "Protocol (tcp/udp/any) [tcp]: " proto
tty_read "Protocol (tcp/udp/any) [tcp]: " proto
proto=${proto:-tcp}
[[ "$proto" == "any" ]] && proto=""
read -rp "Interface (leave empty for any): " onif
read -rp "Log? (none/log/log-all) [none]: " logmode
tty_read "Interface (leave empty for any): " onif
tty_read "Log? (none/log/log-all) [none]: " logmode
[[ "$logmode" == "none" ]] && logmode=""
read -rp "Comment (optional): " comment
tty_read "Comment (optional): " comment
apply_for_versions "$action" "" "$proto" "" "any" "$port_or_svc" "$onif" "$logmode" "$comment"
else
read -rp "IP version (4 / 6 / both) [4]: " ipver
tty_read "IP version (4 / 6 / both) [4]: " ipver
ipver=${ipver:-4}
case "$ipver" in
4) run_cmd ufw "$action" "$port_or_svc" ;;
@@ -162,41 +177,41 @@ add_rule() {
;;
2)
read -rp "Action (allow/deny/reject) [deny]: " action
tty_read "Action (allow/deny/reject) [deny]: " action
action=${action:-deny}
read -rp "From address/CIDR (eg 192.168.1.5 or 10.0.0.0/24): " from
read -rp "To address (leave empty for 'any') [any]: " to
tty_read "From address/CIDR (eg 192.168.1.5 or 10.0.0.0/24): " from
tty_read "To address (leave empty for 'any') [any]: " to
to=${to:-any}
read -rp "Direction (in/out) [in]: " direction
tty_read "Direction (in/out) [in]: " direction
direction=${direction:-in}
read -rp "Port (leave empty if not applicable): " port
read -rp "Protocol (tcp/udp/any) [any]: " proto
tty_read "Port (leave empty if not applicable): " port
tty_read "Protocol (tcp/udp/any) [any]: " proto
[[ "$proto" == "any" ]] && proto=""
read -rp "Interface (leave empty for any): " onif
read -rp "Log? (none/log/log-all) [none]: " logmode
tty_read "Interface (leave empty for any): " onif
tty_read "Log? (none/log/log-all) [none]: " logmode
[[ "$logmode" == "none" ]] && logmode=""
read -rp "Comment (optional): " comment
tty_read "Comment (optional): " comment
apply_for_versions "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment"
;;
3)
read -rp "Action (allow/deny/reject/limit) [allow]: " action
tty_read "Action (allow/deny/reject/limit) [allow]: " action
action=${action:-allow}
read -rp "Direction (in/out) [in]: " direction
tty_read "Direction (in/out) [in]: " direction
direction=${direction:-in}
read -rp "Port number: " port
read -rp "Protocol (tcp/udp/any) [tcp]: " proto
tty_read "Port number: " port
tty_read "Protocol (tcp/udp/any) [tcp]: " proto
[[ "$proto" == "any" ]] && proto=""
read -rp "On interface (leave empty for any): " onif
read -rp "From address (optional): " from
tty_read "On interface (leave empty for any): " onif
tty_read "From address (optional): " from
from=${from:-}
read -rp "To address [any]: " to
tty_read "To address [any]: " to
to=${to:-any}
read -rp "Log? (none/log/log-all) [none]: " logmode
tty_read "Log? (none/log/log-all) [none]: " logmode
[[ "$logmode" == "none" ]] && logmode=""
read -rp "Comment (optional): " comment
read -rp "Insert position (number/prepend/empty): " insert_pos
tty_read "Comment (optional): " comment
tty_read "Insert position (number/prepend/empty): " insert_pos
apply_for_versions "$action" "$direction" "$proto" "$from" "$to" "$port" "$onif" "$logmode" "$comment" "$insert_pos"
;;
@@ -210,16 +225,16 @@ delete_rule() {
echo "Delete rule by:"
echo "1) rule number (use 'ufw status numbered' to see numbers)"
echo "2) rule text (eg: 'allow 22/tcp')"
read -rp "Choice: " dch
tty_read "Choice: " dch
case "$dch" in
1)
ufw status numbered
read -rp "Number to delete: " num
tty_read "Number to delete: " num
run_cmd ufw delete "$num"
;;
2)
read -rp "Exact rule text to delete (eg: deny 80/tcp): " ruletext
tty_read "Exact rule text to delete (eg: deny 80/tcp): " ruletext
run_cmd ufw delete $ruletext
;;
*) echo "Unknown choice." ;;
@@ -231,7 +246,7 @@ show_status() {
echo "1) Simple status"
echo "2) Verbose status"
echo "3) Numbered status (useful for delete)"
read -rp "Choice: " sc
tty_read "Choice: " sc
case "$sc" in
1) run_cmd ufw status ;;
2) run_cmd ufw status verbose ;;
@@ -241,7 +256,8 @@ show_status() {
}
while true; do
cat <<'MENU'
{
cat <<'MENU'
==============================
UFW POWER — human friendly
@@ -257,7 +273,8 @@ while true; do
0) Exit
------------------------------
MENU
read -rp "Choose: " opt
} >&2
tty_read "Choose: " opt
case "$opt" in
1) add_rule ;;
@@ -267,13 +284,13 @@ MENU
5) run_cmd ufw disable ;;
6)
echo "WARNING: ufw reset will disable and remove all rules."
read -rp "Type 'RESET' to confirm: " c
tty_read "Type 'RESET' to confirm: " c
[[ "$c" == "RESET" ]] && run_cmd ufw reset || echo "Reset aborted."
;;
7)
read -rp "Default incoming policy (allow/deny/reject) [deny]: " defin
tty_read "Default incoming policy (allow/deny/reject) [deny]: " defin
defin=${defin:-deny}
read -rp "Default outgoing policy (allow/deny/reject) [allow]: " defout
tty_read "Default outgoing policy (allow/deny/reject) [allow]: " defout
defout=${defout:-allow}
run_cmd ufw default "$defin" incoming
run_cmd ufw default "$defout" outgoing
@@ -303,6 +320,6 @@ MENU
esac
echo
read -rp "Press Enter to continue..."
tty_read "Press Enter to continue..." REPLY
clear
done
+71 -1
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: system schedule — Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently
# POS_SUBCMDS: run list config enable disable status migrate
# POS_SUBCMDS: run list config enable disable status migrate menu
# POS_FLAGS: --dry-run
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/scheduler-lib.sh" 2>/dev/null || source "$(dirname "$0")/scheduler-lib.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
usage() {
cat <<EOF
@@ -29,6 +30,10 @@ NOTIFY policies:
never run only — no notification (side-effect jobs)
Default: threshold if RULE is set, otherwise onchange.
Bare \`pos system schedule\` on a terminal (or \`pos system schedule menu\`)
opens an interactive menu wrapping these subcommands; arguments stay
scriptable — the systemd timers keep calling 'run <name>' directly.
Subcommands:
run [name|all] Execute job(s) now (the systemd timers call 'run <name>')
list Jobs + notify policy + interval + last run
@@ -53,6 +58,71 @@ EOF
exit 0
}
# ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ──
# Every item maps onto an existing subcommand implementation. Run-now goes
# through the same sched_run the systemd timers invoke (`run <name>`),
# behind an explicit y/N confirm.
menu_pick_job() { # $1 = prompt → picked job name on stdout · rc 1 = cancelled
local -a jobs=()
mapfile -t jobs < <(sched_list_jobs)
if [ ${#jobs[@]} -eq 0 ]; then
warn "no jobs in $SCHEDULE_DIR — add one with 'pos system schedule config'"
return 1
fi
local idx
idx="$(menu_pick "$1" "${jobs[@]}")" || return 1
printf '%s\n' "${jobs[$((idx - 1))]}"
}
menu_job_action() { # $1 = run|enable|disable — pick a job, call the verb
local name
name="$(menu_pick_job "${1} which job?")" || return 0
case "$1" in
run)
confirm "Run job '$name' now (executes its COMMAND, applies its NOTIFY policy)?" n \
|| { log "Cancelled"; return 0; }
sched_run "$name"
;;
enable) sched_enable "$name" ;;
disable) sched_disable "$name" ;;
esac
}
run_menu() {
menu_guard || exit 1
while true; do
local choice
choice="$(menu_run "Scheduled jobs ($SCHEDULE_DIR)" \
"List jobs" \
"Timer status (+ next run)" \
"Run a job now" \
"Enable a job" \
"Disable a job" \
"Open the interactive job editor")" || return 0
case "$choice" in
1) sched_list ;;
2) sched_status ;;
3) menu_job_action run ;;
4) menu_job_action enable ;;
5) menu_job_action disable ;;
6) sched_config_editor ;;
esac
done
}
# Menu door: explicit verb, or zero args on a terminal. Everything below —
# including zero args without a terminal — stays byte-compatible with the
# pre-menu CLI; timer invocations (`run <name>`) never enter the menu.
if [ "${1:-}" = "menu" ]; then
run_menu
exit 0
fi
if [ $# -eq 0 ] && [ -t 0 ]; then
run_menu
exit 0
fi
case "${1:-}" in
-h|--help|"") usage ;;
esac