Both Thunderbolt loops reconciled or rescanned every 2 s even on machines with no controller: on an idle M2 Max that was about 1,590 process launches a minute. While nothing is connected or boot-protected and no controller is present, the daemon now reconciles every 10 s, inside a snapshot's 20 s lifetime, and returns to every tick as soon as a controller, a policy change or a boot recovery checkpoint appears. The watcher skips an unchanged, fresh, device-free snapshot, and both loops wait without forking sleep.
357 lines
17 KiB
Bash
357 lines
17 KiB
Bash
# 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
|
|
# How often the daemon re-checks Bolt while there is no controller and no boot
|
|
# protection: well inside the 20 s a published snapshot stays valid.
|
|
TB_IDLE_RECHECK=10
|
|
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; }
|
|
|
|
# Sleeps without forking on each tick: a pipe open for reading and writing
|
|
# never reaches EOF, so a timed read on it returns only on timeout or a signal.
|
|
tb_wait() {
|
|
[[ -n ${TB_TICK:-} ]] || exec {TB_TICK}<> <(:)
|
|
read -r -t "$1" -u "$TB_TICK" _ || true
|
|
}
|
|
|
|
tb_controller_present() {
|
|
local domain
|
|
for domain in "$TB_SYSFS"/domain*; do
|
|
[[ -e $domain ]] && return 0
|
|
done
|
|
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 policy idle_policy='' idle_since=0
|
|
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
|
|
policy=''
|
|
[[ -r $TB_STATE ]] && IFS= read -r -d '' policy < "$TB_STATE" || true
|
|
# With no controller, no device and no boot protection there is nothing to
|
|
# restore or reapply, so Bolt is reconciled every TB_IDLE_RECHECK seconds
|
|
# instead of every tick. A controller appearing, a policy change or a boot
|
|
# recovery checkpoint brings back the full check at once.
|
|
if [[ -n $idle_policy && $policy == "$idle_policy" && ! -e ${TB_STATE%/*}/boot-recovery.json ]] &&
|
|
(( EPOCHSECONDS - idle_since >= 0 && EPOCHSECONDS - idle_since < TB_IDLE_RECHECK )) &&
|
|
! tb_controller_present; then
|
|
tb_wait 2
|
|
continue
|
|
fi
|
|
idle_policy=''
|
|
(
|
|
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"
|
|
snapshot=''
|
|
[[ -r $TB_RUNTIME/snapshot.json ]] && IFS= read -r -d '' snapshot < "$TB_RUNTIME/snapshot.json" || true
|
|
if [[ -n $policy ]] && jq -e '.available == true and .domains == [] and .devices == [] and
|
|
.warnings == [] and .error == "" and .boot_protection == false' <<< "$snapshot" >/dev/null 2>&1; then
|
|
idle_policy=$policy idle_since=$EPOCHSECONDS
|
|
fi
|
|
tb_wait 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"
|
|
}
|