399 lines
17 KiB
Bash
399 lines
17 KiB
Bash
# 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
|
|
}
|