Merge pull request #11874 from acrogenesis/feature/usb-device-authorization
Require approval for new USB and Thunderbolt devices by default. Reported-by: Jurre van Bergen me@jurrevanbergen.nl
This commit is contained in:
57 files changed
+5775
-2
No files matched your search
@@ -7,5 +7,7 @@ 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/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,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
|
||||
@@ -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
|
||||
}
|
||||
@@ -144,6 +144,7 @@ udiskie
|
||||
ufw
|
||||
ufw-docker
|
||||
unzip
|
||||
usbguard
|
||||
usage
|
||||
uwsm
|
||||
vi
|
||||
|
||||
@@ -19,6 +19,8 @@ systemctl --user enable --now \
|
||||
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
|
||||
|
||||
omarchy-hook-install theme-set /usr/share/owe/10-owe-sync
|
||||
Reference in new issue
Block a user