diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..b0e2493 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,124 @@ +# Ultrabunt Code Review — 2026-07-17 + +Full review of `ultrabunt.sh` (17k lines) and `ultrabunt-accessible.sh`. +Scope: real breakage and danger only — style ignored, silliness preserved. + +Line numbers are approximate (the performance pass shifted things by a few +dozen lines in places) — search for the quoted function name if a number is off. + +--- + +## Fixed in this pass (performance + crashers) + +These are already applied to `ultrabunt.sh`: + +| What | Why it was slow/broken | +|---|---| +| Splash screen printed instantly | 78 lines × `sleep 0.05` + `sleep 1.5` = ~5.5s of dead air at every launch | +| `apt-get update` skipped when lists < 6h old (`--refresh` to force) | Ran unconditionally at startup: 10–60s per launch on old hardware | +| `log()` rewritten fork-free (`printf %(...)T` + direct append) | Old version forked ~3 processes per line (`$(date)`, pipe, `tee`) and was called per package per menu redraw; also sprayed stderr underneath whiptail. Set `ULTRABUNT_DEBUG=1` to mirror logs to stderr | +| Removed per-check logging from `is_package_installed` | 3+ log lines (≈9 forks) per package, per menu redraw | +| Category counts computed in ONE pass over `PACKAGES` | Old loop re-walked all ~600 packages once per category (~15,000 iterations) on every main-menu redraw | +| `is_apt_installed` cache miss is now authoritative after startup scan | Old fallback forked `dpkg -l` for every NOT-installed package on every redraw — hundreds of forks per screen | +| `build_package_cache` always does one full dump per package manager | "Selective mode" forked one `dpkg-query` per package — strictly slower than dumping everything (which is 1 fork) | +| Deleted `refresh_cache_silent` + the 🔄 indicator | It forked ~600 background processes whose cache writes happened in a **child shell and were thrown away** — a subshell can't modify the parent's `INSTALLED_CACHE`. 100% wasted CPU on a machine that has none to spare | +| Removed `log "Menu items array contents: ..."` | Wrote the entire ~5KB menu array to the log on every redraw | +| dpkg checks filter on `Status-Status == installed` | `dpkg-query -W` also lists removed-but-not-purged (`rc`) packages, so uninstalled packages showed ✓ Installed | +| Flatpak checks use exact match (`grep -Fxq` on the app-id column) | Bare `grep -q "$pkg"` matched substrings | +| `((installed++))`-style counters → `x=$((x+1))` (6 sites in bulk ops) | `((x++))` returns exit 1 on the 0→1 increment; under `set -e` every bulk install/remove **killed the whole script after the first package** | +| Bulk-select checklists sort names first, then build triples | Old code joined tag/desc/OFF triples into lines and sorted ALL lines, scrambling the checklist alignment | +| `show_buntage_actions` menu guarded with `\|\| return`; install/reinstall success message now conditional on actual success | Cancel killed the script via `set -e`; failures reported "installed successfully!" | +| `view_log_file` menu guarded with `\|\| return` | ESC/Cancel in the log viewer killed the whole script | +| Swappiness regexes fixed (`vm\\.swappiness` → `vm\.swappiness`) + no-match grep guarded | The `\\.` inside single quotes is a literal backslash: "show entry" **crashed the script** and a duplicate `vm.swappiness` line was appended to sysctl.conf on every persist | +| `wordpress_security_hardening`: domain validated, wp-config.php required | Entering `..` made `site_dir=/var/www/..` = `/var`, then "Fix File Permissions" ran `chown -R www-data:www-data /var` — **system-wrecking** | +| Hardening checklist strips whiptail's literal quotes | No case arm ever matched `"file-permissions"` (with quotes), so every hardening option was silently skipped while reporting success | +| `fun` category added to `CATEGORIES` | 15 packages were tagged `fun` but the category wasn't in the menu — permanently invisible | +| `--no-media/--no-web/--no-network/-minimal/-core` use real category ids | They referenced ids that don't exist (`multimedia`, `music`, `web-browsers`, `network`, `education`…), so the exclusions silently did nothing | +| Package-list sort uses `mapfile` instead of unquoted `$(sort)` | Glob chars in descriptions (`(_*_)`) could expand against files in the cwd | + +Expected result: startup goes from ~40–70s to a few seconds (splash + one dpkg/snap/flatpak dump), and menu redraws go from seconds of fork-storm to near-instant pure-bash. + +--- + +## Outstanding — HIGH (dangerous or feature completely broken) + +### WordPress / database +- **`finalize_wordpress_setup`**: custom setup's user-entered `site_dir` is ignored by the actual installers (they all install to `/var/www/$domain`) but the *finalizer still runs* `chown -R $(whoami):www-data "$site_dir"` + recursive chmod on the unvalidated path. Enter `/home` and it re-owns it. Validate/derive `site_dir` the same way the installers do. +- **SQL injection everywhere a password/name is interpolated into `mysql -e "... IDENTIFIED BY '$pass'"`** (`setup_wordpress_database*`, `create_database_user_interactive` ~L16381, DB repair `eval` ~L13870). A single `'` in a password breaks or injects as MariaDB root. The repair path pipes it through `eval` too. Fix: validate names as `[A-Za-z0-9_]+`, pass passwords via `--defaults-extra-file` or here-doc `SET PASSWORD` with proper quoting. +- **`setup_wordpress_database_custom` runs under command substitution but its whiptail dialogs aren't redirected to `/dev/tty`** — dialogs are captured into the result variable instead of being shown; flow appears hung and wp-config gets garbage credentials. +- **wp-config salts**: the awk insert anchors on `/** MySQL settings` which WordPress renamed in 5.8 — the sed first *deletes* all AUTH_KEY…NONCE_SALT lines, then the insert never fires → **modern WP sites end up with no security keys at all**, silently. +- **`setup_nginx_ssl`** writes the 443 block to `sites-available/$domain` while `.localhost` sites were created under the stripped base name → new file is never enabled; reports success, HTTPS never serves. Also appends a duplicate 443 block on every rerun (`tee -a`). +- **phpMyAdmin installer** writes the MariaDB root password to predictable `/tmp/mysql_defaults_phpmyadmin.cnf` (pre-creatable by any local user; 644 window before chmod), and the follow-up `mysql --defaults-file` runs as the wrong user so it silently no-ops. Root password also logged in plaintext to `/var/log/ultrabunt.log` (~L10883) and wp-config gets loosened to 0664 (group-writable by www-data, world-readable). +- **`delete_wordpress_site`** reads `/root/.mysql_root_password` *without sudo* → DB/user never actually dropped while reporting "completely removed including database"; also deletes the nginx vhost matching the *first label* of the domain (deleting `shop.example.com` can nuke `shop.localhost`'s config), and will DROP a database shared by other sites without cross-checking. +- **`grant_file_access_to_user`**: `chmod -R 2775 /var/www` makes every *file* group-writable+setgid and world-readable — www-data can rewrite all PHP (persistence vector) and wp-config passwords are world-readable. Use 664 files / 2775 dirs. +- **`show_individual_site_management`** uses `$site_dir` (never set locally — dynamically inherits the caller's *last* loop value) instead of the `site_root` it computes → wp-config view/edit/DB-info act on the **wrong site** whenever >1 site exists. + +### Cloudflare / firewall +- **`cloudflare_create_rule` builds its JSON with raw printf and no escaping** — every filter expression contains quotes (some contain newlines) → malformed JSON → **every rule creation fails**, including "light". The whole feature can't work as written. Also uses the deprecated legacy `firewall/rules` endpoint, and the country code `UK` should be `GB` (would block UK visitors incl. you). + +### Mirror management +- **The entire "fastest mirror" feature is dead**: it invokes `mirrorselect` (a Gentoo tool) which is never installed (`install_mirrorselect` actually installs `netselect-apt`). The auto mode uses netselect-apt's **Debian** mirror list and seds those hosts over `archive.ubuntu.com` → apt breaks. The "restore" path recomputes `$(date +%s)`-style backup names so it restores from a filename that doesn't exist and dies under `set -e` with sources.list already clobbered. `use_local_mirror`/`setup_local_mirror` write to `/etc/apt/` **without sudo** → instant permission-denied crash. On 24.04+ the real sources live in `/etc/apt/sources.list.d/ubuntu.sources` anyway. Recommend: rip this section out or rewrite around `netselect-apt`-for-Ubuntu with sudo and proper backup naming. +- `run_mirrorselect_custom` does `eval "sudo mirrorselect $custom_flags"` on free-typed input — arbitrary shell as root by design. + +### Reverse-proxy add-ons +- **Umami**: publishes `3000:3000` on 0.0.0.0 (Docker's iptables bypass UFW) with hardcoded `APP_SECRET: replace-me-with-a-random-string` and default admin/umami login → internet-reachable with forgeable sessions on any VPS. +- **Mailcow**: mailcow's own nginx defaults to 80/443 (collides with host nginx) while the proxy snippet assumes `127.0.0.1:8080` which nothing configures — either compose fails or the proxy points at a dead port; completion message advertises `https://` URLs no config provides. +- Both add-on flows have zero error handling: any failed step under `set -e` kills the whole script with containers half-started and nginx confs already symlinked. + +### Installers (selection — full list in agent notes) +- **`install_arduino-ide`**: `unzip --strip-components=1` — flag doesn't exist; wipes `/opt/arduino-ide` then always fails, and `set -e` kills the script. +- **`install_mailhog`**: `curl -Lo` without `-f` → 404 page installed as a root systemd-managed binary; also downloads into whatever cwd the script happens to be in (deno/zed installers `cd` into temp dirs and delete them, leaving a dead cwd). +- **Plex**: `curl | sudo apt-key add -` — `apt-key` is gone on newer Ubuntu, so it aborts *after* deleting the old sources; key would land in the global trusted keyring. +- **Fixed predictable `/tmp` paths + `sudo mv`/`dpkg -i`** (lazydocker, glow, cheat, broot, dog, discord, ums, gollama, koboldcpp, LM Studio…): a local user can pre-plant/race the file and get a root-installed binary. Use `mktemp -d`. +- **`dpkg -i || apt-get install -f -y`** pattern (6+ sites): non-dependency dpkg failures are masked and reported as success. +- **whisper.cpp** builds unpinned master and copies `main`/`quantize` — upstream renamed the binaries; always fails now, `make 2>/dev/null` hides why. +- Version-pinned URLs that 404 now or soon: ctop (latest/download + 0.7.7), kind v0.20.0, Insomnia 8.6.1, mergerfs jammy-only deb, LM Studio 0.3.5, UMS deb name, balena-etcher remove targets the old package name. +- `curl | bash` as root (nodesource, ollama, starship, ddev — ddev also points at the moved `drud/ddev` repo) — accepted risk for a personal tool, but at least pin/checksum the big ones. +- `gpg --dearmor` without `--yes` (caddy, ngrok, docker, warp): reruns abort mid-way after old keys were already deleted. + +### Packages that can never install (verified against live registries) +- **apt, wrong/absent name**: `sqlitestudio` (26.04+ only), `chromium` (→ `chromium-browser`), `asciiquarium`, `batcat` (→ `bat`, and duplicates the existing `bat` entry), `mysql-workbench-community` (needs MySQL repo), `pgadmin4` (needs pgadmin repo), `bottom`, `google-drive-ocamlfuse` (PPA-only), `jellyfin` (needs jellyfin repo), `citra`, `dosbox-staging`, `python3-esptool` (→ `esptool`), `webcamize`; version-dependent: `exa` (gone in 24.04+, keep `eza`), `pastel`, `fastfetch` (25.04+), `qalculate` (→ `qalculate-gtk`), `bsnes`, `git-delta` (23.10+). +- **snap, 404**: `tableplus`, `mongodb-compass`, `nextcloud-server` (→ `nextcloud`), `heroic`, `dosbox`, `platformio`. +- **cargo/npm**: `qmasa` (→ `qmassa`), `dust` (→ `du-dust`), `adminmongo` (not on npm). +- **flatpak**: citra + yuzu delisted from Flathub (Nintendo takedown); `freetube`/`stremio` use bare names instead of app IDs. + +--- + +## Outstanding — MEDIUM + +- MariaDB/MySQL passwords passed as `-p"$pass"` on the command line **everywhere** (~20 sites) — visible in `/proc/*/cmdline` to any local user (matters most on the VPS). Standard fix: `--defaults-extra-file=<(...)` or `MYSQL_PWD` for a personal box. +- DB dumps from `backup_database_interactive` written world-readable (default umask) into `$BACKUP_DIR`; `grep -q "^${database}$"` treats the name as a regex. +- wp-config credential extraction via `grep | cut -d"'" -f4` matches commented lines, no `head -1`, breaks on `'` in passwords (DB test/repair flows). +- `restart_web_services` probes only php 8.1/8.0/7.4-fpm — never restarts the script's own PHP 8.3 FPM, so "optimize uploads" silently doesn't land. Same class: SQL-import optimizer derives paths from CLI `php -v` (can be a different version than FPM); `client_max_body_size` sed lowers larger values across *all* vhosts; `` never matches; `mysql.connect_timeout` should be `mysqli.connect_timeout`. +- MySQL "import optimization" permanently sets `innodb_flush_log_at_trx_commit=2` and blind `buffer_pool=512M` with no revert or RAM check; PHP `memory_limit=8G` per worker can OOM a small machine. +- `modify_php_setting` interpolates unvalidated input into sed (a `/`, `&` or `\` corrupts php.ini). +- Bulk ops: `install_package ... | tee` runs in a pipeline subshell, so cache updates inside are discarded — status can go stale for the session (harmless-ish now that redraws are cheap; re-run `build_package_cache` after bulk ops to be exact). +- `ui_menu` returns the literal string `"back"` on ESC — any future package named `back` breaks navigation; bulk category picker can pass `"back"` into the match loop. +- Web-config-summary site pickers push 3 args per non-live site into a *menu* (that's checklist syntax) — misaligned rows; "No live sites" branch unreachable. +- Log viewer: Ctrl+C on `tail -f` SIGINTs the whole script (no trap); pager temp file at predictable `/tmp/ultrabunt_log_view_$$` copies sudo-read logs world-readable. +- `wordpress_cleanup_menu` find: `-name "*.localhost" -o -name "*.local" -print0` — `-print0` binds to one branch; `.localhost` sites never listed. +- certbot always adds `-d www.$domain` — fails for domains without a www record. +- Hotkeys: >24 categories get lowercase tags but the handler uppercases before lookup (opens the wrong category); >48 reuses letters and silently overwrites. The "cycling" code for shared hotkeys is dead (assignment overwrites, never appends). Currently 27 categories, so the first overflow row (`a`) is already live. +- `ultrabunt-accessible.sh`: launches `./ultrabunt.sh` relative to caller's cwd; instructs running the whole thing via `sudo`, which breaks `$USER`-based ownership logic in the main script and makes TTS silent (root has no Pulse session); `timeout 10s echo | festival` applies the timeout to `echo` (festival can hang forever); `sudo -u user cat > file` redirection still runs as root. + +## Outstanding — LOW (grab-bag) + +- `show_system_info` hardcodes a 50×140 dialog — breaks on a stock 80×24 SSH window. +- Export list writes to predictable `/tmp/ultrabunt-packages-.txt`. +- `get_status()` is dead code (never called; no arms for custom/npm/cargo methods). +- `silversearcher-ag` and `ag` are duplicate entries for the same package. +- `remove_phpmyadmin` only restarts services that are `is-enabled` — a running-but-disabled DB/web server stays down. `--force-yes` no longer exists in modern apt. +- `__site_is_live` needs `host` (not on minimal Ubuntu) → everything reports "not live". +- Keyboard config writes to `$HOME`/gsettings — configures root's session if run under sudo. +- bun installer greps live `$PATH` instead of rc files → duplicate export lines per rerun. +- README drift: claims pip is removed but `install_pip_package`/npm/cargo helpers and CUSTOM npm tools are still in the script; claims 25+ categories/560+ apps — counts don't match the code. + +--- + +## Suggested priorities + +1. **WordPress DB quoting + the `finalize_wordpress_setup` chown** — it's the feature you actually use, on machines you keep. +2. **Delete or rewrite mirror management** — it cannot work and it edits sources.list. +3. **Umami/Mailcow port + secret fixes** if you ever run them on the VPS. +4. **Purge the ~25 never-installable package entries** (list above) — they just waste menu space and end in failure dialogs. +5. Sweep the installers for `curl -f`, `mktemp -d`, and `gpg --dearmor --yes` — three mechanical patterns fix most of the installer findings. diff --git a/ultrabunt.sh b/ultrabunt.sh index be41715..4d0245a 100755 --- a/ultrabunt.sh +++ b/ultrabunt.sh @@ -44,8 +44,14 @@ DIALOG_PRESETS[huge]="40 140 25" # Current dialog preset CURRENT_DIALOG_PRESET="standard" +# Set by -r/--refresh to force apt-get update at startup even if lists are fresh +FORCE_APT_UPDATE=0 + # Cache for installed buntages - populated once at startup declare -A INSTALLED_CACHE +# Set to 1 once build_package_cache has done its full scan; after that a +# cache miss is authoritative (= not installed) and no dpkg fallback is needed +CACHE_PRIMED=0 # Session variables for database authentication @@ -257,11 +263,8 @@ show_ascii_splash() { "░░█████████ ░░░███████░ █████ █████ " ) - # Display each line with scrolling effect - for line in "${ascii_lines[@]}"; do - echo "$line" - sleep 0.05 # Small delay for scrolling effect - done + # Display the art in one write - the old per-line scroll effect cost ~4s + printf '%s\n' "${ascii_lines[@]}" echo -e "${NC}" @@ -274,7 +277,7 @@ show_ascii_splash() { echo "" # Brief pause before continuing - sleep 1.5 + sleep 0.5 } # Parse command line arguments @@ -303,12 +306,15 @@ parse_arguments() { log "Excluding AI category" ;; -media|--no-media) - EXCLUDED_CATEGORIES["multimedia"]=1 - EXCLUDED_CATEGORIES["music"]=1 + # Real category ids are audio/video/media-servers (the old + # multimedia/music ids never existed, so this flag was a no-op) + EXCLUDED_CATEGORIES["audio"]=1 + EXCLUDED_CATEGORIES["video"]=1 + EXCLUDED_CATEGORIES["media-servers"]=1 log "Excluding media categories" ;; -web|--no-web) - EXCLUDED_CATEGORIES["web-browsers"]=1 + EXCLUDED_CATEGORIES["browsers"]=1 log "Excluding web browsers category" ;; -comm|--no-communication) @@ -328,7 +334,7 @@ parse_arguments() { log "Excluding security category" ;; -network|--no-network) - EXCLUDED_CATEGORIES["network"]=1 + EXCLUDED_CATEGORIES["network-monitoring"]=1 log "Excluding network category" ;; -system|--no-system) @@ -356,15 +362,21 @@ parse_arguments() { log "Excluding cloud category" ;; -minimal|--minimal) - # Exclude most categories, keep only core and system - for cat in dev wordpress gaming-platforms gaming-emulators ai multimedia music web-browsers communication office graphics security network education science finance virtualization cloud; do + # Exclude most categories, keep core / system / utilities / web + # (uses the real category ids - the old list was full of ids + # that never existed, so minimal mode barely excluded anything) + for cat in dev ai containers shell editors browsers system-monitoring network-monitoring performance-tools database security office communication graphics audio video media-servers cloud terminals gaming-platforms gaming-emulators sbc fun; do EXCLUDED_CATEGORIES["$cat"]=1 done log "Minimal mode: excluding most categories" ;; + -r|--refresh) + FORCE_APT_UPDATE=1 + log "Forcing APT cache refresh at startup" + ;; -core|--core-only) # Exclude everything except core - for cat in dev wordpress gaming-platforms gaming-emulators ai multimedia music web-browsers communication office graphics security network system education science finance virtualization cloud; do + for cat in dev ai containers web shell editors browsers system-monitoring network-monitoring performance-tools utilities database security system office communication graphics audio video media-servers cloud terminals gaming-platforms gaming-emulators sbc fun; do EXCLUDED_CATEGORIES["$cat"]=1 done log "Core-only mode: excluding all except core category" @@ -390,7 +402,9 @@ USAGE: OPTIONS: -h, --help Show this help message - + -r, --refresh Force 'apt-get update' at startup (otherwise + skipped when lists are less than 6 hours old) + SELECTIVE LOADING (exclude categories to speed up startup): -dev, --no-dev Exclude development tools -wp, --no-wordpress Exclude WordPress tools @@ -444,110 +458,43 @@ init_logging() { sudo chown "$USER:$USER" "$LOGFILE" "$BACKUP_DIR" 2>/dev/null || true } -# Build cache of all installed buntages for fast lookups +# Build cache of all installed buntages for fast lookups. +# Always does one full dump per package manager - a single dpkg-query/snap/ +# flatpak fork each. (The old "selective mode" forked one query per package, +# which was strictly slower than dumping everything.) +# Once this has run, CACHE_PRIMED=1 and a cache miss means "not installed" - +# no per-package fallback forks needed. build_package_cache() { log "Building buntage installation cache..." - - # Clear existing cache + INSTALLED_CACHE=() - - # If selective loading is enabled, only cache packages from non-excluded categories - local selective_mode=false - if [[ ${#EXCLUDED_CATEGORIES[@]} -gt 0 ]]; then - selective_mode=true - log "Selective loading enabled - optimizing cache for included categories only" - fi - - # Cache APT buntages + + # Cache APT buntages. Filter on Status-Status: a bare dpkg-query -W also + # lists removed-but-not-purged (rc) packages, which showed as installed. log "Caching APT buntages..." - if [[ "$selective_mode" == "true" ]]; then - # Only cache packages from included categories - for name in "${!PACKAGES[@]}"; do - local pkg_category="${PKG_CATEGORY[$name]:-}" - local method="${PKG_METHOD[$name]:-}" - - # Skip if category is excluded - if [[ -n "${EXCLUDED_CATEGORIES[$pkg_category]:-}" ]]; then - continue - fi - - # Only check APT packages in this section - if [[ "$method" == "apt" ]]; then - local pkg="${PACKAGES[$name]}" - if dpkg-query -W "$pkg" &>/dev/null; then - INSTALLED_CACHE["apt:$pkg"]=1 - fi - fi - done - else - # Cache all APT packages (original behavior) - while IFS= read -r pkg; do - INSTALLED_CACHE["apt:$pkg"]=1 - done < <(dpkg-query -W -f='${Package}\n' 2>/dev/null | sort) - fi - + local status pkg + while read -r status pkg; do + [[ "$status" == "installed" ]] && INSTALLED_CACHE["apt:$pkg"]=1 + done < <(dpkg-query -W -f='${db:Status-Status} ${Package}\n' 2>/dev/null) + # Cache Snap buntages if command -v snap &>/dev/null; then log "Caching Snap buntages..." - if [[ "$selective_mode" == "true" ]]; then - # Only cache packages from included categories - for name in "${!PACKAGES[@]}"; do - local pkg_category="${PKG_CATEGORY[$name]:-}" - local method="${PKG_METHOD[$name]:-}" - - # Skip if category is excluded - if [[ -n "${EXCLUDED_CATEGORIES[$pkg_category]:-}" ]]; then - continue - fi - - # Only check Snap packages in this section - if [[ "$method" == "snap" ]]; then - local pkg="${PACKAGES[$name]}" - if snap list "$pkg" &>/dev/null; then - INSTALLED_CACHE["snap:$pkg"]=1 - fi - fi - done - else - # Cache all Snap packages (original behavior) - while IFS= read -r pkg; do - INSTALLED_CACHE["snap:$pkg"]=1 - done < <(snap list 2>/dev/null | awk 'NR>1 {print $1}' | sort) - fi + while IFS= read -r pkg; do + [[ -n "$pkg" ]] && INSTALLED_CACHE["snap:$pkg"]=1 + done < <(snap list 2>/dev/null | awk 'NR>1 {print $1}') fi - + # Cache Flatpak buntages if command -v flatpak &>/dev/null; then log "Caching Flatpak buntages..." - if [[ "$selective_mode" == "true" ]]; then - # Only cache packages from included categories - for name in "${!PACKAGES[@]}"; do - local pkg_category="${PKG_CATEGORY[$name]:-}" - local method="${PKG_METHOD[$name]:-}" - - # Skip if category is excluded - if [[ -n "${EXCLUDED_CATEGORIES[$pkg_category]:-}" ]]; then - continue - fi - - # Only check Flatpak packages in this section - if [[ "$method" == "flatpak" ]]; then - local pkg="${PACKAGES[$name]}" - if flatpak list --app | grep -q "$pkg"; then - INSTALLED_CACHE["flatpak:$pkg"]=1 - fi - fi - done - else - # Cache all Flatpak packages (original behavior) - while IFS= read -r pkg; do - INSTALLED_CACHE["flatpak:$pkg"]=1 - done < <(flatpak list --app --columns=application 2>/dev/null | sort) - fi + while IFS= read -r pkg; do + [[ -n "$pkg" ]] && INSTALLED_CACHE["flatpak:$pkg"]=1 + done < <(flatpak list --app --columns=application 2>/dev/null) fi - - local total_cached=${#INSTALLED_CACHE[@]} - log "Buntage cache built with $total_cached entries (selective mode: $selective_mode)" + + CACHE_PRIMED=1 + log "Buntage cache built with ${#INSTALLED_CACHE[@]} entries" } # Function to update cache for a specific buntage @@ -573,7 +520,7 @@ update_package_cache() { # Update the specific buntage in the cache case "$method" in apt) - if dpkg -l "$pkg" 2>/dev/null | grep -q "^ii"; then + if [[ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2>/dev/null)" == "installed" ]]; then INSTALLED_CACHE["apt:$pkg"]="1" [[ "$silent" != "true" ]] && log "Cache updated: $name is installed (APT)" else @@ -591,7 +538,8 @@ update_package_cache() { fi ;; flatpak) - if flatpak list --app 2>/dev/null | grep -q "$pkg"; then + # -Fxq: exact whole-line match; a bare grep "$pkg" matched substrings + if flatpak list --app --columns=application 2>/dev/null | grep -Fxq "$pkg"; then INSTALLED_CACHE["flatpak:$pkg"]="1" [[ "$silent" != "true" ]] && log "Cache updated: $name is installed (Flatpak)" else @@ -612,19 +560,33 @@ update_package_cache() { return 0 } +# Fork-free logging: printf %(..)T is a bash builtin (no $(date) subshell), +# and appending directly avoids the echo|tee pipeline (2 forks per call). +# log() used to fork ~3 processes per line and was called per package per +# menu redraw - thousands of forks on every screen. It also sprayed lines +# onto stderr underneath whiptail. File-only now; set ULTRABUNT_DEBUG=1 to +# mirror logs to stderr again. log() { - local msg="[$(date +'%Y-%m-%d %H:%M:%S')] $*" - echo "$msg" | tee -a "$LOGFILE" >&2 + local ts + printf -v ts '%(%Y-%m-%d %H:%M:%S)T' -1 + printf '[%s] %s\n' "$ts" "$*" >> "$LOGFILE" 2>/dev/null || true + [[ "${ULTRABUNT_DEBUG:-0}" == "1" ]] && printf '[%s] %s\n' "$ts" "$*" >&2 + return 0 } log_error() { - local msg="[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" - echo "$msg" | tee -a "$LOGFILE" >&2 + local ts + printf -v ts '%(%Y-%m-%d %H:%M:%S)T' -1 + printf '[%s] ERROR: %s\n' "$ts" "$*" >> "$LOGFILE" 2>/dev/null || true + printf '[%s] ERROR: %s\n' "$ts" "$*" >&2 } log_warning() { - local msg="[$(date +'%Y-%m-%d %H:%M:%S')] WARNING: $*" - echo "$msg" | tee -a "$LOGFILE" >&2 + local ts + printf -v ts '%(%Y-%m-%d %H:%M:%S)T' -1 + printf '[%s] WARNING: %s\n' "$ts" "$*" >> "$LOGFILE" 2>/dev/null || true + [[ "${ULTRABUNT_DEBUG:-0}" == "1" ]] && printf '[%s] WARNING: %s\n' "$ts" "$*" >&2 + return 0 } # Ensure whiptail is available @@ -873,8 +835,14 @@ is_apt_installed() { return 0 fi - # Fallback to direct dpkg check when cache is missing - if dpkg -l "$pkg" 2>/dev/null | grep -q "^ii"; then + # Once the full scan has run, a miss is authoritative. The old fallback + # forked dpkg for EVERY not-installed package on EVERY menu redraw. + if [[ "$CACHE_PRIMED" == "1" ]]; then + return 1 + fi + + # Fallback to direct dpkg check only before the cache is built + if [[ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2>/dev/null)" == "installed" ]]; then INSTALLED_CACHE["apt:$pkg"]=1 return 0 fi @@ -4077,6 +4045,7 @@ CATEGORIES=( "gaming-platforms:Gaming Platforms" "gaming-emulators:Gaming Emulators" "sbc:Single Board Computers & Microcontrollers" + "fun:Fun & Silly Stuff" ) # Category hotkey mappings (A-Z) @@ -4114,19 +4083,30 @@ declare -A CATEGORY_HOTKEYS=( # INSTALLATION FUNCTIONS # ============================================================================== +# Refresh the APT lists, but skip if they were updated recently. Running +# `apt-get update` unconditionally at startup cost 10-60s on old hardware +# every single launch. Pass "force" to update regardless of age. +APT_UPDATE_MAX_AGE_HOURS=6 apt_update() { + local force="${1:-}" + + if [[ "$force" != "force" ]]; then + # Newest file under /var/lib/apt/lists tells us when update last ran + local newest + newest=$(find /var/lib/apt/lists -maxdepth 1 -name '*Packages*' -newermt "-${APT_UPDATE_MAX_AGE_HOURS} hours" -print -quit 2>/dev/null || true) + if [[ -n "$newest" ]]; then + log "APT lists are less than ${APT_UPDATE_MAX_AGE_HOURS}h old - skipping update (use --refresh to force)" + return 0 + fi + fi + log "Updating APT cache..." if sudo apt-get update -qq 2>&1 | tee -a "$LOGFILE"; then log "APT cache updated successfully" else - local exit_code=$? - if [ $exit_code -eq 0 ]; then - log "APT cache updated with warnings (non-critical)" - else - log "WARNING: APT cache update failed with exit code $exit_code" - log "Continuing anyway - some packages may not be available" - fi + log "WARNING: APT cache update failed - continuing anyway, some packages may not be available" fi + return 0 } install_apt_package() { @@ -7294,29 +7274,25 @@ is_package_installed() { return 1 fi - [[ "$silent" != "true" ]] && log "Buntage '$name' uses method '$method' with buntage name '$pkg'" - - # Add error handling for each method + # No logging in here: this runs for every package on every menu redraw, + # and the old per-check log lines were most of the menu lag. local result=1 case "$method" in - apt) - log "Checking APT installation for '$pkg'" + apt) if is_apt_installed "$pkg"; then result=0 else result=1 fi ;; - snap) - log "Checking Snap installation for '$pkg'" + snap) if is_snap_installed "$pkg"; then result=0 else result=1 fi ;; - flatpak) - log "Checking Flatpak installation for '$pkg'" + flatpak) if is_flatpak_installed "$pkg"; then result=0 else @@ -7324,7 +7300,6 @@ is_package_installed() { fi ;; custom) - log "Checking custom installation for '$name'" case "$name" in docker) if is_apt_installed "docker-ce"; then @@ -7416,36 +7391,11 @@ is_package_installed() { # MENU SYSTEM # ============================================================================== -# Function to refresh package cache in background without logging -refresh_cache_silent() { - local refresh_pid - { - # Update cache for all packages silently - for name in "${!PACKAGES[@]}"; do - update_package_cache "$name" "true" &>/dev/null - done - } & - refresh_pid=$! - - # Store the PID for potential cleanup - echo "$refresh_pid" > "/tmp/ultrabunt_cache_refresh.pid" 2>/dev/null || true -} - -# Function to check if background cache refresh is complete -is_cache_refresh_complete() { - local pid_file="/tmp/ultrabunt_cache_refresh.pid" - if [[ -f "$pid_file" ]]; then - local pid - pid=$(cat "$pid_file" 2>/dev/null) - if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then - return 1 # Still running - else - rm -f "$pid_file" 2>/dev/null || true - return 0 # Complete - fi - fi - return 0 # No refresh running -} +# NOTE: the old refresh_cache_silent forked ~600 background processes whose +# cache updates happened in a child shell and were silently thrown away (a +# subshell cannot modify the parent's INSTALLED_CACHE). Pure wasted CPU on +# every menu entry, so it is gone. build_package_cache at startup plus +# update_package_cache after each install/remove keeps the cache correct. show_category_menu() { log "Entering show_category_menu function" @@ -7453,10 +7403,7 @@ show_category_menu() { # Hotkey cycling state tracking local -A last_hotkey_selection local -A hotkey_categories - - # Start background cache refresh on first load - refresh_cache_silent - + while true; do log "Building category menu items..." local menu_items=() @@ -7480,6 +7427,21 @@ show_category_menu() { menu_items+=("" "(_*_)") log "Processing categories array with ${#CATEGORIES[@]} entries" + # Count installed/total per category in ONE pass over PACKAGES. + # The old code re-walked all ~600 packages once per category + # (~15,000 iterations with forking status checks) on every redraw. + local -A cat_total=() + local -A cat_installed=() + local name + for name in "${!PACKAGES[@]}"; do + local pkg_category="${PKG_CATEGORY[$name]:-}" + [[ -z "$pkg_category" ]] && continue + cat_total[$pkg_category]=$(( ${cat_total[$pkg_category]:-0} + 1 )) + if is_package_installed "$name" "true"; then + cat_installed[$pkg_category]=$(( ${cat_installed[$pkg_category]:-0} + 1 )) + fi + done + # Build sortable array as name:id:installed:total for alphabetical ordering by name local sortable_categories=() for entry in "${CATEGORIES[@]}"; do @@ -7488,31 +7450,10 @@ show_category_menu() { # Skip excluded categories if [[ -n "${EXCLUDED_CATEGORIES[$cat_id]:-}" ]]; then - log "Skipping excluded category: $cat_id" continue fi - # Count installed buntages in category - local total=0 - local installed=0 - - # Use a safer iteration method - local package_names=() - for name in "${!PACKAGES[@]}"; do - package_names+=("$name") - done - - for name in "${package_names[@]}"; do - local pkg_category="${PKG_CATEGORY[$name]:-}" - if [[ "$pkg_category" == "$cat_id" ]]; then - total=$((total + 1)) - if is_package_installed "$name" "true"; then - installed=$((installed + 1)) - fi - fi - done - - sortable_categories+=("$cat_name:$cat_id:$installed:$total") + sortable_categories+=("$cat_name:$cat_id:${cat_installed[$cat_id]:-0}:${cat_total[$cat_id]:-0}") done # Sort categories alphabetically by display name @@ -7549,12 +7490,6 @@ show_category_menu() { index=$((index + 1)) done - # Add refresh indicator if cache is still updating - local refresh_indicator="" - if ! is_cache_refresh_complete; then - refresh_indicator=" 🔄" - fi - # Rebuild menu_items from sorted categories for item in "${sortable_categories[@]}"; do local cat_name="${item%%:*}" @@ -7574,7 +7509,7 @@ show_category_menu() { # Use the hotkey letter as the menu tag so typing the letter jumps # directly to the row that displays that bracketed hotkey. # The actual category id is recovered later via hotkey_categories. - menu_items+=("$hotkey" "${hotkey_display}$cat_name [$installed/$total installed]$refresh_indicator") + menu_items+=("$hotkey" "${hotkey_display}$cat_name [$installed/$total installed]") done log "Adding additional menu items..." @@ -7584,8 +7519,7 @@ show_category_menu() { menu_items+=("Q" "(Q) Exit Installer") log "Calling ui_menu with ${#menu_items[@]} menu items" - log "Menu items array contents: ${menu_items[*]}" - + # Add error handling for ui_menu call local choice="" set +e # Temporarily disable exit on error @@ -7779,7 +7713,7 @@ show_buntage_list() { if [[ "$pkg_category" == "$category" ]]; then local status="✗ Not Installed" local display_text - if is_package_installed "$name"; then + if is_package_installed "$name" "true"; then status="✓ Installed" display_text="(.Y.) ${PKG_DESC[$name]:-No description} [$status]" else @@ -7808,9 +7742,11 @@ show_buntage_list() { fi done - # Sort the temp array - IFS=$'\n' temp_array=($(sort <<<"${temp_array[*]}")) - unset IFS + # Sort the temp array (mapfile avoids glob expansion of entries + # containing * or ? that the old unquoted $(sort) suffered from) + if [[ ${#temp_array[@]} -gt 0 ]]; then + mapfile -t temp_array < <(printf '%s\n' "${temp_array[@]}" | sort) + fi # Convert back to menu_items format for item in "${temp_array[@]}"; do @@ -7911,18 +7847,25 @@ show_buntage_actions() { menu_items+=("zback" "(B) ← Back to Buntage List") local choice - choice=$(ui_menu "Manage: $name" "$info" $DIALOG_HEIGHT $DIALOG_WIDTH $DIALOG_MENU_HEIGHT "${menu_items[@]}") - + # || return: without it, Cancel here made set -e kill the whole script + choice=$(ui_menu "Manage: $name" "$info" $DIALOG_HEIGHT $DIALOG_WIDTH $DIALOG_MENU_HEIGHT "${menu_items[@]}") || return 0 + case "$choice" in install) - install_package_with_choice "$name" - ui_msg "Success" "$name installed successfully!" - status="✓ Installed" + if install_package_with_choice "$name"; then + ui_msg "Success" "$name installed successfully!" + status="✓ Installed" + else + ui_msg "Install Failed" "$name did not install cleanly.\n\nCheck $LOGFILE for details." + fi ;; reinstall) remove_package "$name" - install_package_with_choice "$name" - ui_msg "Success" "$name reinstalled successfully!" + if install_package_with_choice "$name"; then + ui_msg "Success" "$name reinstalled successfully!" + else + ui_msg "Reinstall Failed" "$name did not reinstall cleanly.\n\nCheck $LOGFILE for details." + fi ;; remove) remove_package "$name" @@ -9754,9 +9697,9 @@ bulk_install_category() { if ! is_package_installed "$name"; then log "Bulk installing: $name" if install_package "$name" 2>&1 | tee -a "$LOGFILE"; then - ((installed++)) + installed=$((installed + 1)) else - ((failed++)) + failed=$((failed + 1)) log_error "Failed to install: $name" fi fi @@ -9807,7 +9750,9 @@ bulk_remove_category() { esac ;; esac - ((removed++)) + # ((removed++)) returns 1 on the 0->1 increment, which set -e + # turned into "whole script exits after first removal" + removed=$((removed + 1)) fi fi done @@ -9818,24 +9763,25 @@ bulk_remove_category() { bulk_install_selected() { local checklist_items=() - for name in "${!PACKAGES[@]}"; do - if ! is_package_installed "$name"; then - local desc="${PKG_DESC[$name]}" - local cat="${PKG_CATEGORY[$name]}" + # Sort the names first, then build the tag/desc/state triples in order. + # (The old approach joined the triples into lines and sorted ALL lines, + # scrambling tag<->description<->OFF alignment in the checklist.) + local sorted_names=() + mapfile -t sorted_names < <(printf '%s\n' "${!PACKAGES[@]}" | sort) + + for name in "${sorted_names[@]}"; do + if ! is_package_installed "$name" "true"; then + local desc="${PKG_DESC[$name]:-No description}" + local cat="${PKG_CATEGORY[$name]:-misc}" checklist_items+=("$name" "[$cat] $desc" "OFF") fi done - + if [[ ${#checklist_items[@]} -eq 0 ]]; then ui_msg "Nothing to Install" "All tracked packages are already installed!" return fi - - # Sort checklist - IFS=$'\n' - checklist_items=($(sort -t$'\t' -k1 <<<"${checklist_items[*]}")) - unset IFS - + local selected selected=$(ui_checklist "Select Packages to Install" \ "Choose packages to install (Space to select, Enter to confirm):" \ @@ -9856,9 +9802,9 @@ bulk_install_selected() { for name in $pkg_list; do log "Installing selected: $name" if install_package "$name" 2>&1 | tee -a "$LOGFILE"; then - ((installed++)) + installed=$((installed + 1)) else - ((failed++)) + failed=$((failed + 1)) log_error "Failed to install: $name" fi done @@ -9869,24 +9815,23 @@ bulk_install_selected() { bulk_remove_selected() { local checklist_items=() - for name in "${!PACKAGES[@]}"; do + # Sort names first, then build triples in order (see bulk_install_selected) + local sorted_names=() + mapfile -t sorted_names < <(printf '%s\n' "${!PACKAGES[@]}" | sort) + + for name in "${sorted_names[@]}"; do if is_package_installed "$name" "true"; then - local desc="${PKG_DESC[$name]}" - local cat="${PKG_CATEGORY[$name]}" + local desc="${PKG_DESC[$name]:-No description}" + local cat="${PKG_CATEGORY[$name]:-misc}" checklist_items+=("$name" "[$cat] $desc" "OFF") fi done - + if [[ ${#checklist_items[@]} -eq 0 ]]; then ui_msg "Nothing to Remove" "No tracked packages are currently installed!" return fi - - # Sort checklist - IFS=$'\n' - checklist_items=($(sort -t$'\t' -k1 <<<"${checklist_items[*]}")) - unset IFS - + local selected selected=$(ui_checklist "Select Packages to Remove" \ "Choose packages to remove (Space to select, Enter to confirm):" \ @@ -9922,7 +9867,7 @@ bulk_remove_selected() { esac ;; esac - ((removed++)) + removed=$((removed + 1)) done ui_msg "Removal Complete" "Removed: $removed packages\n\nCheck $LOGFILE for details." @@ -12435,14 +12380,22 @@ wordpress_security_hardening() { if [[ -z "$domain" ]]; then return fi - + + # Only allow sane domain characters. Without this, entering ".." made + # site_dir=/var/www/.. (= /var) and the file-permissions option then + # ran chown -R www-data / chmod -R over the whole of /var. + if [[ ! "$domain" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]; then + ui_msg "Invalid Domain" "Domain may only contain letters, digits, dots and hyphens." + return + fi + local site_dir="/var/www/$domain" - - if [[ ! -d "$site_dir" ]]; then + + if [[ ! -d "$site_dir" || ! -f "$site_dir/wp-config.php" ]]; then ui_msg "Site Not Found" "WordPress installation not found at $site_dir" return fi - + local security_options=( "file-permissions" "Fix File Permissions" on "disable-file-editing" "Disable File Editing in Admin" on @@ -12461,8 +12414,12 @@ wordpress_security_hardening() { ui_msg "Applying Security" "Applying selected security hardening options..." - # Apply selected security measures + # Apply selected security measures. whiptail returns selections as + # "opt1" "opt2" with literal quotes - strip them or no case arm ever + # matches and every option is silently skipped. for option in $selected; do + option="${option%\"}" + option="${option#\"}" case "$option" in "file-permissions") apply_wp_file_permissions "$site_dir" @@ -15748,8 +15705,10 @@ ensure_persistent_swappiness() { if [[ ! -f "$file" ]]; then echo "vm.swappiness=$v" | sudo tee "$file" >/dev/null else - if grep -Eq '^[[:space:]]*#?[[:space:]]*vm\\.swappiness[[:space:]]*=' "$file"; then - sudo sed -i -E 's/^[[:space:]]*#?[[:space:]]*vm\\.swappiness[[:space:]]*=.*/vm.swappiness='"$v"'/g' "$file" + # NB: \\. inside single quotes is a literal backslash - the old pattern + # never matched, so a duplicate vm.swappiness line was appended per run + if grep -Eq '^[[:space:]]*#?[[:space:]]*vm\.swappiness[[:space:]]*=' "$file"; then + sudo sed -i -E 's/^[[:space:]]*#?[[:space:]]*vm\.swappiness[[:space:]]*=.*/vm.swappiness='"$v"'/g' "$file" else echo "vm.swappiness=$v" | sudo tee -a "$file" >/dev/null fi @@ -15772,7 +15731,9 @@ show_sysctl_swappiness_lines() { local file="/etc/sysctl.conf" local lines if [[ -r "$file" ]]; then - lines=$(grep -n -E 'vm\\.swappiness' "$file" 2>/dev/null) + # || true: grep exits 1 on no match, which set -e turned into a + # whole-script exit when this menu item was opened + lines=$(grep -n -E 'vm\.swappiness' "$file" 2>/dev/null) || true fi ui_info "sysctl.conf swappiness" "${lines:-No swappiness entry found in /etc/sysctl.conf}" } @@ -15985,8 +15946,8 @@ view_log_file() { "last100" "View Last 100 Lines (Scrollable)" \ "last500" "View Last 500 Lines (Scrollable)" \ "full" "View Full Log File (Scrollable)" \ - "tail-follow" "Follow Live Updates (tail -f)") - + "tail-follow" "Follow Live Updates (tail -f)") || return 0 + case "$choice" in last100) view_log_with_pager "$log_file" "$log_name" 100 @@ -16597,10 +16558,14 @@ main() { log "║ ULTRABUNT ULTIMATE BUNTSTALLER v4.2.0 STARTED ║" log "╚═══════════════════════════════════════════════╝" - # Update buntage cache + # Update buntage cache (skipped automatically when lists are fresh) log "Starting APT cache update..." speak_if_enabled "Updating package cache, please wait..." - apt_update + if [[ "$FORCE_APT_UPDATE" == "1" ]]; then + apt_update force + else + apt_update + fi log "APT cache update completed" speak_if_enabled "Package cache updated successfully"