Skip to main content

max / alloy

13.5 KB · 287 lines History Blame Raw
1 #!/usr/bin/env bash
2 #
3 # check-installed.sh — assert that an *installed* machine is labelled the way
4 # the policy says it should be, and that the consequence of getting that wrong
5 # is actually absent.
6 #
7 # `bootc container lint` checks the image. On 2026-08-14 the image was fine and
8 # the machine was not: the installer's own configure stages run through a chroot
9 # with no `/sys` mounted, so libselinux concluded SELinux was off and
10 # shadow-utils wrote /etc/passwd, /etc/group, /etc/.pwd.lock, /etc/shadow and
11 # /etc/gshadow with no label, and `systemd-firstboot --root` did the same to
12 # /etc/hostname. Six files. Every build passed, because the defect was in what
13 # the installer wrote afterwards.
14 #
15 # Six mislabelled files would be a triviality if one of them were not
16 # `/etc/.pwd.lock`. systemd cannot take that lock as `etc_t`, so every unit with
17 # `DynamicUser=yes` fails to start, with no AVC in the journal because the
18 # denial is dontaudited. `rpm-ostreed.service` is one of those units, so the
19 # machine cannot layer a package at all: `alloy-layer-components.service` fails
20 # on the first boot after an install and the machine comes up with no console
21 # and no terminal, `rpm-ostree status` fails with `Could not activate remote
22 # peer`, and the hotfix channel is inert. The fix is `relabel_stages` in
23 # `crates/alloy/src/install.rs`; this script is the check that would have caught
24 # it the first time anyone installed, rather than on the day something finally
25 # needed DynamicUser.
26 #
27 # So it checks both ends, and both are cheap:
28 #
29 # labels `restorecon -nvR` over /etc and /usr, which reports what it would
30 # change and changes nothing.
31 # dynuser a real `systemd-run -p DynamicUser=yes`, because that is the
32 # property anyone cares about and no amount of label-reading proves
33 # it as directly as running one.
34 #
35 # A third check answers a different standing question, and is separate because
36 # it needs no root and reports rather than passes or fails:
37 #
38 # policy what polkit's *implicit* defaults actually are on this machine,
39 # for every action the console depends on.
40 #
41 # Wiki `alloy-privilege` records that table, read off a built Alloy image
42 # (NetworkManager 1.54.3, polkit 126). The whole privilege ladder is built on
43 # those values: tier 1 grants five actions precisely because their default is
44 # `auth_admin_keep`, and a Fedora override or an upstream change would make a
45 # shipped rules file either redundant or, worse, the only thing standing
46 # between a user and a prompt nobody can explain. The Containerfile asserts
47 # every row of POLICY_TABLE against the image's own `.policy` files on each
48 # build, reading the table out of this file. `--policy` asks the same question
49 # of a running machine, where a layered package or an `/etc` drop-in can differ
50 # from what the image shipped.
51 #
52 # Usage:
53 # sudo build/check-installed.sh # check this machine
54 # build/check-installed.sh --policy # read the polkit defaults back
55 # build/check-installed.sh --self-test # check the filter; touches nothing
56 #
57 # Or against a VM from the harness in build/vmtest:
58 # ssh alloy-vm 'sudo bash -s' < build/check-installed.sh
59 #
60 # The self-test exists for the same reason check-rust-stage.sh has one: the
61 # filter is the part that fails invisibly, since a wrong verdict still looks
62 # like a verdict. Run it after touching IGNORE.
63 #
64 # Exit codes follow check-rust-stage.sh:
65 #
66 # 0 the machine is labelled correctly and DynamicUser works. Under
67 # `--policy`, every action reads the way wiki `alloy-privilege` says.
68 # 1 it is not. Every offending line is printed, with the repair. Under
69 # `--policy`, at least one action has drifted or is not defined here.
70 # 3 something about the run, not about the machine: not root, no
71 # restorecon, or SELinux is not enforcing here. Prints `error:`.
72 #
73 # Why SELinux-off is 3 and not 0. Installing from the `selinux=0` GRUB entry
74 # (build/make-iso.sh) deliberately produces an unlabelled machine, and the
75 # installer skips relabelling there rather than failing, so that the escape
76 # hatch stays an escape hatch. A machine in that state is not passing this
77 # check; it is outside what the check can answer, and saying so is the honest
78 # result.
79
80 set -Eeuo pipefail
81
82 # Lines restorecon reports that are not a defect.
83 #
84 # A semanage transaction leaves empty lock files under the policy store,
85 # labelled by the process that made them rather than by the policy. They appear
86 # on any machine that has ever layered a package — which, on Alloy, is every
87 # machine after its first boot — and they are not related to anything here.
88 #
89 # Anchored on the policy store path rather than on the basename: a file called
90 # semanage.LOCK somewhere else in /etc would be a real finding, and this filter
91 # is the one place a real finding can be lost.
92 IGNORE='/etc/selinux/[^/ ]+/semanage\.[^/ ]*LOCK'
93
94 REPAIR='sudo restorecon -R /etc'
95
96 die() { printf 'error: %s\n' "$*" >&2; exit 3; }
97
98 trap 'rc=$?; [ "$rc" -eq 1 ] && exit 1; [ "$rc" -eq 3 ] && exit 3; die "unexpected failure at line ${LINENO}"' ERR
99
100 # Everything restorecon would change, minus the lines above. Reads stdin so the
101 # self-test can feed it a fixture instead of a machine.
102 filter_findings() { grep -Ev "$IGNORE" || true; }
103
104 # Fixtures are real: the six lines are the defect as it was found on 2026-08-14,
105 # and the two locks are what a correct machine reports after it has layered its
106 # components.
107 self_test() {
108 local fails=0 out
109
110 out="$(printf '%s\n' \
111 'Would relabel /etc/passwd from unconfined_u:object_r:etc_t:s0 to system_u:object_r:passwd_file_t:s0' \
112 'Would relabel /etc/group from unconfined_u:object_r:etc_t:s0 to system_u:object_r:passwd_file_t:s0' \
113 'Would relabel /etc/.pwd.lock from unconfined_u:object_r:etc_t:s0 to system_u:object_r:passwd_file_t:s0' \
114 'Would relabel /etc/shadow from unconfined_u:object_r:etc_t:s0 to system_u:object_r:shadow_t:s0' \
115 'Would relabel /etc/gshadow from unconfined_u:object_r:etc_t:s0 to system_u:object_r:shadow_t:s0' \
116 'Would relabel /etc/hostname from unconfined_u:object_r:var_run_t:s0 to system_u:object_r:hostname_etc_t:s0' \
117 | filter_findings | wc -l)"
118 if [ "$out" -ne 6 ]; then
119 printf 'self-test: the six known mislabels should all survive the filter, %s did\n' "$out" >&2
120 fails=$((fails + 1))
121 fi
122
123 out="$(printf '%s\n' \
124 'Would relabel /etc/selinux/targeted/semanage.read.LOCK from system_u:object_r:semanage_store_t:s0 to system_u:object_r:selinux_config_t:s0' \
125 'Would relabel /etc/selinux/targeted/semanage.trans.LOCK from system_u:object_r:semanage_store_t:s0 to system_u:object_r:selinux_config_t:s0' \
126 | filter_findings | wc -l)"
127 if [ "$out" -ne 0 ]; then
128 printf 'self-test: the semanage locks should be filtered, %s survived\n' "$out" >&2
129 fails=$((fails + 1))
130 fi
131
132 # The filter is anchored on the policy store, so a lock-shaped name elsewhere
133 # is a finding. This is the assertion that keeps IGNORE from being widened
134 # into something that hides one.
135 out="$(printf '%s\n' \
136 'Would relabel /etc/semanage.trans.LOCK from system_u:object_r:etc_t:s0 to system_u:object_r:selinux_config_t:s0' \
137 | filter_findings | wc -l)"
138 if [ "$out" -ne 1 ]; then
139 printf 'self-test: a lock outside the policy store should survive the filter\n' >&2
140 fails=$((fails + 1))
141 fi
142
143 if [ "$fails" -ne 0 ]; then
144 printf 'self-test: %s failed\n' "$fails" >&2
145 exit 1
146 fi
147 printf 'self-test: the filter is right about all three shapes\n'
148 }
149
150 # The policy table from wiki `alloy-privilege`, as `<action> <implicit active>`.
151 #
152 # Only the implicit defaults are read, not the effective answer. The effective
153 # answer on an Alloy machine includes
154 # `usr/share/polkit-1/rules.d/50-alloy-settings.rules`, which is asserted
155 # separately by `crates/alloy/tests/polkit_rules.rs` and by a Containerfile
156 # block. What this reads is the vendor default underneath: the five granted
157 # actions are granted *because* their default is `auth_admin_keep`, and a
158 # default that quietly became `yes` would make the rules file a security
159 # artifact shipped for no reason, while one that became `auth_admin` would mean
160 # the burst-caching the settings tab counts on is gone.
161 #
162 # This is the only copy of the table. The Containerfile's build assertion
163 # parses it out of this file rather than restating it, so the two cannot
164 # disagree.
165 #
166 # `NetworkManager.settings.modify.system` reads `yes` on the image, so saving a
167 # system connection needs neither a grant nor a prompt. It is in the table
168 # because the net views depend on that value holding.
169 #
170 # rpm-ostree and systemd1.manage-units are in the list without being granted
171 # anything: the first is what `alloy update` and `alloy pkg` go through and the
172 # prompt there is deliberate, and the second is what `run0` asks for, which is
173 # the command Alloy teaches instead of sudo.
174 POLICY_TABLE='
175 org.freedesktop.NetworkManager.network-control yes
176 org.freedesktop.NetworkManager.enable-disable-wifi yes
177 org.freedesktop.NetworkManager.enable-disable-network yes
178 org.freedesktop.NetworkManager.settings.modify.system yes
179 org.freedesktop.timedate1.set-timezone auth_admin_keep
180 org.freedesktop.timedate1.set-ntp auth_admin_keep
181 org.freedesktop.timedate1.set-time auth_admin_keep
182 org.freedesktop.hostname1.set-static-hostname auth_admin_keep
183 org.freedesktop.hostname1.set-machine-info auth_admin_keep
184 org.freedesktop.locale1.set-locale auth_admin_keep
185 org.freedesktop.locale1.set-keyboard auth_admin_keep
186 org.projectatomic.rpmostree1.upgrade auth_admin_keep
187 org.projectatomic.rpmostree1.rollback auth_admin_keep
188 org.projectatomic.rpmostree1.install-uninstall-packages auth_admin_keep
189 org.projectatomic.rpmostree1.client-management yes
190 org.freedesktop.systemd1.manage-units auth_admin_keep
191 '
192
193 # What polkit says the default is for one action, or `undefined` when this
194 # machine does not define it at all.
195 #
196 # An action nobody defines is the quiet failure worth catching: a grant naming
197 # a renamed action is inert and says nothing, and the symptom is a password
198 # prompt on a machine that was supposed to have none.
199 implicit_active() {
200 local out
201 out="$(pkaction --action-id "$1" --verbose 2>/dev/null)" || true
202 [ -n "$out" ] || { printf 'undefined\n'; return; }
203 printf '%s\n' "$out" | awk -F': *' '/implicit active:/ { print $2; found = 1 }
204 END { if (!found) print "unreadable" }'
205 }
206
207 read_policy() {
208 command -v pkaction >/dev/null 2>&1 || die "no pkaction: install polkit"
209
210 local drift=0 absent=0 action expected actual mark
211 while read -r action expected; do
212 [ -n "$action" ] || continue
213 actual="$(implicit_active "$action")"
214 if [ "$actual" = "$expected" ]; then
215 mark='ok '
216 elif [ "$actual" = undefined ]; then
217 mark='ABSENT '
218 absent=$((absent + 1))
219 else
220 mark='DRIFT '
221 drift=$((drift + 1))
222 fi
223 printf '%s %-56s %s\n' "$mark" "$action" "$actual"
224 done <<< "$POLICY_TABLE"
225
226 # Reported apart because they mean different things. DRIFT is the reading
227 # this exists to catch: the default moved and the ladder is built on the old
228 # one. ABSENT is usually the host, not the policy — run this on a machine
229 # without rpm-ostree, as fw13 is, and the four rpm-ostree rows cannot be
230 # answered by anything. On a booted Alloy image both are findings.
231 if [ "$((drift + absent))" -ne 0 ]; then
232 [ "$drift" -eq 0 ] || printf '\n%s action(s) read differently than wiki `alloy-privilege` records.\n' "$drift"
233 [ "$absent" -eq 0 ] || printf '\n%s action(s) are not defined on this machine.\n' "$absent"
234 printf 'That table decides which actions tier 1 grants, so correct the note before the rules file.\n'
235 return 1
236 fi
237 printf '\nEvery action reads the way wiki `alloy-privilege` records it.\n'
238 }
239
240 case "${1:-}" in
241 --self-test) self_test; exit 0 ;;
242 # Spelled as a conditional rather than `read_policy; exit $?`, so a drift
243 # verdict returns through the `if` instead of tripping the ERR trap.
244 --policy) if read_policy; then exit 0; else exit 1; fi ;;
245 '') ;;
246 *) die "unknown argument: $1" ;;
247 esac
248
249 [ "$(id -u)" -eq 0 ] || die "run as root: restorecon cannot stat everything under /etc otherwise, and a partial read would pass"
250 command -v restorecon >/dev/null 2>&1 || die "no restorecon: install policycoreutils"
251 [ -d /sys/fs/selinux ] || die "SELinux is not enforcing on this machine, so there is nothing to compare against"
252
253 status=0
254
255 # -n changes nothing, -v prints what it would have changed. /usr as well as
256 # /etc: the 2026-08-14 defect was confined to /etc, and the reason anyone knew
257 # that was a clean /usr, so checking only the half that broke would leave the
258 # next one unmeasured.
259 findings="$(restorecon -nvR /etc /usr 2>/dev/null | filter_findings)"
260 if [ -n "$findings" ]; then
261 printf 'mislabelled, %s file(s):\n' "$(printf '%s\n' "$findings" | wc -l)"
262 printf '%s\n' "$findings"
263 printf '\nrepair: %s\n' "$REPAIR"
264 status=1
265 else
266 printf 'labels: /etc and /usr match the policy\n'
267 fi
268
269 # The consequence, measured rather than inferred. `systemd-run --wait` returns
270 # the unit's own exit status, so a failure to *start* it is what is being
271 # caught here and /bin/true is only there to be something to start.
272 if command -v systemd-run >/dev/null 2>&1; then
273 if err="$(systemd-run -q --wait --property=DynamicUser=yes /bin/true 2>&1)"; then
274 printf 'dynuser: a DynamicUser unit starts\n'
275 else
276 printf 'dynuser: a DynamicUser unit does NOT start, so rpm-ostreed cannot run and nothing can be layered\n'
277 printf '%s\n' "$err"
278 printf '\nrepair: %s\n' "$REPAIR"
279 status=1
280 fi
281 else
282 # Not fatal: the label half is the check, and this half is its confirmation.
283 printf 'dynuser: skipped, no systemd-run\n'
284 fi
285
286 exit "$status"
287