- splash no longer does the 5.5s slow-scroll flex - apt-get update only runs if lists are >6h old (-r/--refresh to force) - log() fork-free, debug spam gated behind ULTRABUNT_DEBUG=1 - category menu counts in one pass, no more dpkg fork storm per redraw - deleted refresh_cache_silent (600 background forks doing literally nothing) - rc-state packages no longer show as installed; flatpak exact-match - bulk ops no longer die after first package (((x++)) set -e trap) - bulk checklists no longer scramble themselves when sorting - ESC no longer nukes the whole script in buntage/log-viewer menus - swappiness menu: fixed regex crash + duplicate sysctl lines - wp hardening: domain validated (no more chown -R /var via ".."), options actually apply now - fun category (15 buntages) rescued from the void - --no-media/--no-web/--no-network/-minimal/-core now exclude real categories REVIEW.md has the full findings list incl. everything not fixed yet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
16 KiB
16 KiB
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-enteredsite_diris ignored by the actual installers (they all install to/var/www/$domain) but the finalizer still runschown -R $(whoami):www-data "$site_dir"+ recursive chmod on the unvalidated path. Enter/homeand it re-owns it. Validate/derivesite_dirthe 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 repaireval~L13870). A single'in a password breaks or injects as MariaDB root. The repair path pipes it throughevaltoo. Fix: validate names as[A-Za-z0-9_]+, pass passwords via--defaults-extra-fileor here-docSET PASSWORDwith proper quoting. setup_wordpress_database_customruns 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 settingswhich 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_sslwrites the 443 block tosites-available/$domainwhile.localhostsites 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-upmysql --defaults-fileruns 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_sitereads/root/.mysql_root_passwordwithout sudo → DB/user never actually dropped while reporting "completely removed including database"; also deletes the nginx vhost matching the first label of the domain (deletingshop.example.comcan nukeshop.localhost's config), and will DROP a database shared by other sites without cross-checking.grant_file_access_to_user:chmod -R 2775 /var/wwwmakes 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_managementuses$site_dir(never set locally — dynamically inherits the caller's last loop value) instead of thesite_rootit computes → wp-config view/edit/DB-info act on the wrong site whenever >1 site exists.
Cloudflare / firewall
cloudflare_create_rulebuilds 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 legacyfirewall/rulesendpoint, and the country codeUKshould beGB(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_mirrorselectactually installsnetselect-apt). The auto mode uses netselect-apt's Debian mirror list and seds those hosts overarchive.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 underset -ewith sources.list already clobbered.use_local_mirror/setup_local_mirrorwrite to/etc/apt/without sudo → instant permission-denied crash. On 24.04+ the real sources live in/etc/apt/sources.list.d/ubuntu.sourcesanyway. Recommend: rip this section out or rewrite aroundnetselect-apt-for-Ubuntu with sudo and proper backup naming. run_mirrorselect_customdoeseval "sudo mirrorselect $custom_flags"on free-typed input — arbitrary shell as root by design.
Reverse-proxy add-ons
- Umami: publishes
3000:3000on 0.0.0.0 (Docker's iptables bypass UFW) with hardcodedAPP_SECRET: replace-me-with-a-random-stringand 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:8080which nothing configures — either compose fails or the proxy points at a dead port; completion message advertiseshttps://URLs no config provides. - Both add-on flows have zero error handling: any failed step under
set -ekills 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-idethen always fails, andset -ekills the script.install_mailhog:curl -Lowithout-f→ 404 page installed as a root systemd-managed binary; also downloads into whatever cwd the script happens to be in (deno/zed installerscdinto temp dirs and delete them, leaving a dead cwd).- Plex:
curl | sudo apt-key add -—apt-keyis gone on newer Ubuntu, so it aborts after deleting the old sources; key would land in the global trusted keyring. - Fixed predictable
/tmppaths +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. Usemktemp -d. dpkg -i || apt-get install -f -ypattern (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/nullhides 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 | bashas root (nodesource, ollama, starship, ddev — ddev also points at the moveddrud/ddevrepo) — accepted risk for a personal tool, but at least pin/checksum the big ones.gpg --dearmorwithout--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 existingbatentry),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+, keepeza),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/stremiouse 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/*/cmdlineto any local user (matters most on the VPS). Standard fix:--defaults-extra-file=<(...)orMYSQL_PWDfor a personal box. - DB dumps from
backup_database_interactivewritten world-readable (default umask) into$BACKUP_DIR;grep -q "^${database}$"treats the name as a regex. - wp-config credential extraction via
grep | cut -d"'" -f4matches commented lines, nohead -1, breaks on'in passwords (DB test/repair flows). restart_web_servicesprobes 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 CLIphp -v(can be a different version than FPM);client_max_body_sizesed lowers larger values across all vhosts;<IfModule mod_php8.c>never matches;mysql.connect_timeoutshould bemysqli.connect_timeout.- MySQL "import optimization" permanently sets
innodb_flush_log_at_trx_commit=2and blindbuffer_pool=512Mwith no revert or RAM check; PHPmemory_limit=8Gper worker can OOM a small machine. modify_php_settinginterpolates unvalidated input into sed (a/,&or\corrupts php.ini).- Bulk ops:
install_package ... | teeruns 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-runbuild_package_cacheafter bulk ops to be exact). ui_menureturns the literal string"back"on ESC — any future package namedbackbreaks 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 -fSIGINTs the whole script (no trap); pager temp file at predictable/tmp/ultrabunt_log_view_$$copies sudo-read logs world-readable. wordpress_cleanup_menufind:-name "*.localhost" -o -name "*.local" -print0—-print0binds to one branch;.localhostsites 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.shrelative to caller's cwd; instructs running the whole thing viasudo, which breaks$USER-based ownership logic in the main script and makes TTS silent (root has no Pulse session);timeout 10s echo | festivalapplies the timeout toecho(festival can hang forever);sudo -u user cat > fileredirection still runs as root.
Outstanding — LOW (grab-bag)
show_system_infohardcodes a 50×140 dialog — breaks on a stock 80×24 SSH window.- Export list writes to predictable
/tmp/ultrabunt-packages-<ts>.txt. get_status()is dead code (never called; no arms for custom/npm/cargo methods).silversearcher-agandagare duplicate entries for the same package.remove_phpmyadminonly restarts services that areis-enabled— a running-but-disabled DB/web server stays down.--force-yesno longer exists in modern apt.__site_is_liveneedshost(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
$PATHinstead 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
- WordPress DB quoting + the
finalize_wordpress_setupchown — it's the feature you actually use, on machines you keep. - Delete or rewrite mirror management — it cannot work and it edits sources.list.
- Umami/Mailcow port + secret fixes if you ever run them on the VPS.
- Purge the ~25 never-installable package entries (list above) — they just waste menu space and end in failure dialogs.
- Sweep the installers for
curl -f,mktemp -d, andgpg --dearmor --yes— three mechanical patterns fix most of the installer findings.