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
gates / consistency-and-conventions (push) Successful in 2m10s
This commit is contained in:
+122
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user