Merge branch 'quattro' into unified-input-method-setup
This commit is contained in:
commit
101bb547bb
245 files changed
+19949
-375
No files matched your search
@@ -6,6 +6,9 @@ run_logged "$OMARCHY_INSTALL/config/fix-powerprofilesctl-shebang.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/ssh-command-path.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/docker.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/locale.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/snapper.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/usb-authorization.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/enable-services.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/firewall.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/thunderbolt-authorization.sh"
|
||||
@@ -0,0 +1,50 @@
|
||||
# An image built from a distribution's root tarball rather than the ISO (Arch
|
||||
# Linux ARM's ships LANG=C) never went through the ISO's locale step, so it
|
||||
# runs non-UTF-8: byte-wise sorting, ASCII-only \u escapes, and any tool that
|
||||
# reads the locale for its encoding.
|
||||
# Root always writes the real files; the overrides are for unprivileged tests.
|
||||
locale_conf=/etc/locale.conf
|
||||
locale_gen=/etc/locale.gen
|
||||
if (( EUID != 0 )); then
|
||||
locale_conf=${OMARCHY_LOCALE_CONF:-$locale_conf}
|
||||
locale_gen=${OMARCHY_LOCALE_GEN:-$locale_gen}
|
||||
fi
|
||||
|
||||
# Repair only the stock state -- an unset LANG, or the bare C/POSIX the image
|
||||
# ships. Any named locale is somebody's choice, C.UTF-8 included, so leave it.
|
||||
# A machine with no locale.conf at all reads as unset, not as a failure:
|
||||
# under pipefail the missing file would otherwise abort the installer.
|
||||
current=$(sed -n 's/^LANG=//p' "$locale_conf" 2>/dev/null | tail -1 | tr -d '"') || current=""
|
||||
|
||||
case ${current:-C} in
|
||||
C | POSIX) ;;
|
||||
*)
|
||||
echo "Leaving the locale as $current"
|
||||
return 0 2>/dev/null || exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Setting up locale (en_US.UTF-8)..."
|
||||
|
||||
if ! locale -a 2>/dev/null | grep -qi "en_US.utf-\?8"; then
|
||||
if grep -q '^#en_US.UTF-8' "$locale_gen" 2>/dev/null; then
|
||||
sed -i 's/^#en_US.UTF-8/en_US.UTF-8/' "$locale_gen"
|
||||
elif ! grep -q '^en_US.UTF-8' "$locale_gen" 2>/dev/null; then
|
||||
echo "en_US.UTF-8 UTF-8" >>"$locale_gen"
|
||||
fi
|
||||
|
||||
locale-gen >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Only LANG changes; LC_* lines somebody set stay.
|
||||
if grep -q '^LANG=' "$locale_conf" 2>/dev/null; then
|
||||
sed -i 's/^LANG=.*/LANG=en_US.UTF-8/' "$locale_conf"
|
||||
else
|
||||
echo "LANG=en_US.UTF-8" >>"$locale_conf"
|
||||
fi
|
||||
|
||||
# The session that ran this keeps its inherited LANG; everything after it here
|
||||
# should see the new one.
|
||||
export LANG=en_US.UTF-8
|
||||
|
||||
echo "Locale set to en_US.UTF-8"
|
||||
@@ -0,0 +1,10 @@
|
||||
echo "Configure Thunderbolt device authorization"
|
||||
|
||||
install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.thunderbolt.policy" /usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy
|
||||
|
||||
# The builder's accessories must not become the new owner's trust policy.
|
||||
# Keep ordinary Bolt behavior until interactive owner setup has finished.
|
||||
if [[ -n ${OMARCHY_INSTALL_USER:-} ]]; then
|
||||
/usr/bin/omarchy-thunderbolt-authorization-admin prepare
|
||||
systemctl enable omarchy-thunderbolt-authorization.service
|
||||
fi
|
||||
@@ -0,0 +1,26 @@
|
||||
echo "Configure USB device authorization"
|
||||
|
||||
install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.usb.policy" /usr/share/polkit-1/actions/org.omarchy.usb.policy
|
||||
|
||||
# A deferred install has only the builder's peripherals attached. The owner
|
||||
# needs working input before there is an account or a session for approvals.
|
||||
if [[ -z ${OMARCHY_INSTALL_USER:-} ]]; then
|
||||
systemctl disable usbguard.service
|
||||
return 0
|
||||
fi
|
||||
|
||||
rules_file="${OMARCHY_USB_AUTHORIZATION_RULES_FILE:-/etc/usbguard/rules.conf}"
|
||||
daemon_config="${OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG:-/etc/usbguard/usbguard-daemon.conf}"
|
||||
|
||||
source "$OMARCHY_INSTALL/helpers/usb-authorization.sh"
|
||||
usb_authorization_require_secure_settings "$daemon_config"
|
||||
|
||||
if [[ ! -s $rules_file ]]; then
|
||||
usb_authorization_install_policy "$rules_file"
|
||||
fi
|
||||
|
||||
usb_authorization_add_user "$OMARCHY_INSTALL_USER"
|
||||
|
||||
# Installs are followed by a reboot, so enable the daemon without starting it
|
||||
# in the live ISO's chroot.
|
||||
systemctl enable usbguard.service
|
||||
@@ -45,3 +45,5 @@ run_logged "$OMARCHY_INSTALL/hardware/fix-yt6801-ethernet-adapter.sh"
|
||||
run_logged "$OMARCHY_INSTALL/hardware/fix-tuxedo-backlight.sh"
|
||||
run_logged "$OMARCHY_INSTALL/hardware/speaker-tuning.sh"
|
||||
run_logged "$OMARCHY_INSTALL/hardware/pacman.sh"
|
||||
|
||||
run_logged "$OMARCHY_INSTALL/hardware/platform-setup.sh"
|
||||
@@ -18,6 +18,14 @@
|
||||
# machines including the T2-less iMac19,1 and iMac19,2, and BCM4377/4378/4387
|
||||
# from the T2 era on. The BCM4360 in 2013-2015 Macs is deliberately absent: it
|
||||
# runs the out-of-tree wl driver, which never reads a brcmfmac option.
|
||||
#
|
||||
# Apple Silicon is excluded even though its BCM4387 is in that list: there the
|
||||
# firmware supplicant is what works, and disabling it makes firmware commands
|
||||
# time out and breaks scanning entirely.
|
||||
if omarchy-hw-aarch64-apple; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sys_vendor="$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || true)"
|
||||
|
||||
if lspci -nn | grep "106b:180[12]" >/dev/null ||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Detect MacBook models that need SPI keyboard modules
|
||||
product_name="$(cat /sys/class/dmi/id/product_name 2>/dev/null)"
|
||||
# Detect MacBook models that need SPI keyboard modules.
|
||||
# Not every machine has a DMI product name, and under set -e an unguarded read
|
||||
# of a missing one aborts the hardware pass.
|
||||
product_name="$(cat /sys/class/dmi/id/product_name 2>/dev/null || true)"
|
||||
if [[ $product_name =~ MacBook[89],1|MacBook1[02],1|MacBookPro13,[123]|MacBookPro14,[123] ]]; then
|
||||
echo "Detected MacBook with SPI keyboard"
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
systemctl enable bluetooth.service
|
||||
|
||||
# An image's first boot runs this after the adapter has brought up (or queued)
|
||||
# bluetooth.target, and a target never starts a unit enabled after its start
|
||||
# job was made, so Bluetooth would stay off until the next boot. Only there
|
||||
# (omarchy-provision-hardware sets the marker): an install or a rerun of
|
||||
# hardware setup leaves the service as it finds it. Without Bluetooth support
|
||||
# the unit's own condition skips it.
|
||||
if [[ ${OMARCHY_IMAGE_DEFERRED_HARDWARE:-} == "1" ]]; then
|
||||
systemctl start --no-block bluetooth.service || true
|
||||
fi
|
||||
|
||||
# AutoEnable stays at its stock default on purpose. It was set to false here to
|
||||
# persist the power state, which it never did: BlueZ has no such behaviour, so
|
||||
# all it bought was Bluetooth coming up off on every boot. omarchy-bluetooth-power
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Ensure that F-keys on Apple-like keyboards (such as Lofree Flow84) are always F-keys
|
||||
if [[ ! -f /etc/modprobe.d/hid_apple.conf ]]; then
|
||||
sudo mkdir -p /etc/modprobe.d
|
||||
echo "options hid_apple fnmode=2" | sudo tee /etc/modprobe.d/hid_apple.conf >/dev/null
|
||||
# Ensure that F-keys on Apple-like keyboards (such as Lofree Flow84) are always F-keys.
|
||||
# Apple Silicon is left alone: the omarchy-mac package owns its keyboard mode.
|
||||
conf="${OMARCHY_HID_APPLE_CONF:-/etc/modprobe.d/hid_apple.conf}"
|
||||
if [[ ! -f $conf ]] && ! omarchy-hw-aarch64-apple; then
|
||||
sudo mkdir -p "$(dirname "$conf")"
|
||||
echo "options hid_apple fnmode=2" | sudo tee "$conf" >/dev/null
|
||||
fi
|
||||
@@ -0,0 +1,13 @@
|
||||
# The platform packages' own setup, last so it builds on every other leaf
|
||||
# (omarchy-lifecycle-dispatch setup-boot and setup-system, see
|
||||
# docs/lifecycle-dispatch.md): the boot package's first, then the runtime
|
||||
# package's. A no-op where the platform registers none. An image's first boot
|
||||
# says so: there the network may be down, and a boot-file rebuild is asked for
|
||||
# rather than built, since the deferred setup rebuilds once after its last step.
|
||||
if [[ ${OMARCHY_IMAGE_DEFERRED_HARDWARE:-} == "1" ]]; then
|
||||
omarchy-lifecycle-dispatch setup-boot image-first-boot
|
||||
omarchy-lifecycle-dispatch setup-system image-first-boot
|
||||
else
|
||||
omarchy-lifecycle-dispatch setup-boot
|
||||
omarchy-lifecycle-dispatch setup-system
|
||||
fi
|
||||
@@ -0,0 +1,164 @@
|
||||
# Image builds and the hardware setup they defer to the machine's first boot.
|
||||
# Sourced by omarchy-apply-hardware and omarchy-provision-hardware; no shebang.
|
||||
#
|
||||
# An image is built away from the machine it will run on, so hardware setup
|
||||
# during the build would describe the builder, not the target. The builder
|
||||
# declares the build in a root-owned manifest before it runs
|
||||
# omarchy-apply-system in the image root:
|
||||
#
|
||||
# /var/lib/omarchy/image/target
|
||||
# format=1
|
||||
# platform=x86|aarch64|aarch64-apple
|
||||
#
|
||||
# While it exists, omarchy-apply-hardware queues each hardware leaf in
|
||||
# /var/lib/omarchy/image/deferred-steps instead of running it, and arms
|
||||
# omarchy-provision-hardware.service. On the machine's first boot that service,
|
||||
# or the platform's own first boot, retires the manifest (to target.booted) and
|
||||
# runs the queue on the real hardware. Unknown manifest keys are ignored so the
|
||||
# format can grow.
|
||||
#
|
||||
# An image carries no pacman master key of its own, or every machine flashed
|
||||
# from it would share one: where install finalization would make the keyring
|
||||
# (install/post-install/pacman.sh), a build requests it in
|
||||
# /var/lib/omarchy/image/pacman-keyring instead, and the first boot makes it
|
||||
# before any deferred step runs.
|
||||
#
|
||||
# The manifest is also the only source of the image's platform: until that boot,
|
||||
# omarchy-hw-platform reports its platform instead of the build host's, so the
|
||||
# initramfs, services and packages the build sets up are the target's. It reads
|
||||
# the manifest by the same rules as omarchy_image_read_manifest below.
|
||||
#
|
||||
# Root always uses the fixed paths. Only a non-root test may move them under
|
||||
# OMARCHY_IMAGE_ROOT, so no environment variable can make a live system defer,
|
||||
# skip or replay its hardware setup.
|
||||
|
||||
omarchy_image_init() {
|
||||
if (( EUID == 0 )); then
|
||||
omarchy_image_root=""
|
||||
elif [[ -n ${OMARCHY_IMAGE_ROOT:-} ]]; then
|
||||
omarchy_image_root=$OMARCHY_IMAGE_ROOT
|
||||
else
|
||||
echo "Error: image setup state is root-owned; run as root" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
omarchy_image_dir=$omarchy_image_root/var/lib/omarchy/image
|
||||
omarchy_image_manifest=$omarchy_image_dir/target
|
||||
omarchy_image_queue=$omarchy_image_dir/deferred-steps
|
||||
omarchy_image_initramfs_baseline=$omarchy_image_dir/initramfs-inputs
|
||||
omarchy_image_boot_rebuild=$omarchy_image_dir/boot-rebuild
|
||||
omarchy_image_keyring_request=$omarchy_image_dir/pacman-keyring
|
||||
omarchy_image_unit=omarchy-provision-hardware.service
|
||||
omarchy_image_systemd_dir=$omarchy_image_root/etc/systemd/system
|
||||
}
|
||||
|
||||
# Owned by whoever runs this (root on a live system), not a symlink, and not
|
||||
# writable by anyone else.
|
||||
omarchy_image_trusted() {
|
||||
local path=$1 owner mode
|
||||
|
||||
[[ -e $path && ! -L $path ]] || return 1
|
||||
read -r owner mode < <(stat -c '%u %a' -- "$path") || return 1
|
||||
(( owner == EUID && (8#$mode & 8#022) == 0 ))
|
||||
}
|
||||
|
||||
omarchy_image_manifest_present() {
|
||||
[[ -e $omarchy_image_manifest || -L $omarchy_image_manifest ]]
|
||||
}
|
||||
|
||||
# Sets omarchy_image_platform, or fails without guessing whether this is a build.
|
||||
omarchy_image_read_manifest() {
|
||||
local line key value format="" platform=""
|
||||
|
||||
if [[ ! -d $omarchy_image_dir || ! -f $omarchy_image_manifest ]] || ! omarchy_image_trusted "$omarchy_image_dir" ||
|
||||
! omarchy_image_trusted "$omarchy_image_manifest"; then
|
||||
echo "Error: $omarchy_image_manifest is not a root-owned regular file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
[[ -n $line && $line != \#* ]] || continue
|
||||
if [[ $line != *=* ]]; then
|
||||
echo "Error: $omarchy_image_manifest is malformed: $line" >&2
|
||||
return 1
|
||||
fi
|
||||
key=${line%%=*}
|
||||
value=${line#*=}
|
||||
case $key in
|
||||
format) format=$value ;;
|
||||
platform) platform=$value ;;
|
||||
esac
|
||||
done <"$omarchy_image_manifest"
|
||||
|
||||
if [[ $format != "1" ]]; then
|
||||
echo "Error: $omarchy_image_manifest is not format=1" >&2
|
||||
return 1
|
||||
fi
|
||||
# Builders written before the platform names settled say apple-silicon,
|
||||
# generic-aarch64 or generic (as omarchy-hw-platform reads them too).
|
||||
case $platform in
|
||||
apple-silicon) platform=aarch64-apple ;;
|
||||
generic-aarch64) platform=aarch64 ;;
|
||||
generic) platform=x86 ;;
|
||||
esac
|
||||
case $platform in
|
||||
x86 | aarch64 | aarch64-apple) ;;
|
||||
*)
|
||||
echo "Error: $omarchy_image_manifest names no known platform: ${platform:-none}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
omarchy_image_platform=$platform
|
||||
}
|
||||
|
||||
# Holds the image state still for the rest of the caller, so hardware setup and
|
||||
# a first-boot run never overlap or decide from a manifest the other retires.
|
||||
# A machine that was never an image has no state and nothing to lock.
|
||||
omarchy_image_lock() {
|
||||
[[ -e $omarchy_image_dir || -L $omarchy_image_dir ]] || return 0
|
||||
if [[ ! -d $omarchy_image_dir ]] || ! omarchy_image_trusted "$omarchy_image_dir"; then
|
||||
echo "Error: $omarchy_image_dir is not a root-owned directory" >&2
|
||||
return 1
|
||||
fi
|
||||
exec {omarchy_image_lock_fd}>>"$omarchy_image_dir/lock" || return 1
|
||||
flock "$omarchy_image_lock_fd"
|
||||
}
|
||||
|
||||
omarchy_image_write_queue() {
|
||||
local tmp
|
||||
|
||||
tmp=$(mktemp "$omarchy_image_dir/.deferred-steps.XXXXXX") || return 1
|
||||
if (( $# )); then
|
||||
printf '%s\n' "$@" >"$tmp" || { rm -f "$tmp"; return 1; }
|
||||
fi
|
||||
chmod 0644 "$tmp" && mv -f "$tmp" "$omarchy_image_queue"
|
||||
}
|
||||
|
||||
# Queue every leaf install/hardware/all.sh would run, in its order, running
|
||||
# none of them, then arm the first-boot service. Rerunning rewrites the same queue.
|
||||
omarchy_image_defer_hardware() {
|
||||
local listing
|
||||
local -a steps=()
|
||||
|
||||
listing=$(
|
||||
run_logged() {
|
||||
[[ $1 == "$OMARCHY_INSTALL"/hardware/*.sh && $1 != *..* ]] || {
|
||||
echo "Error: cannot defer a step outside the hardware setup: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
printf 'install/%s\n' "${1#"$OMARCHY_INSTALL"/}"
|
||||
}
|
||||
source "$OMARCHY_INSTALL/hardware/all.sh"
|
||||
) || return 1
|
||||
|
||||
[[ -z $listing ]] || mapfile -t steps <<<"$listing"
|
||||
omarchy_image_write_queue "${steps[@]}" || return 1
|
||||
|
||||
install -d -m 0755 "$omarchy_image_systemd_dir/multi-user.target.wants" &&
|
||||
install -m 0644 "$OMARCHY_INSTALL/provisioning/$omarchy_image_unit" "$omarchy_image_systemd_dir/$omarchy_image_unit" &&
|
||||
ln -sfn "/etc/systemd/system/$omarchy_image_unit" "$omarchy_image_systemd_dir/multi-user.target.wants/$omarchy_image_unit" ||
|
||||
return 1
|
||||
|
||||
echo "Image build for $omarchy_image_platform: deferred ${#steps[@]} hardware steps to first boot"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Pacman repository templates. Every platform has a pacman.conf and mirrorlist
|
||||
# for each channel it offers, copied into place whole, as x86's always were:
|
||||
# x86's in default/pacman, each ARM platform's in a directory named for it.
|
||||
# Sourcing this file only defines functions; it never changes the system.
|
||||
|
||||
# The directory holding <platform>'s pacman-<channel>.conf and
|
||||
# mirrorlist-<channel>. A channel without both files isn't offered there.
|
||||
omarchy_pacman_templates() {
|
||||
case ${1:-} in
|
||||
x86) echo "$OMARCHY_PATH/default/pacman" ;;
|
||||
aarch64 | aarch64-apple) echo "$OMARCHY_PATH/default/pacman/$1" ;;
|
||||
*)
|
||||
echo "Error: Unknown platform '${1:-}'." >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The channel a machine takes when none is named: stable, except on aarch64.
|
||||
# Omarchy publishes aarch64 packages on edge alone so far, and the stable and rc
|
||||
# packages there are the release line, which has no aarch64 support, so ARM
|
||||
# platforms have edge templates only until a release does.
|
||||
omarchy_pacman_default_channel() {
|
||||
case ${1:-} in
|
||||
x86) echo stable ;;
|
||||
aarch64 | aarch64-apple) echo edge ;;
|
||||
*)
|
||||
echo "Error: Unknown platform '${1:-}'." >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
# Shared by the root controller and session commands. These paths are fixed at
|
||||
# privileged entrypoints; tests may source the functions in an isolated fixture.
|
||||
TB_STATE=/var/lib/omarchy/thunderbolt-authorization/policy.json
|
||||
TB_CONFIG=/var/lib/boltd/boltd.conf
|
||||
TB_MARKER=/etc/omarchy/thunderbolt-authorization.enabled
|
||||
TB_PENDING=/etc/omarchy/thunderbolt-authorization.pending
|
||||
TB_RUNTIME=/run/omarchy-thunderbolt-authorization
|
||||
TB_LOCK=/run/lock/omarchy-thunderbolt-authorization.lock
|
||||
TB_SERVICE=omarchy-thunderbolt-authorization.service
|
||||
TB_BOLT=org.freedesktop.bolt
|
||||
TB_MANAGER=org.freedesktop.bolt1.Manager
|
||||
TB_DEVICE=org.freedesktop.bolt1.Device
|
||||
TB_DOMAIN=org.freedesktop.bolt1.Domain
|
||||
TB_PATH=/org/freedesktop/bolt
|
||||
TB_SYSFS=/sys/bus/thunderbolt/devices
|
||||
TB_TRUST_JQ='def trusted($state): $state.trusted[.Uid] == {Uid,Name,Vendor} and
|
||||
((.Secure | not) or (.Stored and (.Key | IN("have","new"))));'
|
||||
|
||||
tb_fail() { printf '%s\n' "$*" >&2; return 1; }
|
||||
|
||||
tb_json_write() {
|
||||
local target=$1 mode=${2:-600} temporary
|
||||
temporary=$(mktemp "${target%/*}/.thunderbolt.XXXXXX") || return 1
|
||||
if jq -cS . > "$temporary" && chmod "$mode" "$temporary" && sync "$temporary" &&
|
||||
mv -fT -- "$temporary" "$target" && sync "${target%/*}"; then
|
||||
return 0
|
||||
else
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
tb_state() {
|
||||
jq -ce 'select(.version == 1 and (.trusted | type == "object") and (.enabled | type == "boolean"))' "$TB_STATE"
|
||||
}
|
||||
|
||||
tb_bus() {
|
||||
busctl --system --auto-start=no --timeout=10 --json=short "$@"
|
||||
}
|
||||
|
||||
tb_owner() {
|
||||
tb_bus call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetNameOwner s "$TB_BOLT" | jq -er '.data[0]'
|
||||
}
|
||||
|
||||
tb_properties() {
|
||||
tb_bus call "$1" "$2" org.freedesktop.DBus.Properties GetAll s "$3" | jq -ce '.data[0] | map_values(.data)'
|
||||
}
|
||||
|
||||
tb_set() {
|
||||
local owner=$1 path=$2 interface=$3 key=$4 value=$5 signature=${6:-s} current
|
||||
local -a values=()
|
||||
current=$(tb_properties "$owner" "$path" "$interface") || return 1
|
||||
if ! jq -e --arg key "$key" --argjson value "$value" '.[$key] == $value' <<< "$current" >/dev/null; then
|
||||
if [[ $signature == "as" ]]; then
|
||||
mapfile -t values < <(jq -r '.[]' <<< "$value")
|
||||
tb_bus set-property "$owner" "$path" "$interface" "$key" as "${#values[@]}" "${values[@]}" >/dev/null || return 1
|
||||
else
|
||||
tb_bus set-property "$owner" "$path" "$interface" "$key" s "$(jq -r . <<< "$value")" >/dev/null || return 1
|
||||
fi
|
||||
fi
|
||||
# Bolt rejects no-op BootACL writes; even skipped writes are read back.
|
||||
current=$(tb_properties "$owner" "$path" "$interface") || return 1
|
||||
jq -e --arg key "$key" --argjson value "$value" '.[$key] == $value' <<< "$current" >/dev/null ||
|
||||
tb_fail "Bolt did not retain the requested $key"
|
||||
}
|
||||
|
||||
tb_inventory() {
|
||||
local owner manager paths path item inode domains='[]' devices='[]' stored='[]'
|
||||
owner=$(tb_owner) || return 1
|
||||
manager=$(tb_properties "$owner" "$TB_PATH" "$TB_MANAGER") || return 1
|
||||
paths=$(tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" ListDomains | jq -cer '.data[0]') || return 1
|
||||
while IFS= read -r path; do
|
||||
item=$(tb_properties "$owner" "$path" "$TB_DOMAIN" | jq -c --arg path "$path" '. + {path:$path}') || return 1
|
||||
domains=$(jq -c --argjson item "$item" '. + [$item]' <<< "$domains") || return 1
|
||||
done < <(jq -r '.[]' <<< "$paths")
|
||||
paths=$(tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" ListDevices | jq -cer '.data[0]') || return 1
|
||||
while IFS= read -r path; do
|
||||
item=$(tb_properties "$owner" "$path" "$TB_DEVICE" | jq -c --arg path "$path" '. + {path:$path}') || return 1
|
||||
[[ $(jq -r .Type <<< "$item") == "peripheral" ]] || continue
|
||||
if jq -e .Stored <<< "$item" >/dev/null; then
|
||||
stored=$(jq -c --argjson item "$item" '. + [$item]' <<< "$stored") || return 1
|
||||
fi
|
||||
[[ $(jq -r .Status <<< "$item") != "disconnected" ]] || continue
|
||||
inode=$(stat -Lc '%i' -- "$(jq -r .SysfsPath <<< "$item")") || return 1
|
||||
item=$(jq -c --arg inode "$inode" '. + {inode:$inode}' <<< "$item") || return 1
|
||||
devices=$(jq -c --argjson item "$item" '. + [$item]' <<< "$devices") || return 1
|
||||
done < <(jq -r '.[]' <<< "$paths")
|
||||
[[ $(tb_owner) == "$owner" ]] || { tb_fail "Bolt restarted while reading its inventory"; return 1; }
|
||||
jq -cn --arg owner "$owner" --argjson manager "$manager" --argjson domains "$domains" \
|
||||
--argjson devices "$devices" --argjson stored "$stored" '
|
||||
{owner:$owner,manager:$manager,domains:$domains,stored:$stored,devices:[$devices[] |
|
||||
. + {Secure:((.Domain as $domain | any($domains[]; .Uid == $domain and .SecurityLevel == "secure"))
|
||||
and (.AuthFlags | contains("nokey") | not))}]}'
|
||||
}
|
||||
|
||||
tb_read_inventory() {
|
||||
local inventory
|
||||
inventory=$(tb_inventory) || return 1
|
||||
jq -e '.manager.AuthMode == "disabled"' <<< "$inventory" >/dev/null || {
|
||||
tb_fail "Bolt automatic authorization is enabled; protection is not active"
|
||||
return 1
|
||||
}
|
||||
printf '%s\n' "$inventory"
|
||||
}
|
||||
|
||||
tb_identity() {
|
||||
jq -c --arg owner "$1" --arg generation "$2" \
|
||||
'{owner:$owner,generation:$generation,path,inode,Uid,Name,Vendor,Parent,SysfsPath,ConnectTime}'
|
||||
}
|
||||
|
||||
tb_snapshot() {
|
||||
local state inventory generation recovery=false
|
||||
state=$(tb_state) || return 1
|
||||
inventory=$(tb_read_inventory) || return 1
|
||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
||||
[[ -f ${TB_STATE%/*}/boot-recovery.json ]] && recovery=true
|
||||
jq -c --argjson state "$state" --arg generation "$generation" --argjson recovery "$recovery" \
|
||||
--arg error "${1:-}" --argjson time "$(date +%s)" "$TB_TRUST_JQ"'
|
||||
.owner as $owner |
|
||||
[.devices[] | . as $d | {
|
||||
identity: ({owner:$owner,generation:$generation} + ($d | {path,inode,Uid,Name,Vendor,Parent,SysfsPath,ConnectTime})),
|
||||
trusted: ($d | trusted($state)),
|
||||
status:.Status,stored:.Stored,policy:.Policy,flags:.AuthFlags
|
||||
}] as $devices |
|
||||
{available:true,time:$time,owner:$owner,generation:$generation,devices:$devices,error:$error,
|
||||
boot_protection:($state.boot_protection // false),
|
||||
domains:[.domains[] | {Uid,SecurityLevel,IOMMU,BootACL}],
|
||||
warnings: ([
|
||||
(if $recovery then "A firmware boot-access change needs recovery. Boot protection is not confirmed." else empty end),
|
||||
(.domains[] | if .SecurityLevel == "none" then
|
||||
"This controller allows PCIe connections in firmware. Select user authorization in firmware settings; Omarchy cannot block its devices before their drivers load."
|
||||
else empty end),
|
||||
(.domains[] | .SecurityLevel as $level | select(["none","user","secure","dponly","usbonly","nopcie"] | index($level) | not) |
|
||||
"The controller authorization mode could not be verified."),
|
||||
(.domains[] | select($state.boot_protection and (.SysfsPath == "" or
|
||||
(.SecurityLevel != "user" and .SecurityLevel != "secure") or (.BootACL | length == 0) or any(.BootACL[]; . != ""))) |
|
||||
"The firmware boot allowlist is not confirmed empty on every controller. Check Thunderbolt Boot before relying on boot protection."),
|
||||
($devices[] | select((.status | startswith("authorized")) and (.trusted | not) and (.flags | contains("boot"))) |
|
||||
"An untrusted device was authorized by firmware before Linux started. Disconnect it safely to apply approval on its next connection.")
|
||||
] | unique)}' <<< "$inventory"
|
||||
}
|
||||
|
||||
tb_current() {
|
||||
local expected=$1 inventory owner generation device identity
|
||||
inventory=$(tb_read_inventory) || return 1
|
||||
owner=$(jq -r .owner <<< "$inventory")
|
||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
||||
while IFS= read -r device; do
|
||||
identity=$(tb_identity "$owner" "$generation" <<< "$device") || return 1
|
||||
if jq -e --argjson expected "$expected" '. == $expected' <<< "$identity" >/dev/null; then
|
||||
jq -cn --arg owner "$owner" --argjson device "$device" '{owner:$owner,device:$device}'
|
||||
return 0
|
||||
fi
|
||||
done < <(jq -c '.devices[]' <<< "$inventory")
|
||||
tb_fail "That Thunderbolt device was removed or changed. Open its latest notification."
|
||||
}
|
||||
|
||||
tb_prepare_key() (
|
||||
local identity=$1 current key key_fd
|
||||
current=$(tb_current "$identity") || return 1
|
||||
# Bolt creates keys for stored devices itself. For a new device, disabled
|
||||
# AuthMode prevents enrollment before authorization. Seed the kernel key so
|
||||
# Authorize provisions it and the subsequent enrollment imports it into Bolt.
|
||||
jq -e '.device.Secure and (.device.Stored | not)' <<< "$current" >/dev/null || return 0
|
||||
cd -- "$(jq -r .device.SysfsPath <<< "$current")" || return 1
|
||||
[[ $(stat -Lc '%i' .) == "$(jq -r .device.inode <<< "$current")" &&
|
||||
$(cat unique_id) == "$(jq -r .device.Uid <<< "$current")" ]] || {
|
||||
tb_fail 'The Thunderbolt connection changed before key setup'; return 1;
|
||||
}
|
||||
# Pin the attribute in the original directory; do not follow a replacement
|
||||
# connection at the same path, or truncate an existing key on a retry.
|
||||
exec {key_fd}<>key || return 1
|
||||
key=$(cat key) || return 1
|
||||
if [[ -z $key ]]; then
|
||||
[[ $(cat authorized) == "0" ]] || { tb_fail 'Reconnect the device before saving secure approval'; return 1; }
|
||||
key=$(openssl rand -hex 32) || return 1
|
||||
[[ $key =~ ^[0-9a-f]{64}$ ]] || return 1
|
||||
tb_current "$identity" >/dev/null || return 1
|
||||
printf '%s' "$key" >&"$key_fd" || return 1
|
||||
[[ $(cat key) == "$key" ]] || { tb_fail 'Could not verify the Thunderbolt key'; return 1; }
|
||||
else
|
||||
[[ $key =~ ^[0-9a-fA-F]{64}$ ]] || { tb_fail 'The Thunderbolt key is invalid'; return 1; }
|
||||
fi
|
||||
)
|
||||
|
||||
tb_approve() {
|
||||
local identity=$1 permanent=$2 initialize=${3:-false} current owner path status state
|
||||
state=$(tb_state) || return 1
|
||||
jq -e .enabled <<< "$state" >/dev/null || { tb_fail "Thunderbolt protection is disabled"; return 1; }
|
||||
current=$(tb_current "$identity") || return 1
|
||||
owner=$(jq -r .owner <<< "$current")
|
||||
path=$(jq -r .device.path <<< "$current")
|
||||
status=$(jq -r .device.Status <<< "$current")
|
||||
case "$status" in
|
||||
connected|auth-error)
|
||||
if [[ $permanent == "true" ]]; then
|
||||
if [[ $initialize == "true" ]]; then
|
||||
tb_prepare_key "$identity" || return 1
|
||||
else
|
||||
jq -e '(.device.Secure | not) or (.device.Stored and (.device.Key | IN("have","new")))' <<< "$current" >/dev/null || {
|
||||
tb_fail 'A secure key requires explicit device approval'; return 1;
|
||||
}
|
||||
fi
|
||||
fi
|
||||
current=$(tb_current "$identity") || return 1
|
||||
tb_bus call "$owner" "$path" "$TB_DEVICE" Authorize s '' >/dev/null || return 1 ;;
|
||||
authorized|authorized-newkey|authorized-secure) ;;
|
||||
*) tb_fail "Device authorization is still in progress; try again"; return 1 ;;
|
||||
esac
|
||||
current=$(tb_current "$identity") || return 1
|
||||
jq -e '.device.Status | IN("authorized","authorized-newkey","authorized-secure")' <<< "$current" >/dev/null || {
|
||||
tb_fail "Could not confirm device authorization; try again"; return 1;
|
||||
}
|
||||
if [[ $permanent == "true" ]]; then
|
||||
if ! jq -e .device.Stored <<< "$current" >/dev/null; then
|
||||
# Save enrollment intent before Bolt writes its record, so a lost reply
|
||||
# can be retried without confusing our record with a pre-existing one.
|
||||
state=$(jq --argjson device "$(jq -c .device <<< "$current")" \
|
||||
'.enrolled[$device.Uid] = ($device | {Uid,Name,Vendor})' <<< "$state") || return 1
|
||||
tb_json_write "$TB_STATE" <<< "$state" || return 1
|
||||
tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" EnrollDevice sss "$(jq -r .device.Uid <<< "$current")" manual '' >/dev/null || return 1
|
||||
fi
|
||||
current=$(tb_current "$identity") || return 1
|
||||
jq -e .device.Stored <<< "$current" >/dev/null || { tb_fail "Could not confirm saved device information; try again"; return 1; }
|
||||
jq -e '(.device.Secure | not) or (.device.Key | IN("have","new"))' <<< "$current" >/dev/null || {
|
||||
tb_fail 'Secure approval has no saved key. Safely disconnect, reconnect, and approve the device again.'; return 1;
|
||||
}
|
||||
jq --argjson device "$(jq -c .device <<< "$current")" '
|
||||
if .enrolled[$device.Uid] == ($device | {Uid,Name,Vendor}) then
|
||||
.enrolled[$device.Uid] = ($device | {Uid,Name,Vendor,StoreTime})
|
||||
else . end |
|
||||
.trusted[$device.Uid] = ($device | {Uid,Name,Vendor})' <<< "$state" |
|
||||
tb_json_write "$TB_STATE" || return 1
|
||||
fi
|
||||
tb_snapshot
|
||||
}
|
||||
|
||||
tb_boot_apply() {
|
||||
local saved=$1 inventory owner item uid policies acls value path
|
||||
inventory=$(tb_inventory) || return 1
|
||||
owner=$(jq -r .owner <<< "$inventory")
|
||||
jq -e --argjson saved "$saved" '
|
||||
(($saved.policies | keys) - [.stored[].Uid] | length == 0) and
|
||||
(($saved.acls | keys) - [.domains[] | select(.SysfsPath != "") | .Uid] | length == 0)' <<< "$inventory" >/dev/null || {
|
||||
tb_fail "Reconnect the original controllers and restore missing Bolt records before recovery"; return 1;
|
||||
}
|
||||
while IFS= read -r item; do
|
||||
uid=$(jq -r .Uid <<< "$item")
|
||||
value=$(jq -c --arg uid "$uid" '.policies[$uid] // empty' <<< "$saved") || return 1
|
||||
[[ -n $value ]] || continue
|
||||
tb_set "$owner" "$(jq -r .path <<< "$item")" "$TB_DEVICE" Policy "$value" || return 1
|
||||
done < <(jq -c '.stored[]' <<< "$inventory")
|
||||
# Changing a device policy can update firmware ACLs, so restore ACLs last.
|
||||
while IFS= read -r item; do
|
||||
uid=$(jq -r .Uid <<< "$item")
|
||||
value=$(jq -c --arg uid "$uid" '.acls[$uid] // empty' <<< "$saved") || return 1
|
||||
[[ -n $value ]] || continue
|
||||
[[ $(jq length <<< "$value") == "$(jq '.BootACL | length' <<< "$item")" ]] || {
|
||||
tb_fail "Firmware boot allowlist capacity changed"; return 1;
|
||||
}
|
||||
tb_set "$owner" "$(jq -r .path <<< "$item")" "$TB_DOMAIN" BootACL "$value" as || return 1
|
||||
done < <(jq -c '.domains[]' <<< "$inventory")
|
||||
}
|
||||
|
||||
tb_reconcile() {
|
||||
local inventory state owner generation device identity error='' saved
|
||||
state=$(tb_state) || return 1
|
||||
inventory=$(tb_read_inventory) || return 1
|
||||
owner=$(jq -r .owner <<< "$inventory")
|
||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
||||
if jq -e .boot_protection <<< "$state" >/dev/null; then
|
||||
saved=$(jq -c '{policies:(.stored | map({key:.Uid,value:"manual"}) | from_entries),
|
||||
acls:(.domains | map(select(.SysfsPath != "" and (.SecurityLevel == "user" or .SecurityLevel == "secure") and (.BootACL|length>0))) |
|
||||
map({key:.Uid,value:(.BootACL | map(""))}) | from_entries)}' <<< "$inventory") || return 1
|
||||
tb_boot_apply "$saved" || return 1
|
||||
fi
|
||||
while IFS= read -r device; do
|
||||
identity=$(tb_identity "$owner" "$generation" <<< "$device") || return 1
|
||||
if ! tb_approve "$identity" true >/dev/null; then
|
||||
error='A trusted Thunderbolt device could not be restored. Check the system service log.'
|
||||
fi
|
||||
done < <(jq -c --argjson state "$state" "$TB_TRUST_JQ"'.devices | sort_by(.SysfsPath | length)[] |
|
||||
select((.Status == "connected" or .Status == "auth-error") and (.AuthFlags | contains("nopcie") | not)) |
|
||||
# Never silently initialize a missing secure binding on a later connection.
|
||||
select(trusted($state))' <<< "$inventory")
|
||||
tb_snapshot "$error"
|
||||
}
|
||||
|
||||
tb_daemon() {
|
||||
local snapshot error
|
||||
install -d -m 755 "$TB_RUNTIME"
|
||||
cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation"
|
||||
chmod 644 "$TB_RUNTIME/generation"
|
||||
while [[ -f $TB_MARKER ]]; do
|
||||
(
|
||||
flock -x 9
|
||||
if snapshot=$(tb_reconcile 2> "$TB_RUNTIME/error"); then
|
||||
tb_json_write "$TB_RUNTIME/snapshot.json" 644 <<< "$snapshot"
|
||||
else
|
||||
cat "$TB_RUNTIME/error" >&2
|
||||
error=$(< "$TB_RUNTIME/error")
|
||||
jq -cn --arg error "$error" --argjson time "$(date +%s)" '{available:false,time:$time,error:$error}' |
|
||||
tb_json_write "$TB_RUNTIME/snapshot.json" 644
|
||||
fi
|
||||
) 9> "$TB_LOCK"
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
tb_public_snapshot() {
|
||||
local snapshot generation
|
||||
snapshot=$(cat "$TB_RUNTIME/snapshot.json") || return 1
|
||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
||||
jq -e --argjson now "$(date +%s)" --arg generation "$generation" \
|
||||
'.available and .generation == $generation and .time <= $now and $now - .time < 20' <<< "$snapshot" >/dev/null || {
|
||||
tb_fail 'Thunderbolt policy is unavailable or could not be verified.'; return 1;
|
||||
}
|
||||
printf '%s\n' "$snapshot"
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
# Session-side notification and terminal dialog functions.
|
||||
|
||||
tb_requests() {
|
||||
TB_REQUESTS="$HOME/.local/state/omarchy/thunderbolt-authorization/requests"
|
||||
mkdir -p -m 700 "$TB_REQUESTS"
|
||||
chmod 700 "${TB_REQUESTS%/*}" "$TB_REQUESTS"
|
||||
}
|
||||
|
||||
tb_safe_text() {
|
||||
jq -r 'gsub("[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]"; "?")'
|
||||
}
|
||||
|
||||
tb_notify() {
|
||||
local token=$1 retry=${2:-false} title='Thunderbolt accessory blocked' description
|
||||
description='Click to review it. Keep it blocked unless you recognize the device.'
|
||||
if [[ $retry == "true" ]]; then
|
||||
title='Thunderbolt approval needs retry'
|
||||
description='The last approval could not be confirmed. Click to retry it.'
|
||||
fi
|
||||
omarchy-notification-wait 1 >/dev/null || return 1
|
||||
timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical --glyph '' \
|
||||
"$title" "$description" --exec omarchy-launch-floating-terminal-with-presentation \
|
||||
omarchy-thunderbolt-authorization-review "$token"
|
||||
}
|
||||
|
||||
tb_attention() {
|
||||
omarchy-notification-wait 1 >/dev/null || return 1
|
||||
timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical \
|
||||
'Thunderbolt protection needs attention' 'The approval or firmware policy could not be confirmed. Click for details.' \
|
||||
--exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization status
|
||||
}
|
||||
|
||||
tb_setup_notice() {
|
||||
omarchy-notification-wait 1 >/dev/null || return 1
|
||||
timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical \
|
||||
'Finish Thunderbolt protection setup' 'A connected accessory needs secure enrollment. Protection is not enabled. Click to finish safely.' \
|
||||
--exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization
|
||||
}
|
||||
|
||||
tb_token() {
|
||||
local digest
|
||||
digest=$(jq -cS --arg watch "$2" '{watch:$watch,identity:.}' <<< "$1" | sha256sum) || return 1
|
||||
printf 'request-%s\n' "${digest%% *}"
|
||||
}
|
||||
|
||||
tb_request_read() {
|
||||
[[ -f $1 && ! -L $1 ]] || return 1
|
||||
jq -ce 'select(.identity | type == "object")' "$1"
|
||||
}
|
||||
|
||||
tb_deliver() {
|
||||
local path=$1 request token
|
||||
request=$(tb_request_read "$path") || return 1
|
||||
if ! jq -e '.notified // false' <<< "$request" >/dev/null; then
|
||||
token=${path##*/}
|
||||
if tb_notify "${token%.json}" "$(jq -r '.approval != null' <<< "$request")"; then
|
||||
jq '.notified=true' <<< "$request" | tb_json_write "$path"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
tb_request_apply() {
|
||||
# The watcher has no terminal. Polkit permits this one fixed helper for an
|
||||
# active local administrator; it cannot dispatch general setup operations.
|
||||
pkexec --disable-internal-agent /usr/bin/omarchy-thunderbolt-authorization-approve "$1" "$2"
|
||||
}
|
||||
|
||||
tb_request_process() {
|
||||
local path=$1 request identity permanent result error_file error
|
||||
request=$(tb_request_read "$path") || return 1
|
||||
if jq -e '.pending // false' <<< "$request" >/dev/null; then
|
||||
identity=$(jq -c .identity <<< "$request")
|
||||
permanent=$(jq -r '.approval == "Always allow this device"' <<< "$request")
|
||||
error_file=$(mktemp "$TB_REQUESTS/.error.XXXXXX") || return 1
|
||||
if result=$(tb_request_apply "$permanent" "$identity" 2> "$error_file") &&
|
||||
jq -e --argjson identity "$identity" --argjson permanent "$permanent" \
|
||||
'any(.devices[]; .identity==$identity and (.status | IN("authorized","authorized-newkey","authorized-secure")) and
|
||||
($permanent == false or .trusted))' <<< "$result" >/dev/null; then
|
||||
jq --argjson result "$result" '.pending=false | .result=$result | del(.error)' <<< "$request" |
|
||||
tb_json_write "$path" || return 1
|
||||
else
|
||||
error=$(cat "$error_file")
|
||||
[[ -n $error ]] || error='Could not confirm approval. Click the retry notification.'
|
||||
jq --arg error "$error" '.pending=false | .notified=false | .error=$error' <<< "$request" |
|
||||
tb_json_write "$path" || return 1
|
||||
fi
|
||||
rm -f -- "$error_file"
|
||||
fi
|
||||
tb_deliver "$path"
|
||||
}
|
||||
|
||||
tb_scan() {
|
||||
local watch=$1 snapshot path request device identity token existing
|
||||
snapshot=$(tb_public_snapshot) || return 1
|
||||
(
|
||||
flock -x 9
|
||||
for path in "$TB_REQUESTS"/request-*.json; do
|
||||
[[ -e $path || -L $path ]] || continue
|
||||
if ! request=$(tb_request_read "$path"); then
|
||||
rm -f -- "$path"
|
||||
continue
|
||||
fi
|
||||
identity=$(jq -c .identity <<< "$request")
|
||||
device=$(jq -c --argjson identity "$identity" '.devices[] | select(.identity==$identity)' <<< "$snapshot") || exit 1
|
||||
if [[ -z $device ]] || { ! jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null &&
|
||||
! jq -e '.approval != null' <<< "$request" >/dev/null; }; then
|
||||
rm -- "$path"
|
||||
continue
|
||||
fi
|
||||
if [[ $(jq -r .watch <<< "$request") != "$watch" ]]; then
|
||||
rm -- "$path"
|
||||
# Resume an interrupted attempt only for the same exact connection.
|
||||
jq -e '.approval != null and .result == null' <<< "$request" >/dev/null || continue
|
||||
token=$(tb_token "$identity" "$watch") || exit 1
|
||||
path="$TB_REQUESTS/$token.json"
|
||||
request=$(jq -c --arg watch "$watch" '.watch=$watch | .notified=false' <<< "$request") || exit 1
|
||||
tb_json_write "$path" <<< "$request" || exit 1
|
||||
fi
|
||||
tb_request_process "$path" || true
|
||||
done
|
||||
snapshot=$(tb_public_snapshot) || exit 1
|
||||
while IFS= read -r device; do
|
||||
identity=$(jq -c .identity <<< "$device")
|
||||
token=$(tb_token "$identity" "$watch") || exit 1
|
||||
path="$TB_REQUESTS/$token.json"
|
||||
if [[ ! -f $path ]]; then
|
||||
jq -cn --argjson identity "$identity" --arg watch "$watch" \
|
||||
'{identity:$identity,watch:$watch,notified:false}' | tb_json_write "$path" || exit 1
|
||||
if ! tb_deliver "$path"; then
|
||||
rm -f -- "$path"
|
||||
fi
|
||||
fi
|
||||
done < <(jq -c '.devices[] | select((.trusted | not) and (.status | IN("connected","auth-error")) and
|
||||
(.flags | contains("nopcie") | not))' <<< "$snapshot")
|
||||
) 9> "$TB_REQUESTS/.lock" || return 1
|
||||
printf '%s\n' "$snapshot"
|
||||
}
|
||||
|
||||
tb_watch() {
|
||||
local watch snapshot warning warned='' failures=0
|
||||
tb_requests
|
||||
watch=$(cat /proc/sys/kernel/random/uuid)
|
||||
while true; do
|
||||
if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then
|
||||
if [[ $warned != "pending" ]] && tb_setup_notice; then warned=pending; fi
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
if snapshot=$(tb_scan "$watch"); then
|
||||
failures=0
|
||||
warning=$(jq -c '[.generation,.warnings,.error]' <<< "$snapshot")
|
||||
if jq -e '(.warnings | length > 0) or .error != ""' <<< "$snapshot" >/dev/null; then
|
||||
if [[ $warning != "$warned" ]] && tb_attention; then
|
||||
warned=$warning
|
||||
fi
|
||||
else
|
||||
warned=''
|
||||
fi
|
||||
else
|
||||
failures=$((failures + 1))
|
||||
if (( failures >= 3 )) && [[ $warned != "unavailable" ]] && tb_attention; then
|
||||
warned=unavailable
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
tb_find_device() {
|
||||
local snapshot
|
||||
snapshot=$(tb_public_snapshot) || return 1
|
||||
jq -ce --argjson identity "$1" '.devices[] | select(.identity==$identity)' <<< "$snapshot" ||
|
||||
tb_fail 'That Thunderbolt device was removed or changed. Open its latest notification.'
|
||||
}
|
||||
|
||||
tb_review() {
|
||||
local token=$1 path request identity device choice latest index
|
||||
[[ $token =~ ^request-[0-9a-f]{64}$ ]] || { tb_fail 'Invalid or expired Thunderbolt request'; return 1; }
|
||||
tb_requests
|
||||
path="$TB_REQUESTS/$token.json"
|
||||
request=$(tb_request_read "$path") || return 1
|
||||
identity=$(jq -c .identity <<< "$request")
|
||||
device=$(tb_find_device "$identity") || return 1
|
||||
choice=$(jq -r '.approval // empty' <<< "$request")
|
||||
if [[ -z $choice ]]; then
|
||||
jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null || { tb_fail 'This device is no longer blocked'; return 1; }
|
||||
gum style --foreground 212 --bold 'Blocked Thunderbolt accessory'
|
||||
printf '\nThese details come from the device and can be forged:\n'
|
||||
for index in Name Vendor Uid; do
|
||||
printf '%s: ' "$index"
|
||||
jq --arg key "$index" '.[$key]' <<< "$identity" | tb_safe_text
|
||||
done
|
||||
printf '\nApproval gives this accessory access to its PCIe drivers.\n\n'
|
||||
choice=$(gum choose 'Allow once' 'Always allow this device' 'Keep blocked') || return 0
|
||||
fi
|
||||
case "$choice" in
|
||||
'Allow once'|'Always allow this device'|'Keep blocked') ;;
|
||||
*) tb_fail 'Invalid approval choice'; return 1 ;;
|
||||
esac
|
||||
(
|
||||
flock -x 9
|
||||
latest=$(tb_request_read "$path") || exit 1
|
||||
[[ $latest == "$request" ]] || { tb_fail 'This request changed. Open the latest notification.'; exit 1; }
|
||||
device=$(tb_find_device "$identity") || exit 1
|
||||
if [[ $choice == "Keep blocked" ]]; then
|
||||
printf 'Thunderbolt accessory remains blocked.\n'
|
||||
else
|
||||
if ! jq -e '.approval != null' <<< "$request" >/dev/null; then
|
||||
jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null || exit 1
|
||||
fi
|
||||
jq --arg choice "$choice" '.approval=$choice | .pending=true | del(.error,.result)' <<< "$request" |
|
||||
tb_json_write "$path"
|
||||
fi
|
||||
) 9> "$TB_REQUESTS/.lock" || return 1
|
||||
if [[ $choice != "Keep blocked" ]]; then
|
||||
for ((index=0; index<60; index++)); do
|
||||
latest=$(tb_request_read "$path") || return 1
|
||||
if jq -e '.result != null' <<< "$latest" >/dev/null; then
|
||||
rm -- "$path"
|
||||
if [[ $choice == "Always allow this device" ]]; then
|
||||
printf 'Thunderbolt accessory trusted.\n'
|
||||
else
|
||||
printf 'Thunderbolt accessory allowed for this connection.\n'
|
||||
fi
|
||||
return 0
|
||||
elif jq -e '.error != null' <<< "$latest" >/dev/null; then
|
||||
jq .error <<< "$latest" | tb_safe_text >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
tb_fail 'Approval is still pending. The watcher will retain and retry the request.'
|
||||
fi
|
||||
}
|
||||
|
||||
tb_status() {
|
||||
local snapshot
|
||||
if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then
|
||||
printf 'Thunderbolt protection is deferred. Previous Bolt behavior remains active.\nKeep a keyboard/input device available independently of Thunderbolt. Safely disconnect the Thunderbolt accessories, then run Setup > Security > Thunderbolt. Reconnect each accessory and choose Always allow to save its secure key.\n'
|
||||
return 0
|
||||
fi
|
||||
snapshot=$(tb_public_snapshot) || return 1
|
||||
printf 'Thunderbolt device approval is enabled.\nTrusted accessories reconnect automatically; new accessories require approval.\n'
|
||||
if jq -e '.domains | length == 0' <<< "$snapshot" >/dev/null; then
|
||||
printf 'No Thunderbolt controller is currently visible. Its firmware policy will be checked when it appears.\n'
|
||||
fi
|
||||
jq '.warnings[], (.error | select(. != ""))' <<< "$snapshot" | while IFS= read -r warning; do
|
||||
printf 'Attention: '
|
||||
tb_safe_text <<< "$warning"
|
||||
done
|
||||
printf 'Firmware boot access is separate; see the Security manual before relying on pre-boot protection.\n'
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
# Root setup functions. The caller sources thunderbolt-policy.sh first.
|
||||
|
||||
tb_config_authmode() {
|
||||
local path=$1
|
||||
if [[ -f $path ]]; then
|
||||
awk '
|
||||
/^[[:space:]]*\[/ { section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/) }
|
||||
section && /^[[:space:]]*AuthMode[[:space:]]*=/ {
|
||||
sub(/^[^=]*=[[:space:]]*/, ""); sub(/[[:space:]]*$/, ""); mode=$0
|
||||
}
|
||||
END { print mode == "" ? "enabled" : mode }
|
||||
' "$path"
|
||||
else
|
||||
printf 'enabled\n'
|
||||
fi
|
||||
}
|
||||
|
||||
tb_config_write() {
|
||||
local path=$1 mode=$2 temporary
|
||||
[[ $mode == "enabled" || $mode == "disabled" ]] || return 1
|
||||
install -d -m 700 "${path%/*}" || return 1
|
||||
temporary=$(mktemp "${path%/*}/.config.XXXXXX") || return 1
|
||||
if awk -v mode="$mode" '
|
||||
/^[[:space:]]*\[/ {
|
||||
section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/)
|
||||
if (section) { found=1; print; print "AuthMode = " mode; next }
|
||||
}
|
||||
section && /^[[:space:]]*AuthMode[[:space:]]*=/ { next }
|
||||
{ print }
|
||||
END { if (!found) print "\n[config]\nAuthMode = " mode }
|
||||
' "$(if [[ -e $path ]]; then printf '%s' "$path"; else printf /dev/null; fi)" > "$temporary" &&
|
||||
sync "$temporary" && mv -fT -- "$temporary" "$path" && sync "${path%/*}"; then
|
||||
return 0
|
||||
else
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
tb_guard() {
|
||||
if [[ -f $TB_MARKER ]]; then
|
||||
tb_state | jq -e .enabled >/dev/null || { tb_fail 'Thunderbolt policy and enable marker disagree'; return 1; }
|
||||
tb_config_write "$TB_CONFIG" disabled
|
||||
fi
|
||||
}
|
||||
|
||||
tb_capture() {
|
||||
local path uid name vendor trusted='{}'
|
||||
for path in "$TB_SYSFS"/*; do
|
||||
[[ -f $path/unique_id && $path != *-0 ]] || continue
|
||||
uid=$(cat "$path/unique_id") || return 1
|
||||
# Bolt uses the numeric attribute when a descriptive name is absent.
|
||||
if [[ -e $path/device_name ]]; then
|
||||
name=$(cat "$path/device_name") || return 1
|
||||
else
|
||||
name=$(cat "$path/device") || return 1
|
||||
fi
|
||||
if [[ -e $path/vendor_name ]]; then
|
||||
vendor=$(cat "$path/vendor_name") || return 1
|
||||
else
|
||||
vendor=$(cat "$path/vendor") || return 1
|
||||
fi
|
||||
[[ -n $uid ]] || { tb_fail 'A connected Thunderbolt device has no identity'; return 1; }
|
||||
trusted=$(jq -c --arg uid "$uid" --arg name "$name" --arg vendor "$vendor" \
|
||||
'.[$uid] = {Uid:$uid,Name:$name,Vendor:$vendor}' <<< "$trusted") || return 1
|
||||
done
|
||||
printf '%s\n' "$trusted"
|
||||
}
|
||||
|
||||
tb_capture_ready() {
|
||||
local fresh=${1:-false} offline=${2:-false} path uid inode inventory=''
|
||||
# Do not relax an active policy, or capture new devices when re-enabling an
|
||||
# existing policy. This preflight only guards the initial trust inventory.
|
||||
[[ ! -f $TB_MARKER ]] || return 0
|
||||
if [[ -f $TB_STATE && ! -f $TB_PENDING && $fresh != "true" ]]; then
|
||||
tb_state | jq -e '.enrollment_pending // false' >/dev/null || return 0
|
||||
fi
|
||||
for path in "$TB_SYSFS"/*; do
|
||||
[[ -f $path/unique_id && $path != *-0 && -e $path/key ]] || continue
|
||||
uid=$(cat "$path/unique_id") || return 1
|
||||
[[ $uid =~ ^[0-9a-fA-F-]{36}$ ]] || { tb_fail 'A connected Thunderbolt device has an invalid identity'; return 1; }
|
||||
path=$(realpath -e -- "$path") || return 1
|
||||
inode=$(stat -Lc '%i' -- "$path") || return 1
|
||||
# A key file alone can be orphaned after a failed Bolt record write. Ask
|
||||
# Bolt whether this exact connection has a stored record and key. Offline
|
||||
# prepare must not accept the live installer's daemon as the target store.
|
||||
if [[ $offline != "true" && -z $inventory ]]; then
|
||||
inventory=$(tb_inventory) || inventory='{}'
|
||||
fi
|
||||
if [[ $offline == "true" ]] || ! jq -e --arg uid "$uid" --arg path "$path" --arg inode "$inode" \
|
||||
'any(.devices[]?; .Uid==$uid and .SysfsPath==$path and .inode==$inode and .Stored and (.Key | IN("have","new")))' <<< "$inventory" >/dev/null; then
|
||||
# An active keyless dock cannot receive a key without disconnecting it.
|
||||
# Preserve input/storage and finish installation with setup deferred.
|
||||
install -d -m 755 "${TB_PENDING%/*}" || return 1
|
||||
install -m 644 /dev/null "$TB_PENDING" || return 1
|
||||
printf 'Thunderbolt protection is deferred: safely disconnect accessories without saved secure keys, keep independent input available, then run Setup > Security > Thunderbolt.\n' >&2
|
||||
return 2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
tb_prepare() {
|
||||
local fresh=${1:-false} state='{}' trusted mode
|
||||
if [[ -f $TB_STATE ]]; then
|
||||
state=$(tb_state) || return 1
|
||||
fi
|
||||
if [[ ! -f $TB_STATE || -f $TB_PENDING || $fresh == "true" ]] || jq -e '.enrollment_pending // false' <<< "$state" >/dev/null; then
|
||||
trusted=$(tb_capture) || return 1
|
||||
mode=$(jq -r '.original_authmode // empty' <<< "$state") || return 1
|
||||
[[ -n $mode ]] || mode=$(tb_config_authmode "$TB_CONFIG") || return 1
|
||||
[[ $mode == "enabled" || $mode == "disabled" ]] || { tb_fail 'Unsupported existing Bolt authorization mode'; return 1; }
|
||||
state=$(jq -c --arg mode "$mode" --argjson trusted "$trusted" \
|
||||
'del(.enrollment_pending) | . + {version:1,enabled:true,trusted:$trusted,original_authmode:$mode}' <<< "$state") || return 1
|
||||
else
|
||||
mode=$(jq -r .original_authmode <<< "$state") || return 1
|
||||
[[ $mode == "enabled" || $mode == "disabled" ]] || { tb_fail 'Unsupported existing Bolt authorization mode'; return 1; }
|
||||
state=$(jq -c '.enabled=true' <<< "$state") || return 1
|
||||
fi
|
||||
install -d -m 700 "${TB_STATE%/*}" || return 1
|
||||
tb_json_write "$TB_STATE" <<< "$state" || return 1
|
||||
tb_config_write "$TB_CONFIG" disabled || return 1
|
||||
install -d -m 755 "${TB_MARKER%/*}" || return 1
|
||||
install -m 644 /dev/null "$TB_MARKER"
|
||||
}
|
||||
|
||||
tb_boot_saved() {
|
||||
jq -c '{policies:(.stored | map({key:.Uid,value:.Policy}) | from_entries),
|
||||
acls:(.domains | map({key:.Uid,value:.BootACL}) | from_entries)}'
|
||||
}
|
||||
|
||||
tb_boot_change() {
|
||||
local enabled=$1 state inventory saved after desired backup="${TB_STATE%/*}/boot-recovery.json"
|
||||
state=$(tb_state) || return 1
|
||||
jq -e .enabled <<< "$state" >/dev/null || { tb_fail 'Enable Thunderbolt device authorization first'; return 1; }
|
||||
if [[ $enabled == "false" ]] && ! jq -e .boot_protection <<< "$state" >/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
[[ ! -e $backup ]] || { tb_fail 'Recover the previous firmware boot-access change before retrying'; return 1; }
|
||||
inventory=$(tb_read_inventory) || return 1
|
||||
jq -e '.domains | length > 0 and all(.[]; .SysfsPath != "")' <<< "$inventory" >/dev/null || {
|
||||
tb_fail 'Connect every Thunderbolt controller before changing firmware boot access'; return 1;
|
||||
}
|
||||
saved=$(tb_boot_saved <<< "$inventory" | jq -c --argjson state "$state" '. + {state:$state}') || return 1
|
||||
if [[ $enabled == "true" ]]; then
|
||||
jq -e 'all(.domains[]; (.SecurityLevel == "user" or .SecurityLevel == "secure") and (.BootACL|length > 0))' <<< "$inventory" >/dev/null || {
|
||||
tb_fail 'This controller has no supported firmware boot allowlist. Disable Thunderbolt pre-boot/PCIe boot support in firmware settings; Omarchy cannot verify that setting.'; return 1;
|
||||
}
|
||||
desired=$(jq -c '{policies:(.stored | map({key:.Uid,value:"manual"}) | from_entries),
|
||||
acls:(.domains | map({key:.Uid,value:(.BootACL | map(""))}) | from_entries)}' <<< "$inventory") || return 1
|
||||
after=$(jq -c --argjson saved "$saved" --argjson devices "$(jq -c .devices <<< "$inventory")" '
|
||||
if .boot_protection then . else .boot_original=($saved | {policies,acls}) end |
|
||||
.boot_protection=true | .trusted += ($devices | map({key:.Uid,value:{Uid,Name,Vendor}}) | from_entries)' <<< "$state") || return 1
|
||||
else
|
||||
desired=$(jq -ce .boot_original <<< "$state") || return 1
|
||||
after=$(jq -c 'del(.boot_original) | .boot_protection=false' <<< "$state") || return 1
|
||||
fi
|
||||
tb_json_write "$backup" <<< "$saved" || return 1
|
||||
if tb_boot_apply "$desired" && tb_json_write "$TB_STATE" <<< "$after"; then
|
||||
rm -- "$backup"
|
||||
else
|
||||
if tb_boot_apply "$saved" && tb_json_write "$TB_STATE" <<< "$state"; then
|
||||
rm -- "$backup" || return 1
|
||||
tb_fail 'Firmware change failed; previous state restored'
|
||||
else
|
||||
tb_fail "Firmware change failed; recovery is incomplete. Recovery data retained at $backup"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
tb_boot_recover() {
|
||||
local backup="${TB_STATE%/*}/boot-recovery.json" saved
|
||||
saved=$(jq -ce . "$backup") || return 1
|
||||
tb_boot_apply "$saved" || return 1
|
||||
jq .state <<< "$saved" | tb_json_write "$TB_STATE" || return 1
|
||||
rm -- "$backup"
|
||||
}
|
||||
|
||||
tb_unit_state() {
|
||||
local properties
|
||||
properties=$(systemctl show -p ActiveState -p UnitFileState "$1") || return 1
|
||||
jq -Rn '[inputs | split("=") | {key:.[0],value:.[1]}] | from_entries |
|
||||
{active:(.ActiveState == "active"),enabled:(.UnitFileState == "enabled")}' <<< "$properties"
|
||||
}
|
||||
|
||||
tb_handoff_policies() {
|
||||
local state=$1
|
||||
# StoreTime distinguishes a record recreated outside Omarchy. A completed
|
||||
# enrollment and matching consent are both required, including offline
|
||||
# accessories. Never promote somebody else's manual policy.
|
||||
jq -c --argjson state "$state" '{policies:([.stored[] |
|
||||
select(.Policy == "manual" and .StoreTime > 0 and
|
||||
$state.enrolled[.Uid] == {Uid,Name,Vendor,StoreTime} and
|
||||
$state.trusted[.Uid] == {Uid,Name,Vendor}) |
|
||||
{key:.Uid,value:"auto"}] | from_entries),acls:{}}'
|
||||
}
|
||||
|
||||
tb_config_checkpoint() {
|
||||
local backup="${TB_STATE%/*}/setup-recovery.json" path original files='{}' units='{}' unit value saved inventory data mode state
|
||||
[[ ! -e $backup && ! -e ${TB_STATE%/*}/boot-recovery.json ]] || {
|
||||
tb_fail 'A previous setup change needs recovery before continuing'; return 1;
|
||||
}
|
||||
for path in "$TB_STATE" "$TB_CONFIG" "$TB_MARKER"; do
|
||||
original=null
|
||||
if [[ -e $path ]]; then
|
||||
data=$(base64 -w0 -- "$path") || return 1
|
||||
mode=$(stat -c '%a' -- "$path") || return 1
|
||||
original=$(jq -cn --arg data "$data" --arg mode "$mode" '{data:$data,mode:$mode}') || return 1
|
||||
fi
|
||||
files=$(jq -c --arg path "$path" --argjson original "$original" '.[$path]=$original' <<< "$files") || return 1
|
||||
done
|
||||
for unit in bolt.service "$TB_SERVICE"; do
|
||||
value=$(tb_unit_state "$unit") || return 1
|
||||
units=$(jq -c --arg unit "$unit" --argjson value "$value" '.[$unit]=$value' <<< "$units") || return 1
|
||||
done
|
||||
saved=$(jq -cn --argjson files "$files" --argjson units "$units" '{files:$files,units:$units}') || return 1
|
||||
state='{}'
|
||||
if [[ -f $TB_STATE ]]; then state=$(tb_state) || return 1; fi
|
||||
# A handoff to auto also changes Bolt's firmware allowlists. Checkpoint the
|
||||
# policies and ACLs even when the optional boot protection was never used.
|
||||
if jq -e '.enabled and (.boot_protection or ((.enrolled // {}) | length > 0))' <<< "$state" >/dev/null; then
|
||||
inventory=$(tb_read_inventory) || return 1
|
||||
jq -e 'all(.domains[]; .SysfsPath != "")' <<< "$inventory" >/dev/null || {
|
||||
tb_fail 'Reconnect every Thunderbolt controller before changing device policies'; return 1;
|
||||
}
|
||||
saved=$(jq -c --argjson firmware "$(tb_boot_saved <<< "$inventory")" '.firmware=$firmware' <<< "$saved") || return 1
|
||||
fi
|
||||
install -d -m 700 "${TB_STATE%/*}" || return 1
|
||||
tb_json_write "$backup" <<< "$saved"
|
||||
}
|
||||
|
||||
tb_config_restore() {
|
||||
local backup="${TB_STATE%/*}/setup-recovery.json" saved path original temporary unit
|
||||
saved=$(jq -ce . "$backup") || return 1
|
||||
systemctl stop "$TB_SERVICE" bolt.service || return 1
|
||||
for path in "$TB_STATE" "$TB_CONFIG" "$TB_MARKER"; do
|
||||
original=$(jq -c --arg path "$path" '.files[$path]' <<< "$saved") || return 1
|
||||
if [[ $original == "null" ]]; then
|
||||
rm -f -- "$path" || return 1
|
||||
else
|
||||
mkdir -p -- "${path%/*}" || return 1
|
||||
temporary=$(mktemp "${path%/*}/.restore.XXXXXX") || return 1
|
||||
if jq -r .data <<< "$original" | base64 -d > "$temporary" &&
|
||||
chmod "$(jq -r .mode <<< "$original")" "$temporary" && sync "$temporary" &&
|
||||
mv -fT -- "$temporary" "$path" && sync "${path%/*}"; then
|
||||
:
|
||||
else
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if jq -e .firmware <<< "$saved" >/dev/null; then
|
||||
systemctl start bolt.service || return 1
|
||||
tb_boot_apply "$(jq -c .firmware <<< "$saved")" || return 1
|
||||
fi
|
||||
rm -f -- "${TB_STATE%/*}/boot-recovery.json" || return 1
|
||||
if jq -e --arg unit "$TB_SERVICE" '.units[$unit].enabled' <<< "$saved" >/dev/null; then
|
||||
systemctl enable "$TB_SERVICE" || return 1
|
||||
else
|
||||
systemctl disable "$TB_SERVICE" || return 1
|
||||
fi
|
||||
for unit in bolt.service "$TB_SERVICE"; do
|
||||
if jq -e --arg unit "$unit" '.units[$unit].active' <<< "$saved" >/dev/null; then
|
||||
systemctl start "$unit" || return 1
|
||||
else
|
||||
systemctl stop "$unit" || return 1
|
||||
fi
|
||||
done
|
||||
rm -- "$backup"
|
||||
}
|
||||
|
||||
tb_transaction() {
|
||||
tb_config_checkpoint || return 1
|
||||
if "$@"; then
|
||||
rm -- "${TB_STATE%/*}/setup-recovery.json"
|
||||
else
|
||||
if tb_config_restore; then
|
||||
tb_fail 'Thunderbolt setup failed; previous configuration restored'
|
||||
else
|
||||
tb_fail 'Thunderbolt setup failed; restoration is incomplete. Run the setup recovery command.'
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
tb_enable() {
|
||||
systemctl stop "$TB_SERVICE" bolt.service || return 1
|
||||
tb_prepare "${1:-false}" || return 1
|
||||
systemctl daemon-reload || return 1
|
||||
systemctl start bolt.service || return 1
|
||||
tb_read_inventory >/dev/null || return 1
|
||||
systemctl enable --now "$TB_SERVICE"
|
||||
}
|
||||
|
||||
tb_disable() {
|
||||
local state mode policies
|
||||
systemctl stop "$TB_SERVICE" || return 1
|
||||
tb_boot_change false || return 1
|
||||
state=$(tb_state) || return 1
|
||||
mode=$(jq -er .original_authmode <<< "$state") || return 1
|
||||
policies=$(tb_read_inventory | tb_handoff_policies "$state") || return 1
|
||||
tb_boot_apply "$policies" || return 1
|
||||
systemctl stop bolt.service || return 1
|
||||
tb_config_write "$TB_CONFIG" "$mode" || return 1
|
||||
rm -f -- "$TB_MARKER" || return 1
|
||||
jq '.enabled=false | del(.enrolled)' <<< "$state" | tb_json_write "$TB_STATE" || return 1
|
||||
systemctl disable "$TB_SERVICE" || return 1
|
||||
systemctl start bolt.service || return 1
|
||||
tb_inventory | jq -e --arg mode "$mode" --argjson policies "$policies" '
|
||||
.manager.AuthMode==$mode and
|
||||
(([.stored[] | {key:.Uid,value:.Policy}] | from_entries) as $actual |
|
||||
all($policies.policies | to_entries[]; $actual[.key]==.value))' >/dev/null
|
||||
}
|
||||
|
||||
tb_cancel_pending() {
|
||||
local state
|
||||
if [[ -f $TB_STATE ]]; then
|
||||
state=$(tb_state) || return 1
|
||||
else
|
||||
state=$(jq -cn --arg mode "$(tb_config_authmode "$TB_CONFIG")" \
|
||||
'{version:1,enabled:false,trusted:{},enrollment_pending:true,original_authmode:$mode}') || return 1
|
||||
fi
|
||||
install -d -m 700 "${TB_STATE%/*}" || return 1
|
||||
jq '.enabled=false | .enrollment_pending=true' <<< "$state" | tb_json_write "$TB_STATE" || return 1
|
||||
rm -- "$TB_PENDING"
|
||||
}
|
||||
|
||||
tb_reset_root() {
|
||||
local root directory
|
||||
root=$(realpath -e -- "$1") || return 1
|
||||
[[ $root != "/" && -d $root ]] || { tb_fail 'Refusing to reset the running system'; return 1; }
|
||||
[[ -e $root$TB_MARKER || -e $root$TB_PENDING || -d $root${TB_STATE%/*} ]] || return 0
|
||||
# Resolve fixed paths inside the cloned root, never through an outside link.
|
||||
for directory in etc etc/omarchy var var/lib var/lib/omarchy var/lib/boltd; do
|
||||
[[ ! -L $root/$directory ]] || { tb_fail 'Refusing a linked factory policy directory'; return 1; }
|
||||
done
|
||||
systemctl --root="$root" disable "$TB_SERVICE" || return 1
|
||||
rm -f -- "$root$TB_MARKER" || return 1
|
||||
rm -f -- "$root$TB_PENDING" || return 1
|
||||
rm -rf -- "$root${TB_STATE%/*}" || return 1
|
||||
for directory in devices keys domains; do
|
||||
rm -rf -- "$root/var/lib/boltd/$directory" || return 1
|
||||
done
|
||||
tb_config_write "$root/var/lib/boltd/boltd.conf" enabled
|
||||
}
|
||||
|
||||
tb_admin() {
|
||||
local action=${1:-} status=0 state
|
||||
if [[ $action != "guard" ]]; then
|
||||
exec 9> "$TB_LOCK"
|
||||
flock -x 9 || return 1
|
||||
fi
|
||||
case "$action" in
|
||||
prepare|enable|owner|migrate)
|
||||
# A deferred setup needs explicit consent after the accessories have
|
||||
# been disconnected; a later migration must not silently finish it.
|
||||
if [[ -f $TB_PENDING && $action != "enable" ]]; then return 0; fi
|
||||
if [[ $action == "migrate" && -f $TB_STATE ]] && tb_state | jq -e '.enabled == false' >/dev/null; then return 0; fi
|
||||
if tb_capture_ready "$(if [[ $action == "owner" ]]; then echo true; else echo false; fi)" \
|
||||
"$(if [[ $action == "prepare" ]]; then echo true; else echo false; fi)"; then
|
||||
:
|
||||
else
|
||||
status=$?
|
||||
if (( status == 2 )); then return 0; else return "$status"; fi
|
||||
fi ;;
|
||||
esac
|
||||
case "$action" in
|
||||
guard) tb_guard ;;
|
||||
prepare) tb_prepare && rm -f -- "$TB_PENDING" ;;
|
||||
enable) tb_transaction tb_enable && rm -f -- "$TB_PENDING" ;;
|
||||
owner) tb_transaction tb_enable true && rm -f -- "$TB_PENDING" ;;
|
||||
migrate)
|
||||
state='{"enabled":true}'
|
||||
if [[ -f $TB_STATE ]]; then state=$(tb_state) || return 1; fi
|
||||
if jq -e .enabled <<< "$state" >/dev/null; then
|
||||
tb_transaction tb_enable
|
||||
fi ;;
|
||||
disable)
|
||||
if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then
|
||||
tb_cancel_pending
|
||||
else
|
||||
tb_transaction tb_disable
|
||||
fi ;;
|
||||
recover) tb_config_restore ;;
|
||||
boot-enable|boot-disable|boot-recover)
|
||||
systemctl stop "$TB_SERVICE" || return 1
|
||||
case "$action" in
|
||||
boot-enable) tb_boot_change true || status=$? ;;
|
||||
boot-disable) tb_boot_change false || status=$? ;;
|
||||
boot-recover) tb_boot_recover || status=$? ;;
|
||||
esac
|
||||
systemctl start "$TB_SERVICE" || return 1
|
||||
return "$status" ;;
|
||||
reset-root) [[ $# == 2 ]] && tb_reset_root "$2" ;;
|
||||
*) tb_fail 'Unknown Thunderbolt setup action' ;;
|
||||
esac
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# Device identity is portable; the full live rule is still used when granting
|
||||
# access to the connection shown in the approval dialog.
|
||||
usb_authorization_portable_rule() {
|
||||
local rule="$1" token="" char quoted=0 escaped=0 index
|
||||
local -a tokens=() portable=()
|
||||
|
||||
# Split canonical USBGuard output without evaluating device-controlled text.
|
||||
# An attribute name inside a quoted name/serial is never a rule attribute.
|
||||
for (( index=0; index<${#rule}; index++ )); do
|
||||
char=${rule:index:1}
|
||||
if (( escaped )); then
|
||||
token+="$char"
|
||||
escaped=0
|
||||
elif (( quoted )) && [[ $char == '\' ]]; then
|
||||
token+="$char"
|
||||
escaped=1
|
||||
elif [[ $char == '"' ]]; then
|
||||
token+="$char"
|
||||
quoted=$((1 - quoted))
|
||||
elif (( ! quoted )) && [[ $char == " " ]]; then
|
||||
if [[ -n $token ]]; then tokens+=("$token"); token=""; fi
|
||||
else
|
||||
token+="$char"
|
||||
fi
|
||||
done
|
||||
(( ! quoted && ! escaped )) || return 1
|
||||
if [[ -n $token ]]; then tokens+=("$token"); fi
|
||||
[[ ${tokens[0]:-} == "allow" || ${tokens[0]:-} == "block" ]] || return 1
|
||||
portable=(allow)
|
||||
for (( index=1; index<${#tokens[@]}; index++ )); do
|
||||
case "${tokens[index]}" in
|
||||
via-port | parent-hash)
|
||||
(( ++index < ${#tokens[@]} )) || return 1
|
||||
[[ ${tokens[index]} == \"*\" ]] || return 1
|
||||
;;
|
||||
label) return 1 ;;
|
||||
*) portable+=("${tokens[index]}") ;;
|
||||
esac
|
||||
done
|
||||
local IFS=" "
|
||||
printf '%s label "omarchy-usb-authorization-v1"\n' "${portable[*]}"
|
||||
}
|
||||
|
||||
usb_authorization_permanent() {
|
||||
local id="$1" expected="$2" devices current="" allowed portable policy line target new_id matches rule saved=0
|
||||
|
||||
[[ $id =~ ^[0-9]+$ && ( $expected == block\ * || $expected == allow\ * ) ]] || return 1
|
||||
(( ${#expected} <= 16384 )) || return 1
|
||||
devices=$(usbguard list-devices) || return 1
|
||||
while IFS= read -r line; do
|
||||
if [[ $line == "$id: "* ]]; then current=${line#"$id: "}; fi
|
||||
done <<<"$devices"
|
||||
[[ $current == "$expected" ]] || return 1
|
||||
allowed="allow ${expected#* }"
|
||||
portable=$(usb_authorization_portable_rule "$current") || return 1
|
||||
usbguard-rule-parser "$portable" >/dev/null || return 1
|
||||
|
||||
# Respect earlier administrator deny rules. Do not report permanent success
|
||||
# when a saved allow would lose to an explicit block/reject on reconnect.
|
||||
policy=$(usbguard list-rules) || return 1
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ ^[0-9]+:[[:space:]](allow|block|reject)([[:space:]]|$) ]]; then
|
||||
target=${BASH_REMATCH[1]}
|
||||
rule=${line#*: }
|
||||
# --show-devices filters by current target, hiding deny rules for a
|
||||
# temporarily allowed device. A match query compares identity instead.
|
||||
matches=$(usbguard list-devices "match${rule#"$target"}") || return 1
|
||||
if grep -Fqx -- "$id: $current" <<<"$matches"; then
|
||||
if [[ $target != "allow" ]]; then
|
||||
echo "An administrator rule blocks this device. Review that rule before saving approval." >&2
|
||||
return 1
|
||||
fi
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done <<<"$policy"
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ ^([0-9]+):[[:space:]] && ${line#*: } == "$portable" ]]; then
|
||||
saved=1
|
||||
fi
|
||||
done <<<"$policy"
|
||||
|
||||
# A retry can reuse only an exact owned rule present in both the daemon and
|
||||
# its persistent file. Never remove/reorder rules around manual restrictions.
|
||||
if (( ! saved )) || ! grep -Fqx -- "$portable" /etc/usbguard/rules.conf; then
|
||||
new_id=$(usbguard append-rule "$portable") || return 1
|
||||
[[ $new_id =~ ^[0-9]+$ ]] || return 1
|
||||
grep -Fqx -- "$portable" /etc/usbguard/rules.conf || return 1
|
||||
fi
|
||||
if [[ $current == block\ * ]]; then
|
||||
usbguard allow-device "$expected" || return 1
|
||||
fi
|
||||
devices=$(usbguard list-devices --allowed) || return 1
|
||||
grep -Fqx -- "$1: $allowed" <<<"$devices"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
source "${BASH_SOURCE[0]%/*}/usb-authorization-policy.sh"
|
||||
|
||||
usb_authorization_config_value() {
|
||||
local config="$1" key="$2"
|
||||
local awk_command=(awk)
|
||||
|
||||
if [[ ! -r $config ]]; then
|
||||
if [[ $config == "/etc/usbguard/usbguard-daemon.conf" ]]; then
|
||||
awk_command=(sudo awk)
|
||||
else
|
||||
echo "Cannot read USBGuard configuration: $config" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
"${awk_command[@]}" -F= -v key="$key" '
|
||||
/^[[:space:]]*#/ { next }
|
||||
$1 ~ "^[[:space:]]*" key "[[:space:]]*$" {
|
||||
value=$0
|
||||
sub(/^[^=]*=[[:space:]]*/, "", value)
|
||||
sub(/[[:space:]]*$/, "", value)
|
||||
print value
|
||||
exit
|
||||
}
|
||||
' "$config"
|
||||
}
|
||||
|
||||
usb_authorization_rules_present() {
|
||||
local rules="$1"
|
||||
|
||||
if [[ -r $rules ]]; then
|
||||
[[ -s $rules ]]
|
||||
elif [[ $rules == "/etc/usbguard/rules.conf" ]]; then
|
||||
sudo test -s "$rules"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
usb_authorization_require_secure_setting() {
|
||||
local config="$1" key="$2" expected="$3" actual
|
||||
|
||||
actual=$(usb_authorization_config_value "$config" "$key")
|
||||
if [[ $actual != "$expected" ]]; then
|
||||
echo "USBGuard's $key must be '$expected' before Omarchy enables it." >&2
|
||||
echo "Current value: ${actual:-<unset>} in $config" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
usb_authorization_require_secure_settings() {
|
||||
local config="$1"
|
||||
|
||||
usb_authorization_require_secure_setting "$config" ImplicitPolicyTarget block || return 1
|
||||
usb_authorization_require_secure_setting "$config" PresentDevicePolicy apply-policy || return 1
|
||||
usb_authorization_require_secure_setting "$config" InsertedDevicePolicy apply-policy || return 1
|
||||
usb_authorization_require_secure_setting "$config" AuthorizedDefault none || return 1
|
||||
usb_authorization_require_secure_setting "$config" RestoreControllerDeviceState false || return 1
|
||||
usb_authorization_require_secure_setting "$config" RuleFile /etc/usbguard/rules.conf || return 1
|
||||
}
|
||||
|
||||
usb_authorization_generate_policy() {
|
||||
local policy="$1" generated line
|
||||
|
||||
if ! generated=$(usbguard generate-policy); then
|
||||
echo "USBGuard could not enumerate devices; nothing was enabled." >&2
|
||||
return 1
|
||||
fi
|
||||
: >"$policy"
|
||||
while IFS= read -r line; do
|
||||
if [[ $line == allow\ * ]]; then
|
||||
usb_authorization_portable_rule "$line" >>"$policy" || return 1
|
||||
elif [[ -n $line ]]; then
|
||||
printf '%s\n' "$line" >>"$policy"
|
||||
fi
|
||||
done <<<"$generated"
|
||||
if ! grep -q '^allow ' "$policy"; then
|
||||
if grep -q '^[[:space:]]*[^#[:space:]]' "$policy"; then
|
||||
echo "USBGuard did not generate a usable policy; nothing was enabled." >&2
|
||||
return 1
|
||||
fi
|
||||
# A successful empty inventory is valid on machines without USB controllers.
|
||||
# Keep the file nonempty so re-enabling preserves this default-deny policy.
|
||||
echo '# No USB devices were present during enrollment.' >>"$policy"
|
||||
fi
|
||||
usbguard-rule-parser -f "$policy" >/dev/null
|
||||
}
|
||||
|
||||
usb_authorization_add_user() {
|
||||
local user="$1"
|
||||
|
||||
# This user can inspect USBGuard events and approve the device named by an
|
||||
# event. Policy and daemon parameters remain unavailable through the IPC ACL.
|
||||
usbguard add-user "$user" \
|
||||
--devices=list,listen,modify \
|
||||
--policy=list \
|
||||
--exceptions=listen
|
||||
}
|
||||
|
||||
usb_authorization_install_policy() {
|
||||
local rules="$1" policy status=0
|
||||
|
||||
policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX") || return 1
|
||||
if usb_authorization_generate_policy "$policy"; then
|
||||
install -Dm600 -o root -g root "$policy" "$rules" || status=$?
|
||||
else
|
||||
status=1
|
||||
fi
|
||||
rm -f "$policy"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
usb_authorization_provision_owner() {
|
||||
local user="$1"
|
||||
local rules="${2:-/etc/usbguard/rules.conf}"
|
||||
local config="${3:-/etc/usbguard/usbguard-daemon.conf}"
|
||||
|
||||
usb_authorization_require_secure_settings "$config" || return 1
|
||||
# Enroll the owner's hardware, replacing any policy inherited from the
|
||||
# builder. Do this only after the interactive provisioning steps finish.
|
||||
usb_authorization_install_policy "$rules" || return 1
|
||||
usb_authorization_add_user "$user" || return 1
|
||||
systemctl enable usbguard.service || return 1
|
||||
systemctl restart usbguard.service
|
||||
}
|
||||
|
||||
usb_authorization_trust_present_devices() {
|
||||
local line id devices
|
||||
local count=0
|
||||
devices=$(usbguard list-devices) || return 1
|
||||
local -a inventory
|
||||
mapfile -t inventory <<<"$devices"
|
||||
|
||||
for line in "${inventory[@]}"; do
|
||||
if [[ $line =~ ^([0-9]+):[[:space:]] ]]; then
|
||||
id="${BASH_REMATCH[1]}"
|
||||
sudo /usr/bin/omarchy-usb-authorization-approve "$id" "${line#*: }" || return 1
|
||||
(( ++count ))
|
||||
fi
|
||||
done
|
||||
|
||||
if (( count == 0 )); then
|
||||
echo "USBGuard did not report any connected USB devices; boot authorization was not enabled." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Apple Silicon additions, composed after omarchy-base.packages and
|
||||
# omarchy-aarch64.packages (omarchy-pkg-defaults aarch64-apple); never on Intel
|
||||
# Macs or other aarch64 machines. The kernel and boot chain come with the
|
||||
# Mac's image, as the kernel and bootloader do from the ISO on x86_64. What a
|
||||
# Mac cannot work without (its speaker stack, audio server and GPU driver)
|
||||
# comes as omarchy-mac's dependencies, so a new Mac driver needs no change
|
||||
# here; the rest are defaults an owner may remove.
|
||||
|
||||
# The Mac's runtime and boot packages (omacom/omarchy-mac-pkgs).
|
||||
omarchy-mac
|
||||
omarchy-mac-boot
|
||||
|
||||
# Picks the startup volume (macOS or Omarchy) from Linux.
|
||||
asahi-bless
|
||||
|
||||
# Hardware video decode.
|
||||
avd-fw
|
||||
libva-v4l2_request-avd
|
||||
|
||||
# Widevine for DRM streaming.
|
||||
widevine
|
||||
|
||||
# Screen recording: gpu-screen-recorder finds no display device to capture on
|
||||
# Apple Silicon, whose display and GPU are separate DRM devices, so recording
|
||||
# falls back to wf-recorder (omarchy-capture-screenrecording).
|
||||
wf-recorder
|
||||
@@ -0,0 +1,4 @@
|
||||
# Additions for every aarch64 platform, composed after omarchy-base.packages
|
||||
# (omarchy-pkg-defaults). omarchy-settings configures swap on zram, and the
|
||||
# x86_64 installer provides zram-generator for it; aarch64 gets it here.
|
||||
zram-generator
|
||||
@@ -47,6 +47,7 @@ fontconfig
|
||||
foot
|
||||
fzf
|
||||
git
|
||||
gliff
|
||||
gnome-keyring
|
||||
gnome-themes-extra
|
||||
grim
|
||||
@@ -147,6 +148,7 @@ udiskie
|
||||
ufw
|
||||
ufw-docker
|
||||
unzip
|
||||
usbguard
|
||||
usage
|
||||
uwsm
|
||||
vi
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Base packages with no aarch64 build in any repository Omarchy configures.
|
||||
# omarchy-pkg-defaults leaves them out of every aarch64 platform's set.
|
||||
gliff
|
||||
superwhisper-bin
|
||||
@@ -1,7 +1,39 @@
|
||||
# Configure pacman after package installation completes. Offline target package
|
||||
# installs use the live ISO's offline pacman.conf until this final restore.
|
||||
cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf
|
||||
cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist
|
||||
# installs use the live ISO's offline pacman.conf until this final restore,
|
||||
# which copies the platform's channel templates (install/helpers/pacman.sh). A
|
||||
# Mac's image brings its keyrings, as the ISO does on x86; every other aarch64
|
||||
# platform installs Arch Linux ARM's before its repositories replace the
|
||||
# offline ones, and trust every installed keyring before the first signed sync.
|
||||
source "$OMARCHY_PATH/install/helpers/pacman.sh"
|
||||
source "$OMARCHY_PATH/install/helpers/image-target.sh"
|
||||
platform=$(omarchy-hw-platform)
|
||||
templates=$(omarchy_pacman_templates "$platform")
|
||||
alarm_keyring=0
|
||||
if [[ $platform == aarch64* && $platform != "aarch64-apple" ]]; then
|
||||
alarm_keyring=1
|
||||
fi
|
||||
|
||||
if (( alarm_keyring )); then
|
||||
omarchy-pkg-add archlinuxarm-keyring
|
||||
fi
|
||||
|
||||
# An install built for a channel this platform has no templates for (stable or
|
||||
# rc on aarch64) gets the platform's default channel instead.
|
||||
channel=${OMARCHY_MIRROR:-}
|
||||
[[ -n $channel && -f $templates/pacman-$channel.conf ]] || channel=$(omarchy_pacman_default_channel "$platform")
|
||||
cp -f "$templates/pacman-$channel.conf" /etc/pacman.conf
|
||||
cp -f "$templates/mirrorlist-$channel" /etc/pacman.d/mirrorlist
|
||||
|
||||
# An image build leaves the keyring to each machine's first boot, so no two
|
||||
# share a master key (install/helpers/image-target.sh).
|
||||
if (( alarm_keyring )); then
|
||||
if omarchy_image_init && omarchy_image_manifest_present; then
|
||||
install -m 0644 /dev/null "$omarchy_image_keyring_request"
|
||||
else
|
||||
pacman-key --init
|
||||
pacman-key --populate
|
||||
fi
|
||||
fi
|
||||
|
||||
# Wait for CUPS to own the file, the way omarchy-settings does, so pacman does
|
||||
# not turn the override into a .pacnew during ISO package installation.
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# Resumable re-key of the root LUKS volume from the staged install key to the
|
||||
# owner's password. Sourcing performs no setup.
|
||||
#
|
||||
# Caller contract:
|
||||
# - Set PROVISIONING_DIR (holds the staged luks-key), REKEY_STATE, LOG_FILE and
|
||||
# password. Secrets reach cryptsetup through key files and process
|
||||
# substitution, never argv, and the entry points turn tracing off.
|
||||
# - Define luks_boot_layout, printing a fingerprint of the keyboard layout the
|
||||
# boot files ask for the disk password in (the same value while it is
|
||||
# unchanged, and never the layout's name).
|
||||
# - Define log_step and say, plus the platform's boot-time unlock:
|
||||
# luks_auto_unlock_present succeeds while any boot-time copy of the staged
|
||||
# key or its unlock configuration remains; luks_auto_unlock_drop removes
|
||||
# them and rebuilds the boot files, restoring the unlock before it fails.
|
||||
# luks_record_slots <owner> records the kept slot wherever the platform's
|
||||
# boot checks look for it, and succeeds where nothing does.
|
||||
#
|
||||
# The journal (REKEY_STATE) records only the phase, slot numbers and the layout
|
||||
# fingerprint, never key material. Phases advance staged → owner → boot → done, each written durably
|
||||
# after its step, so an attempt interrupted anywhere resumes from the last one;
|
||||
# the retire and record steps before done are idempotent. A retry may still
|
||||
# choose a new password while the staged key opens the disk, even after the
|
||||
# boot step, so the kept slot is recorded only once it is final. The
|
||||
# caller removes the journal with the rest of its provisioning state, so until
|
||||
# then a retry can only use the password the disk holds.
|
||||
|
||||
# cryptsetup open tries enrolled tokens (TPM2, FIDO2, keyring) before the key
|
||||
# and reports a token's slot whatever key it was given. Restricting it to a
|
||||
# token type no token has leaves only the key to decide.
|
||||
luks_key_slot() {
|
||||
local out
|
||||
out=$(LC_ALL=C cryptsetup open --test-passphrase --verbose --token-type passphrase-only --key-file "$1" "$2" 2>&1) || return 0
|
||||
grep -o 'Key slot [0-9]* unlocked' <<<"$out" | grep -o '[0-9]*' | head -1 || true
|
||||
}
|
||||
|
||||
luks_slot_for() {
|
||||
local -
|
||||
set +x
|
||||
luks_key_slot <(printf '%s' "$1") "$2"
|
||||
}
|
||||
|
||||
# LUKS2 lists keyslots under "Keyslots:", and a luks2-keyring token under
|
||||
# "Tokens:" looks the same, so read only the keyslot section.
|
||||
luks_dump_slots() {
|
||||
cryptsetup luksDump "$1" | awk '
|
||||
/^[^ \t]/ { keyslots = ($0 == "Keyslots:") }
|
||||
keyslots && /^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 }
|
||||
/^Key Slot [0-9]+: ENABLED/ { sub(":", "", $3); print $3 }'
|
||||
}
|
||||
|
||||
rekey_state_get() {
|
||||
[[ -f $REKEY_STATE ]] || return 1
|
||||
awk -F= -v k="$1" '$1 == k { print $2; exit }' "$REKEY_STATE"
|
||||
}
|
||||
|
||||
# Set key/value pairs, keeping every other recorded key.
|
||||
rekey_state_put() {
|
||||
local line key tmp
|
||||
local -A updates=()
|
||||
local -a lines=()
|
||||
|
||||
while (( $# >= 2 )); do
|
||||
updates[$1]=$2
|
||||
shift 2
|
||||
done
|
||||
|
||||
if [[ -f $REKEY_STATE ]]; then
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
if [[ -n $line && -z ${updates[${line%%=*}]+set} ]]; then
|
||||
lines+=("$line")
|
||||
fi
|
||||
done <"$REKEY_STATE"
|
||||
fi
|
||||
for key in "${!updates[@]}"; do
|
||||
lines+=("$key=${updates[$key]}")
|
||||
done
|
||||
|
||||
tmp=$(mktemp "$REKEY_STATE.XXXXXX") || return 1
|
||||
if printf '%s\n' "${lines[@]}" >"$tmp" && chmod 600 "$tmp" && sync "$tmp" && mv -f "$tmp" "$REKEY_STATE"; then
|
||||
sync "$(dirname "$REKEY_STATE")"
|
||||
return
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
}
|
||||
|
||||
staged_key_slot() {
|
||||
[[ -f $PROVISIONING_DIR/luks-key ]] || return 0
|
||||
luks_key_slot "$PROVISIONING_DIR/luks-key" "$1"
|
||||
}
|
||||
|
||||
# The staged key or its boot-time auto-unlock is still on this machine.
|
||||
luks_staged_unlock_remains() {
|
||||
[[ -e $PROVISIONING_DIR/luks-key ]] || luks_auto_unlock_present
|
||||
}
|
||||
|
||||
# Re-key work is due while the staged unlock remains or the journal is open.
|
||||
luks_rekey_pending() {
|
||||
[[ -e $REKEY_STATE ]] || luks_staged_unlock_remains
|
||||
}
|
||||
|
||||
# Whether an interrupted re-key can finish with $password. Until the staged key
|
||||
# is retired it can add any password; afterwards only one the disk holds.
|
||||
luks_rekey_accepts_password() {
|
||||
local -
|
||||
set +x
|
||||
local device=$1
|
||||
|
||||
[[ -f $REKEY_STATE ]] || return 0
|
||||
[[ -n $(luks_slot_for "$password" "$device") || -n $(staged_key_slot "$device") ]]
|
||||
}
|
||||
|
||||
# Record the slot $password opens, adding it with the staged key when none does.
|
||||
# A retry after a crash between the add and the journal write finds the slot
|
||||
# instead of adding a duplicate, and a retry with a new password adds its own
|
||||
# while the staged key still works; the retire step removes the stale one.
|
||||
luks_rekey_owner() {
|
||||
local device=$1 phase=$2 owner
|
||||
|
||||
owner=$(luks_slot_for "$password" "$device")
|
||||
if [[ -z $owner ]]; then
|
||||
if [[ -z $(staged_key_slot "$device") ]]; then
|
||||
log_step "the password opens no LUKS slot and the staged key no longer unlocks $device"
|
||||
say --foreground 1 "Use the disk password chosen earlier in setup."
|
||||
return 1
|
||||
fi
|
||||
if ! cryptsetup luksAddKey --key-file "$PROVISIONING_DIR/luks-key" "$device" <(printf '%s' "$password"); then
|
||||
log_step "could not add the owner's key to $device"
|
||||
say --foreground 1 "Could not add your password to the disk; will retry."
|
||||
return 1
|
||||
fi
|
||||
owner=$(luks_slot_for "$password" "$device")
|
||||
if [[ -z $owner ]]; then
|
||||
log_step "could not identify the owner's LUKS slot after adding it"
|
||||
say --foreground 1 "Could not confirm the LUKS re-key; will retry."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $owner == "$(rekey_state_get staged_slot || true)" ]]; then
|
||||
log_step "the owner's password is the staged install key; refusing to keep it"
|
||||
say --foreground 1 "Choose a disk password different from the temporary install key."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $phase == "staged" ]]; then
|
||||
rekey_state_put owner_slot "$owner" phase owner
|
||||
else
|
||||
rekey_state_put owner_slot "$owner"
|
||||
fi
|
||||
}
|
||||
|
||||
luks_rekey_retire() {
|
||||
local device=$1 owner slot slots
|
||||
|
||||
owner=$(rekey_state_get owner_slot || true)
|
||||
if [[ -z $owner ]]; then
|
||||
log_step "no owner slot is recorded; refusing to retire LUKS slots"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! slots=$(luks_dump_slots "$device"); then
|
||||
log_step "luksDump failed while retiring slots; keeping the staged key for retry"
|
||||
say --foreground 1 "Could not enumerate LUKS slots; will retry."
|
||||
return 1
|
||||
fi
|
||||
for slot in $slots; do
|
||||
[[ $slot == "$owner" ]] && continue
|
||||
if ! cryptsetup luksKillSlot -q --key-file <(printf '%s' "$password") "$device" "$slot"; then
|
||||
log_step "failed to kill LUKS slot $slot; keeping the staged key for retry"
|
||||
say --foreground 1 "Could not remove the throwaway LUKS key; will retry."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# The staged key must open nothing before it is destroyed: only the owner slot
|
||||
# remains and no boot-time copy or unlock configuration is left behind.
|
||||
luks_rekey_verify() {
|
||||
local device=$1 owner slots
|
||||
|
||||
owner=$(rekey_state_get owner_slot || true)
|
||||
if ! slots=$(luks_dump_slots "$device") || [[ -z $owner || $slots != "$owner" ]]; then
|
||||
log_step "LUKS slots other than the owner's remain on $device"
|
||||
return 1
|
||||
fi
|
||||
if [[ -n $(staged_key_slot "$device") ]]; then
|
||||
log_step "the staged LUKS key still unlocks $device"
|
||||
return 1
|
||||
fi
|
||||
if luks_auto_unlock_present; then
|
||||
log_step "the boot-time auto-unlock is still configured"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# The boot files carry the console keyboard layout the disk password is typed
|
||||
# in at boot, so rebuild them again when the owner chose another layout since
|
||||
# they were built, or when an older journal recorded none. The phase stays.
|
||||
luks_rekey_boot_layout() {
|
||||
local layout=$1
|
||||
|
||||
[[ $(rekey_state_get boot_layout || true) != "$layout" ]] || return 0
|
||||
log_step "the boot files were not built with the current keyboard layout; rebuilding them"
|
||||
if ! luks_auto_unlock_drop; then
|
||||
say --foreground 1 "Could not rebuild the boot files for your keyboard layout; will retry."
|
||||
return 1
|
||||
fi
|
||||
sync
|
||||
rekey_state_put boot_layout "$layout"
|
||||
}
|
||||
|
||||
# Order: record the staged slot, add the owner's key, rebuild boot without the
|
||||
# auto-unlock (keeping the staged slot as the fallback while that can fail),
|
||||
# retire every other slot, then verify, record the kept slot for the platform,
|
||||
# destroy the staged key and record done.
|
||||
# Failing is loud: silently keeping the staged key would leave the disk
|
||||
# effectively unencrypted.
|
||||
luks_rekey() {
|
||||
local -
|
||||
set +x
|
||||
local device=$1 phase staged layout
|
||||
|
||||
if ! layout=$(luks_boot_layout) || [[ -z $layout ]]; then
|
||||
log_step "could not fingerprint the keyboard layout for the boot files"
|
||||
say --foreground 1 "Could not read the keyboard layout for the boot files; will retry."
|
||||
return 1
|
||||
fi
|
||||
|
||||
phase=$(rekey_state_get phase || true)
|
||||
case $phase in
|
||||
"")
|
||||
staged=$(staged_key_slot "$device")
|
||||
if [[ -z $staged ]]; then
|
||||
log_step "the staged LUKS key is missing or does not unlock $device"
|
||||
say --foreground 1 "The staged LUKS key no longer unlocks $device."
|
||||
return 1
|
||||
fi
|
||||
rekey_state_put staged_slot "$staged" phase staged || return 1
|
||||
phase=staged
|
||||
;;
|
||||
staged | owner | boot | done) ;;
|
||||
*)
|
||||
log_step "unknown LUKS re-key phase '$phase' in $REKEY_STATE"
|
||||
say --foreground 1 "The LUKS re-key journal is unreadable."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $phase != "done" ]]; then
|
||||
luks_rekey_owner "$device" "$phase" || return 1
|
||||
[[ $phase == "staged" ]] && phase=owner
|
||||
|
||||
if [[ $phase == "owner" ]] || luks_auto_unlock_present; then
|
||||
if ! luks_auto_unlock_drop; then
|
||||
say --foreground 1 "Could not rebuild the boot files without the install key; will retry."
|
||||
return 1
|
||||
fi
|
||||
sync
|
||||
rekey_state_put phase boot boot_layout "$layout" || return 1
|
||||
else
|
||||
luks_rekey_boot_layout "$layout" || return 1
|
||||
fi
|
||||
|
||||
luks_rekey_retire "$device" || return 1
|
||||
else
|
||||
if [[ $(luks_slot_for "$password" "$device") != "$(rekey_state_get owner_slot || true)" ]]; then
|
||||
log_step "the password does not open the owner slot the finished re-key recorded on $device"
|
||||
say --foreground 1 "Use the disk password chosen earlier in setup."
|
||||
return 1
|
||||
fi
|
||||
luks_rekey_boot_layout "$layout" || return 1
|
||||
fi
|
||||
|
||||
if ! luks_rekey_verify "$device"; then
|
||||
say --foreground 1 "Could not confirm the temporary install key was removed; will retry."
|
||||
return 1
|
||||
fi
|
||||
if ! luks_record_slots "$(rekey_state_get owner_slot)"; then
|
||||
log_step "the platform could not record the kept LUKS slot"
|
||||
say --foreground 1 "Could not record the disk's key slot for the boot checks; will retry."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -e $PROVISIONING_DIR/luks-key ]]; then
|
||||
shred -u "$PROVISIONING_DIR/luks-key" 2>/dev/null || rm -f "$PROVISIONING_DIR/luks-key" || return 1
|
||||
fi
|
||||
rm -f "$REKEY_STATE".??????
|
||||
[[ $phase == "done" ]] || rekey_state_put phase done
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Runs the hardware setup an image build deferred, on the machine it was built
|
||||
# for, before owner setup and the login screen. Armed with its queue by
|
||||
# omarchy-apply-hardware during the build (install/helpers/image-target.sh);
|
||||
# retried on every boot until the queue is empty and any keyring an image build
|
||||
# asked for exists, then it disables itself (the unit file stays; with neither
|
||||
# file left it no longer runs).
|
||||
[Unit]
|
||||
Description=Omarchy first-boot hardware setup deferred from the image build
|
||||
ConditionPathExists=|/var/lib/omarchy/image/deferred-steps
|
||||
ConditionPathExists=|/var/lib/omarchy/image/pacman-keyring
|
||||
Before=omarchy-provision-owner.service display-manager.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/omarchy-provision-hardware
|
||||
TimeoutStartSec=infinity
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -14,3 +14,6 @@ run_logged "$OMARCHY_INSTALL/user/hardware/vm-no-animations.sh"
|
||||
|
||||
run_logged "$OMARCHY_INSTALL/user/default-keyring.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/mise.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/dictation-default.sh"
|
||||
|
||||
run_logged "$OMARCHY_INSTALL/user/platform-setup.sh"
|
||||
@@ -0,0 +1,8 @@
|
||||
# The settings package presets Superwhisper as the dictation backend, and it has
|
||||
# no aarch64 build. A new user there starts with no backend selected and picks
|
||||
# one in Setup > Defaults > Dictation. Any other selection is left alone.
|
||||
dictation_default="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation"
|
||||
if ! omarchy-hw-x86 && omarchy-cmd-missing superwhisper &&
|
||||
[[ -f $dictation_default && $(<"$dictation_default") == "superwhisper" ]]; then
|
||||
rm -f "$dictation_default"
|
||||
fi
|
||||
@@ -14,13 +14,22 @@ set -euo pipefail
|
||||
omarchy-input-method configure --defaults || echo "Input method defaults deferred; run omarchy setup input after login." >&2
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now \
|
||||
|
||||
# One at a time: given a list, systemctl enables none of it when it cannot find
|
||||
# one unit, and that should not cost the session its bluetooth agent as well.
|
||||
failed=0
|
||||
for unit in \
|
||||
bt-agent.service \
|
||||
owed.service \
|
||||
omarchy-recover-internal-monitor.service \
|
||||
omarchy-sleep-lock.service \
|
||||
omarchy-migrate-notify.service \
|
||||
omarchy-fcitx5.service \
|
||||
omarchy-crash-watch.service
|
||||
omarchy-crash-watch.service \
|
||||
omarchy-usb-authorization.service \
|
||||
omarchy-thunderbolt-authorization.service; do
|
||||
systemctl --user enable --now "$unit" || failed=1
|
||||
done
|
||||
|
||||
omarchy-hook-install theme-set /usr/share/owe/10-owe-sync
|
||||
exit "$failed"
|
||||
@@ -0,0 +1,10 @@
|
||||
# The platform package's own setup for this user, last so it builds on every
|
||||
# other leaf (omarchy-lifecycle-dispatch setup-user, see
|
||||
# docs/lifecycle-dispatch.md). A no-op where the platform registers none. In an
|
||||
# image build the detector would read the build host, so it waits for first run
|
||||
# on the machine, which runs it again.
|
||||
if [[ -e ${OMARCHY_IMAGE_ROOT:-}/var/lib/omarchy/image/target ]]; then
|
||||
echo "Image build: the platform's user setup waits for first run on the machine"
|
||||
else
|
||||
omarchy-lifecycle-dispatch setup-user
|
||||
fi
|
||||
Reference in new issue
Block a user