507 lines
18 KiB
Bash
Executable File
507 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Panama's front door: the one command a fresh Fedora machine needs.
|
|
#
|
|
# Download this file from the documented commit URL, verify its documented
|
|
# SHA-256, then pass both immutable values as PANAMA_BOOT_REVISION and
|
|
# PANAMA_BOOT_SHA256 when invoking it.
|
|
#
|
|
# Deliberately dumb, because a copy of this script leaves the repository the
|
|
# moment somebody curls it -- nothing here can be fixed by re-running
|
|
# ./install, so there is as little here as possible: get git, get the clone,
|
|
# hand off. Everything with judgment in it lives in `install`, which is also
|
|
# where re-runs and upgrades already work.
|
|
#
|
|
# The one exception to dumb is the root path below, which cannot live in
|
|
# `install`: a fresh VPS hands you a root login and nothing else, and the user
|
|
# that `install` needs to exist is exactly what has not been created yet.
|
|
|
|
set -euo pipefail
|
|
|
|
if [[ ! "${PANAMA_BOOT_REVISION:-}" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "boot: PANAMA_BOOT_REVISION must be a full lowercase commit" >&2
|
|
exit 1
|
|
fi
|
|
if [[ ! "${PANAMA_BOOT_SHA256:-}" =~ ^[0-9a-f]{64}$ ]]; then
|
|
echo "boot: PANAMA_BOOT_SHA256 must be a lowercase SHA-256" >&2
|
|
exit 1
|
|
fi
|
|
actual_boot_sha="$(sha256sum "${BASH_SOURCE[0]}" | cut -d' ' -f1)"
|
|
if [[ "$actual_boot_sha" != "$PANAMA_BOOT_SHA256" ]]; then
|
|
echo "boot: downloaded boot file does not match PANAMA_BOOT_SHA256" >&2
|
|
exit 1
|
|
fi
|
|
|
|
REPO_URL="https://git.gbrown.org/gib/Panama.git"
|
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
|
export PANAMA_PATH
|
|
BOOTSTRAP_USER=""
|
|
|
|
checkout_command() {
|
|
if [[ -n "$BOOTSTRAP_USER" ]]; then
|
|
runuser -u "$BOOTSTRAP_USER" -- "$@"
|
|
else
|
|
"$@"
|
|
fi
|
|
}
|
|
|
|
prepare_panama_checkout() {
|
|
local checkout="$1" actual_head checkout_status
|
|
|
|
if [[ -e "$checkout" && ! -d "$checkout/.git" ]]; then
|
|
printf 'boot: %s exists but is not a Panama Git checkout\n' "$checkout" >&2
|
|
return 1
|
|
fi
|
|
|
|
if [[ -d "$checkout/.git" ]]; then
|
|
checkout_status="$(checkout_command git -C "$checkout" status --porcelain)" || {
|
|
printf 'boot: could not inspect the existing checkout at %s\n' "$checkout" >&2
|
|
return 1
|
|
}
|
|
if [[ -n "$checkout_status" ]]; then
|
|
printf 'boot: existing checkout at %s has local changes\n' "$checkout" >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "Panama is already cloned at $checkout; fetching the verified revision"
|
|
checkout_command git -C "$checkout" fetch origin "$PANAMA_BOOT_REVISION" || return 1
|
|
if ! checkout_command git -C "$checkout" merge-base --is-ancestor \
|
|
HEAD "$PANAMA_BOOT_REVISION"; then
|
|
echo "boot: existing checkout cannot fast-forward to the verified revision" >&2
|
|
return 1
|
|
fi
|
|
checkout_command git -C "$checkout" merge --ff-only "$PANAMA_BOOT_REVISION" || return 1
|
|
else
|
|
checkout_command mkdir -p "$(dirname "$checkout")" || return 1
|
|
checkout_command git init "$checkout" || return 1
|
|
checkout_command git -C "$checkout" remote add origin "$REPO_URL" || return 1
|
|
checkout_command git -C "$checkout" fetch --depth=1 origin \
|
|
"$PANAMA_BOOT_REVISION" || return 1
|
|
checkout_command git -C "$checkout" checkout --detach \
|
|
"$PANAMA_BOOT_REVISION" || return 1
|
|
|
|
actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1
|
|
if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then
|
|
echo "boot: fetched checkout does not match PANAMA_BOOT_REVISION" >&2
|
|
return 1
|
|
fi
|
|
|
|
checkout_command git -C "$checkout" checkout -b main || return 1
|
|
checkout_command git -C "$checkout" config branch.main.remote origin || return 1
|
|
checkout_command git -C "$checkout" config branch.main.merge refs/heads/main || return 1
|
|
fi
|
|
|
|
actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1
|
|
if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then
|
|
echo "boot: checkout HEAD does not match PANAMA_BOOT_REVISION" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
SERVER=0
|
|
INSTALL_ARGS=()
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--server) SERVER=1; INSTALL_ARGS+=(--server) ;;
|
|
*)
|
|
printf 'boot: unknown argument: %s\n' "$arg" >&2
|
|
printf 'usage: boot [--server]\n' >&2
|
|
exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
# The public bootstrap contract runs this branch as an ordinary user with a
|
|
# stubbed root identity. Keep its filesystem adapter unavailable to a real root
|
|
# shell so it cannot redirect a real installation by accident.
|
|
BOOT_ROOT="${PANAMA_BOOT_FIXTURE_ROOT:-}"
|
|
if [[ -n "$BOOT_ROOT" && "$EUID" -eq 0 ]]; then
|
|
echo "boot: PANAMA_BOOT_FIXTURE_ROOT is test-only" >&2
|
|
exit 1
|
|
fi
|
|
|
|
system_path() {
|
|
local path="$1"
|
|
[[ "$path" == /* ]] || return 2
|
|
printf '%s%s\n' "$BOOT_ROOT" "$path"
|
|
}
|
|
|
|
valid_authorized_keys() {
|
|
local keys="$1" line saw_key=0
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]]; then
|
|
continue
|
|
fi
|
|
if ! ssh-keygen -l -f /dev/stdin >/dev/null 2>&1 <<<"$line"; then
|
|
return 1
|
|
fi
|
|
saw_key=1
|
|
done <"$keys"
|
|
(( saw_key ))
|
|
}
|
|
|
|
safe_authorized_keys() {
|
|
local username="$1" user_home="$2" uid ssh_dir keys
|
|
uid="$(id -u "$username")" || return 1
|
|
[[ "$uid" =~ ^[0-9]+$ && "$uid" != 0 && "$user_home" == /* ]] || return 1
|
|
ssh_dir="$user_home/.ssh"
|
|
keys="$ssh_dir/authorized_keys"
|
|
[[ -d "$ssh_dir" && ! -L "$ssh_dir" && -f "$keys" && ! -L "$keys" ]] || return 1
|
|
[[ "$(stat -Lc '%u:%a' "$ssh_dir")" == "$uid:700" ]] || return 1
|
|
[[ "$(stat -Lc '%u:%a' "$keys")" == "$uid:600" ]] || return 1
|
|
valid_authorized_keys "$keys"
|
|
}
|
|
|
|
safe_root_authorized_keys() {
|
|
local keys
|
|
keys="$(system_path /root/.ssh/authorized_keys)" || return 1
|
|
[[ -f "$keys" && ! -L "$keys" ]] || return 1
|
|
[[ "$(stat -Lc '%u:%a' "$keys")" == '0:600' ]] || return 1
|
|
valid_authorized_keys "$keys"
|
|
}
|
|
|
|
detect_ssh_unit() {
|
|
local unit
|
|
for unit in sshd.service ssh.service; do
|
|
systemctl cat "$unit" >/dev/null 2>&1 && {
|
|
printf '%s\n' "$unit"
|
|
return 0
|
|
}
|
|
done
|
|
return 1
|
|
}
|
|
|
|
restore_ssh_dropin() {
|
|
local restore
|
|
if (( ssh_had_prior )); then
|
|
[[ -n "$ssh_backup" && -f "$ssh_backup" && ! -L "$ssh_backup" ]] || return 1
|
|
restore="$(mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.restore)" || return 1
|
|
if ! cp -a -- "$ssh_backup" "$restore"; then
|
|
remove_ssh_artifact "$restore" || true
|
|
return 1
|
|
fi
|
|
if ! mv -f -- "$restore" "$ssh_dropin"; then
|
|
remove_ssh_artifact "$restore" || true
|
|
return 1
|
|
fi
|
|
else
|
|
remove_ssh_artifact "$ssh_dropin"
|
|
fi
|
|
}
|
|
|
|
restore_ssh_transaction_traps() {
|
|
trap - EXIT INT TERM
|
|
[[ -n "${ssh_saved_exit_trap:-}" ]] && eval "$ssh_saved_exit_trap"
|
|
[[ -n "${ssh_saved_int_trap:-}" ]] && eval "$ssh_saved_int_trap"
|
|
[[ -n "${ssh_saved_term_trap:-}" ]] && eval "$ssh_saved_term_trap"
|
|
return 0
|
|
}
|
|
|
|
remove_ssh_artifact() {
|
|
local artifact="$1"
|
|
[[ -n "$artifact" && ( -e "$artifact" || -L "$artifact" ) ]] || return 0
|
|
if rm -f -- "$artifact"; then
|
|
return 0
|
|
fi
|
|
printf 'SSH transaction cleanup failed. Retained artifact: %s\n' "$artifact" >&2
|
|
printf ' rm -f -- %q\n' "$artifact" >&2
|
|
return 1
|
|
}
|
|
|
|
print_ssh_recovery() {
|
|
if (( ssh_had_prior )); then
|
|
printf 'SSH rollback needs manual recovery. Backup: %s\n' "$ssh_backup" >&2
|
|
printf ' cp -a -- %q %q\n' "$ssh_backup" "$ssh_dropin" >&2
|
|
else
|
|
printf 'SSH rollback needs manual recovery. No prior drop-in existed.\n' >&2
|
|
printf ' rm -f -- %q\n' "$ssh_dropin" >&2
|
|
fi
|
|
printf ' sshd -t\n' >&2
|
|
printf ' systemctl reload %s\n' "$ssh_unit" >&2
|
|
}
|
|
|
|
policy_is_no() {
|
|
local policy="$1" setting="$2"
|
|
awk -v setting="$setting" '
|
|
$1 == setting { count += 1; if ($2 != "no") bad = 1 }
|
|
END { exit count != 1 || bad }
|
|
' <<<"$policy"
|
|
}
|
|
|
|
effective_ssh_policy_is_hardened() {
|
|
local username="$1" root_policy target_policy context
|
|
context='host=localhost,addr=127.0.0.1'
|
|
root_policy="$(sshd -T -C "user=root,$context")" || return 1
|
|
policy_is_no "$root_policy" permitrootlogin || return 1
|
|
policy_is_no "$root_policy" passwordauthentication || return 1
|
|
policy_is_no "$root_policy" kbdinteractiveauthentication || return 1
|
|
|
|
target_policy="$(sshd -T -C "user=$username,$context")" || return 1
|
|
policy_is_no "$target_policy" passwordauthentication || return 1
|
|
policy_is_no "$target_policy" kbdinteractiveauthentication
|
|
}
|
|
|
|
rollback_ssh_transaction() {
|
|
local reload_restored="$1" rollback_failed=0
|
|
restore_ssh_dropin || rollback_failed=1
|
|
sshd -t || rollback_failed=1
|
|
if (( reload_restored )); then
|
|
systemctl reload "$ssh_unit" || rollback_failed=1
|
|
fi
|
|
ssh_transaction_state=""
|
|
restore_ssh_transaction_traps
|
|
if (( rollback_failed )); then
|
|
print_ssh_recovery
|
|
else
|
|
remove_ssh_artifact "$ssh_backup" || true
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
handle_ssh_transaction_exit() {
|
|
if [[ "$ssh_transaction_state" == preparing \
|
|
|| ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then
|
|
remove_ssh_artifact "$ssh_candidate" || true
|
|
remove_ssh_artifact "$ssh_backup" || true
|
|
elif [[ "$ssh_transaction_state" == activating || "$ssh_transaction_state" == activated ]]; then
|
|
restore_ssh_dropin || true
|
|
fi
|
|
}
|
|
|
|
handle_ssh_transaction_signal() {
|
|
local signal_status="$1"
|
|
trap - INT TERM
|
|
if [[ "$ssh_transaction_state" == preparing \
|
|
|| ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then
|
|
ssh_transaction_state=""
|
|
restore_ssh_transaction_traps
|
|
remove_ssh_artifact "$ssh_candidate" || true
|
|
remove_ssh_artifact "$ssh_backup" || true
|
|
else
|
|
rollback_ssh_transaction 1 || true
|
|
fi
|
|
exit "$signal_status"
|
|
}
|
|
|
|
harden_server_ssh() {
|
|
local username="$1" user_home="$2" sshd_dir ssh_dropin harden ssh_unit
|
|
local ssh_candidate="" ssh_backup="" ssh_had_prior=0
|
|
local ssh_transaction_state=""
|
|
local ssh_saved_exit_trap ssh_saved_int_trap ssh_saved_term_trap
|
|
sshd_dir="$(system_path /etc/ssh/sshd_config.d)" || return 1
|
|
ssh_dropin="$sshd_dir/00-panama.conf"
|
|
|
|
if [[ -L "$ssh_dropin" || ( -e "$ssh_dropin" && ! -f "$ssh_dropin" ) ]]; then
|
|
printf 'SSH hardening unavailable: %s is not a regular file\n' "$ssh_dropin" >&2
|
|
return 2
|
|
fi
|
|
|
|
if ! ssh_unit="$(detect_ssh_unit)"; then
|
|
echo "SSH hardening unavailable: neither sshd.service nor ssh.service is installed" >&2
|
|
return 2
|
|
fi
|
|
|
|
printf 'Harden sshd (disable root, password, and keyboard-interactive authentication)? [Y/n]: '
|
|
read -r harden </dev/tty || harden=""
|
|
if [[ "$harden" =~ ^[Nn] ]]; then
|
|
return 0
|
|
fi
|
|
|
|
ssh_saved_exit_trap="$(trap -p EXIT)"
|
|
ssh_saved_int_trap="$(trap -p INT)"
|
|
ssh_saved_term_trap="$(trap -p TERM)"
|
|
ssh_transaction_state=preparing
|
|
trap 'handle_ssh_transaction_exit' EXIT
|
|
trap 'handle_ssh_transaction_signal 130' INT
|
|
trap 'handle_ssh_transaction_signal 143' TERM
|
|
|
|
if ! ssh_candidate="$(umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.tmp)"; then
|
|
ssh_transaction_state=""
|
|
restore_ssh_transaction_traps
|
|
return 1
|
|
fi
|
|
if ! printf 'PermitRootLogin no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n' >"$ssh_candidate"; then
|
|
ssh_transaction_state=""
|
|
restore_ssh_transaction_traps
|
|
remove_ssh_artifact "$ssh_candidate" || true
|
|
return 1
|
|
fi
|
|
|
|
if [[ -e "$ssh_dropin" ]]; then
|
|
ssh_had_prior=1
|
|
if ! ssh_backup="$(umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.backup)"; then
|
|
ssh_transaction_state=""
|
|
restore_ssh_transaction_traps
|
|
remove_ssh_artifact "$ssh_candidate" || true
|
|
return 1
|
|
fi
|
|
if ! cp -a -- "$ssh_dropin" "$ssh_backup"; then
|
|
ssh_transaction_state=""
|
|
restore_ssh_transaction_traps
|
|
remove_ssh_artifact "$ssh_candidate" || true
|
|
remove_ssh_artifact "$ssh_backup" || true
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
ssh_transaction_state=activating
|
|
if ! mv -f -- "$ssh_candidate" "$ssh_dropin"; then
|
|
ssh_transaction_state=""
|
|
restore_ssh_transaction_traps
|
|
remove_ssh_artifact "$ssh_candidate" || true
|
|
remove_ssh_artifact "$ssh_backup" || true
|
|
return 1
|
|
fi
|
|
ssh_candidate=""
|
|
ssh_transaction_state=activated
|
|
|
|
if ! sshd -t || ! effective_ssh_policy_is_hardened "$username"; then
|
|
rollback_ssh_transaction 0 || true
|
|
return 1
|
|
fi
|
|
|
|
if ! systemctl reload "$ssh_unit"; then
|
|
rollback_ssh_transaction 1 || true
|
|
return 1
|
|
fi
|
|
|
|
ssh_transaction_state=""
|
|
restore_ssh_transaction_traps
|
|
remove_ssh_artifact "$ssh_backup" || return 1
|
|
echo "Wrote $ssh_dropin; make sure your key works before logging out."
|
|
}
|
|
|
|
# Panama assumes Fedora's repositories and package names.
|
|
if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then
|
|
echo "This looks like something other than Fedora; Panama only supports Fedora." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# ── Root ─────────────────────────────────────────────────────────────────────
|
|
#
|
|
# On a desktop, root is a mistake: the clone and every dotfile would land in
|
|
# root's home and configure the wrong user. On a fresh VPS it is the starting
|
|
# condition -- Hetzner hands over a root login and nothing else -- so with
|
|
# --server this walks the machine from that to a normal Panama install: a
|
|
# user with sudo, keys, an optionally hardened sshd, and `install --server`
|
|
# running as that user. Every step checks before acting, because the machine
|
|
# may be anywhere along this path already: a user half-created by hand, keys
|
|
# already copied, sshd already locked down.
|
|
if [[ "$(id -u)" -eq 0 ]]; then
|
|
if (( ! SERVER )); then
|
|
echo "Run this as your own user, not root: the install configures YOUR desktop." >&2
|
|
echo "Setting up a fresh server from its root login is: boot --server" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Everything here asks, and a root shell from `bash <(curl ...)` can have
|
|
# the pipe as stdin, so every prompt reads the terminal explicitly.
|
|
if ! (exec </dev/tty) 2>/dev/null; then
|
|
echo "No terminal to ask on; run this from an interactive root shell." >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf 'Username for this server [gib]: '
|
|
read -r username </dev/tty || username=""
|
|
username="${username:-gib}"
|
|
|
|
if id -u "$username" >/dev/null 2>&1; then
|
|
echo "User $username already exists"
|
|
# wheel is what makes sudo work on Fedora; a user created by hand may not
|
|
# have it, and everything after this depends on it.
|
|
id -nG "$username" | grep -qw wheel || usermod -aG wheel "$username"
|
|
else
|
|
echo "Creating $username with sudo (wheel)"
|
|
useradd -m -G wheel "$username"
|
|
fi
|
|
|
|
# useradd leaves the account locked, and sudo asks for this password -- a
|
|
# user who cannot sudo is a user the install cannot run as.
|
|
if ! passwd -S "$username" 2>/dev/null | awk '{exit $2 != "PS" && $2 != "P"}'; then
|
|
echo "Set a password for $username (sudo will ask for it):"
|
|
passwd "$username" </dev/tty
|
|
fi
|
|
|
|
# Do not close root/password access until the account's key is an exact,
|
|
# usable login path. The fixture adapter resolves these logical system paths
|
|
# beneath a temporary root; ordinary execution receives the original paths.
|
|
logical_user_home="$(getent passwd "$username" | cut -d: -f6)"
|
|
user_home=""
|
|
if [[ "$logical_user_home" == /* ]]; then
|
|
user_home="$(system_path "$logical_user_home")" || true
|
|
fi
|
|
bootstrap_home="$user_home"
|
|
if [[ -z "$bootstrap_home" ]]; then
|
|
bootstrap_home="$(system_path "/home/$username")"
|
|
fi
|
|
|
|
user_ssh_dir="$user_home/.ssh"
|
|
user_keys="$user_ssh_dir/authorized_keys"
|
|
if [[ -n "$user_home" && ! -e "$user_keys" && ! -L "$user_keys" \
|
|
&& ! -L "$user_ssh_dir" ]] && safe_root_authorized_keys; then
|
|
copy_root_key=0
|
|
if [[ ! -e "$user_ssh_dir" ]]; then
|
|
copy_root_key=1
|
|
elif [[ ! -d "$user_ssh_dir" \
|
|
|| "$(stat -Lc '%u:%a' "$user_ssh_dir")" != "$(id -u "$username"):700" ]]; then
|
|
echo "SSH hardening unavailable: $username has no safe authorized_keys" >&2
|
|
else
|
|
copy_root_key=1
|
|
fi
|
|
if (( copy_root_key )); then
|
|
echo "Copying root's authorized_keys to $username"
|
|
root_keys="$(system_path /root/.ssh/authorized_keys)"
|
|
if ! runuser -u "$username" -- install -d -m 0700 -- "$user_ssh_dir" \
|
|
|| ! runuser -u "$username" -- install -m 0600 -- /dev/stdin "$user_keys" \
|
|
<"$root_keys"; then
|
|
echo "SSH hardening unavailable: could not install root's key for $username" >&2
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if safe_authorized_keys "$username" "$user_home"; then
|
|
harden_status=0
|
|
harden_server_ssh "$username" "$user_home" || harden_status=$?
|
|
if (( harden_status != 0 && harden_status != 2 )); then
|
|
echo "SSH hardening failed; stopping before install handoff." >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "SSH hardening unavailable: $username has no safe authorized_keys" >&2
|
|
fi
|
|
|
|
if ! command -v git >/dev/null 2>&1; then
|
|
echo "Installing git, which the clone needs"
|
|
dnf install -y git
|
|
fi
|
|
|
|
# Create or advance the checkout as the target user. A root-owned .git in a
|
|
# user's home would break every later update.
|
|
PANAMA_PATH="$bootstrap_home/.local/share/Panama"
|
|
BOOTSTRAP_USER="$username"
|
|
prepare_panama_checkout "$PANAMA_PATH"
|
|
|
|
echo "Handing off to install as $username"
|
|
exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \
|
|
"$PANAMA_PATH/install" --server </dev/tty
|
|
fi
|
|
|
|
# git is the one dependency the clone itself needs. Everything else -- gum
|
|
# included -- is bootstrapped by `install`.
|
|
if ! command -v git >/dev/null 2>&1; then
|
|
echo "Installing git, which the clone needs"
|
|
sudo dnf install -y git
|
|
fi
|
|
|
|
prepare_panama_checkout "$PANAMA_PATH"
|
|
|
|
# A shell invoked from automation can have a pipe as stdin, while the first
|
|
# thing install runs is the interview, which has to be able to ask.
|
|
# Reattach the terminal when there is one; without one the interview will say
|
|
# so itself.
|
|
# The probe actually opens /dev/tty rather than testing -r: a process with no
|
|
# controlling terminal passes -r and then fails the redirect.
|
|
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
|
|
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
|
|
fi
|
|
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
|