1
0

Compare commits

...

13 Commits

Author SHA1 Message Date
matmoul c138d9201e fix: detach reboot command to avoid false SSH failures 2026-04-26 02:08:34 +02:00
matmoul ba5a8c9397 docs: update dependency and sample config notes 2026-04-26 01:36:02 +02:00
matmoul a08efd54c4 fix: stop passing -y to apk upgrade
The Alpine apk upgrade command does not accept -y, so the flag is no longer appended there. The CLI -y option remains available for other package managers.
2026-04-26 01:17:50 +02:00
matmoul 3e78f8afe6 fix: simplify pacman orphan cleanup command construction 2026-04-26 01:08:09 +02:00
matmoul afea447887 docs: update roadmap with review findings and next priorities 2026-04-26 00:45:03 +02:00
matmoul e2b3a0a88d fix: correct package cleanup commands in netupgrade
Use apt-get autoremove --purge instead of a separate empty purge step, and pass the CLI yes flag through to apk upgrade so the logged commands match actual behavior.
2026-04-26 00:40:24 +02:00
matmoul 3fe7959850 fix: avoid sed interpolation when prepending log summary
Use a temporary file to write the summary header and existing log content, then replace the original log atomically. This removes the dependency on sed for summary insertion and avoids unsafe string interpolation.
2026-04-26 00:36:01 +02:00
matmoul 46f42c8893 fix: harden checklist selection parsing and clarify -f help text 2026-04-26 00:33:27 +02:00
matmoul f9af0f4823 docs: update roadmap with next hardening priorities 2026-04-26 00:26:09 +02:00
matmoul a25caf6f3d feat: make SSH user configurable and harden remote actions 2026-04-26 00:19:39 +02:00
matmoul f3c649341a fix: make log viewer optional and document runtime dependencies 2026-04-26 00:08:02 +02:00
matmoul 2ed34b97be docs: expand netupgrade usage and configuration docs
Update the README with installation, requirements, supported actions, config format, and usage details. Align the CLI help text with current behavior and add startup checks for required runtime dependencies.
2026-04-25 23:59:28 +02:00
matmoul 9de65f9aa5 chore: add repository guidance and state context files 2026-04-25 23:51:29 +02:00
5 changed files with 478 additions and 94 deletions
+14
View File
@@ -0,0 +1,14 @@
---
alwaysApply: true
---
Apply consistent coding standards, preserve existing project conventions, and avoid introducing breaking changes in all files unless explicitly requested.
Also read and consider the repository state file when it exists:
- `state.md`
When this file is present:
- use it as evolving project context,
- keep recommendations aligned with it,
- update suggestions to remain consistent with documented architecture, constraints, and review notes,
- update `state.md` as part of the same task whenever changes materially affect repository behavior, architecture, constraints, or review notes.
+97 -12
View File
@@ -1,32 +1,117 @@
# netupgrade # netupgrade
Servers full upgrade script Interactive CLI tool to run upgrade and maintenance actions on multiple remote hosts over SSH.
## Where to use ## Where to use
- On a dedicated server (bastion, ...) - On a dedicated server (bastion, jump host, ...)
- On your computer with an alias to your dedicated server - On your computer with an alias to your dedicated server
- On your computer (not recommended) - On your computer directly (not recommended)
## Features
- Select one or more hosts from an interactive checklist
- Run predefined actions on each selected host
- Write execution logs to `~/netupgrade.log`
Supported actions:
- `apt`
- `yum`
- `pkg`
- `pacman`
- `apk`
- `reboot`
- `cmd:<remote command>`
- `docker-stacks:<directory>`
## Requirements
Required locally:
- `bash`
- `ssh`
- `whiptail`
- core utilities such as `cat`, `tee`, `rm`, `touch`, and `mv`
Optional log viewer:
- `$EDITOR` if it points to an installed command
- otherwise one of: `nano`, `vi`, `less`
Remote hosts must also provide the commands needed by the configured actions, such as:
`apt-get`, `yum`, `pkg`, `pacman`, `apk`, `docker`, `docker compose`, or `reboot`.
## Install ## Install
### Bin as root ### Install the executable
``` bash ```bash README.md
cp bin/netupgrade to /usr/local/bin cp bin/netupgrade /usr/local/bin/netupgrade
chmod +x /usr/local/bin/netupgrade
``` ```
### Config as user ### Create the config directory
``` bash ```bash README.md
mkdir -p ~/.config/netuprade mkdir -p ~/.config/netupgrade
touch ~/.config/netuprade/index.cfg touch ~/.config/netupgrade/index.cfg
``` ```
### Alias on your computer with a dedicated server ### Alias on your computer with a dedicated server
You can save it in your .bashrc You can save it in your `.bashrc`
``` bash ```bash README.md
alias netupgrade='ssh -t user@10.0.0.10 netupgrade' alias netupgrade='ssh -t user@10.0.0.10 netupgrade'
``` ```
## Configuration
The default config file is:
```text README.md
~/.config/netupgrade/index.cfg
```
The script sources this file as Bash code. It must define a `NODES` array.
Each entry uses this format:
```text README.md
host;display-name;action1;action2;...
```
Example:
```bash README.md
SSH_USER="root"
NODES=(
"192.168.1.10;web-01;apt;reboot"
"192.168.1.11;db-01;apt;cmd:systemctl restart postgresql"
"192.168.1.12;docker-01;docker-stacks:/opt/stacks"
)
```
## Usage
```bash README.md
netupgrade [--help] [-f] [-y] [configfilename]
```
Options:
- `--help`: show help
- `-f`: preselect all nodes in the interactive checklist
- `-y`: pass non-interactive confirmation flags to supported package managers
- `configfilename`: path to a config file
## Notes
- SSH connections use `root@host` by default and can be changed with `SSH_USER` in the config file
- `cmd:<remote command>` is executed through a remote shell, so shell operators such as pipes, redirections, `&&`, and `||` are supported
- The tool is interactive and intended for manual administration workflows
- After execution, the log file is opened with `$EDITOR` when available, otherwise with `nano`, `vi`, or `less`
- If no supported log viewer is available, the script keeps running and prints the log file path
- The configuration file is sourced as shell code, so only use trusted config files
+200 -79
View File
@@ -1,12 +1,47 @@
#!/bin/bash #!/bin/bash
showHelp() { showHelp() {
echo "netupgrade [-f] [-y] [configfilename]" echo "netupgrade [--help] [-f] [-y] [configfilename]"
echo "" echo ""
echo " -f : Select all nodes" echo " --help Show this help message"
echo " -y : No confirmation" echo " -f Preselect all nodes in the checklist"
echo " -b : Breack on error" echo " -y No confirmation for supported package managers"
echo " configfilename : a cfg filename" echo " configfilename Path to a cfg file"
}
checkDependencies() {
local -a REQUIRED_CMDS=(ssh whiptail cat tee rm touch mv)
local -a MISSING_CMDS=()
local CMD
for CMD in "${REQUIRED_CMDS[@]}"; do
if ! command -v "${CMD}" >/dev/null 2>&1; then
MISSING_CMDS+=("${CMD}")
fi
done
if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
echo "Error: missing required dependencies: ${MISSING_CMDS[*]}"
exit 1
fi
}
resolveLogViewer() {
if [ -n "${EDITOR}" ] && command -v "${EDITOR}" >/dev/null 2>&1; then
LOGVIEWER="${EDITOR}"
return
fi
local -a CANDIDATES=(nano vi less)
local CANDIDATE
for CANDIDATE in "${CANDIDATES[@]}"; do
if command -v "${CANDIDATE}" >/dev/null 2>&1; then
LOGVIEWER="${CANDIDATE}"
return
fi
done
LOGVIEWER=""
} }
pressAnyKey(){ pressAnyKey(){
@@ -23,25 +58,76 @@ loadConfig(){
fi fi
} }
parseNode() {
local NODE_VALUE="${1}"
local -n NODE_FIELDS_REF="${2}"
IFS=';' read -r -a NODE_FIELDS_REF <<< "${NODE_VALUE}"
}
parseSelection() {
local SELECTION_RAW="${1}"
local -n SELECTION_REF="${2}"
local ITEM_INDEX=0
SELECTION_REF=()
read -r -a SELECTION_REF <<< "${SELECTION_RAW}"
for ITEM_INDEX in "${!SELECTION_REF[@]}"; do
SELECTION_REF[ITEM_INDEX]="${SELECTION_REF[ITEM_INDEX]%\"}"
SELECTION_REF[ITEM_INDEX]="${SELECTION_REF[ITEM_INDEX]#\"}"
done
}
runSSH() {
local HOST="${1}"
shift
ssh "${SSH_USER}@${HOST}" "$@"
}
prependLogSummary() {
local SUMMARY_CONTENT="${1}"
local TEMP_LOGFILENAME="${LOGFILENAME}.tmp"
{
echo "Results :"
echo "---------"
echo ""
echo -e "${SUMMARY_CONTENT}"
echo ""
echo ""
cat "${LOGFILENAME}"
} > "${TEMP_LOGFILENAME}"
mv "${TEMP_LOGFILENAME}" "${LOGFILENAME}"
}
selectNodes(){ selectNodes(){
local -a OPTIONS=() local -a OPTIONS=()
local -a SELECTED_ITEMS=()
local -i INDEX=1 local -i INDEX=1
for NODE in "${NODES[@]}"; do local -i I=0
# shellcheck disable=SC2206 local -a FIELDS=()
local FIELDS=(${NODE//;/ })
local DESKSKIP=0
local DESC="" local DESC=""
for FIELD in "${FIELDS[@]}"; do local FIELD=""
if [ ${DESKSKIP} -gt 1 ]; then local SEL=""
if [ "${DESC}" == "" ]; then local DEFAULT_STATE="OFF"
DESC="${FIELD/:*/}"
if [ "${FULL}" -eq 1 ]; then
DEFAULT_STATE="ON"
fi
for NODE in "${NODES[@]}"; do
FIELDS=()
DESC=""
parseNode "${NODE}" FIELDS
for ((I = 2; I < ${#FIELDS[@]}; ++I)); do
FIELD="${FIELDS[I]}"
if [ -z "${DESC}" ]; then
DESC="${FIELD%%:*}"
else else
DESC="${DESC}|${FIELD/:*/}" DESC="${DESC}|${FIELD%%:*}"
fi fi
fi
DESKSKIP=$(( DESKSKIP + 1))
done done
OPTIONS+=("${INDEX}:${FIELDS[0]}" "${FIELDS[1]} [${DESC}]" "${FULL}") OPTIONS+=("${INDEX}:${FIELDS[0]}" "${FIELDS[1]} [${DESC}]" "${DEFAULT_STATE}")
INDEX+=1 INDEX+=1
done done
if ! SEL=$(whiptail --title "NetUpgrade" --checklist "" 0 0 0 \ if ! SEL=$(whiptail --title "NetUpgrade" --checklist "" 0 0 0 \
@@ -49,7 +135,12 @@ selectNodes(){
3>&1 1>&2 2>&3); then 3>&1 1>&2 2>&3); then
exit 0 exit 0
fi fi
if [ ${#SEL} == 0 ]; then if [ -z "${SEL}" ]; then
exit 0
fi
parseSelection "${SEL}" SELECTED_ITEMS
if [ ${#SELECTED_ITEMS[@]} -eq 0 ]; then
exit 0 exit 0
fi fi
@@ -59,17 +150,17 @@ selectNodes(){
touch "${LOGFILENAME}" touch "${LOGFILENAME}"
local RESULT="\n" local RESULT="\n"
for ITM in ${SEL}; do for ITM in "${SELECTED_ITEMS[@]}"; do
INDEX=1 INDEX=1
for NODE in "${NODES[@]}"; do for NODE in "${NODES[@]}"; do
# shellcheck disable=SC2206 FIELDS=()
local FIELDS=(${NODE//;/ }) parseNode "${NODE}" FIELDS
if [ "${ITM}" = "\"${INDEX}:${FIELDS[0]}\"" ]; then if [ "${ITM}" = "${INDEX}:${FIELDS[0]}" ]; then
for ((I = 2; I < ${#FIELDS[@]}; ++I)); do for ((I = 2; I < ${#FIELDS[@]}; ++I)); do
if runCmd "${FIELDS[0]}" "${FIELDS[1]}" "${FIELDS[${I}]}"; then if runCmd "${FIELDS[0]}" "${FIELDS[1]}" "${FIELDS[I]}"; then
RESULT+="Ok: ${FIELDS[1]} @ ${FIELDS[0]} : ${FIELDS[${I}]}\n" RESULT+="Ok: ${FIELDS[1]} @ ${FIELDS[0]} : ${FIELDS[I]}\n"
else else
RESULT+="Error: ${FIELDS[1]} @ ${FIELDS[0]} : ${FIELDS[${I}]}\n" RESULT+="Error: ${FIELDS[1]} @ ${FIELDS[0]} : ${FIELDS[I]}\n"
fi fi
done done
fi fi
@@ -77,10 +168,12 @@ selectNodes(){
done done
done done
sed -i "1s/^/${RESULT//\//\\\/}\n\n\n\n/" "${LOGFILENAME}" prependLogSummary "${RESULT}"
sed -i "1s/^/---------\n\n/" "${LOGFILENAME}" if [ -n "${LOGVIEWER}" ]; then
sed -i "1s/^/Results :\n/" "${LOGFILENAME}" "${LOGVIEWER}" "${LOGFILENAME}"
nano "${LOGFILENAME}" else
echo "Warning: no log viewer found, showing log path instead: ${LOGFILENAME}"
fi
rm -i "${LOGFILENAME}" rm -i "${LOGFILENAME}"
echo "" echo ""
echo "Results :" echo "Results :"
@@ -88,136 +181,160 @@ selectNodes(){
echo -e "${RESULT}" echo -e "${RESULT}"
} }
runCmd() { #$1=host $2=name #3=cmd runCmd() { # $1=host $2=name $3=cmd
local -r HOST=${1} local -r HOST="${1}"
local -r NAME=${2} local -r NAME="${2}"
local -r CMD=${3//:*} local -r ACTION="${3}"
local -r CMDVAL=${3//*:} local -r CMD="${ACTION%%:*}"
local -r CMDVAL="${ACTION#*:}"
local -i ERROR=0 local -i ERROR=0
echo "${NAME} @ ${HOST} : ${CMD}" | tee -a "${LOGFILENAME}" local TITLELENGTH=0
local TITLELENGTH=$((${#NAME} + ${#HOST} + ${#CMD} + 6))
local SUBTITLE="-----------------------------------------------------------------------------" local SUBTITLE="-----------------------------------------------------------------------------"
echo ${SUBTITLE:0:${TITLELENGTH}} | tee -a "${LOGFILENAME}" local YESARG=""
echo "${NAME} @ ${HOST} : ${CMD}" | tee -a "${LOGFILENAME}"
TITLELENGTH=$((${#NAME} + ${#HOST} + ${#CMD} + 6))
echo "${SUBTITLE:0:${TITLELENGTH}}" | tee -a "${LOGFILENAME}"
date +'%Y-%m-%d %H:%M:%S %A' | tee -a "${LOGFILENAME}" date +'%Y-%m-%d %H:%M:%S %A' | tee -a "${LOGFILENAME}"
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
set -o pipefail set -o pipefail
local YESARG=""
case ${CMD} in case ${CMD} in
reboot) reboot)
ssh root@"${HOST}" reboot | tee -a "${LOGFILENAME}" echo "reboot (detached)" | tee -a "${LOGFILENAME}"
if ! runSSH "${HOST}" sh -c 'nohup sh -c "reboot || /sbin/reboot || shutdown -r now" >/dev/null 2>&1 </dev/null &' | tee -a "${LOGFILENAME}"; then
ERROR=1
else
sleep 1
fi
;; ;;
apt) apt)
if [ ${YES} == 1 ]; then if [ "${YES}" -eq 1 ]; then
YESARG="-y" YESARG="-y"
fi fi
echo "apt-get ${YESARG} update" | tee -a "${LOGFILENAME}" echo "apt-get ${YESARG} update" | tee -a "${LOGFILENAME}"
if ! ssh root@"${HOST}" apt-get ${YESARG} update | tee -a "${LOGFILENAME}"; then if ! runSSH "${HOST}" apt-get ${YESARG} update | tee -a "${LOGFILENAME}"; then
ERROR=1 ERROR=1
else else
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
echo "apt-get ${YESARG} dist-upgrade" | tee -a "${LOGFILENAME}" echo "apt-get ${YESARG} dist-upgrade" | tee -a "${LOGFILENAME}"
if ! ssh root@"${HOST}" apt-get ${YESARG} dist-upgrade | tee -a "${LOGFILENAME}"; then if ! runSSH "${HOST}" apt-get ${YESARG} dist-upgrade | tee -a "${LOGFILENAME}"; then
ERROR=1 ERROR=1
fi fi
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
echo "apt-get ${YESARG} autoremove" | tee -a "${LOGFILENAME}" echo "apt-get ${YESARG} autoremove --purge" | tee -a "${LOGFILENAME}"
ssh root@"${HOST}" apt-get ${YESARG} autoremove | tee -a "${LOGFILENAME}" runSSH "${HOST}" apt-get ${YESARG} autoremove --purge | tee -a "${LOGFILENAME}"
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
echo "apt-get ${YESARG} autoclean" | tee -a "${LOGFILENAME}" echo "apt-get ${YESARG} autoclean" | tee -a "${LOGFILENAME}"
ssh root@"${HOST}" apt-get ${YESARG} autoclean | tee -a "${LOGFILENAME}" runSSH "${HOST}" apt-get ${YESARG} autoclean | tee -a "${LOGFILENAME}"
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
echo "apt-get ${YESARG} clean" | tee -a "${LOGFILENAME}" echo "apt-get ${YESARG} clean" | tee -a "${LOGFILENAME}"
ssh root@"${HOST}" apt-get ${YESARG} clean | tee -a "${LOGFILENAME}" runSSH "${HOST}" apt-get ${YESARG} clean | tee -a "${LOGFILENAME}"
echo "" | tee -a "${LOGFILENAME}"
echo "apt-get ${YESARG} purge" | tee -a "${LOGFILENAME}"
ssh root@"${HOST}" apt-get ${YESARG} purge | tee -a "${LOGFILENAME}"
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
fi fi
;; ;;
yum) yum)
if [ ${YES} == 1 ]; then if [ "${YES}" -eq 1 ]; then
YESARG="-y" YESARG="-y"
fi fi
echo "yum ${YESARG} update" | tee -a "${LOGFILENAME}" echo "yum ${YESARG} update" | tee -a "${LOGFILENAME}"
if ! ssh root@"${HOST}" yum ${YESARG} update | tee -a "${LOGFILENAME}"; then if ! runSSH "${HOST}" yum ${YESARG} update | tee -a "${LOGFILENAME}"; then
ERROR=1 ERROR=1
fi fi
;; ;;
pkg) pkg)
if [ ${YES} == 1 ]; then if [ "${YES}" -eq 1 ]; then
YESARG="-y" YESARG="-y"
fi fi
echo "pkg ${YESARG} update" | tee -a "${LOGFILENAME}" echo "pkg ${YESARG} update" | tee -a "${LOGFILENAME}"
if ! ssh root@"${HOST}" pkg ${YESARG} update | tee -a "${LOGFILENAME}"; then if ! runSSH "${HOST}" pkg ${YESARG} update | tee -a "${LOGFILENAME}"; then
ERROR=1 ERROR=1
else else
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
echo "pkg upgrade ${YESARG}" | tee -a "${LOGFILENAME}" echo "pkg upgrade ${YESARG}" | tee -a "${LOGFILENAME}"
if ! ssh root@"${HOST}" pkg upgrade ${YESARG} | tee -a "${LOGFILENAME}"; then if ! runSSH "${HOST}" pkg upgrade ${YESARG} | tee -a "${LOGFILENAME}"; then
ERROR=1 ERROR=1
fi fi
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
echo "pkg autoremove ${YESARG}" | tee -a "${LOGFILENAME}" echo "pkg autoremove ${YESARG}" | tee -a "${LOGFILENAME}"
ssh root@"${HOST}" pkg autoremove ${YESARG} | tee -a "${LOGFILENAME}" runSSH "${HOST}" pkg autoremove ${YESARG} | tee -a "${LOGFILENAME}"
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
echo "pkg clean ${YESARG}" | tee -a "${LOGFILENAME}" echo "pkg clean ${YESARG}" | tee -a "${LOGFILENAME}"
ssh root@"${HOST}" pkg clean ${YESARG} | tee -a "${LOGFILENAME}" runSSH "${HOST}" pkg clean ${YESARG} | tee -a "${LOGFILENAME}"
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
fi fi
;; ;;
pacman) pacman)
if [ ${YES} == 1 ]; then if [ "${YES}" -eq 1 ]; then
YESARG="--noconfirm" YESARG="--noconfirm"
fi fi
echo "pacman -Sy ${YESARG} archlinux-keyring" | tee -a "${LOGFILENAME}" echo "pacman -Sy ${YESARG} archlinux-keyring" | tee -a "${LOGFILENAME}"
if ! ssh root@"${HOST}" pacman -Sy ${YESARG} archlinux-keyring | tee -a "${LOGFILENAME}"; then if ! runSSH "${HOST}" pacman -Sy ${YESARG} archlinux-keyring | tee -a "${LOGFILENAME}"; then
ERROR=1 ERROR=1
fi fi
echo "pacman -Syu ${YESARG}" | tee -a "${LOGFILENAME}" echo "pacman -Syu ${YESARG}" | tee -a "${LOGFILENAME}"
if ! ssh root@"${HOST}" pacman -Syu ${YESARG} | tee -a "${LOGFILENAME}"; then if ! runSSH "${HOST}" pacman -Syu ${YESARG} | tee -a "${LOGFILENAME}"; then
ERROR=1
fi
echo "pacman orphan cleanup" | tee -a "${LOGFILENAME}"
if [ -n "${YESARG}" ]; then
if ! runSSH "${HOST}" sh -c 'orphans=$(pacman -Qqtd 2>/dev/null || true); if [ -n "$orphans" ]; then pacman -Rns --noconfirm $orphans; fi' | tee -a "${LOGFILENAME}"; then
ERROR=1
fi
else
if ! runSSH "${HOST}" sh -c 'orphans=$(pacman -Qqtd 2>/dev/null || true); if [ -n "$orphans" ]; then pacman -Rns $orphans; fi' | tee -a "${LOGFILENAME}"; then
ERROR=1
fi
fi
echo "pacman -Sc ${YESARG}" | tee -a "${LOGFILENAME}"
if ! runSSH "${HOST}" pacman -Sc ${YESARG} | tee -a "${LOGFILENAME}"; then
ERROR=1 ERROR=1
fi fi
# shellcheck disable=SC2046
ssh root@"${HOST}" pacman -Rns $(pacman -Qqtd) ${YESARG} | tee -a "${LOGFILENAME}"
ssh root@"${HOST}" pacman -Sc ${YESARG} | tee -a "${LOGFILENAME}"
;; ;;
apk) apk)
if [ ${YES} == 1 ]; then
YESARG="-y"
fi
echo "apk update" | tee -a "${LOGFILENAME}" echo "apk update" | tee -a "${LOGFILENAME}"
if ! ssh root@"${HOST}" apk update | tee -a "${LOGFILENAME}"; then if ! runSSH "${HOST}" apk update | tee -a "${LOGFILENAME}"; then
ERROR=1 ERROR=1
fi fi
echo "apk upgrade" | tee -a "${LOGFILENAME}" echo "apk upgrade" | tee -a "${LOGFILENAME}"
if ! ssh root@"${HOST}" apk upgrade | tee -a "${LOGFILENAME}"; then if ! runSSH "${HOST}" apk upgrade | tee -a "${LOGFILENAME}"; then
ERROR=1 ERROR=1
fi fi
;; ;;
cmd) cmd)
echo "cmd: ${CMDVAL}" | tee -a "${LOGFILENAME}" echo "cmd: ${CMDVAL}" | tee -a "${LOGFILENAME}"
# shellcheck disable=SC2029 if ! runSSH "${HOST}" sh -c "${CMDVAL}" | tee -a "${LOGFILENAME}"; then
if ! ssh root@"${HOST}" "${CMDVAL}" | tee -a "${LOGFILENAME}"; then
ERROR=1 ERROR=1
fi fi
;; ;;
docker-stacks) docker-stacks)
echo "docker stacks update" | tee -a "${LOGFILENAME}" echo "docker stacks update in ${CMDVAL}" | tee -a "${LOGFILENAME}"
echo "for each" | tee -a "${LOGFILENAME}" if ! ssh "${SSH_USER}@${HOST}" "STACK_ROOT=$(printf '%q' "${CMDVAL}") bash -s" <<'EOF' | tee -a "${LOGFILENAME}"
echo " docker compose pull; docker compose up -d" | tee -a "${LOGFILENAME}" stack_root="${STACK_ROOT}"
if ! ssh root@"${HOST}" 'for dir in '"${CMDVAL}"'/*; do (cd "${dir}"; docker compose pull; docker compose up -d); done; docker image prune -f' | tee -a "${LOGFILENAME}"; then
ERROR=1 for dir in "$stack_root"/*; do
fi [ -d "$dir" ] || continue
echo "docker image prune -a -f" | tee -a "${LOGFILENAME}" (
if ! ssh root@"${HOST}" docker image prune -f | tee -a "${LOGFILENAME}"; then cd "$dir" || exit 1
docker compose pull
docker compose up -d
) || exit 1
done
docker image prune -f
EOF
then
ERROR=1 ERROR=1
fi fi
;; ;;
*) echo "Error: Command ${CMD} unknown" | tee -a "${LOGFILENAME}";; *)
echo "Error: Command ${CMD} unknown" | tee -a "${LOGFILENAME}"
ERROR=1
;;
esac esac
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
echo "" | tee -a "${LOGFILENAME}" echo "" | tee -a "${LOGFILENAME}"
if [ ${ERROR} == 1 ]; then if [ "${ERROR}" -eq 1 ]; then
return 1 return 1
fi fi
} }
@@ -227,6 +344,8 @@ declare -i YES=0
declare -i FULL=0 declare -i FULL=0
declare CONFIGFILENAME="${HOME}/.config/netupgrade/index.cfg" declare CONFIGFILENAME="${HOME}/.config/netupgrade/index.cfg"
declare LOGFILENAME="${HOME}/netupgrade.log" declare LOGFILENAME="${HOME}/netupgrade.log"
declare LOGVIEWER=""
declare SSH_USER="root"
declare -a NODES=() declare -a NODES=()
while [[ ${#} -gt 0 ]]; do while [[ ${#} -gt 0 ]]; do
@@ -238,6 +357,8 @@ while [[ ${#} -gt 0 ]]; do
esac esac
done done
checkDependencies
resolveLogViewer
loadConfig loadConfig
selectNodes selectNodes
+1 -1
View File
@@ -6,4 +6,4 @@ NODES+=("10.0.0.103;alpine-01;apk;reboot")
NODES+=("10.0.0.104;redhat-01;yum;reboot") NODES+=("10.0.0.104;redhat-01;yum;reboot")
NODES+=("10.0.0.105;freebsd-01;pkg;reboot") NODES+=("10.0.0.105;freebsd-01;pkg;reboot")
NODES+=("10.0.0.211;docker-01;docker-stacks:/srv/stacks") NODES+=("10.0.0.211;docker-01;docker-stacks:/srv/stacks")
NODES+=("10.0.0.105;docker-01;cmd:reboot") #NODES+=("10.0.0.211;docker-01;cmd:reboot")
+164
View File
@@ -0,0 +1,164 @@
# Repository analysis
## Project overview
- Project name: `netupgrade`
- Primary language: Bash
- Main entrypoint: `bin/netupgrade`
- Project type: interactive CLI administration tool
- Main purpose: orchestrate remote upgrade and maintenance actions on multiple hosts over SSH
- Configuration model: Bash-based configuration files sourced from `~/.config/netupgrade/*.cfg`
## Repository structure
- `bin/netupgrade`: main executable script containing CLI parsing, node selection, remote execution, and logging
- `config/netupgrade/*.cfg`: sample configuration files defining host groups and action sequences
- `README.md`: installation and usage documentation
- `docs/`: project documentation
## Functional behavior
The tool:
1. Loads a Bash configuration file
2. Expects a `NODES` array populated with entries formatted like:
`host;display-name;action1;action2;...`
3. Displays an interactive multi-select checklist using `whiptail`
4. Executes the selected actions on each selected host through SSH, using `root@host` by default or `SSH_USER@host` when configured
5. Writes execution logs to `~/netupgrade.log`
6. Opens the log with `$EDITOR` when available, otherwise `nano`, `vi`, or `less`; if none is available, it prints the log path, then optionally removes the log file
Supported action types currently include:
- `apt`
- `yum`
- `pkg`
- `pacman`
- `apk`
- `reboot`
- `cmd:<remote command>`
- `docker-stacks:<directory>`
## Architecture notes
- The project is intentionally lightweight and script-based
- Configuration is code-driven rather than declarative, since config files are sourced as shell files
- The entire execution flow currently lives in a single Bash script
- Remote operations are performed sequentially, not in parallel
- Logging is file-based and coupled directly to command execution
## Strengths
- Very small and easy to deploy
- Clear practical purpose for system administration workflows
- Flexible host/action configuration model
- Supports several Linux/BSD package managers
- Suitable for use from a bastion host or admin workstation
## Main issues identified
### 1. Documentation accuracy problems
- `README.md` and CLI help were updated to better match current behavior
- The previous typo in the configuration path (`netuprade`) has been fixed
- The unsupported `-b` option was removed from the displayed help
- The configuration format and supported actions are now documented in more detail
### 2. Shell robustness concerns
- Config files are sourced directly, which is flexible but implies arbitrary code execution
- The script still has some quoting-sensitive areas and does not use a stricter shell safety baseline
- The `NODES` parsing was hardened to split on `;` with `IFS`/`read -r -a`, which now preserves spaces in action values such as `cmd:...`
### 3. Remote execution correctness and safety
- SSH execution now goes through a dedicated `runSSH` helper and the SSH user is configurable via `SSH_USER`, defaulting to `root`
- `cmd:<...>` intentionally allows arbitrary remote command execution and is now executed through a remote shell, which improves support for shell operators but remains a powerful unsafe feature
- The `pacman` orphan-removal command was corrected so orphan detection happens on the remote host instead of locally
- The `docker-stacks` remote loop was rewritten to pass the stack root as an argument to a remote shell script, improving quoting and path handling
### 4. UX and dependency issues
- Required runtime dependencies are now checked at startup (`ssh`, `whiptail`, `cat`, `tee`, `rm`, `touch`)
- Log viewing no longer depends strictly on `nano`; the script now falls back to `$EDITOR`, then `nano`, `vi`, or `less`
- If no supported log viewer is available, execution continues and the log path is shown
- The workflow is highly interactive and not well suited for automation
- `rm -i` introduces an extra prompt even when the rest of the flow is meant to be streamlined
### 5. Error handling limitations
- Error propagation is inconsistent depending on the action type
- Cleanup commands often do not affect the final failure state
- The script continues through action sequences without a documented policy
- The advertised “break on error” behavior does not exist yet
### 6. Maintainability limitations
- Most logic is concentrated in one script
- There is duplication in package-manager handling
- No tests or validation tooling are present in the repository
- Some wording, typos, and naming inconsistencies reduce clarity
## Recommended direction
### Short term
- Tackle the next hardening work as small, reviewable commits instead of one broad patch
- Define and document an explicit error-handling policy: what is fatal, what is best-effort, and whether host action sequences continue after a failure
- Review the remaining quoting-sensitive areas, especially around remote shell command construction for `cmd:<...>` and `docker-stacks:<...>`
- Align `README.md`, CLI help, and runtime behavior on dependencies and interaction details, especially the actual meaning of `-f`, current log-file behavior, and whether `sed` is still required
- Review sample configuration files for naming or targeting inconsistencies that could lead to operator mistakes
### Medium term
- Make SSH user, log path, and editor configurable
- Improve non-interactive usage options with explicit batch-friendly flags such as a true `--all`, `--no-log-view`, `--keep-log`, and custom log-path support
- Standardize error handling and exit codes with a documented policy for best-effort cleanup steps versus fatal failures
- Add lightweight validation or warnings around sourced config files, for example around trust expectations or overly permissive file permissions
- Consider adopting a clearer shell option baseline such as an explicit global `pipefail` policy
### Long term
- Refactor the script into smaller functions with less duplication
- Add shell linting guidance and automation (for example ShellCheck, optionally shfmt)
- Consider a safer declarative configuration format if the project grows
- Add test coverage for parsing, command construction, and non-regression around SSH quoting behavior
## Recent changes
- `README.md` was expanded to document installation, requirements, usage, configuration format, and supported actions
- `bin/netupgrade` help output was aligned with actual CLI behavior and now documents `--help`
- A startup dependency check was added before loading configuration or opening the interactive selector
- Log viewer selection was made more flexible: `$EDITOR` is preferred, then `nano`, `vi`, or `less`
- `nano` is no longer a strict runtime dependency
- The unsupported `-b` option remains unimplemented and is no longer shown in help output
- `NODES` parsing was hardened to preserve spaces in action values by splitting on `;` with `IFS` and `read -r -a`
- SSH calls were centralized through a `runSSH` helper and `SSH_USER` is now configurable, defaulting to `root`
- The `pacman` orphan cleanup now runs entirely on the remote host instead of evaluating orphan detection locally
- The `pacman` orphan cleanup remote command now avoids nested `bash -lc` argument-passing issues by selecting between two simple remote `sh -c` commands, one with `--noconfirm` and one without
- The `docker-stacks` action uses a remote shell script sent over SSH stdin, with the stack directory exported as a remote environment assignment before `bash -s`, to keep path handling working after recent SSH command-construction changes
- Unknown actions and reboot SSH failures now propagate error status more consistently
- The `reboot` action now triggers a detached remote reboot command (`reboot || /sbin/reboot || shutdown -r now` under `nohup`) so an expected SSH disconnect during restart is less likely to be reported as a failure
- A focused code review identified the next recommended work items and suggested splitting them into separate commits rather than combining them in one larger hardening change
- `whiptail` checklist defaults are now passed explicitly as `ON`/`OFF`, and selected items are parsed through a dedicated helper instead of relying on raw shell word splitting
- The CLI help and README now clarify that `-f` preselects all nodes in the interactive checklist
- Log summary generation no longer uses `sed -i` interpolation; the script now writes a temporary file with the summary header plus the existing log content and replaces the original log atomically
- The `apk` action no longer passes `-y` to `apk upgrade`, because current Alpine `apk` does not accept that option there; `-y` remains a best-effort flag for other supported package managers
- The `apt` action now uses `apt-get autoremove --purge` and no longer runs `apt-get purge` without arguments, which makes the cleanup step more meaningful and avoids a misleading command in the log
- The `pacman` action was further hardened by simplifying orphan cleanup command construction, reducing quoting-related regressions while still skipping removal when no orphan packages are present
- `README.md` no longer lists `sed` as a required dependency and now better reflects the local utilities actually used by the script
- Startup dependency checks now include `mv`, which is required by the log-summary rewrite
- The sample configuration in `config/netupgrade/hypervisor-01.cfg` now comments out the alternate `docker-01` reboot step so the two-step example remains visible without being active by default
## Change guidance
- Preserve backward compatibility for existing config files where possible
- Prefer incremental hardening over a full rewrite
- Keep the tool simple and admin-friendly
- Split behavioral fixes into small logical commits when possible, for example: selection handling, log generation, package-manager cleanup semantics, error-policy changes, and non-interactive CLI improvements
- Be cautious with changes to remote command construction, as quoting changes can introduce regressions
- Treat documentation and behavior alignment as part of functional quality, not as a separate cleanup task
- Avoid introducing a global `set -euo pipefail` baseline in one step without first documenting and testing the expected failure semantics
## Suggested review focus for future changes
- Correctness of package-manager cleanup commands and confirmation flags
- Correctness of remote command execution
- Safe quoting and shell expansion behavior
- Compatibility of config format with existing user setups
- Error-handling policy consistency across action types
- Package-manager command correctness and cleanup-step behavior
- Usability in both interactive and semi-automated contexts
- Documentation consistency with actual runtime behavior and dependencies
- Review of sample config accuracy to avoid mislabelled hosts or risky operator confusion
## Additional recommendations from latest review
- Highest priority should go to defining an explicit execution and failure policy, because it currently affects operator trust more than missing features do
- The next highest priority should be protecting against regressions in SSH command construction by documenting manual test cases for commands with spaces, pipes, redirections, `&&`, `||`, and quoted arguments
- A small CLI usability pass would have strong value: `-f` currently only preselects nodes in `whiptail`, so a true non-interactive selection mode would improve automation without changing the overall project model
- The dependency list was realigned: `README.md` no longer mentions `sed`, and the script dependency check now includes `mv` for the log-summary rewrite
- The sample configuration set was clarified to avoid an active duplicate reboot step for `docker-01`; the alternate `cmd:reboot` example remains commented out for illustration
- The sample configuration set should be reviewed for consistency; for example, duplicate or mismatched display names attached to different IPs increase the risk of accidental operations on the wrong host
- Shell quality improvements should favor linting, targeted helpers, and incremental refactors before any broad strict-mode changes
- Future testing should focus first on parser behavior, command construction, and result reporting rather than trying to build a large end-to-end framework immediately