max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
7 files changed,
+397 insertions,
-6 deletions
| @@ -4550,6 +4550,9 @@ | |||
| 4550 | 4550 | # 4. the ConditionFileNotEmpty drop-in so if 3 fails the machine boots | |
| 4551 | 4551 | # UNARMED rather than with every | |
| 4552 | 4552 | # USB device denied | |
| 4553 | + | # 5. alloy-usb-watch.service so a refusal is said out loud rather | |
| 4554 | + | # than leaving someone holding a stick | |
| 4555 | + | # that does nothing | |
| 4553 | 4556 | # | |
| 4554 | 4557 | # State 4 is checked by its path and its exact line, and deliberately not by | |
| 4555 | 4558 | # asking systemd. `systemctl show usbguard.service -p ConditionFileNotEmpty` | |
| @@ -4582,7 +4585,11 @@ | |||
| 4582 | 4585 | || { echo "$dropin is gone; nothing now stops usbguard arming against an empty policy when seeding fails" >&2; exit 1; }; \ | |
| 4583 | 4586 | grep -qx "ConditionFileNotEmpty=/etc/usbguard/rules.conf" "$dropin" \ | |
| 4584 | 4587 | || { echo "$dropin no longer refuses an empty policy, which is the one thing it exists to do" >&2; exit 1; }; \ | |
| 4585 | - | echo "usbguard: armed after preset-all, with seeding and the empty-policy refusal both in place" | |
| 4588 | + | systemctl is-enabled alloy-usb-watch.service >/dev/null 2>&1 \ | |
| 4589 | + | || { echo "alloy-usb-watch.service is not enabled, so a device this machine refuses is refused in silence and the person holding it has nothing to read" >&2; exit 1; }; \ | |
| 4590 | + | test -x /usr/bin/alloy-usb-notify \ | |
| 4591 | + | || { echo "/usr/bin/alloy-usb-notify is missing or not executable; the watcher would run nothing on every event" >&2; exit 1; }; \ | |
| 4592 | + | echo "usbguard: armed after preset-all, with seeding, the empty-policy refusal and the prompt all in place" | |
| 4586 | 4593 | ||
| 4587 | 4594 | # Template instances have to be enabled by name. `preset-all` iterates over | |
| 4588 | 4595 | # the unit *files* that exist, and `alloy-debug-shell@.service` is a template |
| @@ -317,10 +317,12 @@ | |||
| 317 | 317 | by usbguard`, with the rule for the selected device in the detail pane and three | |
| 318 | 318 | keys that act on it. | |
| 319 | 319 | ||
| 320 | - | A device attached after setup is denied, and today it is denied silently: the | |
| 321 | - | plug-then-prompt hook is `usbguard watch --exec` and nothing runs it yet, so | |
| 322 | - | this screen is where a person finds out. That is the gap worth knowing about | |
| 323 | - | when a stick appears not to work. | |
| 320 | + | A device attached after setup is denied, and the machine says so: a notification | |
| 321 | + | names the device and points here, from `alloy-usb-watch.service` running | |
| 322 | + | `usbguard watch --exec`. So this screen is where a person is sent rather than | |
| 323 | + | somewhere they have to already know about. See docs/STACK.md for what that | |
| 324 | + | measured about usbguard's event stream, including why one plug had to be turned | |
| 325 | + | into one notification rather than three. | |
| 324 | 326 | ||
| 325 | 327 | **Two tabs, because a charger and a keyboard are answered by different | |
| 326 | 328 | subsystems.** The bus knows what enumerated and what each interface claims to be; |
| @@ -487,7 +487,13 @@ | |||
| 487 | 487 | ||
| 488 | 488 | Verified on a booted machine rather than reasoned about. From an empty policy: seeder starts at +0.779s, writes 3 rules at +0.823s, finishes at +0.839s; usbguard starts at +0.881s and applies the policy at +0.920s with every device `target.new='allow'`. End state is enforcement on, every device authorized, keyboard working. With the seeder prevented from running, usbguard is skipped with `unmet condition check ConditionFileNotEmpty` and the machine boots usable and unprotected, which is the intended failure. | |
| 489 | 489 | ||
| 490 | - | One thing this does not have and the ruling asked for: the prompt. `usbguard watch --exec` is the hook and nothing runs it yet, so a device plugged in after setup is denied silently and the way to find out is `alloy usb`. That gap is worth closing before this reaches anyone who did not build it. | |
| 490 | + | **The prompt shipped with it**, which is the third clause of the ruling and the thing that keeps the other two honest. `alloy-usb-watch.service` runs `usbguard watch --exec /usr/bin/alloy-usb-notify`, so a refused device produces a notification naming it and saying to run `alloy usb`. A CLI with an argv rather than the D-Bus interface the stock applets use, which is the same correction already made for `alloy bluetooth`. | |
| 491 | + | ||
| 492 | + | Three things about it were measured rather than assumed, and each changed the code. One plug fires the exec **three times** (`Device.PolicyChanged`, `Device.PresenceChanged`, `Device.PolicyApplied`), so the signal has to be chosen or one device becomes three popups; `PresenceChanged` with `DEVICE_EVENT=Insert` is the one that means somebody just plugged something in. `USBGUARD_DEVICE_RULE_ID` is **not** set on that signal, though it is on the other two, and an earlier version branched on it to say whether the machine had a rule against the device or had simply never heard of it; that branch was wrong every time, so the message says one true thing instead. And an ordinary desktop login has **four** active logind sessions for one uid, so notifying per session delivered four identical popups; it is one per person now. | |
| 493 | + | ||
| 494 | + | The delivery is mako, through `notify-send`, reached from a system unit with the user's `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS` taken from logind rather than guessed. That is not a new notification daemon: mako is already in the image and is D-Bus activated on demand, which is why it does not appear in a session's process list until the first notification. The VT1 notice the ruling described is kept as the fallback for when there is nobody logged in, because sway owns the display and text written to `/dev/tty1` is not on a working user's screen. It is a fallback rather than a second channel, since writing there draws over the greeter. | |
| 495 | + | ||
| 496 | + | Verified in a booted graphical session: plugging a device usbguard refuses puts one notification on screen reading `USB device blocked: <name>`, and at the greeter with nobody logged in the same event writes to VT1 instead. | |
| 491 | 497 | ||
| 492 | 498 | ## VPN | |
| 493 | 499 |
| @@ -642,3 +642,148 @@ | |||
| 642 | 642 | "close_gate checks whether the daemon is armed before trying to start it", | |
| 643 | 643 | ); | |
| 644 | 644 | } | |
| 645 | + | ||
| 646 | + | // ---- the prompt ---- | |
| 647 | + | ||
| 648 | + | fn notify_script() -> String { | |
| 649 | + | repo("usr/bin/alloy-usb-notify") | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | fn watch_unit() -> String { | |
| 653 | + | repo("etc/systemd/system/alloy-usb-watch.service") | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | // The third clause of the ruling. Deny is usbguard and remember is the three | |
| 657 | + | // keys on the screen; without this a refused device is refused in silence and | |
| 658 | + | // the report a person can make is "this stick is broken". | |
| 659 | + | #[test] | |
| 660 | + | fn a_refused_device_is_said_out_loud() { | |
| 661 | + | let preset = code(&preset()); | |
| 662 | + | assert!( | |
| 663 | + | preset.contains("enable alloy-usb-watch.service"), | |
| 664 | + | "nothing watches usbguard, so enforcement refuses devices with no way \ | |
| 665 | + | for the person holding one to find out why", | |
| 666 | + | ); | |
| 667 | + | let unit = code(&watch_unit()); | |
| 668 | + | assert!( | |
| 669 | + | unit.contains("usbguard watch") && unit.contains("--exec /usr/bin/alloy-usb-notify"), | |
| 670 | + | "the watcher no longer runs the notifier over usbguard's own CLI", | |
| 671 | + | ); | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | // One plug fires the exec three times. Measured on usbguard-1.1.4: | |
| 675 | + | // Device.PolicyChanged, Device.PresenceChanged and Device.PolicyApplied all | |
| 676 | + | // arrive for a single insert, so speaking on each is three popups for one | |
| 677 | + | // device, which is how someone learns to dismiss them without reading. | |
| 678 | + | #[test] | |
| 679 | + | fn the_notifier_speaks_once_per_plug_and_only_for_a_refusal() { | |
| 680 | + | let code = code(¬ify_script()); | |
| 681 | + | assert!( | |
| 682 | + | code.contains(r#"= "Device.PresenceChanged""#), | |
| 683 | + | "the notifier no longer pins itself to one signal, so one plug is \ | |
| 684 | + | several notifications", | |
| 685 | + | ); | |
| 686 | + | assert!( | |
| 687 | + | code.contains(r#"= "Insert""#), | |
| 688 | + | "the notifier no longer checks that the device was inserted, so it \ | |
| 689 | + | also speaks when one is unplugged", | |
| 690 | + | ); | |
| 691 | + | assert!( | |
| 692 | + | code.contains("block|reject"), | |
| 693 | + | "the notifier no longer restricts itself to refusals; an allowed \ | |
| 694 | + | device needs no announcement because it works", | |
| 695 | + | ); | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | // `USBGUARD_DEVICE_RULE_ID` is not set on the signal this listens to, and an | |
| 699 | + | // earlier version branched on it and therefore told every person with an | |
| 700 | + | // unknown device that the machine had a rule refusing it. It was false every | |
| 701 | + | // time. See the script's header. | |
| 702 | + | #[test] | |
| 703 | + | fn the_notifier_does_not_claim_to_know_which_rule_refused_the_device() { | |
| 704 | + | let code = code(¬ify_script()); | |
| 705 | + | assert!( | |
| 706 | + | !code.contains("USBGUARD_DEVICE_RULE_ID"), | |
| 707 | + | "the notifier reads USBGUARD_DEVICE_RULE_ID, which is absent on \ | |
| 708 | + | Device.PresenceChanged, so whichever branch it takes is a guess", | |
| 709 | + | ); | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | // Per person, not per session. Measured: an ordinary desktop login had FOUR | |
| 713 | + | // active sessions for one uid, and notifying per session delivered four | |
| 714 | + | // identical popups for one device. | |
| 715 | + | #[test] | |
| 716 | + | fn the_notifier_tells_each_person_once_rather_than_each_session() { | |
| 717 | + | let code = code(¬ify_script()); | |
| 718 | + | let collect = code | |
| 719 | + | .find("people=\"\"") | |
| 720 | + | .expect("the notifier no longer collects the set of people to tell"); | |
| 721 | + | let send = code | |
| 722 | + | .find("notify-send") | |
| 723 | + | .expect("the notifier no longer sends a notification"); | |
| 724 | + | assert!( | |
| 725 | + | collect < send, | |
| 726 | + | "the notifier sends inside the session loop, so one device produces \ | |
| 727 | + | one notification per session rather than one per person", | |
| 728 | + | ); | |
| 729 | + | assert!( | |
| 730 | + | code.contains("*\" $uid \"*"), | |
| 731 | + | "the notifier no longer skips a uid it has already collected", | |
| 732 | + | ); | |
| 733 | + | // The greeter runs as its own uid and would otherwise be told about every | |
| 734 | + | // device plugged in at the login screen. | |
| 735 | + | assert!( | |
| 736 | + | code.contains("-ge 1000"), | |
| 737 | + | "the notifier no longer excludes system accounts, so the greeter gets \ | |
| 738 | + | notified about hardware nobody is holding", | |
| 739 | + | ); | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | // The console is the fallback and not a second channel. Writing to VT1 draws | |
| 743 | + | // over whatever tuigreet has on it, which is right when there is nobody logged | |
| 744 | + | // in to notify and wrong on every device plug. | |
| 745 | + | #[test] | |
| 746 | + | fn the_console_notice_is_only_for_when_there_is_nobody_to_tell() { | |
| 747 | + | let code = code(¬ify_script()); | |
| 748 | + | let guard = code | |
| 749 | + | .find(r#"if [ "$told" -eq 0 ]"#) | |
| 750 | + | .expect("the notifier no longer gates its console write on having told nobody"); | |
| 751 | + | let tty = code | |
| 752 | + | .find("/dev/tty1") | |
| 753 | + | .expect("the notifier no longer has a console fallback at all"); | |
| 754 | + | assert!( | |
| 755 | + | guard < tty, | |
| 756 | + | "the notifier writes to the console unconditionally, so every plug \ | |
| 757 | + | scribbles over the greeter", | |
| 758 | + | ); | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | // The watcher must not be a reason for enforcement to start. Same rule as the | |
| 762 | + | // seeder and the gate: only the preset line and a person may arm this machine. | |
| 763 | + | #[test] | |
| 764 | + | fn the_watcher_never_pulls_the_daemon_in() { | |
| 765 | + | let unit = code(&watch_unit()); | |
| 766 | + | for forbidden in [ | |
| 767 | + | "Wants=usbguard.service", | |
| 768 | + | "Requires=usbguard.service", | |
| 769 | + | "BindsTo=usbguard.service", | |
| 770 | + | ] { | |
| 771 | + | assert!( | |
| 772 | + | !unit.contains(forbidden), | |
| 773 | + | "the watcher pulls usbguard in with {forbidden}, which makes \ | |
| 774 | + | watching for refusals a way to start refusing", | |
| 775 | + | ); | |
| 776 | + | } | |
| 777 | + | assert!( | |
| 778 | + | unit.contains("ConditionFileNotEmpty=/etc/usbguard/rules.conf"), | |
| 779 | + | "the watcher no longer shares usbguard's own condition, so on a machine \ | |
| 780 | + | with no policy it runs against a daemon that was skipped", | |
| 781 | + | ); | |
| 782 | + | // The gate stops the daemon whenever the machine loses its keyboard. A | |
| 783 | + | // watcher that exited on disconnect would be gone for good after the first | |
| 784 | + | // such episode, on exactly the machine that has already had one problem. | |
| 785 | + | assert!( | |
| 786 | + | unit.contains("--wait") && !unit.contains("--once"), | |
| 787 | + | "the watcher no longer survives the daemon going away and coming back", | |
| 788 | + | ); | |
| 789 | + | } |
| @@ -252,3 +252,18 @@ | |||
| 252 | 252 | # so, from `alloy usb`. That is the ruling, and it is the one default in this | |
| 253 | 253 | # file that makes the machine do less than it did yesterday. | |
| 254 | 254 | enable usbguard.service | |
| 255 | + | ||
| 256 | + | # And the half of the ruling that tells a person what just happened. usbguard | |
| 257 | + | # denies a device it has not been told about; without this the denial is silent, | |
| 258 | + | # and the report you get is "this stick is broken", which nobody can act on. | |
| 259 | + | # | |
| 260 | + | # `usbguard watch --exec` is the hook, so this fronts a CLI with an argv rather | |
| 261 | + | # than the D-Bus interface the stock applets use, which is the same correction | |
| 262 | + | # already made for `alloy bluetooth`. The message goes to whoever is logged in, | |
| 263 | + | # through the notification daemon this image already runs, and falls back to the | |
| 264 | + | # console when there is nobody to tell. | |
| 265 | + | # | |
| 266 | + | # Both profiles. A server has nobody sitting at it, and the journal line this | |
| 267 | + | # writes is the only record that a device was refused, which is exactly what | |
| 268 | + | # someone reads when a machine they cannot see does not see its own hardware. | |
| 269 | + | enable alloy-usb-watch.service |
| @@ -1,0 +1,51 @@ | |||
| 1 | + | # Watch usbguard for refused devices, so a person is told rather than left | |
| 2 | + | # holding something that does not work. | |
| 3 | + | # | |
| 4 | + | # The reasoning is in usr/bin/alloy-usb-notify; this file is only what keeps it | |
| 5 | + | # listening. `usbguard watch` is a long-lived IPC client that runs the program | |
| 6 | + | # once per event, which is why this is a service rather than something the | |
| 7 | + | # console starts. | |
| 8 | + | # | |
| 9 | + | # ## Why it is not tied to usbguard.service the obvious ways | |
| 10 | + | # | |
| 11 | + | # BindsTo= and Requires= both PULL THE DAEMON IN, and this must never be a | |
| 12 | + | # reason for enforcement to start. Same rule as the seeder and the gate: the | |
| 13 | + | # only thing allowed to arm this machine is the preset line and a person. | |
| 14 | + | # | |
| 15 | + | # Requisite= would avoid that but fails the unit when the daemon is not running, | |
| 16 | + | # which on a machine with no policy is the normal state and would leave a failed | |
| 17 | + | # unit sitting in `systemctl --failed` saying nothing useful. So the condition is | |
| 18 | + | # the same one usbguard itself carries: no policy, no enforcement, nothing to | |
| 19 | + | # watch, and both units are skipped quietly for the same reason on the same | |
| 20 | + | # machines. | |
| 21 | + | # | |
| 22 | + | # ## Why it survives the daemon going away | |
| 23 | + | # | |
| 24 | + | # The keyboard gate stops usbguard whenever the machine loses its keyboard, and | |
| 25 | + | # starts it again when one comes back. A watcher that exited on disconnect would | |
| 26 | + | # be gone for good after the first such episode, on exactly the machine that has | |
| 27 | + | # already had one problem. `usbguard watch --wait` reconnects rather than | |
| 28 | + | # exiting. `--once` is the flag that would make it exit, and it is deliberately | |
| 29 | + | # not passed. Restart=always covers the rest. | |
| 30 | + | # | |
| 31 | + | # Measured: with the daemon stopped and started underneath it, the watcher stays | |
| 32 | + | # up and keeps delivering. | |
| 33 | + | ||
| 34 | + | [Unit] | |
| 35 | + | Description=Tell someone when a USB device is refused | |
| 36 | + | Documentation=https://makenot.work/git/max/alloy | |
| 37 | + | After=usbguard.service | |
| 38 | + | ConditionPathExists=/usr/bin/usbguard | |
| 39 | + | # The same condition usbguard.service carries. A machine with no policy is not | |
| 40 | + | # enforcing, so there is nothing to report and this is skipped rather than | |
| 41 | + | # failed. See etc/systemd/system/usbguard.service.d/. | |
| 42 | + | ConditionFileNotEmpty=/etc/usbguard/rules.conf | |
| 43 | + | ||
| 44 | + | [Service] | |
| 45 | + | Type=simple | |
| 46 | + | ExecStart=/usr/bin/usbguard watch --wait --exec /usr/bin/alloy-usb-notify | |
| 47 | + | Restart=always | |
| 48 | + | RestartSec=2 | |
| 49 | + | ||
| 50 | + | [Install] | |
| 51 | + | WantedBy=multi-user.target |
| @@ -1,0 +1,165 @@ | |||
| 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 |