|
| 1 | +# AppArmor |
| 2 | + |
| 3 | +CuOS boots with AppArmor enabled (`apparmor=1` on the kernel command line) on |
| 4 | +every platform except LXC. `cuos selftest` reports the state under the |
| 5 | +`apparmor` check. |
| 6 | + |
| 7 | +## What is confined |
| 8 | + |
| 9 | +| | Profile | Where it comes from | |
| 10 | +|---|---|---| |
| 11 | +| Every container dockerd starts | `docker-default` | dockerd's built-in template, loaded when the daemon starts | |
| 12 | +| `dhclient` | `/usr/sbin/dhclient` | Debian's `isc-dhcp-client` package | |
| 13 | +| PAM's password check | `unix-chkpwd`, in complain mode | Debian's `apparmor` package | |
| 14 | + |
| 15 | +That is the whole list, and `docker-default` is the point of the exercise: it is |
| 16 | +what stands between a container and the host — the application container and |
| 17 | +everything deployed through cuos-iac alike. Debian's `apparmor-profiles` and |
| 18 | +`apparmor-profiles-extra` packages are not installed — they cover samba, |
| 19 | +syslog, avahi and desktop software, none of which is here. |
| 20 | + |
| 21 | +The `apparmor` package's own ~100 profiles are inert here: most are the Debian |
| 22 | +13 userns stubs (`flags=(unconfined)`, body `userns,`), and every one of the |
| 23 | +rest is either in complain mode or carries no attachment path. None of them can |
| 24 | +deny anything, including inside a container that runs unconfined. |
| 25 | + |
| 26 | +## What is not confined |
| 27 | + |
| 28 | +- **dockerd, and the CuOS scripts.** A daemon that must be able to do anything |
| 29 | + cannot usefully be confined, and the CuOS scripts run as root and start |
| 30 | + privileged containers. |
| 31 | +- **The updater.** `do-update.sh` runs it with `--privileged`, and Docker runs |
| 32 | + privileged containers unconfined. It repartitions the root disk, so it needs |
| 33 | + to be. It is pulled by digest, and the digest is checked before it runs. |
| 34 | +- **sshd.** Debian ships no profile for it, and it is masked unless |
| 35 | + `os_ssh_server` is set. |
| 36 | +- **The LXC platform.** Profiles are loaded into the host's kernel and a |
| 37 | + container cannot load its own, so the LXC image carries no AppArmor at all. |
| 38 | + Confining a CuOS guest is the LXC host's business. |
| 39 | +- **The installer ISO.** A separate image without the AppArmor package. |
| 40 | + |
| 41 | +## What `docker-default` forbids a container |
| 42 | + |
| 43 | +The profile is permissive by design: `network,`, `capability,`, `file,` and |
| 44 | +`umount,` are allowed outright, so every file and every capability is on the |
| 45 | +table. What it carries is a short list of denials, and three of them reach |
| 46 | +ordinary containers. |
| 47 | + |
| 48 | +| Denied | What stops working | |
| 49 | +|---|---| |
| 50 | +| `deny mount,` | FUSE (sshfs, s3fs, rclone), `mount --bind` in an entrypoint, NFS/CIFS from inside, docker-in-docker, systemd as PID 1 | |
| 51 | +| writes below `/sys`, everything but `/sys/fs/cgroup/**` | sysfs GPIO (`/sys/class/gpio/export`), LEDs, PWM, backlight, driver `bind`/`unbind`, `/sys/class/net/*` | |
| 52 | +| `deny @{PROC}/sys/[^k]** w,` | `sysctl -w` run inside the container | |
| 53 | + |
| 54 | +Reading is untouched — only writes to `/sys` and `/proc` are denied. |
| 55 | + |
| 56 | +**`cap_add: [SYS_ADMIN]` does not lift any of this.** The capability is granted |
| 57 | +and AppArmor refuses anyway, and the error names neither. `privileged: true` |
| 58 | +does lift it, because Docker runs privileged containers unconfined. |
| 59 | + |
| 60 | +Unaffected, and usually the cheaper way out of a denial: |
| 61 | + |
| 62 | +- `volumes:`, `tmpfs:`, `read_only:` and `sysctls:` in a compose file — runc |
| 63 | + applies those before the container starts, and runc is unconfined. |
| 64 | +- Device nodes: `/dev/gpiochip*` (libgpiod), `/dev/i2c-*`, `/dev/spidev*` are |
| 65 | + covered by `file,`. Only the sysfs route to the same hardware is denied. |
| 66 | + |
| 67 | +`docker-default` separates a container from the **host**, not from other |
| 68 | +containers. Every container carries the same profile name, so the |
| 69 | +`signal (send,receive) peer=docker-default` and |
| 70 | +`ptrace (trace,read) peer=docker-default` rules of the template apply between |
| 71 | +containers as soon as they share a PID namespace (`pid: host`, |
| 72 | +`pid: service:…`). |
| 73 | + |
| 74 | +The rules above are moby's built-in template as of 26.1.5, the version in |
| 75 | +Debian trixie's `docker.io`. Read `aa-status` on the device rather than this |
| 76 | +page if a denial does not match. |
| 77 | + |
| 78 | +## Giving an application more than `docker-default` |
| 79 | + |
| 80 | +For a service deployed through cuos-iac, the profile is chosen per service in |
| 81 | +the compose file, and adds to what is already there: |
| 82 | + |
| 83 | +```yml |
| 84 | +services: |
| 85 | + app: |
| 86 | + security_opt: |
| 87 | + - apparmor=unconfined |
| 88 | +``` |
| 89 | +
|
| 90 | +For the application container, the flags come from the `dev.cuos.app_command` |
| 91 | +label on the app image (see `cuos-app-init.md`), so it is selected there: |
| 92 | + |
| 93 | +``` |
| 94 | +LABEL dev.cuos.app_command="--security-opt apparmor=my-profile ..." |
| 95 | +``` |
| 96 | + |
| 97 | +Note that the label **replaces** the default flag set rather than adding to it. |
| 98 | + |
| 99 | +The profile itself has to be loaded into the kernel before the container |
| 100 | +starts, and CuOS ships no mechanism for that yet. So the choice today is |
| 101 | +`docker-default` or nothing: while an application is being brought up, |
| 102 | +`--security-opt apparmor=unconfined` is the escape hatch. |
| 103 | + |
| 104 | +## Reading a denial |
| 105 | + |
| 106 | +``` |
| 107 | +journalctl -b | grep DENIED |
| 108 | +``` |
| 109 | +
|
| 110 | +Each line names the profile, the operation and the path. To take one profile |
| 111 | +out of the way without touching the rest: |
| 112 | +
|
| 113 | +``` |
| 114 | +aa-complain /etc/apparmor.d/usr.sbin.dhclient # log, do not block |
| 115 | +aa-enforce /etc/apparmor.d/usr.sbin.dhclient # back to blocking |
| 116 | +``` |
| 117 | +
|
| 118 | +Both are lost on the next boot, which is what you want while diagnosing. A |
| 119 | +permanent addition goes into the profile's `local/` include — |
| 120 | +`/etc/apparmor.d/local/<profile>` — which survives package upgrades. |
| 121 | +
|
| 122 | +## The `/data` trap |
| 123 | +
|
| 124 | +CuOS keeps mutable state in `/data` and symlinks into it: `/var/log`, |
| 125 | +`/var/lib/dhcp`, `/var/lib/docker`, `/var/lib/containerd`, `/root`. |
| 126 | +
|
| 127 | +**AppArmor mediates the resolved path, not the symlink.** A stock profile that |
| 128 | +allows `/var/lib/dhcp/dhclient*` therefore denies the write that actually lands |
| 129 | +on `/data/dhcp/dhclient.leases`. That is why the image ships |
| 130 | +
|
| 131 | +``` |
| 132 | +/data/dhcp/ r, |
| 133 | +/data/dhcp/dhclient* lrw, |
| 134 | +``` |
| 135 | +
|
| 136 | +in `/etc/apparmor.d/local/usr.sbin.dhclient`. Any profile adopted from a |
| 137 | +distribution package has to be read against these symlinks first. |
0 commit comments