From 386b558c4e5503340947efd07ffcec26c1b7b78f Mon Sep 17 00:00:00 2001 From: Simon Walz Date: Tue, 25 Aug 2026 12:37:42 +0200 Subject: [PATCH 1/8] feat: apparmor support Signed-off-by: Simon Walz --- system/Dockerfile | 1 + system/Dockerfile.lxc | 1 + system/Dockerfile.orangepi-zero3 | 1 + system/Dockerfile.rpi-arm32 | 1 + system/Dockerfile.rpi-arm64 | 1 + system/armbianEnv.txt | 2 +- system/cuos/install-kernel-grub.sh | 2 +- system/cuos/install-kernel-rpi.sh | 2 +- 8 files changed, 8 insertions(+), 3 deletions(-) diff --git a/system/Dockerfile b/system/Dockerfile index af1b6e8..fe2d3bc 100644 --- a/system/Dockerfile +++ b/system/Dockerfile @@ -48,6 +48,7 @@ RUN sed -i 's/^Components: main$/Components: main non-free-firmware/' /etc/apt/s ## cuos os extra for wake on lan \ ethtool \ ## cuos os dependencies \ + apparmor \ btrfs-progs \ console-setup \ dbus \ diff --git a/system/Dockerfile.lxc b/system/Dockerfile.lxc index fe3d8c6..b0e77fb 100644 --- a/system/Dockerfile.lxc +++ b/system/Dockerfile.lxc @@ -16,6 +16,7 @@ RUN echo "Cache buster: v1" \ ## cuos system dependencies (lxc) \ busybox-static \ ## cuos os dependencies \ + apparmor \ #-btrfs-progs \ dbus \ dbus-broker \ diff --git a/system/Dockerfile.orangepi-zero3 b/system/Dockerfile.orangepi-zero3 index 9f5e4ed..76093d8 100644 --- a/system/Dockerfile.orangepi-zero3 +++ b/system/Dockerfile.orangepi-zero3 @@ -75,6 +75,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ # rpi-swap \ # iw \ ## cuos os dependencies \ + apparmor \ btrfs-progs \ console-setup \ dbus \ diff --git a/system/Dockerfile.rpi-arm32 b/system/Dockerfile.rpi-arm32 index d61b01c..0a0738b 100644 --- a/system/Dockerfile.rpi-arm32 +++ b/system/Dockerfile.rpi-arm32 @@ -114,6 +114,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ # rpi-swap \ # iw \ ## cuos os dependencies \ + apparmor \ btrfs-progs \ console-setup \ dbus \ diff --git a/system/Dockerfile.rpi-arm64 b/system/Dockerfile.rpi-arm64 index f6c627d..062ec59 100644 --- a/system/Dockerfile.rpi-arm64 +++ b/system/Dockerfile.rpi-arm64 @@ -95,6 +95,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ # rpi-swap \ # iw \ ## cuos os dependencies \ + apparmor \ btrfs-progs \ console-setup \ dbus \ diff --git a/system/armbianEnv.txt b/system/armbianEnv.txt index 2c304f4..df848ab 100644 --- a/system/armbianEnv.txt +++ b/system/armbianEnv.txt @@ -7,5 +7,5 @@ disp_mode=1920x1080p60 overlay_prefix=sun50i-h616 rootdev=LABEL=system rootfstype=btrfs -extraargs=rootflags=subvol=@os/system-{{SLOT}} loglevel=3 noresume apparmor=0 +extraargs=rootflags=subvol=@os/system-{{SLOT}} loglevel=3 noresume apparmor usbstoragequirks=0x2537:0x1066:u,0x2537:0x1068:u diff --git a/system/cuos/install-kernel-grub.sh b/system/cuos/install-kernel-grub.sh index 8ab3b51..6665a5f 100755 --- a/system/cuos/install-kernel-grub.sh +++ b/system/cuos/install-kernel-grub.sh @@ -82,7 +82,7 @@ menuentry '${SLOT_NAME}' --unrestricted { search --no-floppy --label boot --set=root - linux /${filename_kernel} root=LABEL=system rootfstype=btrfs rootflags=subvol=${CONTAINER_ROOTFS} ro loglevel=3 noresume apparmor=0 + linux /${filename_kernel} root=LABEL=system rootfstype=btrfs rootflags=subvol=${CONTAINER_ROOTFS} ro loglevel=3 noresume apparmor initrd /${filename_initrd} } diff --git a/system/cuos/install-kernel-rpi.sh b/system/cuos/install-kernel-rpi.sh index 9633144..30eb707 100755 --- a/system/cuos/install-kernel-rpi.sh +++ b/system/cuos/install-kernel-rpi.sh @@ -38,7 +38,7 @@ fi mv "${TARGET_BOOT}"/cmdline.txt "${TARGET_BOOT}"/cmdline-previous.txt 2>/dev/null || true cat < "${TARGET_BOOT}/cmdline.txt" -console=serial0,115200 console=tty1 rootwait root=LABEL=system rootfstype=btrfs rootflags=subvol=${CONTAINER_ROOTFS} fsck.repair=yes ro loglevel=3 noresume apparmor=0 +console=serial0,115200 console=tty1 rootwait root=LABEL=system rootfstype=btrfs rootflags=subvol=${CONTAINER_ROOTFS} fsck.repair=yes ro loglevel=3 noresume apparmor EOF # Attach options from system.json From 3bdb16af0bbb19c68cac337d6ee86ea922fed388 Mon Sep 17 00:00:00 2001 From: Simon Walz Date: Wed, 9 Sep 2026 16:45:05 +0000 Subject: [PATCH 2/8] feat(lxc): no apparmor package in the LXC image Profiles are loaded into the host's kernel, and a container cannot load its own - that needs AppArmor namespaces and a writable /sys/kernel/security/apparmor. On the LXC platform apparmor.service would only fail, and what confines the guest is the host's business. Not built yet. Signed-off-by: Simon Walz --- system/Dockerfile.lxc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/system/Dockerfile.lxc b/system/Dockerfile.lxc index b0e77fb..3bfa38d 100644 --- a/system/Dockerfile.lxc +++ b/system/Dockerfile.lxc @@ -16,7 +16,9 @@ RUN echo "Cache buster: v1" \ ## cuos system dependencies (lxc) \ busybox-static \ ## cuos os dependencies \ - apparmor \ + # AppArmor profiles are loaded into the host's kernel, and a container \ + # cannot load its own. Confining an LXC guest is the host's business. \ + #-apparmor \ #-btrfs-progs \ dbus \ dbus-broker \ From c0e28ce26018a69a5067aeaf0df3061ff109eda2 Mon Sep 17 00:00:00 2001 From: Simon Walz Date: Wed, 9 Sep 2026 16:52:00 +0000 Subject: [PATCH 3/8] fix: apparmor=1 on the kernel command line, not a bare apparmor A valueless boolean module parameter most likely parses as true, but apparmor=1 is the documented form and leaves nothing to read twice. No security=apparmor alongside it: Debian's kernel already carries apparmor in CONFIG_LSM, and security= would override that whole list and drop landlock and yama with it. The installer ISO keeps apparmor=0 and now says why - it is a separate image without the apparmor package, so there is nothing to load there. Not built yet. Signed-off-by: Simon Walz --- installer-factory/prepare_iso.sh | 5 +++++ system/armbianEnv.txt | 2 +- system/cuos/install-kernel-grub.sh | 2 +- system/cuos/install-kernel-rpi.sh | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/installer-factory/prepare_iso.sh b/installer-factory/prepare_iso.sh index f922dd9..ef931b9 100755 --- a/installer-factory/prepare_iso.sh +++ b/installer-factory/prepare_iso.sh @@ -20,6 +20,11 @@ mv "/boot/vmlinuz"-* "${ISO_DIR}/boot/vmlinuz" mv "${INITRD_IMAGE}" "${ISO_DIR}/boot/initrd.img" # Create grub config +# +# The installer keeps apparmor=0 while the system image boots with apparmor=1. +# This image is not the system image: it carries no apparmor package, so there +# is no profile to load and no apparmor.service to load one. Switching the LSM +# on here would confine nothing and could only cost an installation. cat <"${ISO_DIR}/boot/grub/grub.cfg" set timeout=0 set default=0 diff --git a/system/armbianEnv.txt b/system/armbianEnv.txt index df848ab..b74244f 100644 --- a/system/armbianEnv.txt +++ b/system/armbianEnv.txt @@ -7,5 +7,5 @@ disp_mode=1920x1080p60 overlay_prefix=sun50i-h616 rootdev=LABEL=system rootfstype=btrfs -extraargs=rootflags=subvol=@os/system-{{SLOT}} loglevel=3 noresume apparmor +extraargs=rootflags=subvol=@os/system-{{SLOT}} loglevel=3 noresume apparmor=1 usbstoragequirks=0x2537:0x1066:u,0x2537:0x1068:u diff --git a/system/cuos/install-kernel-grub.sh b/system/cuos/install-kernel-grub.sh index 6665a5f..47660a2 100755 --- a/system/cuos/install-kernel-grub.sh +++ b/system/cuos/install-kernel-grub.sh @@ -82,7 +82,7 @@ menuentry '${SLOT_NAME}' --unrestricted { search --no-floppy --label boot --set=root - linux /${filename_kernel} root=LABEL=system rootfstype=btrfs rootflags=subvol=${CONTAINER_ROOTFS} ro loglevel=3 noresume apparmor + linux /${filename_kernel} root=LABEL=system rootfstype=btrfs rootflags=subvol=${CONTAINER_ROOTFS} ro loglevel=3 noresume apparmor=1 initrd /${filename_initrd} } diff --git a/system/cuos/install-kernel-rpi.sh b/system/cuos/install-kernel-rpi.sh index 30eb707..1c3a91d 100755 --- a/system/cuos/install-kernel-rpi.sh +++ b/system/cuos/install-kernel-rpi.sh @@ -38,7 +38,7 @@ fi mv "${TARGET_BOOT}"/cmdline.txt "${TARGET_BOOT}"/cmdline-previous.txt 2>/dev/null || true cat < "${TARGET_BOOT}/cmdline.txt" -console=serial0,115200 console=tty1 rootwait root=LABEL=system rootfstype=btrfs rootflags=subvol=${CONTAINER_ROOTFS} fsck.repair=yes ro loglevel=3 noresume apparmor +console=serial0,115200 console=tty1 rootwait root=LABEL=system rootfstype=btrfs rootflags=subvol=${CONTAINER_ROOTFS} fsck.repair=yes ro loglevel=3 noresume apparmor=1 EOF # Attach options from system.json From b959f4dc674b184626ffba3a8e7fe652fb455b17 Mon Sep 17 00:00:00 2001 From: Simon Walz Date: Wed, 9 Sep 2026 16:52:26 +0000 Subject: [PATCH 4/8] feat: enable apparmor.service, and let dhclient reach its lease file Every other unit in these images is enabled or disabled by hand rather than left to the maintainer scripts; apparmor.service now is too. The one profile Debian gives us for free is also the one that breaks here. isc-dhcp-client ships /etc/apparmor.d/usr.sbin.dhclient, which allows /var/lib/dhcp{,3}/dhclient* lrw, but /var/lib/dhcp is a symlink to /data/dhcp, and AppArmor mediates the resolved path. The write lands on /data/dhcp/dhclient.leases, no rule covers it, and dhclient loses its lease. The rule goes into the profile's local/ include, which is what it is for and survives a package upgrade. Not built yet - neither the unit state nor the denial is verified on a running system. Signed-off-by: Simon Walz --- system/Dockerfile | 5 +++++ system/Dockerfile.orangepi-zero3 | 5 +++++ system/Dockerfile.rpi-arm32 | 5 +++++ system/Dockerfile.rpi-arm64 | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/system/Dockerfile b/system/Dockerfile index fe2d3bc..f4af6d7 100644 --- a/system/Dockerfile +++ b/system/Dockerfile @@ -90,6 +90,11 @@ RUN sed -i 's/^Components: main$/Components: main non-free-firmware/' /etc/apt/s && systemctl disable hv-kvp-daemon.service \ && systemctl disable hv-vss-daemon.service \ && systemctl enable systemd-timesyncd.service \ + && systemctl enable apparmor.service \ + ## AppArmor mediates the resolved path, and /var/lib/dhcp below is a \ + ## symlink into /data - without this rule the stock dhclient profile \ + ## denies dhclient its own lease file \ + && printf "/data/dhcp/ r,\n/data/dhcp/dhclient* lrw,\n" >>/etc/apparmor.d/local/usr.sbin.dhclient \ && systemctl mask ssh.socket \ && systemctl mask sshd-keygen.service \ && printf "Port 4222\nPermitRootLogin yes\n" >/etc/ssh/sshd_config.d/cuos.conf \ diff --git a/system/Dockerfile.orangepi-zero3 b/system/Dockerfile.orangepi-zero3 index 76093d8..c68565e 100644 --- a/system/Dockerfile.orangepi-zero3 +++ b/system/Dockerfile.orangepi-zero3 @@ -113,6 +113,11 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ vim-tiny \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ && systemctl enable systemd-timesyncd.service \ + && systemctl enable apparmor.service \ + ## AppArmor mediates the resolved path, and /var/lib/dhcp below is a \ + ## symlink into /data - without this rule the stock dhclient profile \ + ## denies dhclient its own lease file \ + && printf "/data/dhcp/ r,\n/data/dhcp/dhclient* lrw,\n" >>/etc/apparmor.d/local/usr.sbin.dhclient \ && systemctl mask ssh.socket \ && systemctl mask sshd-keygen.service \ && printf "Port 4222\nPermitRootLogin yes\n" >/etc/ssh/sshd_config.d/cuos.conf \ diff --git a/system/Dockerfile.rpi-arm32 b/system/Dockerfile.rpi-arm32 index 0a0738b..fbb48bc 100644 --- a/system/Dockerfile.rpi-arm32 +++ b/system/Dockerfile.rpi-arm32 @@ -152,6 +152,11 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ vim-tiny \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ && systemctl enable systemd-timesyncd.service \ + && systemctl enable apparmor.service \ + ## AppArmor mediates the resolved path, and /var/lib/dhcp below is a \ + ## symlink into /data - without this rule the stock dhclient profile \ + ## denies dhclient its own lease file \ + && printf "/data/dhcp/ r,\n/data/dhcp/dhclient* lrw,\n" >>/etc/apparmor.d/local/usr.sbin.dhclient \ && systemctl mask ssh.socket \ && systemctl mask sshd-keygen.service \ && printf "Port 4222\nPermitRootLogin yes\n" >/etc/ssh/sshd_config.d/cuos.conf \ diff --git a/system/Dockerfile.rpi-arm64 b/system/Dockerfile.rpi-arm64 index 062ec59..4e0a18d 100644 --- a/system/Dockerfile.rpi-arm64 +++ b/system/Dockerfile.rpi-arm64 @@ -133,6 +133,11 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ vim-tiny \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ && systemctl enable systemd-timesyncd.service \ + && systemctl enable apparmor.service \ + ## AppArmor mediates the resolved path, and /var/lib/dhcp below is a \ + ## symlink into /data - without this rule the stock dhclient profile \ + ## denies dhclient its own lease file \ + && printf "/data/dhcp/ r,\n/data/dhcp/dhclient* lrw,\n" >>/etc/apparmor.d/local/usr.sbin.dhclient \ && systemctl mask ssh.socket \ && systemctl mask sshd-keygen.service \ && printf "Port 4222\nPermitRootLogin yes\n" >/etc/ssh/sshd_config.d/cuos.conf \ From 88d32c54718ec9ebced0388159fd471f03d8ce02 Mon Sep 17 00:00:00 2001 From: Simon Walz Date: Wed, 9 Sep 2026 16:52:32 +0000 Subject: [PATCH 5/8] refact: drop selinux-policy-default It came with the initial import (4188543) and nothing ever enabled SELinux: no selinux= on any kernel command line, no policy loaded. With AppArmor now the LSM, two half-installed stacks are worse than one chosen. The installer factory still installs it; that image is untouched here. Not built yet. Signed-off-by: Simon Walz --- system/Dockerfile | 1 - system/Dockerfile.orangepi-zero3 | 1 - system/Dockerfile.rpi-arm32 | 1 - system/Dockerfile.rpi-arm64 | 1 - 4 files changed, 4 deletions(-) diff --git a/system/Dockerfile b/system/Dockerfile index f4af6d7..6525d09 100644 --- a/system/Dockerfile +++ b/system/Dockerfile @@ -58,7 +58,6 @@ RUN sed -i 's/^Components: main$/Components: main non-free-firmware/' /etc/apt/s kbd \ kmod \ lsb-release \ - selinux-policy-default \ systemd \ systemd-sysv \ systemd-timesyncd \ diff --git a/system/Dockerfile.orangepi-zero3 b/system/Dockerfile.orangepi-zero3 index c68565e..bde95cd 100644 --- a/system/Dockerfile.orangepi-zero3 +++ b/system/Dockerfile.orangepi-zero3 @@ -85,7 +85,6 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ kbd \ kmod \ lsb-release \ - selinux-policy-default \ systemd \ systemd-sysv \ systemd-timesyncd \ diff --git a/system/Dockerfile.rpi-arm32 b/system/Dockerfile.rpi-arm32 index fbb48bc..d43376a 100644 --- a/system/Dockerfile.rpi-arm32 +++ b/system/Dockerfile.rpi-arm32 @@ -124,7 +124,6 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ kbd \ kmod \ lsb-release \ - selinux-policy-default \ systemd \ systemd-sysv \ systemd-timesyncd \ diff --git a/system/Dockerfile.rpi-arm64 b/system/Dockerfile.rpi-arm64 index 4e0a18d..9925e40 100644 --- a/system/Dockerfile.rpi-arm64 +++ b/system/Dockerfile.rpi-arm64 @@ -105,7 +105,6 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ kbd \ kmod \ lsb-release \ - selinux-policy-default \ systemd \ systemd-sysv \ systemd-timesyncd \ From 8f0feda57fba13251608c4301b06c86eb4bc4961 Mon Sep 17 00:00:00 2001 From: Simon Walz Date: Wed, 9 Sep 2026 16:55:23 +0000 Subject: [PATCH 6/8] feat(selftest): report AppArmor, and ask for docker-default by name docker-default is what AppArmor is here for - it is the only thing confining the application container, and dockerd loads it silently or not at all. The check fails on an LSM that is off, on a missing docker-default, and on one loaded in complain mode, where it would look present and confine nothing. Skipped in a container, like subvolumes and network. Tests: 43 pass, shellcheck clean. Never run on a booted system. Signed-off-by: Simon Walz --- installer-factory/prepare_iso.sh | 6 +---- system/Dockerfile | 4 +-- system/Dockerfile.lxc | 3 +-- system/Dockerfile.orangepi-zero3 | 4 +-- system/Dockerfile.rpi-arm32 | 4 +-- system/Dockerfile.rpi-arm64 | 4 +-- system/cuos/selftest.sh | 39 +++++++++++++++++++++++++++++ system/cuos/selftest.test.sh | 42 +++++++++++++++++++++++++++++++- 8 files changed, 86 insertions(+), 20 deletions(-) diff --git a/installer-factory/prepare_iso.sh b/installer-factory/prepare_iso.sh index ef931b9..76bbe71 100755 --- a/installer-factory/prepare_iso.sh +++ b/installer-factory/prepare_iso.sh @@ -20,11 +20,7 @@ mv "/boot/vmlinuz"-* "${ISO_DIR}/boot/vmlinuz" mv "${INITRD_IMAGE}" "${ISO_DIR}/boot/initrd.img" # Create grub config -# -# The installer keeps apparmor=0 while the system image boots with apparmor=1. -# This image is not the system image: it carries no apparmor package, so there -# is no profile to load and no apparmor.service to load one. Switching the LSM -# on here would confine nothing and could only cost an installation. +# apparmor=0, unlike the system image: this one has no apparmor package. cat <"${ISO_DIR}/boot/grub/grub.cfg" set timeout=0 set default=0 diff --git a/system/Dockerfile b/system/Dockerfile index 6525d09..4d90676 100644 --- a/system/Dockerfile +++ b/system/Dockerfile @@ -90,9 +90,7 @@ RUN sed -i 's/^Components: main$/Components: main non-free-firmware/' /etc/apt/s && systemctl disable hv-vss-daemon.service \ && systemctl enable systemd-timesyncd.service \ && systemctl enable apparmor.service \ - ## AppArmor mediates the resolved path, and /var/lib/dhcp below is a \ - ## symlink into /data - without this rule the stock dhclient profile \ - ## denies dhclient its own lease file \ + ## /var/lib/dhcp is a symlink into /data, and AppArmor sees the resolved path \ && printf "/data/dhcp/ r,\n/data/dhcp/dhclient* lrw,\n" >>/etc/apparmor.d/local/usr.sbin.dhclient \ && systemctl mask ssh.socket \ && systemctl mask sshd-keygen.service \ diff --git a/system/Dockerfile.lxc b/system/Dockerfile.lxc index 3bfa38d..053b228 100644 --- a/system/Dockerfile.lxc +++ b/system/Dockerfile.lxc @@ -16,8 +16,7 @@ RUN echo "Cache buster: v1" \ ## cuos system dependencies (lxc) \ busybox-static \ ## cuos os dependencies \ - # AppArmor profiles are loaded into the host's kernel, and a container \ - # cannot load its own. Confining an LXC guest is the host's business. \ + # A container cannot load profiles - that is the LXC host's business \ #-apparmor \ #-btrfs-progs \ dbus \ diff --git a/system/Dockerfile.orangepi-zero3 b/system/Dockerfile.orangepi-zero3 index bde95cd..1d08bd8 100644 --- a/system/Dockerfile.orangepi-zero3 +++ b/system/Dockerfile.orangepi-zero3 @@ -113,9 +113,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ && systemctl enable systemd-timesyncd.service \ && systemctl enable apparmor.service \ - ## AppArmor mediates the resolved path, and /var/lib/dhcp below is a \ - ## symlink into /data - without this rule the stock dhclient profile \ - ## denies dhclient its own lease file \ + ## /var/lib/dhcp is a symlink into /data, and AppArmor sees the resolved path \ && printf "/data/dhcp/ r,\n/data/dhcp/dhclient* lrw,\n" >>/etc/apparmor.d/local/usr.sbin.dhclient \ && systemctl mask ssh.socket \ && systemctl mask sshd-keygen.service \ diff --git a/system/Dockerfile.rpi-arm32 b/system/Dockerfile.rpi-arm32 index d43376a..9115c02 100644 --- a/system/Dockerfile.rpi-arm32 +++ b/system/Dockerfile.rpi-arm32 @@ -152,9 +152,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ && systemctl enable systemd-timesyncd.service \ && systemctl enable apparmor.service \ - ## AppArmor mediates the resolved path, and /var/lib/dhcp below is a \ - ## symlink into /data - without this rule the stock dhclient profile \ - ## denies dhclient its own lease file \ + ## /var/lib/dhcp is a symlink into /data, and AppArmor sees the resolved path \ && printf "/data/dhcp/ r,\n/data/dhcp/dhclient* lrw,\n" >>/etc/apparmor.d/local/usr.sbin.dhclient \ && systemctl mask ssh.socket \ && systemctl mask sshd-keygen.service \ diff --git a/system/Dockerfile.rpi-arm64 b/system/Dockerfile.rpi-arm64 index 9925e40..82baaa3 100644 --- a/system/Dockerfile.rpi-arm64 +++ b/system/Dockerfile.rpi-arm64 @@ -133,9 +133,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ && systemctl enable systemd-timesyncd.service \ && systemctl enable apparmor.service \ - ## AppArmor mediates the resolved path, and /var/lib/dhcp below is a \ - ## symlink into /data - without this rule the stock dhclient profile \ - ## denies dhclient its own lease file \ + ## /var/lib/dhcp is a symlink into /data, and AppArmor sees the resolved path \ && printf "/data/dhcp/ r,\n/data/dhcp/dhclient* lrw,\n" >>/etc/apparmor.d/local/usr.sbin.dhclient \ && systemctl mask ssh.socket \ && systemctl mask sshd-keygen.service \ diff --git a/system/cuos/selftest.sh b/system/cuos/selftest.sh index 277a018..70e36e9 100755 --- a/system/cuos/selftest.sh +++ b/system/cuos/selftest.sh @@ -80,6 +80,15 @@ listening_on() { ss -ltn 2>/dev/null | grep -qE "[:.]$1[[:space:]]" } +apparmor_enabled() { + [[ "$(cat /sys/module/apparmor/parameters/enabled 2>/dev/null)" == "Y" ]] +} + +# " ()" per line, for every profile loaded into the kernel. +apparmor_profiles() { + cat /sys/kernel/security/apparmor/profiles 2>/dev/null +} + # How many cuos journal entries of priority error or worse this boot produced. journal_errors() { journalctl -t cuos -b -p 3 --no-pager -o cat 2>/dev/null | grep -c . || true @@ -177,6 +186,35 @@ check_docker() { failed "docker" "docker is not responding" } +# docker-default is the only thing confining the application container, and +# both ways of losing it - the LSM off, or dockerd never loading the profile - +# are silent. So it is asked for by name. +check_apparmor() { + if is_container; then + skipped "apparmor" "profiles belong to the host's kernel; a container loads none of its own" + return + fi + if ! apparmor_enabled; then + failed "apparmor" "the kernel has AppArmor off - check apparmor=1 on the command line" + return + fi + + local profiles count mode + profiles="$(apparmor_profiles)" + count="$(grep -c . <<<"${profiles}")" + mode="$(sed -n 's/^docker-default (\(.*\))$/\1/p' <<<"${profiles}")" + + if [[ -z "${mode}" ]]; then + failed "apparmor" "dockerd loaded no docker-default; profiles in the kernel: ${count}" + return + fi + if [[ "${mode}" != "enforce" ]]; then + failed "apparmor" "docker-default is in ${mode} mode, so it confines nothing" + return + fi + ok "apparmor" "${count} profiles loaded, docker-default enforcing" +} + check_app() { local status status="$(container_state "${APP_CONTAINER}")" @@ -278,6 +316,7 @@ selftest() { check_subvolumes check_state check_docker + check_apparmor check_app check_network check_hostname diff --git a/system/cuos/selftest.test.sh b/system/cuos/selftest.test.sh index 7f9b227..a068a25 100644 --- a/system/cuos/selftest.test.sh +++ b/system/cuos/selftest.test.sh @@ -33,6 +33,10 @@ MOCK_DOCKER_OK=0 MOCK_APP_STATE="running" MOCK_LISTENING=0 MOCK_JOURNAL_ERRORS=0 +MOCK_APPARMOR_ENABLED=0 +MOCK_APPARMOR_PROFILES="docker-default (enforce) +/usr/sbin/dhclient (enforce) +unix-chkpwd (enforce)" virt_type() { echo "${MOCK_VIRT}"; } subvolumes() { echo "${MOCK_SUBVOLUMES}"; } @@ -42,6 +46,8 @@ docker_ok() { return "${MOCK_DOCKER_OK}"; } container_state() { echo "${MOCK_APP_STATE}"; } listening_on() { return "${MOCK_LISTENING}"; } journal_errors() { echo "${MOCK_JOURNAL_ERRORS}"; } +apparmor_enabled() { return "${MOCK_APPARMOR_ENABLED}"; } +apparmor_profiles() { echo "${MOCK_APPARMOR_PROFILES}"; } # A healthy system, which each test then breaks in exactly one way. reset_system() { @@ -56,6 +62,10 @@ reset_system() { MOCK_APP_STATE="running" MOCK_LISTENING=0 MOCK_JOURNAL_ERRORS=0 + MOCK_APPARMOR_ENABLED=0 + MOCK_APPARMOR_PROFILES="docker-default (enforce) +/usr/sbin/dhclient (enforce) +unix-chkpwd (enforce)" cat >"${CONFIG_PATH}" <<'EOF' { @@ -151,6 +161,36 @@ expect "state: anything but running fails" "failed" status_of state is_updating break_docker() { MOCK_DOCKER_OK=1; } expect "docker: an unresponsive daemon fails" "failed" status_of docker break_docker +lsm_off() { MOCK_APPARMOR_ENABLED=1; } +expect "apparmor: a kernel with the LSM off fails" "failed" status_of apparmor lsm_off +expect "apparmor: the failure names the command line" \ + "the kernel has AppArmor off - check apparmor=1 on the command line" \ + detail_of apparmor lsm_off + +no_docker_default() { MOCK_APPARMOR_PROFILES="/usr/sbin/dhclient (enforce)"; } +expect "apparmor: enabled but no docker-default fails" "failed" status_of \ + apparmor no_docker_default +expect "apparmor: the failure counts what did load" \ + "dockerd loaded no docker-default; profiles in the kernel: 1" detail_of \ + apparmor no_docker_default + +complaining() { MOCK_APPARMOR_PROFILES="docker-default (complain)"; } +expect "apparmor: a complaining docker-default fails" "failed" status_of \ + apparmor complaining +expect "apparmor: and says it confines nothing" \ + "docker-default is in complain mode, so it confines nothing" detail_of \ + apparmor complaining + +# "docker-default-something" is a different profile, not this one. +similar_name() { MOCK_APPARMOR_PROFILES="docker-default-old (enforce)"; } +expect "apparmor: a profile merely starting with the name does not count" \ + "failed" status_of apparmor similar_name + +expect "apparmor: a healthy system reports the count" \ + "3 profiles loaded, docker-default enforcing" detail_of apparmor nothing +expect "apparmor: skipped in a container" "skipped" status_of apparmor \ + be_a_container + app_exited() { MOCK_APP_STATE="exited"; } expect "app: a stopped application fails" "failed" status_of app app_exited app_missing() { MOCK_APP_STATE=""; } @@ -193,7 +233,7 @@ expect "a skipped check does not" "true" overall be_a_container check_count() { report nothing | jq -r '.checks | length' } -expect "every check reports" "11" check_count +expect "every check reports" "12" check_count summary_adds_up() { report nothing | jq -r ' From 13d43537e3a2dfd43f7d7ae694216c07f75c4c43 Mon Sep 17 00:00:00 2001 From: Simon Walz Date: Wed, 9 Sep 2026 16:57:05 +0000 Subject: [PATCH 7/8] doc: what AppArmor confines on CuOS, and what it does not Three profiles are loaded and only one of them matters. The page says so, names the four places that stay unconfined and why - the privileged updater above all - and records the /data trap: apparmor mediates the resolved path, so a stock profile written against /var/lib/... denies the write that lands in /data. Signed-off-by: Simon Walz --- docs/architecture.md | 2 + docs/common/apparmor.md | 84 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 docs/common/apparmor.md diff --git a/docs/architecture.md b/docs/architecture.md index 4dbd807..b9428ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -140,6 +140,7 @@ In-place patch (`cuos patch*`) modifies `/system.json` directly and keeps previo | Network | Minimal exposed ports, host network only if required | | Privileges | App container without `--privileged`; granular capabilities | | CA | Inject custom CA via `custom_ca_certs` & rotate regularly | +| Confinement | AppArmor is on, and dockerd confines the app container with `docker-default` — `docs/common/apparmor.md` | --- @@ -150,3 +151,4 @@ In-place patch (`cuos patch*`) modifies `/system.json` directly and keeps previo * `image-factory/create_image.sh` – Raw image creation * `installer-factory/create_iso.sh` – Installer ISO * `docs/common/btrfs-usage.md` – Subvolume layout +* `docs/common/apparmor.md` – What AppArmor confines, and what it does not diff --git a/docs/common/apparmor.md b/docs/common/apparmor.md new file mode 100644 index 0000000..c09a72a --- /dev/null +++ b/docs/common/apparmor.md @@ -0,0 +1,84 @@ +# AppArmor + +CuOS boots with AppArmor enabled (`apparmor=1` on the kernel command line) on +every platform except LXC. `cuos selftest` reports the state under the +`apparmor` check. + +## What is confined + +| | Profile | Where it comes from | +|---|---|---| +| The application container | `docker-default` | dockerd's built-in template, loaded when the daemon starts | +| `dhclient` | `/usr/sbin/dhclient` | Debian's `isc-dhcp-client` package | +| PAM's password check | `unix-chkpwd` | Debian's `apparmor` package | + +That is the whole list, and `docker-default` is the point of the exercise: it is +what stands between the application container and the host. Debian's +`apparmor-profiles` and `apparmor-profiles-extra` packages are not installed — +they cover samba, syslog, avahi and desktop software, none of which is here. + +## What is not confined + +- **dockerd, and the CuOS scripts.** A daemon that must be able to do anything + cannot usefully be confined, and the CuOS scripts run as root and start + privileged containers. +- **The updater.** `do-update.sh` runs it with `--privileged`, and Docker runs + privileged containers unconfined. It repartitions the root disk, so it needs + to be. It is pulled by digest, and the digest is checked before it runs. +- **sshd.** Debian ships no profile for it, and it is masked unless + `os_ssh_server` is set. +- **The LXC platform.** Profiles are loaded into the host's kernel and a + container cannot load its own, so the LXC image carries no AppArmor at all. + Confining a CuOS guest is the LXC host's business. +- **The installer ISO.** A separate image without the AppArmor package. + +## Giving an application more than `docker-default` + +The flags of the application container come from the `dev.cuos.app_command` +label on the app image (see `cuos-app-init.md`), so the profile is selected +there: + +``` +LABEL dev.cuos.app_command="--security-opt apparmor=my-profile ..." +``` + +Note that the label **replaces** the default flag set rather than adding to it. + +The profile itself has to be loaded into the kernel before the container +starts, and CuOS ships no mechanism for that yet. While an application is being +brought up, `--security-opt apparmor=unconfined` is the escape hatch. + +## Reading a denial + +``` +journalctl -b | grep DENIED +``` + +Each line names the profile, the operation and the path. To take one profile +out of the way without touching the rest: + +``` +aa-complain /etc/apparmor.d/usr.sbin.dhclient # log, do not block +aa-enforce /etc/apparmor.d/usr.sbin.dhclient # back to blocking +``` + +Both are lost on the next boot, which is what you want while diagnosing. A +permanent addition goes into the profile's `local/` include — +`/etc/apparmor.d/local/` — which survives package upgrades. + +## The `/data` trap + +CuOS keeps mutable state in `/data` and symlinks into it: `/var/log`, +`/var/lib/dhcp`, `/var/lib/docker`, `/var/lib/containerd`, `/root`. + +**AppArmor mediates the resolved path, not the symlink.** A stock profile that +allows `/var/lib/dhcp/dhclient*` therefore denies the write that actually lands +on `/data/dhcp/dhclient.leases`. That is why the image ships + +``` +/data/dhcp/ r, +/data/dhcp/dhclient* lrw, +``` + +in `/etc/apparmor.d/local/usr.sbin.dhclient`. Any profile adopted from a +distribution package has to be read against these symlinks first. From d163a5a996950a6cef8edf32cbfeb25a59258fe9 Mon Sep 17 00:00:00 2001 From: Simon Walz Date: Wed, 9 Sep 2026 20:56:29 +0000 Subject: [PATCH 8/8] refact: drop selinux-policy-default from the installer factory too Same dead weight as in the system images (f0a1b0c): nothing enabled SELinux, and the installer has even less reason to carry a policy than the system does. The #-selinux-policy-default marker in Dockerfile.lxc goes with it - it pointed at a line that no longer exists. Not built yet. Signed-off-by: Simon Walz --- installer-factory/Dockerfile | 1 - system/Dockerfile.lxc | 1 - 2 files changed, 2 deletions(-) diff --git a/installer-factory/Dockerfile b/installer-factory/Dockerfile index 4813f5d..8aadbac 100644 --- a/installer-factory/Dockerfile +++ b/installer-factory/Dockerfile @@ -27,7 +27,6 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ linux-image-amd64 \ mtools \ pv \ - selinux-policy-default \ syslinux \ xorriso \ && apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/system/Dockerfile.lxc b/system/Dockerfile.lxc index 053b228..8d12ef0 100644 --- a/system/Dockerfile.lxc +++ b/system/Dockerfile.lxc @@ -26,7 +26,6 @@ RUN echo "Cache buster: v1" \ #-kbd \ kmod \ lsb-release \ - #-selinux-policy-default \ systemd \ systemd-sysv \ #-systemd-timesyncd \