| 58 |
58 |
|
//! go straight to sysfs and every parse is a pure function over a string, so the
|
| 59 |
59 |
|
//! tests describe machines this one is not.
|
| 60 |
60 |
|
//!
|
| 61 |
|
- |
//! The image gained `usbguard` on 2026-08-22 and this module still does not use
|
| 62 |
|
- |
//! it, which is deliberate on both counts. The package is there so the action
|
| 63 |
|
- |
//! half has something to call; its daemon ships disabled, because its stock
|
| 64 |
|
- |
//! policy is an empty rule file with `ImplicitPolicyTarget=block` and arming
|
| 65 |
|
- |
//! that deauthorizes the machine's own keyboard at boot. Reading sysfs rather
|
| 66 |
|
- |
//! than `usbguard list-devices` is what keeps this screen answering on a machine
|
| 67 |
|
- |
//! where the daemon is off, which today is every machine. The action half, when
|
| 68 |
|
- |
//! it arrives, fronts the CLI: `allow-device`, `block-device`, and
|
| 69 |
|
- |
//! `allow-device --permanent` for a decision that survives a reboot.
|
|
61 |
+ |
//! The image gained `usbguard` on 2026-08-22 and the device lists still do not
|
|
62 |
+ |
//! use it, which is deliberate on both counts. The package is there so the
|
|
63 |
+ |
//! action half has something to call; its daemon ships disabled, because its
|
|
64 |
+ |
//! stock policy is an empty rule file with `ImplicitPolicyTarget=block` and
|
|
65 |
+ |
//! arming that deauthorizes the machine's own keyboard at boot. Reading sysfs
|
|
66 |
+ |
//! rather than `usbguard list-devices` is what keeps this screen answering on a
|
|
67 |
+ |
//! machine where the daemon is off, which today is every machine.
|
|
68 |
+ |
//!
|
|
69 |
+ |
//! What the screen does say is [`Enforcement`]: whether anything is policing the
|
|
70 |
+ |
//! bus at all. That is a read of two files and one `systemctl is-active`, no
|
|
71 |
+ |
//! privilege and no IPC, and it is on screen because the alternative is worse
|
|
72 |
+ |
//! than silence. A reader who can see that the image carries usbguard, and is
|
|
73 |
+ |
//! told nothing about it, can reasonably conclude the bus is being policed.
|
|
74 |
+ |
//!
|
|
75 |
+ |
//! The acting half is not here yet, and the reason is a measurement rather than
|
|
76 |
+ |
//! a plan. On usbguard-1.1.4 every verb except `generate-policy` goes over the
|
|
77 |
+ |
//! daemon's IPC socket, and `allow-device` is addressed by the device id the
|
|
78 |
+ |
//! daemon assigns in `list-devices` — not by anything in sysfs. So the join from
|
|
79 |
+ |
//! a row on this screen to a device usbguard will act on cannot be written
|
|
80 |
+ |
//! against a machine where the daemon has never run, which is every machine
|
|
81 |
+ |
//! this has been developed on. It ships with the bench tests, against a real
|
|
82 |
+ |
//! armed daemon, rather than as a guess that reads as a fact.
|
| 70 |
83 |
|
//!
|
| 71 |
84 |
|
//! What did arrive on 2026-08-22 is the keyboard gate, `usr/bin/alloy-usb-gate`,
|
| 72 |
85 |
|
//! which is the clause that makes deny-unknown safe to arm at all: it drops
|
| 91 |
104 |
|
use ratatui::text::Line;
|
| 92 |
105 |
|
use ratatui::widgets::{Paragraph, Wrap};
|
| 93 |
106 |
|
|
| 94 |
|
- |
use crate::cli::CommandLog;
|
|
107 |
+ |
use crate::cli::{CommandLog, Invocation};
|
| 95 |
108 |
|
use crate::shell::{Flow, View, block_title, truncate};
|
| 96 |
109 |
|
|
| 97 |
110 |
|
/// The USB device tree. One directory per device, plus one per interface.
|
| 771 |
784 |
|
modes
|
| 772 |
785 |
|
}
|
| 773 |
786 |
|
|
|
787 |
+ |
// ---- reading the enforcement state ----
|
|
788 |
+ |
|
|
789 |
+ |
/// usbguard's own binary. Its presence is what says the action half is even
|
|
790 |
+ |
/// possible on this machine; images before 2026-08-22 do not carry it.
|
|
791 |
+ |
const USBGUARD: &str = "usr/bin/usbguard";
|
|
792 |
+ |
|
|
793 |
+ |
/// The rule set the daemon enforces.
|
|
794 |
+ |
///
|
|
795 |
+ |
/// Mode 0600 root:root, so an ordinary console user cannot read it. It can
|
|
796 |
+ |
/// still `stat` it, because the directory above is world-executable, and the
|
|
797 |
+ |
/// size is the whole of what this screen needs: a policy that exists against
|
|
798 |
+ |
/// one that does not is the difference between arming being safe and arming
|
|
799 |
+ |
/// being a brick.
|
|
800 |
+ |
const POLICY: &str = "etc/usbguard/rules.conf";
|
|
801 |
+ |
|
|
802 |
+ |
/// What USB device authorization is actually doing on this machine.
|
|
803 |
+ |
///
|
|
804 |
+ |
/// Read rather than assumed, and worth reading because the answer is
|
|
805 |
+ |
/// counter-intuitive on every machine shipped so far: the image carries
|
|
806 |
+ |
/// usbguard and deliberately does not run it (docs/STACK.md, and the
|
|
807 |
+ |
/// Containerfile assertion that holds it that way). A screen that showed
|
|
808 |
+ |
/// attachments without saying that would let someone conclude the bus was
|
|
809 |
+ |
/// being policed because the tooling was clearly present.
|
|
810 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
811 |
+ |
pub(crate) enum Enforcement {
|
|
812 |
+ |
/// usbguard is not on this machine at all.
|
|
813 |
+ |
Absent,
|
|
814 |
+ |
/// Installed, not running. `policy` is whether a rule set has been written.
|
|
815 |
+ |
///
|
|
816 |
+ |
/// The two cases are very different and the screen says which. With no
|
|
817 |
+ |
/// policy, arming denies everything including the keyboard, and the whole
|
|
818 |
+ |
/// reason the unit ships disabled. With a policy, arming is an ordinary
|
|
819 |
+ |
/// decision.
|
|
820 |
+ |
Unarmed { policy: bool },
|
|
821 |
+ |
/// The daemon is running and applying its rule set.
|
|
822 |
+ |
Armed,
|
|
823 |
+ |
}
|
|
824 |
+ |
|
|
825 |
+ |
impl Enforcement {
|
|
826 |
+ |
/// The one-line answer for the status bar.
|
|
827 |
+ |
pub(crate) fn line(self) -> (Severity, &'static str) {
|
|
828 |
+ |
match self {
|
|
829 |
+ |
// Not a warning. This is what every Alloy machine ships as, it is
|
|
830 |
+ |
// a deliberate position with a document behind it, and a screen
|
|
831 |
+ |
// that cried WARN about its own shipped default would train the
|
|
832 |
+ |
// reader to ignore the row.
|
|
833 |
+ |
Self::Absent => (
|
|
834 |
+ |
Severity::Info,
|
|
835 |
+ |
"usbguard is not on this machine, so nothing can enforce USB policy",
|
|
836 |
+ |
),
|
|
837 |
+ |
Self::Unarmed { policy: false } => (
|
|
838 |
+ |
Severity::Info,
|
|
839 |
+ |
"USB authorization is not enforced, and no policy is written yet",
|
|
840 |
+ |
),
|
|
841 |
+ |
Self::Unarmed { policy: true } => (
|
|
842 |
+ |
Severity::Info,
|
|
843 |
+ |
"USB authorization is not enforced; a policy is written and waiting",
|
|
844 |
+ |
),
|
|
845 |
+ |
Self::Armed => (
|
|
846 |
+ |
Severity::Healthy,
|
|
847 |
+ |
"USB authorization is enforced by usbguard",
|
|
848 |
+ |
),
|
|
849 |
+ |
}
|
|
850 |
+ |
}
|
|
851 |
+ |
}
|
|
852 |
+ |
|
|
853 |
+ |
/// Read the enforcement state.
|
|
854 |
+ |
///
|
|
855 |
+ |
/// `armed` comes from the caller because it is a process spawn rather than a
|
|
856 |
+ |
/// file read, and keeping it out means everything below is a pure function over
|
|
857 |
+ |
/// a directory tree that the tests can build.
|
|
858 |
+ |
pub(crate) fn enforcement(log: &mut CommandLog) -> Enforcement {
|
|
859 |
+ |
enforcement_in(Path::new("/"), daemon_running(log))
|
|
860 |
+ |
}
|
|
861 |
+ |
|
|
862 |
+ |
fn enforcement_in(root: &Path, armed: bool) -> Enforcement {
|
|
863 |
+ |
if !root.join(USBGUARD).exists() {
|
|
864 |
+ |
// Armed with no binary is not a state that exists; if the caller says
|
|
865 |
+ |
// so, the binary is what is authoritative.
|
|
866 |
+ |
return Enforcement::Absent;
|
|
867 |
+ |
}
|
|
868 |
+ |
if armed {
|
|
869 |
+ |
return Enforcement::Armed;
|
|
870 |
+ |
}
|
|
871 |
+ |
// `len` rather than reading: see POLICY. A file we are not allowed to open
|
|
872 |
+ |
// still has a size, and "empty" is the only thing this has to distinguish.
|
|
873 |
+ |
let policy = std::fs::metadata(root.join(POLICY)).is_ok_and(|meta| meta.len() > 0);
|
|
874 |
+ |
Enforcement::Unarmed { policy }
|
|
875 |
+ |
}
|
|
876 |
+ |
|
|
877 |
+ |
/// Whether usbguard is running, asked of systemd rather than of usbguard.
|
|
878 |
+ |
///
|
|
879 |
+ |
/// `usbguard list-devices` would answer too, and answers wrongly for this
|
|
880 |
+ |
/// purpose: measured on usbguard-1.1.4, every verb except `generate-policy`
|
|
881 |
+ |
/// goes over the daemon's IPC socket and exits 1 with "IPC connect: Connection
|
|
882 |
+ |
/// refused" when it is not there. So the CLI conflates "not running" with
|
|
883 |
+ |
/// "running and refusing me", which are different sentences to put on a screen.
|
|
884 |
+ |
/// `systemctl is-active` needs no privilege and no socket.
|
|
885 |
+ |
fn daemon_running(log: &mut CommandLog) -> bool {
|
|
886 |
+ |
// Quiet: this is state the screen reads to draw itself, not an action
|
|
887 |
+ |
// anyone asked for, and the log pane's promise is about the latter.
|
|
888 |
+ |
log.quiet(|_log| {
|
|
889 |
+ |
Invocation::new("systemctl")
|
|
890 |
+ |
.args(["is-active", "--quiet", "usbguard.service"])
|
|
891 |
+ |
.probe()
|
|
892 |
+ |
})
|
|
893 |
+ |
}
|
|
894 |
+ |
|
| 774 |
895 |
|
// ---- the view ----
|
| 775 |
896 |
|
|
| 776 |
897 |
|
/// Which list `alloy usb` is showing.
|
| 812 |
933 |
|
/// nobody plugged in are the first eight rows otherwise, and the question
|
| 813 |
934 |
|
/// this screen answers is about what is attached.
|
| 814 |
935 |
|
show_controllers: bool,
|
|
936 |
+ |
/// What is policing the bus, if anything. Re-read on every refresh, because
|
|
937 |
+ |
/// arming and disarming happen outside this screen today.
|
|
938 |
+ |
enforcement: Enforcement,
|
| 815 |
939 |
|
}
|
| 816 |
940 |
|
|
| 817 |
941 |
|
impl UsbView {
|
| 823 |
947 |
|
attachment_cursor: Cursor::new(),
|
| 824 |
948 |
|
connector_cursor: Cursor::new(),
|
| 825 |
949 |
|
show_controllers: false,
|
|
950 |
+ |
enforcement: Enforcement::Absent,
|
| 826 |
951 |
|
};
|
| 827 |
952 |
|
view.refresh(log);
|
| 828 |
953 |
|
view
|
| 833 |
958 |
|
/// Quiet as far as the log is concerned: nothing runs, so there is no argv
|
| 834 |
959 |
|
/// to show, and a read that writes nothing is not an action the log's
|
| 835 |
960 |
|
/// promise is about.
|
| 836 |
|
- |
fn refresh(&mut self, _log: &mut CommandLog) {
|
|
961 |
+ |
fn refresh(&mut self, log: &mut CommandLog) {
|
| 837 |
962 |
|
self.attachments = attachments();
|
| 838 |
963 |
|
self.connectors = connectors();
|
|
964 |
+ |
self.enforcement = enforcement(log);
|
| 839 |
965 |
|
self.attachment_cursor.resize(self.rows().len());
|
| 840 |
966 |
|
self.connector_cursor.resize(self.connectors.len());
|
| 841 |
967 |
|
}
|
| 1112 |
1238 |
|
|
| 1113 |
1239 |
|
fn status(&self) -> Option<(Severity, String)> {
|
| 1114 |
1240 |
|
// A device the kernel refused is the one state on this screen that is
|
| 1115 |
|
- |
// not merely information, so it gets the line.
|
|
1241 |
+ |
// not merely information, so it gets the line ahead of everything else.
|
| 1116 |
1242 |
|
let blocked = self
|
| 1117 |
1243 |
|
.attachments
|
| 1118 |
1244 |
|
.iter()
|
| 1124 |
1250 |
|
format!("{blocked} attachment(s) not authorized"),
|
| 1125 |
1251 |
|
));
|
| 1126 |
1252 |
|
}
|
|
1253 |
+ |
// Then what is policing the bus, which on every machine shipped so far
|
|
1254 |
+ |
// is nothing. Saying so is the point: the image carries usbguard, so a
|
|
1255 |
+ |
// reader who saw the tooling and no statement about it could reasonably
|
|
1256 |
+ |
// conclude the bus was being policed.
|
|
1257 |
+ |
if self.enforcement != Enforcement::Armed {
|
|
1258 |
+ |
let (severity, line) = self.enforcement.line();
|
|
1259 |
+ |
return Some((severity, line.to_string()));
|
|
1260 |
+ |
}
|
| 1127 |
1261 |
|
if self.tab == Tab::Connectors && self.connectors.is_empty() {
|
| 1128 |
1262 |
|
return Some((
|
| 1129 |
1263 |
|
Severity::Healthy,
|
| 1130 |
1264 |
|
"this machine reports no Type-C connectors".to_string(),
|
| 1131 |
1265 |
|
));
|
| 1132 |
1266 |
|
}
|
| 1133 |
|
- |
None
|
|
1267 |
+ |
let (severity, line) = self.enforcement.line();
|
|
1268 |
+ |
Some((severity, line.to_string()))
|
| 1134 |
1269 |
|
}
|
| 1135 |
1270 |
|
|
| 1136 |
1271 |
|
fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
| 1592 |
1727 |
|
Some("left top")
|
| 1593 |
1728 |
|
);
|
| 1594 |
1729 |
|
}
|
|
1730 |
+ |
|
|
1731 |
+ |
// ---- the enforcement state ----
|
|
1732 |
+ |
|
|
1733 |
+ |
/// A fake root carrying whichever of usbguard's two files the case wants.
|
|
1734 |
+ |
fn machine(case: &str, binary: bool, policy: Option<&str>) -> PathBuf {
|
|
1735 |
+ |
let root = scratch(case);
|
|
1736 |
+ |
if binary {
|
|
1737 |
+ |
std::fs::create_dir_all(root.join("usr/bin")).unwrap();
|
|
1738 |
+ |
std::fs::write(root.join(USBGUARD), "").unwrap();
|
|
1739 |
+ |
}
|
|
1740 |
+ |
if let Some(rules) = policy {
|
|
1741 |
+ |
std::fs::create_dir_all(root.join("etc/usbguard")).unwrap();
|
|
1742 |
+ |
std::fs::write(root.join(POLICY), rules).unwrap();
|
|
1743 |
+ |
}
|
|
1744 |
+ |
root
|
|
1745 |
+ |
}
|
|
1746 |
+ |
|
|
1747 |
+ |
// The state every Alloy machine ships in, and the one the screen most has
|
|
1748 |
+ |
// to say out loud: the tooling is present and nothing is running it.
|
|
1749 |
+ |
#[test]
|
|
1750 |
+ |
fn an_image_that_carries_usbguard_and_does_not_run_it_reads_as_unarmed() {
|
|
1751 |
+ |
let root = machine("unarmed", true, Some(""));
|
|
1752 |
+ |
assert_eq!(
|
|
1753 |
+ |
enforcement_in(&root, false),
|
|
1754 |
+ |
Enforcement::Unarmed { policy: false },
|
|
1755 |
+ |
);
|
|
1756 |
+ |
}
|
|
1757 |
+ |
|
|
1758 |
+ |
// The difference the screen exists to draw. An empty rule file plus
|
|
1759 |
+ |
// ImplicitPolicyTarget=block means arming denies the keyboard; a written
|
|
1760 |
+ |
// one means arming is an ordinary decision. Same daemon state, and the two
|
|
1761 |
+ |
// must not read alike.
|
|
1762 |
+ |
#[test]
|
|
1763 |
+ |
fn a_written_policy_is_a_different_state_from_an_empty_one() {
|
|
1764 |
+ |
let empty = machine("policy-empty", true, Some(""));
|
|
1765 |
+ |
let written = machine("policy-written", true, Some("allow id 1d6b:0002\n"));
|
|
1766 |
+ |
assert_eq!(
|
|
1767 |
+ |
enforcement_in(&empty, false),
|
|
1768 |
+ |
Enforcement::Unarmed { policy: false },
|
|
1769 |
+ |
);
|
|
1770 |
+ |
assert_eq!(
|
|
1771 |
+ |
enforcement_in(&written, false),
|
|
1772 |
+ |
Enforcement::Unarmed { policy: true },
|
|
1773 |
+ |
);
|
|
1774 |
+ |
assert_ne!(
|
|
1775 |
+ |
enforcement_in(&empty, false).line().1,
|
|
1776 |
+ |
enforcement_in(&written, false).line().1,
|
|
1777 |
+ |
"the two say the same sentence, and they are the difference between \
|
|
1778 |
+ |
arming being safe and arming being a brick",
|
|
1779 |
+ |
);
|
|
1780 |
+ |
}
|
|
1781 |
+ |
|
|
1782 |
+ |
// A missing rules.conf is the same answer as an empty one. usbguard ships
|
|
1783 |
+ |
// the file, so its absence is a stripped machine rather than a policy.
|
|
1784 |
+ |
#[test]
|
|
1785 |
+ |
fn a_missing_rule_file_reads_as_no_policy_rather_than_as_an_error() {
|
|
1786 |
+ |
let root = machine("policy-absent", true, None);
|
|
1787 |
+ |
assert_eq!(
|
|
1788 |
+ |
enforcement_in(&root, false),
|
|
1789 |
+ |
Enforcement::Unarmed { policy: false },
|
|
1790 |
+ |
);
|
|
1791 |
+ |
}
|
|
1792 |
+ |
|
|
1793 |
+ |
#[test]
|
|
1794 |
+ |
fn a_machine_without_the_binary_is_absent_whatever_systemd_says() {
|
|
1795 |
+ |
let root = machine("no-binary", false, None);
|
|
1796 |
+ |
assert_eq!(enforcement_in(&root, false), Enforcement::Absent);
|
|
1797 |
+ |
// Armed with no binary is not a state that exists. If systemd claims a
|
|
1798 |
+ |
// unit is active on a machine with no usbguard, the binary is what the
|
|
1799 |
+ |
// screen believes, rather than reporting an enforcement nothing can be
|
|
1800 |
+ |
// performing.
|
|
1801 |
+ |
assert_eq!(enforcement_in(&root, true), Enforcement::Absent);
|
|
1802 |
+ |
}
|
|
1803 |
+ |
|
|
1804 |
+ |
#[test]
|
|
1805 |
+ |
fn a_running_daemon_reads_as_armed() {
|
|
1806 |
+ |
let root = machine("armed", true, Some("allow id 1d6b:0002\n"));
|
|
1807 |
+ |
assert_eq!(enforcement_in(&root, true), Enforcement::Armed);
|
|
1808 |
+ |
assert_eq!(enforcement_in(&root, true).line().0, Severity::Healthy);
|
|
1809 |
+ |
}
|
|
1810 |
+ |
|
|
1811 |
+ |
// Only the armed state is Healthy. The other three are Info rather than
|
|
1812 |
+ |
// Warn on purpose: an image shipping its own documented default must not
|
|
1813 |
+ |
// cry warning about it, or the row stops being read.
|
|
1814 |
+ |
#[test]
|
|
1815 |
+ |
fn the_shipped_default_is_stated_rather_than_warned_about() {
|
|
1816 |
+ |
for state in [
|
|
1817 |
+ |
Enforcement::Absent,
|
|
1818 |
+ |
Enforcement::Unarmed { policy: false },
|
|
1819 |
+ |
Enforcement::Unarmed { policy: true },
|
|
1820 |
+ |
] {
|
|
1821 |
+ |
assert_eq!(
|
|
1822 |
+ |
state.line().0,
|
|
1823 |
+ |
Severity::Info,
|
|
1824 |
+ |
"{state:?} warns about a state every Alloy machine ships in",
|
|
1825 |
+ |
);
|
|
1826 |
+ |
}
|
|
1827 |
+ |
}
|
| 1595 |
1828 |
|
}
|