Skip to main content

max / alloy

7.6 KB · 166 lines History Blame Raw
1 #!/bin/sh
2 # alloy-usb-notify — say so when a USB device is refused.
3 #
4 # The third clause of the ruling (GoingsOn alloy 63de3d4c): "deny unknown,
5 # PROMPT, remember on request". Deny is usbguard, remember is the three keys on
6 # `alloy usb`, and this is the prompt. Without it a device plugged into an armed
7 # machine simply does not work, with nothing anywhere a person is looking to
8 # connect that to a policy decision. "This stick is broken" is the report you get
9 # instead, and it is not a report anyone can act on.
10 #
11 # Run by `usbguard watch --exec`, one process per event. That is a CLI with an
12 # argv rather than the D-Bus interface the stock applets use, which is the same
13 # correction already made for `alloy bluetooth` and for the console's own action
14 # half: the log pane can only show a command that exists.
15 #
16 # ## The event contract, measured rather than read
17 #
18 # `usbguard watch --exec` passes everything in the environment, and ONE plug
19 # fires this program THREE times. Measured on usbguard-1.1.4 by attaching a
20 # probe that dumped its own environment:
21 #
22 # USBGUARD_IPC_SIGNAL=Device.PolicyChanged ... TARGET_OLD + TARGET_NEW
23 # USBGUARD_IPC_SIGNAL=Device.PresenceChanged ... DEVICE_EVENT=Insert, TARGET
24 # USBGUARD_IPC_SIGNAL=Device.PolicyApplied ... TARGET_NEW
25 #
26 # So the signal has to be chosen, not merely read: speaking on each of them is
27 # three notifications for one plug, which is how a person learns to dismiss
28 # them without looking. `Device.PresenceChanged` with `DEVICE_EVENT=Insert` is
29 # the one that means "somebody just plugged something in", and it happens once.
30 #
31 # `USBGUARD_DEVICE_RULE_ID` is NOT available here, and that is worth writing
32 # down because it looks like it should be. It reads 4294967294 when a device
33 # matched no rule and the implicit policy refused it, which would separate "this
34 # machine has never heard of your device" from "this machine has a rule against
35 # it", two genuinely different sentences. But it is set only on
36 # `Device.PolicyChanged` and `Device.PolicyApplied`, never on the
37 # `PresenceChanged` this program listens to. Measured: the first version of this
38 # script branched on it and therefore told every person with an unknown device
39 # that the machine had a rule refusing it, which was false every time.
40 #
41 # Switching signals to get the field back is the wrong trade. `PolicyApplied`
42 # carries no `DEVICE_EVENT`, so it cannot tell a plug from a policy change, and
43 # it fires when somebody blocks a device deliberately from `alloy usb`, which
44 # would answer their own decision with a notification telling them how to undo
45 # it. One true sentence beats two precise ones that fire at the wrong moments.
46 #
47 # ## Where the message goes
48 #
49 # The ruling scoped this as "`alloy usb` plus a VT1 notice, not a new
50 # notification daemon or a desktop applet", and the VT1 half of that does not
51 # survive contact with the case it exists for. A person plugging in a stick is
52 # in a sway session, and sway owns the display: text written to /dev/tty1 is
53 # not on their screen. VT1 is right for the keyboard gate, which fires when the
54 # greeter is the only thing drawing, and wrong here.
55 #
56 # So: mako, which is already in the image, through `notify-send`, which
57 # `alloy-open` and `alloy-shot` already use. That is not a new notification
58 # daemon; it is the one this system already runs. Reaching it from a system
59 # unit is the part with no precedent in this tree, and it is two environment
60 # variables per logged-in user, taken from logind rather than guessed.
61 #
62 # The console fallback is kept for the case where there is nobody to notify:
63 # a machine sitting at the greeter, or a headless one. It is a fallback rather
64 # than a second channel on purpose, because writing to VT1 draws over whatever
65 # tuigreet has on it, and doing that on every device plug is worse than the
66 # silence it replaces.
67
68 set -u
69
70 # Only the one signal. See the header: the other two describe the same plug.
71 [ "${USBGUARD_IPC_SIGNAL:-}" = "Device.PresenceChanged" ] || exit 0
72 [ "${USBGUARD_DEVICE_EVENT:-}" = "Insert" ] || exit 0
73
74 # Only a refusal. An allow needs no announcement: the device works, which is
75 # the whole of what the person wanted to know.
76 case "${USBGUARD_DEVICE_TARGET:-}" in
77 block|reject) ;;
78 *) exit 0 ;;
79 esac
80
81 rule="${USBGUARD_DEVICE_RULE:-}"
82
83 # The device's own name, out of the rule usbguard just handed us. Falling back
84 # to the ids, because a device that reports no name still has to be nameable in
85 # a sentence, and `0781:55a9` is at least something to match against a label.
86 name=$(printf '%s' "$rule" | sed -n 's/.* name "\([^"]*\)".*/\1/p')
87 ids=$(printf '%s' "$rule" | sed -n 's/.*\bid \([0-9a-fA-F]\{4\}:[0-9a-fA-F]\{4\}\).*/\1/p')
88 if [ -z "$name" ]; then
89 name="${ids:-an unnamed USB device}"
90 fi
91
92 # One sentence, true whether the device matched no rule or matched one that
93 # refuses it. See the header for why the field that would separate those is not
94 # available on this signal.
95 why="Alloy has not authorized it."
96
97 summary="USB device blocked: $name"
98 body="$why Run 'alloy usb' to allow it."
99
100 # The journal always, whatever else happens. It is the one surface that is
101 # there on every machine and keeps a record after the notification is gone.
102 printf 'alloy-usb-notify: blocked %s (%s); %s\n' "$name" "${ids:-unknown ids}" "$why" >&2
103
104 # Every logged-in PERSON gets told once, because the one who plugged the thing in
105 # is whoever is sitting there. Read from logind rather than guessed: a hardcoded
106 # uid 1000 is wrong on a machine with two accounts, and wrong on the first one
107 # where somebody made a second.
108 #
109 # Per user and not per session, which is the whole reason this is two loops.
110 # Measured: one plug on an ordinary desktop login produced FOUR active sessions
111 # for the same uid (a wayland one, a couple of tty ones, and the manager), so
112 # notifying per session delivered four identical popups for one device. The
113 # notification is per person; the bus is per user; the session is neither.
114 people=""
115 for session in $(loginctl list-sessions --no-legend 2>/dev/null | awk '{print $1}'); do
116 [ -n "$session" ] || continue
117 [ "$(loginctl show-session "$session" -p Active --value 2>/dev/null)" = "yes" ] || continue
118 uid=$(loginctl show-session "$session" -p User --value 2>/dev/null)
119 case "$uid" in
120 ''|*[!0-9]*) continue ;;
121 esac
122 # A system account with an active session is not a person. greeter runs as
123 # uid 971 on this image and would otherwise be told about every device.
124 # 1000 is the first human uid here, and `alloy install` creates uid 1000.
125 [ "$uid" -ge 1000 ] || continue
126 case " $people " in
127 *" $uid "*) continue ;;
128 esac
129 people="$people $uid"
130 done
131
132 told=0
133 for uid in $people; do
134 who=$(getent passwd "$uid" 2>/dev/null | cut -d: -f1)
135 [ -n "$who" ] || continue
136 runtime="/run/user/$uid"
137 [ -d "$runtime" ] || continue
138 # The two variables a client needs to find the session bus mako is on.
139 # Nothing else from the session is required, and nothing else is taken.
140 if runuser -u "$who" -- env \
141 XDG_RUNTIME_DIR="$runtime" \
142 DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime/bus" \
143 notify-send -a Alloy -u critical -i drive-removable-media \
144 "$summary" "$body" >/dev/null 2>&1
145 then
146 told=$((told + 1))
147 fi
148 done
149
150 # Nobody to tell, so fall back to the console the way the gate does. This is the
151 # greeter and the headless case, and it is the only case where drawing on VT1 is
152 # better than staying quiet.
153 if [ "$told" -eq 0 ]; then
154 {
155 printf '\n%s\n' "$summary"
156 printf '%s\n\n' "$body"
157 } > /dev/tty1 2>/dev/null \
158 || {
159 printf '\n%s\n' "$summary"
160 printf '%s\n\n' "$body"
161 } > /dev/console 2>/dev/null \
162 || true
163 fi
164
165 exit 0
166