Files
Panama/setup/scripts/install-packages
T
Gabriel Brown fa8b14e05e Fix: Adopt a Terra the machine already trusts
The repository audit made any Terra that is not Panama's own pinned form a
trust-root failure, and status 78 then stopped every stage before it ran. A
machine that installed Terra the way Terra documents it -- terra-release's own
repo file, a metalink, the key at its stock path -- was classified hostile and
had no way back, because install_terra_repository refused to touch a machine
terra-release had already reached. A gate with no door.

The trust root is the signing key, and that key is byte-for-byte the
fingerprint this repository reviewed and pinned, with every signature check
already on. So verify the fingerprint and adopt the configuration into the
pinned form instead of refusing it. Adoption needs no network and no DNF, it
runs before any other transaction in the stage, and it is repeatable, which it
has to be: terra-release owns that file and restores it on update.

Adoption stays narrow. The pinned fingerprint must match both the reviewed key
and the key the machine actually verifies against, the gpgkey must be a local
file under the system trust directory, and the endpoint must be one Terra
itself serves -- so the reviewed baseurl or the reviewed metalink host, now
pinned as TERRA_METALINK_BASEURL. An unknown key, a redirected baseurl, a
second enabled Terra, or a disabled signature check is still a hard refusal.

A refusal also stops less than it did. It suppresses the stages that open DNF
and the migrations, which may run a transaction of their own, and the run still
exits 78. It no longer stops link-dotfiles, link-skills or link-user, which
read no repository and install no package. Exiting before them is what left
this laptop with a stale ~/.claude/skills and no shipped skill reachable.

Also stub ensure_flathub_remote in the extras contract, which has been failing
since that call was added to install_extra_category without one.

Claude-Session: https://claude.ai/code/session_01PeTrG9dGY89UWuhGm4Pr1s
2026-08-28 14:53:48 -04:00

1553 lines
60 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# --- Helper functions ---
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
exists() { command -v "$1" >/dev/null 2>&1; }
# The package names in a list, without the comments that explain them.
#
# The lists are annotated -- which package exists for which settings page, why
# an exception was made -- and those annotations are for whoever reads the file
# next. dnf is not so forgiving: it does not ignore an argument it cannot
# match, it reports "No match for argument: #" and exits 1, and with `set -e`
# above that ends this stage on the first annotated list it reaches.
#
# It could not be seen from here. On a machine that already has everything, a
# re-run matches every real name and fails only on the comments; and every
# contract that reads these lists strips comments before comparing, so the
# tests were reading a file this script was not.
packages_in() {
sed 's/#.*//' "$1" | tr "\n" " "
}
# Names a list asked for that still are not installed, so --skip-unavailable
# above can never silently shrink a list: a skipped font is a warning somebody
# reads, not an absence somebody debugs a month later.
report_missing() {
local file="$1" name missing=()
for name in $(packages_in "$file"); do
# Three ways a list entry can be satisfied: it is a package name
# (rpm -q), a capability another package provides (--whatprovides,
# e.g. wget -> wget2-wget), or a bare command name provided as a file
# path (command -v, e.g. awk -> /usr/bin/awk from gawk, which
# --whatprovides misses because the provide is the path, not the word).
rpm -q --whatprovides "$name" >/dev/null 2>&1 && continue
command -v "$name" >/dev/null 2>&1 && continue
missing+=("$name")
done
(( ${#missing[@]} > 0 )) && log "WARNING: not available on this machine: ${missing[*]}"
return 0
}
# Runs something whose failure must not cost you the desktop.
#
# `set -e` above is right for the packages Panama cannot work without and wrong
# for everything else. A codec swap that finds nothing to swap, a group update
# renamed upstream, a third-party host that is down -- each of those used to end
# this stage wherever it happened to sit, and the desktop was installed near the
# bottom, so any one of them meant a machine with no Hyprland on it and a single
# line of dnf output to explain why.
#
# So the ordering rule for this file: anything that can fail for a reason
# outside this repository goes below the desktop, and goes through here.
# stdout only. Swallowing stderr here would hide the one line that says WHY a
# step was stepped over -- and worse, every one of these runs under sudo, whose
# password prompt is the thing you would be hiding on a machine that asks for
# one.
soft() {
local what="$1"; shift
"$@" >/dev/null || { log "$what did not complete; continuing"; softly_failed+=("$what"); }
}
softly_failed=()
# --- Defined Paths ---
# The default, not an assignment: ./install and link-dotfiles honor an exported
# PANAMA_PATH, and clobbering it here made a clone anywhere else source the
# extras catalog from a path that does not exist.
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
# Kept as a named path so the hermetic contract can redirect reads after
# sourcing this file. Normal installer execution always resets it to /etc.
PANAMA_SYSTEM_ETC=/etc
PANAMA_SYSTEM_FLATPAK_REPO=/var/lib/flatpak/repo
# Reviewed installer data and verification primitives. The config parser treats
# every value as inert data and rejects unknown, duplicate, or missing fields.
# shellcheck source=../lib/artifact-provenance
source "$PANAMA_PATH/setup/lib/artifact-provenance"
load_installer_provenance "$PANAMA_PATH/setup/provenance/installers.conf"
# Reading the extras catalog, shared with `panama apps` so the two front doors
# cannot disagree about what a category contains.
# shellcheck source=../lib/extras-catalog
source "$PANAMA_PATH/setup/lib/extras-catalog"
# Which machine this is. A server takes the short path below: core tools,
# node, the agents -- no third-party repos, no desktop, no flatpaks.
# shellcheck source=../lib/machine-role
source "$PANAMA_PATH/setup/lib/machine-role"
ROLE="$(panama_role)"
# Establishing the verified ChatGPT repository, shared with the migration that
# replaces the community build, so neither can install it a less careful way.
# shellcheck source=../lib/chatgpt-package
source "$PANAMA_PATH/setup/lib/chatgpt-package"
# One list, installed the way every list is installed: --skip-unavailable so a
# single rotted name cannot cost the transaction, then report_missing so a
# skipped name is a warning somebody reads.
install_list() {
local file="$PANAMA_PATH/setup/packages/$1" label="$2" packages
if [[ -f "$file" ]]; then
packages=$(packages_in "$file")
log "Installing $label Packages"
echo -e "Includes the following packages:"
echo -e "$(<"$file")"
sudo dnf install -y --skip-unavailable $packages > /dev/null
report_missing "$file"
log "$label packages installed!"
else
log "Package list was not in specified path: $file"
fi
}
# --- Reviewed language runtimes and agent tools ------------------------------
_record_installer_failure() {
local component="$1"
log "$component install did not complete; continuing"
softly_failed+=("$component")
return 1
}
_set_artifact_arch() {
local machine_arch
machine_arch="$(uname -m)" || return 1
case "$machine_arch" in
x86_64) artifact_arch=X86_64 ;;
aarch64) artifact_arch=AARCH64 ;;
*) log "Unsupported architecture: $machine_arch"; return 1 ;;
esac
}
_archive_path_is_safe() {
local member="$1"
[[ -n "$member" && "$member" != /* && "$member" != *'//'*
&& ! "$member" =~ (^|/)\.\.?(/|$) ]]
}
_archive_member_is_safe() {
local member="$1" expected_top="$2"
_archive_path_is_safe "$member"
[[ "$member" == "$expected_top" || "$member" == "$expected_top/" \
|| "$member" == "$expected_top/"* ]]
}
_bun_zip_entry_types_match() {
local archive="$1" archive_top="$2" details entry_types
details="$(unzip -Z -s "$archive")" || return 1
entry_types="$(awk -v directory="$archive_top/" -v binary="$archive_top/bun" '
$NF == directory || $NF == binary { print substr($1, 1, 1), $NF }
' <<<"$details")" || return 1
[[ "$entry_types" == "d $archive_top/"$'\n'"- $archive_top/bun" ]]
}
_tree_links_stay_inside() {
local root="$1" link resolved scan_fd scan_pid scan_status=0 invalid=0
# Retain and wait for find's PID: a loop fed directly by process substitution
# cannot otherwise distinguish an empty tree from a failed traversal.
exec {scan_fd}< <(find "$root" -type l -print0)
scan_pid=$!
while IFS= read -r -d '' link <&"$scan_fd"; do
resolved="$(realpath -m -- "$link")" || { invalid=1; continue; }
[[ "$resolved" == "$root" || "$resolved" == "$root/"* ]] || invalid=1
done
exec {scan_fd}<&-
wait "$scan_pid" || scan_status=$?
(( scan_status == 0 && invalid == 0 ))
}
_tree_hardlinks_stay_inside() {
local root="$1" device inode link_count key scan_fd scan_pid scan_status=0
local invalid=0
local -A names_in_tree=() inode_links=()
exec {scan_fd}< <(find "$root" -type f -printf '%D %i %n\n')
scan_pid=$!
while read -r device inode link_count <&"$scan_fd"; do
key="$device:$inode"
names_in_tree["$key"]=$(( ${names_in_tree[$key]:-0} + 1 ))
inode_links["$key"]="$link_count"
done
exec {scan_fd}<&-
wait "$scan_pid" || scan_status=$?
(( scan_status == 0 )) || return 1
for key in "${!names_in_tree[@]}"; do
[[ "${names_in_tree[$key]}" == "${inode_links[$key]}" ]] || invalid=1
done
(( invalid == 0 ))
}
_atomic_symlink() (
local target="$1" destination="$2" directory temporary=""
trap '[[ -z "$temporary" ]] || rm -f -- "$temporary"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
directory="$(dirname -- "$destination")"
mkdir -p -- "$directory" || return 1
temporary="$(mktemp "$directory/.$(basename -- "$destination").link.XXXXXX")" || return 1
rm -f -- "$temporary" || return 1
ln -s -- "$target" "$temporary" || return 1
if ! mv -Tf -- "$temporary" "$destination"; then
return 1
fi
)
_activate_directory_no_replace() {
local staged="$1" destination="$2"
mv -Tn -- "$staged" "$destination" || return 1
[[ ! -e "$staged" && ! -L "$staged" && -d "$destination" && ! -L "$destination" ]]
}
_write_runtime_receipt() {
local directory="$1" artifact_digest="$2" binary_digest="$3"
local receipt="$directory/.panama-provenance"
[[ -d "$directory" && ! -L "$directory" && ! -e "$receipt" && ! -L "$receipt" ]] \
|| return 1
( umask 077 && printf 'schema=1\nartifact_sha256=%s\nbinary_sha256=%s\n' \
"$artifact_digest" "$binary_digest" > "$receipt" )
}
_runtime_receipt_matches() {
local directory="$1" binary="$2" artifact_digest="$3" binary_digest="$4"
local receipt="$directory/.panama-provenance" actual
[[ -d "$directory" && ! -L "$directory"
&& -f "$receipt" && ! -L "$receipt"
&& -f "$binary" && ! -L "$binary" ]] || return 1
cmp -s "$receipt" <(printf 'schema=1\nartifact_sha256=%s\nbinary_sha256=%s\n' \
"$artifact_digest" "$binary_digest") || return 1
actual="$(sha256sum "$binary" | awk '{ print $1 }')" || return 1
[[ "$actual" == "$binary_digest" ]]
}
_load_nvm() {
local nvm_script="$PANAMA_SYSTEM_ETC/profile.d/nvm.sh"
[[ -s "$nvm_script" ]] || return 1
set +u
# shellcheck source=/dev/null
source "$nvm_script"
set -u
declare -F nvm >/dev/null
}
_install_node() (
local artifact_arch machine_arch archive_top parent target stage archive extract listing member
local artifact_digest binary_digest staged_binary
stage=""
trap '[[ -z "$stage" ]] || rm -rf -- "$stage"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
_set_artifact_arch || return 1
_load_nvm || return 1
case "$artifact_arch" in
X86_64) machine_arch=x64 ;;
AARCH64) machine_arch=arm64 ;;
esac
archive_top="node-v${INSTALLER_PROVENANCE[NODE_VERSION]}-linux-$machine_arch"
parent="${NVM_DIR:-$HOME/.nvm}/versions/node"
target="$parent/v${INSTALLER_PROVENANCE[NODE_VERSION]}"
artifact_digest="${INSTALLER_PROVENANCE[NODE_${artifact_arch}_SHA256]}"
binary_digest="${INSTALLER_PROVENANCE[NODE_${artifact_arch}_BINARY_SHA256]}"
if [[ -e "$target" || -L "$target" ]]; then
_runtime_receipt_matches "$target" "$target/bin/node" \
"$artifact_digest" "$binary_digest" || return 1
[[ -x "$target/bin/node"
&& "$($target/bin/node --version 2>/dev/null)" == "v${INSTALLER_PROVENANCE[NODE_VERSION]}" ]] \
|| return 1
nvm alias default "${INSTALLER_PROVENANCE[NODE_VERSION]}" >/dev/null 2>&1 || return 1
return 0
fi
mkdir -p -- "$parent" || return 1
stage="$(mktemp -d "$parent/.v${INSTALLER_PROVENANCE[NODE_VERSION]}.stage.XXXXXX")" \
|| return 1
chmod 0700 "$stage"
archive="$stage/artifact"
extract="$stage/extract"
mkdir -m 0700 "$extract" || { rm -rf -- "$stage"; return 1; }
if ! download_sha256 "${INSTALLER_PROVENANCE[NODE_${artifact_arch}_URL]}" \
"${INSTALLER_PROVENANCE[NODE_${artifact_arch}_SHA256]}" \
"${INSTALLER_PROVENANCE[NODE_${artifact_arch}_MAX_BYTES]}" "$archive"; then
rm -rf -- "$stage"
return 1
fi
listing="$(tar -tJf "$archive")" || { rm -rf -- "$stage"; return 1; }
[[ -n "$listing" ]] || { rm -rf -- "$stage"; return 1; }
while IFS= read -r member; do
_archive_member_is_safe "$member" "$archive_top" \
|| { rm -rf -- "$stage"; return 1; }
done <<<"$listing"
tar -xJf "$archive" --no-same-owner --no-same-permissions -C "$extract" \
|| { rm -rf -- "$stage"; return 1; }
_tree_links_stay_inside "$extract/$archive_top" \
|| { rm -rf -- "$stage"; return 1; }
_tree_hardlinks_stay_inside "$extract/$archive_top" \
|| { rm -rf -- "$stage"; return 1; }
staged_binary="$extract/$archive_top/bin/node"
[[ "$(sha256sum "$staged_binary" | awk '{ print $1 }')" == "$binary_digest" ]] \
|| { rm -rf -- "$stage"; return 1; }
[[ -d "$extract/$archive_top" && ! -L "$extract/$archive_top"
&& -x "$staged_binary"
&& "$($staged_binary --version 2>/dev/null)" \
== "v${INSTALLER_PROVENANCE[NODE_VERSION]}" ]] \
|| { rm -rf -- "$stage"; return 1; }
_write_runtime_receipt "$extract/$archive_top" "$artifact_digest" "$binary_digest" \
|| { rm -rf -- "$stage"; return 1; }
_activate_directory_no_replace "$extract/$archive_top" "$target" \
|| { rm -rf -- "$stage"; return 1; }
_runtime_receipt_matches "$target" "$target/bin/node" \
"$artifact_digest" "$binary_digest" || { rm -rf -- "$stage"; return 1; }
[[ -x "$target/bin/node"
&& "$($target/bin/node --version 2>/dev/null)" \
== "v${INSTALLER_PROVENANCE[NODE_VERSION]}" ]] \
|| { rm -rf -- "$stage"; return 1; }
_tree_links_stay_inside "$target" || { rm -rf -- "$stage"; return 1; }
_tree_hardlinks_stay_inside "$target" || { rm -rf -- "$stage"; return 1; }
rm -rf -- "$stage"
stage=""
nvm alias default "${INSTALLER_PROVENANCE[NODE_VERSION]}" >/dev/null 2>&1
)
install_node() {
_install_node || _record_installer_failure Node
}
# Kept as the call-site name used by the desktop-first ordering contract.
setup_node() {
install_node
}
install_pnpm() {
if require_reviewed_fedora_release \
&& sudo dnf install -y --repo=fedora --repo=updates pnpm >/dev/null; then
return 0
fi
_record_installer_failure pnpm
}
_install_bun() (
local artifact_arch archive_top target bin_link parent stage archive listing
local staged_binary version_dir artifact_digest binary_digest
stage=""
trap '[[ -z "$stage" ]] || rm -rf -- "$stage"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
_set_artifact_arch || return 1
case "$artifact_arch" in
X86_64) archive_top=bun-linux-x64 ;;
AARCH64) archive_top=bun-linux-aarch64 ;;
esac
version_dir="$HOME/.bun/versions/${INSTALLER_PROVENANCE[BUN_VERSION]}"
target="$version_dir/bin/bun"
bin_link="$HOME/.bun/bin/bun"
artifact_digest="${INSTALLER_PROVENANCE[BUN_${artifact_arch}_SHA256]}"
binary_digest="${INSTALLER_PROVENANCE[BUN_${artifact_arch}_BINARY_SHA256]}"
if [[ -e "$version_dir" || -L "$version_dir" ]]; then
_runtime_receipt_matches "$version_dir" "$target" \
"$artifact_digest" "$binary_digest" || return 1
[[ -x "$target"
&& "$($target --version 2>/dev/null)" == "${INSTALLER_PROVENANCE[BUN_VERSION]}" ]] \
|| return 1
[[ -L "$bin_link" && "$(readlink -- "$bin_link")" == "$target" ]] \
|| _atomic_symlink "$target" "$bin_link"
return
fi
parent="$HOME/.bun/versions"
mkdir -p -- "$parent" || return 1
stage="$(mktemp -d "$parent/.${INSTALLER_PROVENANCE[BUN_VERSION]}.stage.XXXXXX")" \
|| return 1
chmod 0700 "$stage"
archive="$stage/artifact"
if ! download_sha256 "${INSTALLER_PROVENANCE[BUN_${artifact_arch}_URL]}" \
"${INSTALLER_PROVENANCE[BUN_${artifact_arch}_SHA256]}" \
"${INSTALLER_PROVENANCE[BUN_${artifact_arch}_MAX_BYTES]}" "$archive"; then
rm -rf -- "$stage"
return 1
fi
listing="$(unzip -Z1 "$archive")" || { rm -rf -- "$stage"; return 1; }
[[ "$listing" == "$archive_top/"$'\n'"$archive_top/bun" ]] \
|| { rm -rf -- "$stage"; return 1; }
_bun_zip_entry_types_match "$archive" "$archive_top" \
|| { rm -rf -- "$stage"; return 1; }
while IFS= read -r member; do
_archive_member_is_safe "$member" "$archive_top" \
|| { rm -rf -- "$stage"; return 1; }
done <<<"$listing"
mkdir -m 0700 "$stage/extract" "$stage/version" "$stage/version/bin" \
|| { rm -rf -- "$stage"; return 1; }
unzip -q "$archive" -d "$stage/extract" || { rm -rf -- "$stage"; return 1; }
staged_binary="$stage/extract/$archive_top/bun"
[[ -f "$staged_binary" && ! -L "$staged_binary" && -x "$staged_binary"
&& "$(sha256sum "$staged_binary" | awk '{ print $1 }')" == "$binary_digest"
&& "$($staged_binary --version 2>/dev/null)" == "${INSTALLER_PROVENANCE[BUN_VERSION]}" ]] \
|| { rm -rf -- "$stage"; return 1; }
mv -- "$staged_binary" "$stage/version/bin/bun" \
|| { rm -rf -- "$stage"; return 1; }
_write_runtime_receipt "$stage/version" "$artifact_digest" "$binary_digest" \
|| { rm -rf -- "$stage"; return 1; }
_activate_directory_no_replace "$stage/version" "$version_dir" \
|| { rm -rf -- "$stage"; return 1; }
_runtime_receipt_matches "$version_dir" "$target" \
"$artifact_digest" "$binary_digest" || { rm -rf -- "$stage"; return 1; }
[[ -x "$target" && "$($target --version 2>/dev/null)" \
== "${INSTALLER_PROVENANCE[BUN_VERSION]}" ]] \
|| { rm -rf -- "$stage"; return 1; }
rm -rf -- "$stage"
stage=""
_atomic_symlink "$target" "$bin_link"
)
install_bun() {
_install_bun || _record_installer_failure Bun
}
_codex_version_matches() {
local binary="$1" output version_pattern
output="$($binary --version 2>/dev/null)" || return 1
version_pattern="${INSTALLER_PROVENANCE[CODEX_VERSION]//./\\.}"
[[ "$output" =~ (^|[^0-9])${version_pattern}([^0-9]|$) ]]
}
_install_codex() (
local artifact_arch version_dir target bin_link parent expected_listing member
local stage archive listing staged_binary artifact_digest binary_digest
stage=""
trap '[[ -z "$stage" ]] || rm -rf -- "$stage"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
_set_artifact_arch || return 1
version_dir="$HOME/.local/lib/panama/codex/${INSTALLER_PROVENANCE[CODEX_VERSION]}"
target="$version_dir/codex"
bin_link="$HOME/.local/bin/codex"
artifact_digest="${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_SHA256]}"
binary_digest="${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_BINARY_SHA256]}"
if [[ -e "$version_dir" || -L "$version_dir" ]]; then
_runtime_receipt_matches "$version_dir" "$target" \
"$artifact_digest" "$binary_digest" || return 1
[[ -x "$target" ]] || return 1
_codex_version_matches "$target" || return 1
[[ -L "$bin_link" && "$(readlink -- "$bin_link")" == "$target" ]] \
|| _atomic_symlink "$target" "$bin_link"
return
fi
parent="$HOME/.local/lib/panama/codex"
mkdir -p -- "$parent" || return 1
stage="$(mktemp -d "$parent/.${INSTALLER_PROVENANCE[CODEX_VERSION]}.stage.XXXXXX")" \
|| return 1
chmod 0700 "$stage"
archive="$stage/artifact"
if ! download_sha256 "${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_URL]}" \
"${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_SHA256]}" \
"${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_MAX_BYTES]}" "$archive"; then
rm -rf -- "$stage"
return 1
fi
listing="$(tar -tzf "$archive")" || { rm -rf -- "$stage"; return 1; }
expected_listing=$'bin/\nbin/codex\nbin/codex-code-mode-host\ncodex-package.json\ncodex-path/\ncodex-path/rg\ncodex-resources/\ncodex-resources/bwrap\ncodex-resources/zsh/\ncodex-resources/zsh/bin/\ncodex-resources/zsh/bin/zsh'
[[ "$listing" == "$expected_listing" ]] || { rm -rf -- "$stage"; return 1; }
while IFS= read -r member; do
_archive_path_is_safe "$member" || { rm -rf -- "$stage"; return 1; }
done <<<"$listing"
mkdir -m 0700 "$stage/extract" "$stage/version" \
|| { rm -rf -- "$stage"; return 1; }
tar -xzf "$archive" --no-same-owner --no-same-permissions -C "$stage/extract" \
|| { rm -rf -- "$stage"; return 1; }
_tree_links_stay_inside "$stage/extract" || { rm -rf -- "$stage"; return 1; }
_tree_hardlinks_stay_inside "$stage/extract" || { rm -rf -- "$stage"; return 1; }
staged_binary="$stage/extract/bin/codex"
[[ -f "$staged_binary" && ! -L "$staged_binary" && -x "$staged_binary"
&& "$(sha256sum "$staged_binary" | awk '{ print $1 }')" == "$binary_digest" ]] \
|| { rm -rf -- "$stage"; return 1; }
_codex_version_matches "$staged_binary" \
|| { rm -rf -- "$stage"; return 1; }
mv -- "$staged_binary" "$stage/version/codex" \
|| { rm -rf -- "$stage"; return 1; }
_write_runtime_receipt "$stage/version" "$artifact_digest" "$binary_digest" \
|| { rm -rf -- "$stage"; return 1; }
_activate_directory_no_replace "$stage/version" "$version_dir" \
|| { rm -rf -- "$stage"; return 1; }
_runtime_receipt_matches "$version_dir" "$target" \
"$artifact_digest" "$binary_digest" || { rm -rf -- "$stage"; return 1; }
_codex_version_matches "$target" || { rm -rf -- "$stage"; return 1; }
rm -rf -- "$stage"
stage=""
_atomic_symlink "$target" "$bin_link"
)
install_codex() {
_install_codex || _record_installer_failure Codex
}
_install_rustdesk() (
local artifact_arch installed_version="" work rpm_path status=0
work=""
trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
_set_artifact_arch || return 1
if [[ "$artifact_arch" == AARCH64 ]]; then
log "RustDesk ${INSTALLER_PROVENANCE[RUSTDESK_VERSION]} has no reviewed aarch64 RPM"
return 1
fi
installed_version="$(rpm -q --queryformat '%{VERSION}' rustdesk 2>/dev/null)" || true
if [[ "$installed_version" == "${INSTALLER_PROVENANCE[RUSTDESK_VERSION]}" ]]; then
return 0
fi
work="$(mktemp -d)" || return 1
chmod 0700 "$work"
rpm_path="$work/rustdesk.rpm"
if ! download_sha256 "${INSTALLER_PROVENANCE[RUSTDESK_X86_64_URL]}" \
"${INSTALLER_PROVENANCE[RUSTDESK_X86_64_SHA256]}" \
"${INSTALLER_PROVENANCE[RUSTDESK_X86_64_MAX_BYTES]}" "$rpm_path"; then
rm -rf -- "$work"
return 1
fi
# RustDesk 1.4.9's reviewed RPM is unsigned. Its exact SHA-256 is the trust
# assertion; this exception applies only to the verified private local file
# and does not change signature policy for any repository.
sudo dnf install -y --setopt=localpkg_gpgcheck=0 "$rpm_path" >/dev/null || status=$?
rm -rf -- "$work"
work=""
return "$status"
)
install_rustdesk() {
_install_rustdesk || _record_installer_failure RustDesk
}
# --- What was stepped over ---------------------------------------------------
#
# Tolerating a failure is only better than aborting on it if somebody is told.
# The whole point of surviving a soft failure is that the rest gets installed
# anyway -- but a machine missing something should say so once, here, rather
# than be discovered a week later.
report_soft_failures() {
if (( ${#softly_failed[@]} > 0 )); then
log "Installed, but these were stepped over:"
printf ' - %s\n' "${softly_failed[@]}"
log "None of them stops the machine, but this run is not recorded as"
log "complete, so the next 'panama update' tries them again."
# A step that did not complete has not happened. Exiting non-zero is what
# keeps ./install from stamping the packages hash over the gaps -- stamped,
# they would never be retried (the hash-skip would say nothing changed).
exit 1
fi
}
# --- Reviewed third-party repositories -------------------------------------
_require_policy_value() {
local name="$1" expected="$2"
[[ "${INSTALLER_PROVENANCE[$name]:-}" == "$expected" ]] || {
log "Installer provenance for $name does not match Panama's reviewed policy"
return 1
}
}
require_reviewed_fedora_release() {
local current
_require_policy_value FEDORA_RELEASE 44 || return 1
current="$(rpm -E %fedora)" || return 1
[[ "$current" == "${INSTALLER_PROVENANCE[FEDORA_RELEASE]}" ]] || {
log "Fedora $current is not reviewed for third-party repositories; expected ${INSTALLER_PROVENANCE[FEDORA_RELEASE]}"
return 1
}
}
# RPM repository bootstrap packages and Flatpak descriptors are authenticated
# after download rather than by a SHA-256 pin. Keep their untrusted bytes in a
# private file, enforce the reviewed size limit, and publish the file only after
# curl has completed successfully.
_download_bounded() {
local url="$1" max_bytes="$2" destination="$3" directory filename
directory="$(dirname -- "$destination")"
filename="$(basename -- "$destination")"
(
local part=""
trap '[[ -z "$part" ]] || rm -f -- "$part"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
[[ "$max_bytes" =~ ^[1-9][0-9]*$ && -d "$directory" ]] || exit 1
umask 077
part="$(mktemp "$directory/.${filename}.part.XXXXXX")" || exit 1
curl --fail --location --connect-timeout 10 --max-time 600 \
--max-filesize "$max_bytes" --output "$part" "$url" || exit 1
[[ -f "$part" && "$(stat -c %s "$part")" -le "$max_bytes" ]] || exit 1
mv -f -- "$part" "$destination"
)
}
_stage_reviewed_key() {
local source_key="$1" destination="$2" fingerprint_name="$3" expected="$4"
_require_policy_value "$fingerprint_name" "$expected" || return 1
cp -- "$source_key" "$destination" || return 1
chmod 0600 "$destination"
key_fingerprint_matches "$destination" "${INSTALLER_PROVENANCE[$fingerprint_name]}"
}
_ini_value() {
local file="$1" wanted_section="$2" wanted_key="$3"
local -a values=()
mapfile -t values < <(awk -v wanted_section="$wanted_section" -v wanted_key="$wanted_key" '
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
{
sub(/\r$/, "")
line = trim($0)
if (line == "" || line ~ /^[#;]/) next
if (line ~ /^\[[^]]+\]$/) {
section = substr(line, 2, length(line) - 2)
next
}
equals = index(line, "=")
if (tolower(section) == tolower(wanted_section) && equals > 1) {
key = trim(substr(line, 1, equals - 1))
if (tolower(key) == tolower(wanted_key)) print trim(substr(line, equals + 1))
}
}
' "$file")
(( ${#values[@]} > 0 )) || return 1
[[ ${#values[@]} -eq 1 && -n "${values[0]}" ]] || return 2
printf '%s\n' "${values[0]}"
}
_ini_section_count() {
local file="$1" wanted_section="$2"
awk -v wanted_section="$wanted_section" '
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
{
sub(/\r$/, "")
line = trim($0)
if (line ~ /^\[[^]]+\]$/) {
section = substr(line, 2, length(line) - 2)
if (tolower(section) == tolower(wanted_section)) count++
}
}
END { print count + 0 }
' "$file"
}
_ini_key_occurrence_count() {
local file="$1" wanted_section="$2" wanted_key="$3"
awk -v wanted_section="$wanted_section" -v wanted_key="$wanted_key" '
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
{
sub(/\r$/, "")
line = trim($0)
if (line == "" || line ~ /^[#;]/) next
if (line ~ /^\[[^]]+\]$/) {
section = substr(line, 2, length(line) - 2)
next
}
if (tolower(section) != tolower(wanted_section)) next
equals = index(line, "=")
if (equals > 0) {
key = trim(substr(line, 1, equals - 1))
} else {
split(line, words, /[[:space:]]+/)
key = words[1]
}
if (tolower(key) == tolower(wanted_key)) count++
}
END { print count + 0 }
' "$file"
}
_restore_repository_file() {
local existed="$1" backup="$2" mode="$3" destination="$4"
if (( existed )); then
sudo install -m "$mode" "$backup" "$destination"
else
sudo rm -f -- "$destination"
fi
}
# A key and its repository file form one trust root. If either activation
# write fails after touching its target, restore both prior files or return both
# targets to absence before reporting failure.
_publish_repository_pair() {
local staged_key="$1" key_destination="$2" staged_repo="$3" repo_destination="$4"
local backup_dir key_backup repo_backup key_mode=0644 repo_mode=0644
local key_current repo_current
local key_existed=0 repo_existed=0 status=0 rollback_status=0
[[ "$key_destination" == /etc/* && "$repo_destination" == /etc/* ]] || return 1
key_current="$PANAMA_SYSTEM_ETC${key_destination#/etc}"
repo_current="$PANAMA_SYSTEM_ETC${repo_destination#/etc}"
[[ ! -L "$key_current" && ! -L "$repo_current" ]] || return 1
backup_dir="$(dirname -- "$staged_key")"
key_backup="$backup_dir/prior-key"
repo_backup="$backup_dir/prior-repo"
if [[ -e "$key_current" ]]; then
[[ -f "$key_current" ]] || return 1
cp -- "$key_current" "$key_backup" || return 1
key_mode="$(stat -c %a "$key_current")" || return 1
key_existed=1
fi
if [[ -e "$repo_current" ]]; then
[[ -f "$repo_current" ]] || return 1
cp -- "$repo_current" "$repo_backup" || return 1
repo_mode="$(stat -c %a "$repo_current")" || return 1
repo_existed=1
fi
sudo install -m 0644 "$staged_key" "$key_destination" || status=$?
if (( status == 0 )); then
sudo install -m 0644 "$staged_repo" "$repo_destination" || status=$?
fi
(( status == 0 )) && return 0
_restore_repository_file "$repo_existed" "$repo_backup" "$repo_mode" "$repo_destination" \
|| rollback_status=$?
_restore_repository_file "$key_existed" "$key_backup" "$key_mode" "$key_destination" \
|| rollback_status=$?
(( rollback_status == 0 )) || log "Repository activation rollback did not complete"
return "$status"
}
# Reads the gpgkey of the single enabled Terra identity out of dnf's effective
# configuration, applying the structural safety rules either way: one enabled
# identity, named terra, with every signature check turned on.
#
# With require_pinned set it additionally demands Panama's own reviewed baseurl
# and key path. Without it, the answer is just "what trust root is this machine
# actually verifying against?" -- the question adoption turns on.
_effective_terra_key() {
awk -v reviewed_baseurl="${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \
-v reviewed_metalink="${INSTALLER_PROVENANCE[TERRA_METALINK_BASEURL]}" \
-v require_pinned="${1:-}" '
function reset_block() {
delete values
delete seen
in_block = 0
id = ""
terra_like = 0
}
function finish_block( key) {
if (!in_block || !terra_like) return
if (seen["enabled"] != 1) {
bad = 1
return
}
if (values["enabled"] != "1") return
enabled_count++
if (id != "terra") bad = 1
for (key in required) {
if (seen[key] != 1) bad = 1
}
if (require_pinned != "") {
if (values["baseurl"] != reviewed_baseurl || values["metalink"] != "" \
|| values["mirrorlist"] != "" \
|| values["gpgkey"] != "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama") bad = 1
} else if (values["mirrorlist"] != "") {
bad = 1
} else if (values["baseurl"] == reviewed_baseurl && values["metalink"] == "") {
# Already on the reviewed baseurl, just not via the Panama key path.
} else if (values["baseurl"] == "" \
&& index(values["metalink"], reviewed_metalink "?") == 1) {
# The stock terra-release metalink, on the reviewed host.
} else {
bad = 1
}
if (values["gpgcheck"] != "1" || values["pkg_gpgcheck"] != "1" \
|| values["repo_gpgcheck"] != "1" || values["gpgkey"] == "") bad = 1
trusted_key = values["gpgkey"]
}
BEGIN {
split("enabled baseurl metalink mirrorlist gpgcheck pkg_gpgcheck repo_gpgcheck gpgkey", fields)
for (field_index in fields) required[fields[field_index]] = 1
reset_block()
}
/^======== ".*" repository configuration: ========$/ {
finish_block()
reset_block()
saw_nonempty = 1
header_count++
id = $0
sub(/^======== "/, "", id)
sub(/" repository configuration: ========$/, "", id)
if (id == "") bad = 1
terra_like = (tolower(id) ~ /^terra/)
in_block = 1
next
}
{
if ($0 == "") next
saw_nonempty = 1
if (!in_block || $0 ~ /^========/) {
bad = 1
next
}
separator = index($0, " = ")
if (separator > 0) {
key = substr($0, 1, separator - 1)
value = substr($0, separator + 3)
} else if ($0 ~ /^[[:alnum:]_.-]+$/) {
key = $0
value = ""
} else {
bad = 1
next
}
if (key !~ /^[[:alnum:]_.-]+$/) {
bad = 1
next
}
if (terra_like && key in required) {
seen[key]++
values[key] = value
}
}
END {
finish_block()
if (saw_nonempty && header_count == 0) bad = 1
if (bad || enabled_count > 1) exit 2
if (enabled_count == 0) exit 1
print trusted_key
}
'
}
_terra_repo_config_dump() {
LC_ALL=C dnf --quiet --no-plugins --dump-repo-config='*'
}
# Status 0 is one trusted effective Terra identity, 1 is no enabled Terra
# identity, and 2 is an unsafe, duplicated, or unreadable effective state.
#
# Both this and _terra_adoptable_status take an already-read dump when the
# caller needs both verdicts, so the two cannot disagree about a configuration
# that changed between them.
_terra_effective_status() {
local dump gpgkey parse_status=0 local_key
if (( $# > 0 )); then
dump="$1"
else
dump="$(_terra_repo_config_dump)" || return 2
fi
gpgkey="$(printf '%s\n' "$dump" | _effective_terra_key pinned)" || parse_status=$?
(( parse_status == 0 )) || return "$parse_status"
[[ "$gpgkey" == 'file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' ]] || return 2
local_key="$PANAMA_SYSTEM_ETC/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama"
[[ -f "$local_key" ]] || return 2
key_fingerprint_matches "$PANAMA_PATH/setup/provenance/keys/terra44.asc" \
"${INSTALLER_PROVENANCE[TERRA_FINGERPRINT]}" \
&& key_fingerprint_matches "$local_key" \
"${INSTALLER_PROVENANCE[TERRA_FINGERPRINT]}" \
|| return 2
}
# Whether an effective Terra that is not in Panama's pinned form can be safely
# converted into it rather than refused.
#
# The trust root is the signing key, not the URL it is served from. A machine
# that installed Terra the way Terra documents has terra-release's own repo
# file: a metalink instead of the reviewed baseurl, and the key at
# RPM-GPG-KEY-terra44 rather than Panama's renamed copy. Every signature check
# is already on, and that key is the same fingerprint this repository reviewed
# and pinned. Cosmetics, in other words -- not a compromised trust root.
#
# Refusing it outright built a gate with no door. install_terra_repository
# declined to touch a machine terra-release had already reached, so an ordinary
# Fedora desktop could never reach the pinned state, and a routine `panama
# update` died before it ran a single stage. Adoption is the door.
#
# It is deliberately narrow: the pinned fingerprint must match on both the
# reviewed key and the key the machine actually verifies against, and the
# gpgkey must be a local file under the system trust directory. An unknown key,
# a remote gpgkey, a second enabled Terra, or a disabled signature check is
# still a hard refusal.
_terra_adoptable_status() {
local dump gpgkey parse_status=0 key_file key_path
_require_policy_value TERRA_METALINK_BASEURL 'https://tetsudou.fyralabs.com/metalink' || return 2
if (( $# > 0 )); then
dump="$1"
else
dump="$(_terra_repo_config_dump)" || return 2
fi
gpgkey="$(printf '%s\n' "$dump" | _effective_terra_key)" || parse_status=$?
(( parse_status == 0 )) || return "$parse_status"
key_file="${gpgkey#file://}"
[[ "$gpgkey" == "file://$key_file" && "$key_file" == /etc/pki/rpm-gpg/* ]] || return 2
[[ "$key_file" != *..* ]] || return 2
key_path="$PANAMA_SYSTEM_ETC${key_file#/etc}"
[[ -f "$key_path" && ! -L "$key_path" ]] || return 2
key_fingerprint_matches "$PANAMA_PATH/setup/provenance/keys/terra44.asc" \
"${INSTALLER_PROVENANCE[TERRA_FINGERPRINT]}" \
&& key_fingerprint_matches "$key_path" \
"${INSTALLER_PROVENANCE[TERRA_FINGERPRINT]}" \
|| return 2
}
TERRA_TRUST_FAILURE_STATUS=78
preflight_terra_trust() {
local status=0 adoptable_status=0 dump
_require_policy_value TERRA_BASEURL 'https://repos.fyralabs.com/terra44' \
|| return "$TERRA_TRUST_FAILURE_STATUS"
_require_policy_value TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F \
|| return "$TERRA_TRUST_FAILURE_STATUS"
dump="$(_terra_repo_config_dump)" || {
log "Effective Terra repository configuration is not trusted; refusing all package work"
return "$TERRA_TRUST_FAILURE_STATUS"
}
_terra_effective_status "$dump" || status=$?
if (( status == 0 || status == 1 )); then
return 0
fi
# Terra signed by the pinned key, on a reviewed endpoint, passes here because
# install-packages adopts it into the pinned form before it opens any other
# DNF transaction.
_terra_adoptable_status "$dump" || adoptable_status=$?
if (( adoptable_status == 0 )); then
return 0
fi
log "Effective Terra repository configuration is not trusted; refusing all package work"
return "$TERRA_TRUST_FAILURE_STATUS"
}
# Status 0 is trusted, 1 is absent, and 2 is present but untrusted or malformed.
_flathub_remote_status() {
local config section_count url gpg_verify summary_verify disabled disabled_status
local alternate_key_count
config="$PANAMA_SYSTEM_FLATPAK_REPO/config"
[[ -f "$config" ]] || return 1
section_count="$(_ini_section_count "$config" 'remote "flathub"')" || return 2
(( section_count > 0 )) || return 1
(( section_count == 1 )) || return 2
url="$(_ini_value "$config" 'remote "flathub"' url)" || return 2
gpg_verify="$(_ini_value "$config" 'remote "flathub"' gpg-verify)" || return 2
summary_verify="$(_ini_value "$config" 'remote "flathub"' gpg-verify-summary)" || return 2
[[ "$url" == 'https://dl.flathub.org/repo/' ]] || return 2
case "${gpg_verify,,}" in true|yes|1) ;; *) return 2 ;; esac
case "${summary_verify,,}" in true|yes|1) ;; *) return 2 ;; esac
disabled_status=0
disabled="$(_ini_value "$config" 'remote "flathub"' xa.disable)" || disabled_status=$?
if (( disabled_status == 0 )); then
case "${disabled,,}" in true|yes|1) return 2 ;; esac
elif (( disabled_status != 1 )); then
return 2
fi
alternate_key_count="$(_ini_key_occurrence_count "$config" 'remote "flathub"' gpgkeypath)" \
|| return 2
# The reviewed default keyring is the only permitted trust source. Empty,
# duplicate, malformed, and nonempty alternate paths all fail closed.
(( alternate_key_count == 0 )) || return 2
[[ -f "$PANAMA_SYSTEM_FLATPAK_REPO/flathub.trustedkeys.gpg" ]] || return 2
key_fingerprint_matches "$PANAMA_SYSTEM_FLATPAK_REPO/flathub.trustedkeys.gpg" \
"${INSTALLER_PROVENANCE[FLATHUB_FINGERPRINT]}" || return 2
}
install_rpmfusion_repositories() {
local work free_rpm nonfree_rpm
require_reviewed_fedora_release || return 1
_require_policy_value RPMFUSION_FREE_RELEASE_URL \
'https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-44.noarch.rpm' || return 1
_require_policy_value RPMFUSION_FREE_RELEASE_MAX_BYTES 4194304 || return 1
_require_policy_value RPMFUSION_NONFREE_RELEASE_URL \
'https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-44.noarch.rpm' || return 1
_require_policy_value RPMFUSION_NONFREE_RELEASE_MAX_BYTES 4194304 || return 1
_require_policy_value RPMFUSION_FREE_FINGERPRINT E9A491A3DE247814E7E067EAE06F8ECDD651FF2E || return 1
_require_policy_value RPMFUSION_NONFREE_FINGERPRINT 79BDB88F9BBF73910FD4095B6A2AF96194843C65 || return 1
work="$(mktemp -d)" || return 1
chmod 0700 "$work"
free_rpm="$work/rpmfusion-free-release.rpm"
nonfree_rpm="$work/rpmfusion-nonfree-release.rpm"
if ! _download_bounded "${INSTALLER_PROVENANCE[RPMFUSION_FREE_RELEASE_URL]}" \
"${INSTALLER_PROVENANCE[RPMFUSION_FREE_RELEASE_MAX_BYTES]}" "$free_rpm" \
|| ! rpm_signature_matches "$free_rpm" \
"$PANAMA_PATH/setup/provenance/keys/rpmfusion-free.asc" \
"${INSTALLER_PROVENANCE[RPMFUSION_FREE_FINGERPRINT]}" \
|| ! _download_bounded "${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_RELEASE_URL]}" \
"${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_RELEASE_MAX_BYTES]}" "$nonfree_rpm" \
|| ! rpm_signature_matches "$nonfree_rpm" \
"$PANAMA_PATH/setup/provenance/keys/rpmfusion-nonfree.asc" \
"${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_FINGERPRINT]}"; then
rm -rf -- "$work"
return 1
fi
local status=0
sudo dnf install -y --setopt=localpkg_gpgcheck=1 "$free_rpm" "$nonfree_rpm" || status=$?
rm -rf -- "$work"
return "$status"
}
# Rewrites an adoptable Terra into Panama's pinned form.
#
# No network and no DNF: terra-release is already installed, so this is only the
# key copy and the repository file, published as one pair so a half-written
# trust root rolls back. The effective state is re-read afterwards, because the
# only acceptable proof that adoption worked is the check that judged it.
#
# terra-release owns /etc/yum.repos.d/terra.repo, so a later update to that
# package restores the stock file. That is fine and deliberate: the next run
# adopts it again, which is why adoption has to be repeatable rather than a
# one-time migration.
adopt_terra_repository() {
local work staged_key staged_repo status=0 effective_status=0
work="$(mktemp -d)" || return 1
chmod 0700 "$work"
staged_key="$work/terra44.asc"
staged_repo="$work/terra.repo"
if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/terra44.asc" "$staged_key" \
TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F; then
rm -rf -- "$work"
return 1
fi
printf '%s\n' \
'[terra]' \
'name=Panama reviewed Terra 44' \
"baseurl=${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \
'enabled=1' \
'gpgcheck=1' \
'repo_gpgcheck=1' \
'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' > "$staged_repo"
chmod 0600 "$staged_repo"
_publish_repository_pair \
"$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama \
"$staged_repo" /etc/yum.repos.d/terra.repo || status=$?
if (( status == 0 )); then
_terra_effective_status || effective_status=$?
(( effective_status == 0 )) || status="$TERRA_TRUST_FAILURE_STATUS"
fi
rm -rf -- "$work"
return "$status"
}
install_terra_repository() {
local work staged_key staged_repo status effective_status=0 adoptable_status=0 dump
require_reviewed_fedora_release || return 1
_require_policy_value TERRA_BASEURL 'https://repos.fyralabs.com/terra44' || return 1
_require_policy_value TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F || return 1
dump="$(_terra_repo_config_dump)" || return 1
_terra_effective_status "$dump" || effective_status=$?
if (( effective_status == 0 )); then
log "Terra repository already configured and verified"
return 0
elif (( effective_status != 1 )); then
_terra_adoptable_status "$dump" || adoptable_status=$?
if (( adoptable_status != 0 )); then
log "Effective Terra repository configuration is not trusted"
return "$TERRA_TRUST_FAILURE_STATUS"
fi
log "Adopting the existing Terra repository into Panama's reviewed form"
adopt_terra_repository
return $?
fi
if rpm -q terra-release >/dev/null 2>&1; then
log "terra-release is installed without one trusted enabled Terra repository"
return "$TERRA_TRUST_FAILURE_STATUS"
fi
work="$(mktemp -d)" || return 1
chmod 0700 "$work"
staged_key="$work/terra44.asc"
staged_repo="$work/terra.repo"
if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/terra44.asc" "$staged_key" \
TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F; then
rm -rf -- "$work"
return 1
fi
sudo install -m 0644 "$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama || {
rm -rf -- "$work"
return 1
}
printf '%s\n' \
'[terra]' \
'name=Panama reviewed Terra 44' \
"baseurl=${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \
'enabled=1' \
'gpgcheck=1' \
'repo_gpgcheck=1' \
'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' > "$staged_repo"
chmod 0600 "$staged_repo"
status=0
sudo dnf install -y \
--repofrompath "terra,${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \
--setopt=terra.pkg_gpgcheck=1 \
--setopt=terra.repo_gpgcheck=1 \
--setopt=terra.gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama \
terra-release || status=$?
if (( status == 0 )); then
sudo install -m 0644 "$staged_repo" /etc/yum.repos.d/terra.repo || status=$?
fi
if (( status == 0 )); then
effective_status=0
_terra_effective_status || effective_status=$?
(( effective_status == 0 )) || status="$TERRA_TRUST_FAILURE_STATUS"
fi
rm -rf -- "$work"
return "$status"
}
configure_hyprland_repository() {
local work staged_key staged_repo status
require_reviewed_fedora_release || return 1
_require_policy_value HYPRLAND_COPR_BASEURL \
'https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/' \
|| return 1
work="$(mktemp -d)" || return 1
chmod 0700 "$work"
staged_key="$work/hyprland-copr.asc"
staged_repo="$work/panama-hyprland.repo"
if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/hyprland-copr.asc" "$staged_key" \
HYPRLAND_COPR_FINGERPRINT 97E23476C89635135407C7D5E9BA41342C4B2995; then
rm -rf -- "$work"
return 1
fi
printf '%s\n' \
'[panama-hyprland]' \
'name=Panama reviewed Hyprland COPR' \
"baseurl=${INSTALLER_PROVENANCE[HYPRLAND_COPR_BASEURL]}" \
'enabled=1' \
'gpgcheck=1' \
'repo_gpgcheck=0' \
'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland' > "$staged_repo"
chmod 0600 "$staged_repo"
status=0
_publish_repository_pair \
"$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland \
"$staged_repo" /etc/yum.repos.d/panama-hyprland.repo || status=$?
rm -rf -- "$work"
return "$status"
}
ensure_flathub_remote() {
local work descriptor encoded key_file url no_gpg_verify gpg_verify
local alternate_key_count status remote_status no_gpg_status gpg_status
require_reviewed_fedora_release || return 1
_require_policy_value FLATHUB_DESCRIPTOR_URL 'https://flathub.org/repo/flathub.flatpakrepo' || return 1
_require_policy_value FLATHUB_DESCRIPTOR_MAX_BYTES 1048576 || return 1
_require_policy_value FLATHUB_FINGERPRINT 6E5C05D979C76DAF93C081354184DD4D907A7CAE || return 1
remote_status=0
_flathub_remote_status || remote_status=$?
if (( remote_status == 0 )); then
return 0
elif (( remote_status != 1 )); then
log "Existing Flathub remote does not match Panama's reviewed trust policy"
return 1
fi
work="$(mktemp -d)" || return 1
chmod 0700 "$work"
descriptor="$work/flathub.flatpakrepo"
key_file="$work/flathub-key.asc"
if ! _download_bounded "${INSTALLER_PROVENANCE[FLATHUB_DESCRIPTOR_URL]}" \
"${INSTALLER_PROVENANCE[FLATHUB_DESCRIPTOR_MAX_BYTES]}" "$descriptor" \
|| ! url="$(_ini_value "$descriptor" 'Flatpak Repo' Url)" \
|| [[ "$url" != 'https://dl.flathub.org/repo/' ]] \
|| ! encoded="$(_ini_value "$descriptor" 'Flatpak Repo' GPGKey)" \
|| ! printf '%s' "$encoded" | base64 --decode > "$key_file"; then
rm -rf -- "$work"
return 1
fi
no_gpg_status=0
no_gpg_verify="$(_ini_value "$descriptor" 'Flatpak Repo' NoGPGVerify)" \
|| no_gpg_status=$?
if (( no_gpg_status == 0 )); then
case "${no_gpg_verify,,}" in true|yes|1) rm -rf -- "$work"; return 1 ;; esac
elif (( no_gpg_status != 1 )); then
rm -rf -- "$work"
return 1
fi
gpg_status=0
gpg_verify="$(_ini_value "$descriptor" 'Flatpak Repo' GPGVerify)" || gpg_status=$?
if (( gpg_status == 0 )); then
case "${gpg_verify,,}" in false|no|0) rm -rf -- "$work"; return 1 ;; esac
elif (( gpg_status != 1 )); then
rm -rf -- "$work"
return 1
fi
alternate_key_count="$(_ini_key_occurrence_count "$descriptor" 'Flatpak Repo' GPGKeyPath)" \
|| alternate_key_count=1
if (( alternate_key_count != 0 )); then
rm -rf -- "$work"
return 1
fi
if ! key_fingerprint_matches "$key_file" "${INSTALLER_PROVENANCE[FLATHUB_FINGERPRINT]}"; then
rm -rf -- "$work"
return 1
fi
status=0
sudo flatpak remote-add --if-not-exists --gpg-import="$key_file" flathub "$url" \
|| status=$?
if (( status == 0 )); then
_flathub_remote_status || status=$?
fi
rm -rf -- "$work"
return "$status"
}
_install_claude_code() {
local work staged_key staged_repo status
if command -v claude >/dev/null 2>&1; then
log "Claude Code already installed at \"$(command -v claude)\""
return 0
fi
require_reviewed_fedora_release || return 1
_require_policy_value CLAUDE_CODE_BASEURL 'https://downloads.claude.ai/claude-code/rpm/stable' || return 1
work="$(mktemp -d)" || return 1
chmod 0700 "$work"
staged_key="$work/claude-code.asc"
staged_repo="$work/claude-code.repo"
if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/claude-code.asc" "$staged_key" \
CLAUDE_CODE_FINGERPRINT 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE; then
rm -rf -- "$work"
return 1
fi
printf '%s\n' \
'[claude-code]' \
'name=Claude Code' \
"baseurl=${INSTALLER_PROVENANCE[CLAUDE_CODE_BASEURL]}" \
'enabled=1' \
'gpgcheck=1' \
'repo_gpgcheck=1' \
'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama' > "$staged_repo"
chmod 0600 "$staged_repo"
status=0
_publish_repository_pair \
"$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama \
"$staged_repo" /etc/yum.repos.d/claude-code.repo || status=$?
if (( status == 0 )); then
sudo dnf install -y --repo=claude-code --repo=fedora --repo=updates \
--from-repo=claude-code claude-code || status=$?
fi
rm -rf -- "$work"
return "$status"
}
install_claude_code() {
_install_claude_code || _record_installer_failure "Claude Code"
}
_claude_desktop_manual() {
log "Claude Desktop is optional; configure its reviewed local-key repository manually to install it"
}
install_claude_desktop_if_trusted() {
local repo_file baseurl gpgcheck repo_gpgcheck gpgkey local_key
require_reviewed_fedora_release || return 1
_require_policy_value CLAUDE_DESKTOP_BASEURL \
'https://patrickjaja.github.io/claude-desktop-extra/rpm/' || return 1
_require_policy_value CLAUDE_DESKTOP_FINGERPRINT 825A7D15D78BABE45646D5DF382409F597908867 || return 1
repo_file="$PANAMA_SYSTEM_ETC/yum.repos.d/claude-desktop.repo"
if [[ ! -f "$repo_file" ]] \
|| ! baseurl="$(_ini_value "$repo_file" claude-desktop baseurl)" \
|| [[ "$baseurl" != "${INSTALLER_PROVENANCE[CLAUDE_DESKTOP_BASEURL]}" ]] \
|| ! gpgcheck="$(_ini_value "$repo_file" claude-desktop gpgcheck)" \
|| [[ "$gpgcheck" != 1 ]] \
|| ! repo_gpgcheck="$(_ini_value "$repo_file" claude-desktop repo_gpgcheck)" \
|| [[ "$repo_gpgcheck" != 1 ]] \
|| ! gpgkey="$(_ini_value "$repo_file" claude-desktop gpgkey)" \
|| [[ "$gpgkey" != file:///* ]]; then
_claude_desktop_manual
return 0
fi
local_key="${gpgkey#file://}"
if [[ ! -f "$local_key" ]] \
|| ! key_fingerprint_matches "$PANAMA_PATH/setup/provenance/keys/claude-desktop.asc" \
"${INSTALLER_PROVENANCE[CLAUDE_DESKTOP_FINGERPRINT]}" \
|| ! key_fingerprint_matches "$local_key" \
"${INSTALLER_PROVENANCE[CLAUDE_DESKTOP_FINGERPRINT]}"; then
_claude_desktop_manual
return 0
fi
sudo dnf install -y claude-desktop-extra
}
# --- The server path ---------------------------------------------------------
#
# Everything a server runs is above this line plus the lists it installs. No
# RPM Fusion, no Terra, no COPR, no multimedia, no flatpaks: those exist for a
# desktop, and every one of them is a network dependency and a failure mode a
# headless machine has no reason to carry.
if [[ "${1:-}" == --trust-preflight ]]; then
if preflight_terra_trust; then
exit 0
else
exit $?
fi
fi
# Repeat the enclosing installer's early preflight at the package boundary so
# a repository change made after startup cannot reach this stage's first DNF.
if ! preflight_terra_trust; then
exit "$TERRA_TRUST_FAILURE_STATUS"
fi
if [[ "$ROLE" == server ]]; then
echo -e "\n--- Installing packages (server) ---"
log "Updating all packages. This may take a while"
sudo dnf update -y --refresh > /dev/null
install_list core-packages "Core"
install_list server-packages "Server"
set +e
setup_node
install_pnpm
install_bun
install_claude_code
install_codex
set -e
report_soft_failures
exit 0
fi
echo -e "\n--- Installing Repositories ---"
# Terra goes first so a machine whose Terra is enabled but not yet in Panama's
# reviewed form is adopted before any other transaction below runs against it.
#
# Terra bootstraps itself: --repofrompath defines a throwaway repo just long
# enough to install terra-release, which then writes the real /etc/yum.repos.d
# entry. Doing that a second time is not harmless -- dnf5 refuses the whole
# transaction with 'Id is present more than once in the configuration', because
# the throwaway id collides with the one terra-release already installed.
#
# That is what killed a re-run on a machine Terra had already reached: this sits
# in the repository section, above everything, so `set -e` ended the stage
# before a single package was considered. An installer whose second run does
# less than its first is worse than one that never ran.
log "Installing Terra Repository"
install_terra_repository > /dev/null
log "Installing RPM Fusion Free and Nonfree Repositories"
install_rpmfusion_repositories > /dev/null
log "Enabling Fedora Cisco OpenH264 Repository"
# soft: this repo does not exist on every spin, and its absence must not cost
# the desktop -- the ordering rule at soft()'s definition applies to the
# repository extras just as much as to the codec swaps below.
soft "enabling the openh264 repository" sudo dnf config-manager setopt fedora-cisco-openh264.enabled=1
log "Installing RPM Fusion AppStream Metadata"
soft "the core group update" sudo dnf update @core -y
soft "the RPM Fusion appstream metadata" sudo dnf install -y rpmfusion-\*-appstream-data
echo -e "\n--- Installing relevant packages ---"
log "Updating all packages. This may take a while"
sudo dnf update -y --refresh > /dev/null
# --- Install the shared core, then the desktop-only lists ---
# --skip-unavailable throughout (inside install_list): dnf5 refuses a whole
# transaction over one missing name, so a single rotted entry used to cost
# every package in a list -- and the desktop below never installed. The
# skipped names are reported afterwards rather than silently dropped.
install_list core-packages "Core"
install_list initial-packages "Initial"
install_list desktop-packages "Desktop"
# --- Install the Hyprland desktop ---
#
# Directly after desktop-packages and deliberately before anything optional.
# The reviewed local repository below supplies these packages. This is the one
# thing on the list that Panama is; a machine that gets only this far is a
# machine you can log into, and every step below it is a convenience.
#
# Most of these live in the lionheartp/Hyprland COPR rather than Fedora proper.
HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages"
if [[ -f "$HYPR_FILE" ]]; then
log "Configuring the reviewed Hyprland repository"
configure_hyprland_repository > /dev/null
HYPR_PACKAGES=$(packages_in "$HYPR_FILE")
log "Installing Hyprland desktop packages"
echo -e "Includes the following packages:"
echo -e "$(<"$HYPR_FILE")"
sudo dnf install -y --setopt=install_weak_deps=False $HYPR_PACKAGES > /dev/null
log "Hyprland packages installed!"
else
log "Package list was not in specified path: $HYPR_FILE"
fi
# Said out loud, because the failure this guards against was silent. The stage
# used to die somewhere above this point and report one red line among twenty
# minutes of scrollback, and the machine looked installed until you tried to log
# into it.
if rpm -q hyprland >/dev/null 2>&1; then
log "Hyprland $(rpm -q --queryformat '%{VERSION}' hyprland) is installed."
else
log "Hyprland is NOT installed. Nothing below this point will give you a desktop."
exit 1
fi
# --- Codecs and multimedia ---------------------------------------------------
#
# Below the desktop and every one of them non-fatal, because none is a
# dependency of it and each can fail for reasons that have nothing to do with
# this repository -- a swap whose source package this spin never shipped, a
# group renamed upstream between Fedora releases.
#
# A trailing `&& sync` on the group update previously meant a failure was exempt
# from set -e as well (bash does not apply -e to the left of a && list), so it
# went unreported rather than being deliberately tolerated. It is deliberate now.
log "Updating core, multimedia, and sound-and-video groups"
soft "the multimedia group update" \
sudo dnf4 groupupdate -y 'core' 'multimedia' 'sound-and-video' \
--setop='install_weak_deps=False' \
--exclude='PackageKit-gstreamer-plugin' \
--allowerasing
sync
log "Swapping ffmpeg-free for ffmpeg"
soft "the ffmpeg swap" sudo dnf swap -y 'ffmpeg-free' 'ffmpeg' --allowerasing
log "Swapping mesa-va-drivers for mesa-va-drivers-freeworld"
soft "the mesa driver swap" sudo dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld
log "Upgrading Multimedia group with optional packages"
soft "the optional Multimedia upgrade" sudo dnf4 group upgrade -y --with-optional Multimedia
log "Installing GStreamer plugins (bad, good, base)"
soft "the GStreamer plugins" \
sudo dnf install -y gstreamer1-plugins-{bad-\*,good-\*,base} \
--exclude=gstreamer1-plugins-bad-free-devel
# --- Install Development Packages needed for Neovim ---
DEV_FILE="$PANAMA_PATH/setup/packages/development-packages"
if [[ -f "$DEV_FILE" ]]; then
DEV_PACKAGES=$(packages_in "$DEV_FILE")
log "Installing Development Packages. Mostly for Neovim."
echo -e "Includes the following packages:"
echo -e "$(<"$DEV_FILE")"
soft "the development packages" sudo dnf install -y $DEV_PACKAGES
log "Development packages installed!"
else
log "Package list was not in specified path: $DEV_FILE"
fi
set +e
setup_node
install_pnpm
install_bun
install_claude_code
install_codex
set -e
# Claude Desktop remains optional. Panama never downloads its community setup
# script; only a repository an operator has already configured with the exact
# reviewed local key is eligible for installation.
if ! install_claude_desktop_if_trusted; then
log "Claude Desktop install failed; skipping"
softly_failed+=("Claude Desktop")
fi
# ChatGPT Desktop: OpenAI ships an official Linux RPM now. Panama used to build
# a community wrapper from the macOS disk image -- it was `panama app
# chatgpt-desktop` -- because no packaged form existed; that build froze often
# and carried its own local rebuild daemon. The official package is strictly
# better: it comes from a repository, so it upgrades with every other package
# from then on.
#
# The repository and its signing key are established first, from the copy
# pinned in setup/keys/, so dnf verifies the metadata and the package before
# either reaches root. Upstream's own instructions do not allow that -- see
# setup/lib/chatgpt-package for why they are not followed here.
if rpm -q chatgpt >/dev/null 2>&1; then
log "ChatGPT Desktop already installed"
elif ! chatgpt_install_repository sudo; then
log "Could not establish the verified ChatGPT repository; skipping"
softly_failed+=("ChatGPT Desktop")
else
log "Installing ChatGPT Desktop..."
sudo dnf install -y chatgpt > /dev/null \
|| { log "ChatGPT Desktop install failed; skipping"; softly_failed+=("ChatGPT Desktop"); }
fi
# The RPM ships rustdesk.service already enabled, which is what provides
# unattended access; Panama deliberately does not start it a second time.
install_rustdesk || true
# --- Install Flatpak Packages ---
FLATPAK_FILE="$PANAMA_PATH/setup/packages/flatpak-packages"
if [[ -f "$FLATPAK_FILE" ]]; then
FLATPAK_PACKAGES=$(packages_in "$FLATPAK_FILE")
log "Adding Flathub remote"
if ensure_flathub_remote; then
log "Installing Flatpak Packages"
echo -e "Includes the following packages:"
echo -e "$(<"$FLATPAK_FILE")"
# One ID renamed on Flathub must not cost the rest of the run; the desktop
# is already installed by this point and none of these is part of it.
soft "some Flatpak packages" sudo flatpak install -y flathub $FLATPAK_PACKAGES
log "Flatpak packages installed!"
else
log "Flathub trust verification failed; Flatpak packages were not installed"
softly_failed+=("Flathub")
fi
else
log "Package list was not in specified path: $FLATPAK_FILE"
fi
# --- Install the extras that were chosen ------------------------------------
#
# Everything above is what every Panama machine gets. This is what one machine
# asked for: the interview offers the categories in setup/packages/extras/ as a
# checklist and records the chosen names, so a work laptop does not acquire
# emulators and a desktop does not skip Steam.
#
# Absent means none. That is what makes this stage safe to re-run by hand while
# repairing one piece of a machine -- and it means a category is installed only
# by an explicit answer, never by a default that drifted.
#
# A category mixes both package managers, because the applications do: some are
# in Fedora or RPM Fusion and some publish only a flatpak. A bare line is a dnf
# package and a `flatpak:` line is a Flathub ID, so one file per category holds
# the whole answer rather than splitting each category across two.
#
# Reading the file is setup/lib/extras-catalog's job, not this function's, because
# `panama apps` offers the same catalog from the other side. Two parsers would
# eventually disagree about what a category contains, and the one that disagreed
# quietly would be this one -- it runs unattended.
#
# Neither install is fatal. A category is a set of applications somebody wanted,
# not a dependency of the desktop, and losing the rest of the run because one of
# them was renamed upstream would be the wrong trade.
install_extra_category() {
local file="$1" name
name="$(basename "$file")"
local dnf_packages flatpak_ids
# sed rather than grep -v: most categories are flatpak-only, and grep exits 1
# when it selects nothing, which set -e above turns into a dead stage.
dnf_packages=$(catalog_all_targets "$file" | sed '/^flatpak:/d' | tr "\n" " ")
flatpak_ids=$(catalog_all_targets "$file" | sed -n 's/^flatpak://p' | tr "\n" " ")
if [[ -n "${dnf_packages// /}" ]]; then
log "Installing $name: $dnf_packages"
sudo dnf install -y $dnf_packages > /dev/null || { log "Some $name packages did not install"; softly_failed+=("$name packages"); }
fi
if [[ -n "${flatpak_ids// /}" ]]; then
log "Installing $name flatpaks: $flatpak_ids"
if ensure_flathub_remote; then
sudo flatpak install -y flathub $flatpak_ids > /dev/null \
|| { log "Some $name flatpaks did not install"; softly_failed+=("$name flatpaks"); }
else
log "Flathub trust verification failed; $name flatpaks were not installed"
softly_failed+=("$name flatpaks")
fi
fi
}
EXTRAS_DIR="$PANAMA_PATH/setup/packages/extras"
for extra in ${PANAMA_EXTRAS:-}; do
if [[ -f "$EXTRAS_DIR/$extra" ]]; then
install_extra_category "$EXTRAS_DIR/$extra"
else
log "No such extras category: $extra"
fi
done
report_soft_failures