Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ jobs:
docker:
name: Docker integration harness
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Test BOTH supported Debian bases, not just trixie. BASE_IMAGE
# flows in via --build-arg (env), never inline ${{ }} in a shell.
base: [debian:trixie-slim, debian:bookworm-slim]
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
Expand All @@ -21,7 +27,12 @@ jobs:
fetch-depth: 1

- name: Build test image
run: docker build -f tests/docker/Dockerfile -t pi-optimiser-tests:ci .
env:
BASE_IMAGE: ${{ matrix.base }}
run: |
docker build -f tests/docker/Dockerfile \
--build-arg "BASE_IMAGE=${BASE_IMAGE}" \
-t pi-optimiser-tests:ci .

- name: Run integration tests
run: docker run --rm pi-optimiser-tests:ci
23 changes: 23 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,29 @@ jobs:
*) echo "unexpected ref_name: $REF_NAME" >&2; exit 1 ;;
esac

- name: Verify version matches tag
# A release whose SCRIPT_VERSION or man-page footer disagrees with
# the tag ships an installer that reports the wrong version and
# makes --check-update comparisons wrong. Fail rather than publish
# the mismatch. REF_NAME flows in via env (not inline ${{ }}) so a
# crafted ref can't inject shell.
env:
REF_NAME: ${{ github.ref_name }}
run: |
expected="${REF_NAME#v}"
expected="${expected%%-*}" # strip any -rc/-beta pre-release suffix
script_ver=$(sed -n 's/^SCRIPT_VERSION="\([^"]*\)".*/\1/p' pi-optimiser.sh | head -1)
if [[ "$script_ver" != "$expected" ]]; then
echo "SCRIPT_VERSION ($script_ver) does not match tag ($expected)" >&2
exit 1
fi
man_ver=$(sed -n 's/^footer: pi-optimiser \(.*\)$/\1/p' share/man/pi-optimiser.8.md | head -1)
if [[ -n "$man_ver" && "$man_ver" != "$expected" ]]; then
echo "man page footer ($man_ver) does not match tag ($expected)" >&2
exit 1
fi
echo "Version $expected matches tag, SCRIPT_VERSION, and man footer."

- name: Build bundle
env:
REF_NAME: ${{ github.ref_name }}
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/shellcheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ jobs:
[[ -f "$f" ]] && bash -n "$f"
done
bash -n install.sh
# The test harness and the Pi-binary stubs are shell too — a
# broken stub silently weakens every integration run, so syntax-
# check them here as well.
for f in tests/docker/run-tests.sh tests/docker/stubs/*; do
[[ -f "$f" ]] && bash -n "$f"
done

- name: ShellCheck
run: |
Expand All @@ -42,7 +48,9 @@ jobs:
lib/features/*.sh \
lib/ui/*.sh \
scripts/*.sh \
install.sh
install.sh \
tests/docker/run-tests.sh \
tests/docker/stubs/*

- name: --help smoke test
run: |
Expand Down
98 changes: 98 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,103 @@
# Changelog

## 9.5.0 — 2026-06-13

Hardening release from a full multi-agent audit. Two critical fixes, a
batch of correctness/security fixes, several safer defaults, and a round
of de-duplication. Re-run after upgrading; affected tasks bump their
version so corrected values re-apply automatically.

### Fixed (critical)
- **config.txt `dtparam=` lines no longer clobber each other.**
`ensure_config_key_value` keyed every `dtparam=NAME=value` line on the
bare word `dtparam`, so writing one (e.g. `--disable-leds`,
`--enable-watchdog`, `--pcie-gen3`) overwrote/deleted unrelated stock
`dtparam` lines — silently disabling onboard audio, I2C and SPI. The
`dtparam`/`dtoverlay` families are now keyed by parameter name so
distinct settings coexist. If a prior run damaged your config.txt,
`--undo <task>` restores the pre-change backup.
- **UFW no longer locks you out on a custom SSH port.** SSH-port
detection now reads the effective config via `sshd -T`, honouring
`/etc/ssh/sshd_config.d/*.conf` drop-ins (the canonical place to set
`Port` on Bookworm/Trixie). Previously the firewall opened 22 while
sshd listened elsewhere, dropping the operator's session on enable;
fail2ban was also jailing the wrong port.

### Fixed (high)
- **`--force` no longer wipes completion markers** of opt-in tasks that
aren't re-requested; the pre-clear is now gated on the task actually
running. `--diff` and `--dry-run` no longer mutate `state.json` (the
proxy/ufw re-run detection moved to dry-run-safe `value_changed` hooks).
- **`ipv6_disable` is now reversible** — it records the sysctl file it
creates so `--undo` actually removes it (and IPv6 comes back).
- **`boot_config` replaces existing keys** (`gpu_mem`, `disable_overscan`,
…) instead of appending a second conflicting line.
- **`usb_uas_quirks` merges** with any existing `usb-storage.quirks=`
token instead of appending a duplicate the kernel would ignore.
- **`tailscale` installs via `ensure_packages`** so a held dpkg lock no
longer fails the task.
- **`var_log_tmpfs` journal archive** is now mode 0600 under
`/var/backups` (was world-readable in `/var/log`, leaking journal
contents on multi-user hosts).
- **Snapshot/restore consistency** — `config-optimisations.json` is now
excluded from snapshots like `state.json`, so `--restore` can't desync
the two state files.
- **TUI**: a non-default ZRAM algorithm (zstd) survives the storage menu;
value-typed tasks (proxy backend, SSH import) set via the Values menu
now actually run; default-enabled tasks in unvisited categories are no
longer dropped on Apply.

### Changed (safer defaults — please review)
- **IP forwarding is no longer enabled by default.** The default `sysctl`
task previously turned every Pi into a router (`ip_forward=1`); single-
homed hosts no longer get it. Routers/VPN gateways should set it
explicitly.
- **Raspberry Pi Connect is no longer purged** by the default
`remove_bloat` run — it is a first-party remote-access tool, not demo
bloat.
- **`rsyslog` is no longer disabled** by `disable_services`. Combined
with volatile journald + tmpfs `/var/log` it had left no persistent
log after reboot; the `journald` task now also logs that the journal
is RAM-only and how to make it persistent.

### Fixed (correctness)
- `dns_cache` backs up an existing `/etc/resolv.conf` (including a
symlink) before repointing it, so `--undo` restores the prior resolver.
- `eeprom_config` records the bootloader-config backup path and the
manual revert command (the file-based `--undo` can't reflash EEPROM).
- `config.yaml` `zram.algo` is validated against the CLI allowlist.
- Compute Module 4/5 (and CM3) are now mapped to their Pi-class
generation, so model-gated tasks (KMS, fan, overclock) stop skipping
on a CM.
- `--diff` no longer launches the interactive TUI on a TTY; the
menu-skip flag set is now derived from the task registry so it can't
drift out of sync with the tasks.
- `pi_rollback_release` resolves both paths before comparing, so it can't
"roll back" to the release it's already on.
- `libliftoff` no longer uncomments a deliberately-disabled
`#dtoverlay=vc4-kms-v3d`, and collapses duplicate KMS overlay lines.
- `install.sh` distinguishes a 404 ref from a network error, and no
longer prints "Verified" wording that could outlast the verified bytes.

### Refactored
- New `apply_config_entries` / `pi_curl_secure` / `pi_add`-style
`write_systemd_unit` helpers collapse loops duplicated across the
config.txt, downloader, and systemd-unit tasks; run and `--diff`
previews now share a single entry source per task (eliminating the
stale-diff class). `oc_conservative` / `underclock` use one per-model
table. Removed dead `validate_timezone`, `is_pi500`,
`fstab_root_has_option`; gave `pi_daemon_reload_now` real callers;
implemented `pi_supports_eeprom` for an accurate `--self-test` label;
replaced inline `systemctl list-unit-files` checks with `unit_exists`.

### CI / tests
- Release workflow fails if the tag, `SCRIPT_VERSION`, and man-page
footer disagree. ShellCheck/`bash -n` now also cover the test harness
and Pi-binary stubs. Integration matrix runs against **both** Debian
Trixie and Bookworm. The stub invocation log is now exercised by a
test (it was previously dead). Bundle "shebang parity" gate now
actually checks the shebang.

## 9.4.5 — 2026-04-24

### Fixed
Expand Down
39 changes: 33 additions & 6 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,44 @@ trap 'rm -f "$tmp_tar"; rm -rf "$tmp_extract"' EXIT

echo "Fetching pi-optimiser @ ${PI_OPTIMISER_REF}"
# Try tag first (the common case for versioned refs like v9.0.1), then
# fall back to branches (for master/main). `curl -fsSL -o` emits its
# own message on failure, so we route both attempts' stderr to /dev/null
# and only surface a combined error if both fail.
# fall back to branches (for master/main). A tag 404 is expected for a
# branch ref (and vice versa), so we only fall through on an HTTP error
# (curl exit 22). A genuine network failure (DNS/connect/timeout/TLS)
# returns a different exit code — in that case there's no point retrying
# the branch URL against the same broken network, so abort immediately
# with the real curl diagnostic instead of a misleading "tried tag +
# branch" message that hides whether the ref or the network was at fault.
tag_url="https://codeload.github.com/${PI_OPTIMISER_REPO}/tar.gz/refs/tags/${PI_OPTIMISER_REF}"
branch_url="https://codeload.github.com/${PI_OPTIMISER_REPO}/tar.gz/refs/heads/${PI_OPTIMISER_REF}"
if ! curl "${CURL_SECURE_OPTS[@]}" "$tag_url" -o "$tmp_tar" 2>/dev/null; then
if ! curl "${CURL_SECURE_OPTS[@]}" "$branch_url" -o "$tmp_tar" 2>/dev/null; then
echo "Failed to download ${PI_OPTIMISER_REF} (tried tag + branch)" >&2
_curl_err=$(mktemp)
# Capture curl's own exit code via `|| rc=$?` (set -e safe). NOTE: an
# `if ! curl; then rc=$?` would capture the NEGATION's exit (0), not
# curl's — so this form is required to tell HTTP 404 (22) from a network
# error apart at all.
tag_rc=0
curl "${CURL_SECURE_OPTS[@]}" "$tag_url" -o "$tmp_tar" 2>"$_curl_err" || tag_rc=$?
if [[ $tag_rc -ne 0 ]]; then
if [[ $tag_rc -ne 22 ]]; then
echo "Failed to fetch ${PI_OPTIMISER_REF} (network/transport error, curl exit $tag_rc):" >&2
cat "$_curl_err" >&2
rm -f "$_curl_err"
exit 1
fi
# HTTP error on the tag (likely the ref is a branch) — try the branch.
branch_rc=0
curl "${CURL_SECURE_OPTS[@]}" "$branch_url" -o "$tmp_tar" 2>"$_curl_err" || branch_rc=$?
if [[ $branch_rc -ne 0 ]]; then
if [[ $branch_rc -eq 22 ]]; then
echo "Ref '${PI_OPTIMISER_REF}' not found as a tag or a branch (HTTP 404)" >&2
else
echo "Failed to fetch ${PI_OPTIMISER_REF} from branch URL (curl exit $branch_rc):" >&2
cat "$_curl_err" >&2
fi
rm -f "$_curl_err"
exit 1
fi
fi
rm -f "$_curl_err"

# If the ref is an exact version tag (vX.Y.Z), attempt to fetch and
# verify the published release bundle sha256 alongside the tarball.
Expand Down
22 changes: 17 additions & 5 deletions lib/features/diff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,27 @@
# Globals (read): PI_CONFIG_PREVIEW_DIR, CONFIG_TXT_FILE
# ======================================================================

# Replay a list of config.txt entries through ensure_config_key_value,
# which buffers under PI_CONFIG_PREVIEW=1. "Unchanged" (rc=1) and parse
# failures (rc=2) are swallowed so a preview doesn't bail on one bad
# line. Used by pi_preview_<task> functions.
# Replay a list of config.txt entries through the SAME dispatch +
# section as run_<task> (_pi_config_apply_one), which buffers under
# PI_CONFIG_PREVIEW=1. "Unchanged" (rc=1) and parse failures (rc=2) are
# swallowed so a preview doesn't bail on one bad line.
#
# Usage:
# pi_preview_apply_entries <entry>... # section 'all'
# pi_preview_apply_entries --section pi5 <entry>... # explicit section
#
# The --section form lets previews of [pi5]-scoped tasks (pi5_fan,
# pcie_gen3, ...) match where run_<task> actually writes.
pi_preview_apply_entries() {
local target=${CONFIG_TXT_FILE:-/boot/firmware/config.txt}
local section=all
if [[ ${1:-} == --section ]]; then
section=${2:-all}
shift 2
fi
local entry
for entry in "$@"; do
ensure_config_key_value "$entry" "$target" >/dev/null 2>&1 || true
_pi_config_apply_one "$entry" "$target" "$section" >/dev/null 2>&1 || true
done
}

Expand Down
12 changes: 9 additions & 3 deletions lib/features/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,17 @@ pi_rollback_release() {
if [[ -L "$PI_PREFIX/current" ]]; then
current_target=$(readlink -f "$PI_PREFIX/current")
fi
# Pick the newest release that isn't the current target.
# Pick the newest release that isn't the current target. Resolve BOTH
# sides with readlink -f before comparing: current_target is already
# fully resolved, so comparing it against the unresolved "$releases/
# $entry" string would falsely treat the current release as a rollback
# candidate whenever any path component (e.g. PI_PREFIX) is itself a
# symlink — flipping `current` to the release it already points at.
mapfile -t candidates < <(ls -1t "$releases" 2>/dev/null)
local entry
local entry entry_real
for entry in "${candidates[@]}"; do
if [[ "$releases/$entry" != "$current_target" ]]; then
entry_real=$(readlink -f "$releases/$entry" 2>/dev/null || echo "$releases/$entry")
if [[ "$entry_real" != "$current_target" ]]; then
previous="$releases/$entry"
break
fi
Expand Down
2 changes: 1 addition & 1 deletion lib/features/profiles.sh
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ pi_self_test() {
# EEPROM tasks on non-Pi4/5
case "$tid" in
eeprom_config|eeprom_refresh)
pi_supports_kms_overlays \
pi_supports_eeprom \
|| { ok="SKIP (no EEPROM support)"; any_fail=1; }
;;
esac
Expand Down
1 change: 1 addition & 0 deletions lib/features/snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ pi_take_snapshot() {
--exclude="$MARKER_DIR/state.json" \
--exclude="$MARKER_DIR/state.schema" \
--exclude="$MARKER_DIR/state" \
--exclude="$MARKER_DIR/config-optimisations.json" \
"${existing[@]}" 2>/dev/null; then
chmod 600 "$archive"
log_info "Snapshot written: $archive ($(stat -c%s "$archive") bytes)"
Expand Down
39 changes: 14 additions & 25 deletions lib/tasks/boot_config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,33 +61,22 @@ run_boot_config() {
backup_file "$CONFIG_TXT_FILE"
local -a entries=()
mapfile -t entries < <(_boot_config_entries)
local applied=0
local entry rc safe_key
for entry in "${entries[@]}"; do
if ensure_config_line "$entry"; then
log_info "Applied $entry to config.txt"
safe_key=${entry//=/_}
write_json_field "$CONFIG_OPTIMISER_STATE" "boot_config.${safe_key}" "$entry"
applied=1
else
rc=$?
if [[ $rc -gt 1 ]]; then
log_warn "Failed to ensure $entry in config.txt"
fi
fi
done
if [[ $applied -eq 1 ]]; then
log_info "Boot config tuned for Raspberry Pi desktop display"
else
log_info "Boot config already matched recommended defaults"
fi
# apply_config_entries upserts key=value entries (gpu_mem, disable_overscan,
# dtparam=audio=on, framebuffer_*) by key so an existing raspi-config value
# is REPLACED rather than leaving a second conflicting line, while the bare
# `dtoverlay=vc4-kms-v3d` keeps additive whole-line semantics.
local rc=0
apply_config_entries "boot_config" all "${entries[@]}" || rc=$?
case $rc in
0) log_info "Boot config tuned for Raspberry Pi desktop display" ;;
1) log_info "Boot config already matched recommended defaults" ;;
*) log_warn "One or more boot config entries failed to apply" ;;
esac
}

pi_preview_boot_config() {
pi_supports_kms_overlays || return 0
local target=${CONFIG_TXT_FILE:-/boot/firmware/config.txt}
local entry
while IFS= read -r entry; do
ensure_config_line "$entry" "$target" >/dev/null 2>&1 || true
done < <(_boot_config_entries)
local -a entries=()
mapfile -t entries < <(_boot_config_entries)
pi_preview_apply_entries "${entries[@]}"
}
11 changes: 5 additions & 6 deletions lib/tasks/cpu_governor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ run_cpu_governor() {
fi

mkdir -p "$(dirname "$CPU_GOVERNOR_SERVICE")"
# record_created falls back to backup_file when the path already
# exists, so --undo still restores a pre-existing operator override.
record_created "$CPU_GOVERNOR_SERVICE"
cat <<'CFG' > "$CPU_GOVERNOR_SERVICE"
# write_systemd_unit: record_created (undo) + atomic write + 0644 +
# flag a daemon-reload. pi_daemon_reload_now below forces the reload
# immediately so the enable picks up the freshly-written unit.
write_systemd_unit "$CPU_GOVERNOR_SERVICE" <<'CFG'
[Unit]
Description=Pin CPU scaling governor to performance (pi-optimiser)
After=multi-user.target
Expand All @@ -38,9 +38,8 @@ ExecStart=/bin/sh -c 'for g in /sys/devices/system/cpu/cpu*/cpufreq/scaling_gove
[Install]
WantedBy=multi-user.target
CFG
chmod 644 "$CPU_GOVERNOR_SERVICE"

systemctl daemon-reload >/dev/null 2>&1 || true
pi_daemon_reload_now
# Use enable (not --now) here: the oneshot would try to write to
# sysfs paths that may not be writable until late in boot, and the
# manual sysfs loop below already applies the setting for the
Expand Down
Loading