max / alloy
| 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 | |
| 69 | |
| 70 | # Only the one signal. See the header: the other two describe the same plug. |
| 71 | [ || |
| 72 | [ || |
| 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 "" in |
| 77 | block|reject) ;; |
| 78 | *) ;; |
| 79 | esac |
| 80 | |
| 81 | 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= |
| 87 | ids= |
| 88 | if [; then |
| 89 | name="" |
| 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: " |
| 98 | body=" 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 | |
| 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; do |
| 116 | [ || continue |
| 117 | [ || continue |
| 118 | uid= |
| 119 | case "" 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 | [ || continue |
| 126 | case " " in |
| 127 | *" "*) continue ;; |
| 128 | esac |
| 129 | people=" " |
| 130 | done |
| 131 | |
| 132 | told=0 |
| 133 | for; do |
| 134 | who= |
| 135 | [ || continue |
| 136 | runtime="/run/user/" |
| 137 | [ || 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 |
| 141 | XDG_RUNTIME_DIR="" \ |
| 142 | DBUS_SESSION_BUS_ADDRESS="unix:path=/bus" \ |
| 143 | notify-send -a Alloy -u critical -i drive-removable-media \ |
| 144 | "" "" >/dev/null 2>&1 |
| 145 | then |
| 146 | told= |
| 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 [; then |
| 154 | { |
| 155 | |
| 156 | |
| 157 | } |
| 158 | || { |
| 159 | |
| 160 | |
| 161 | } |
| 162 | || |
| 163 | fi |
| 164 | |
| 165 | |
| 166 |